From ffafb46d78eccfe93ce5f109517c3cc123fffc3e Mon Sep 17 00:00:00 2001 From: ergosteur Date: Sun, 26 Jul 2026 12:53:54 -0400 Subject: [PATCH] Add continuity report with per-junction gaps and copy-pasteable runs When a clip boundary's audio doesn't match, print a full table of every junction (capture-time gap, confidence, verdict) plus the input clips grouped into continuous runs, each formatted as a ready-to-paste clip list -- so a discontinuous batch can be immediately re-run on just the subset that's actually one sequence, without manually diffing timestamps. --- README.md | 22 +++++++++++ concat_live_clips.py | 94 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f9bdefa..d9db369 100644 --- a/README.md +++ b/README.md @@ -98,3 +98,25 @@ Three outcomes: as evidence the clips aren't actually a continuous sequence, and the run aborts with an error before any encoding happens. Pass `--allow-discontinuous` to concatenate them anyway. + +If any boundary is a confident mismatch, a continuity report is printed +before aborting: a table of every junction (gap between capture times, +confidence, verdict), followed by the clips broken into continuous runs — +each printed as a ready-to-paste clip list so you can immediately re-run on +just the subset that's actually one sequence. For example: + +``` +Continuity report: + junction gap confidence verdict + IMG_2449.MOV -> IMG_2450.MOV 3s 0.216 NO MATCH + IMG_2450.MOV -> IMG_2451.MOV 15s 0.134 NO MATCH + IMG_2451.MOV -> IMG_2452.MOV 3s 1.000 match + IMG_2452.MOV -> IMG_2453.MOV 1s 1.000 match + IMG_2453.MOV -> IMG_2454.MOV 4s 0.590 NO MATCH + +Continuous runs (copy-paste to re-run on just that subset): + Run 1 (1 clip): IMG_2449.MOV -- nothing to concatenate on its own + Run 2 (1 clip): IMG_2450.MOV -- nothing to concatenate on its own + Run 3 (3 clips): IMG_2451.MOV IMG_2452.MOV IMG_2453.MOV + Run 4 (1 clip): IMG_2454.MOV -- nothing to concatenate on its own +``` diff --git a/concat_live_clips.py b/concat_live_clips.py index dfaf671..1cc481b 100755 --- a/concat_live_clips.py +++ b/concat_live_clips.py @@ -32,6 +32,7 @@ import re import subprocess import sys import tempfile +from datetime import datetime from pathlib import Path try: @@ -104,6 +105,61 @@ def best_overlap(a_tail, b_head, sr=SR, kmin_s=0.05, coarse_step_s=0.002, refine return best_k / sr, best_score, rms +def fetch_creation_time(clip): + """Best-effort capture timestamp for a clip, preferring the + local-time Apple tag (matches what Photos/Finder show) and falling + back to the UTC container tag. Returns None if neither is present or + parseable.""" + out = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", + "format_tags=com.apple.quicktime.creationdate,creation_time", + "-of", "default=noprint_wrappers=1:nokey=0", str(clip)], + check=True, capture_output=True, text=True, + ).stdout + tags = dict(line.split("=", 1) for line in out.strip().splitlines() if "=" in line) + raw = tags.get("TAG:com.apple.quicktime.creationdate") or tags.get("TAG:creation_time") + if not raw: + return None + raw = raw.strip().replace("Z", "+0000") + for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"): + try: + return datetime.strptime(raw, fmt) + except ValueError: + continue + return None + + +def format_gap(gap): + return f"{gap:.0f}s" if gap is not None else "unknown" + + +def print_continuity_report(clips, results): + """results: list of (gap_seconds_or_None, score, rms, verdict) per + junction, one entry per consecutive clip pair.""" + print("\nContinuity report:", file=sys.stderr) + header = f" {'junction':<40} {'gap':>8} {'confidence':>11} {'verdict':>10}" + print(header, file=sys.stderr) + for i, (gap, score, rms, verdict) in enumerate(results): + junction = f"{clips[i].name} -> {clips[i + 1].name}" + label = {"match": "match", "quiet": "inconclusive", "no match": "NO MATCH"}[verdict] + print(f" {junction:<40} {format_gap(gap):>8} {score:>11.3f} {label:>10}", file=sys.stderr) + + runs = [[clips[0]]] + for i, (_, _, _, verdict) in enumerate(results): + if verdict == "no match": + runs.append([clips[i + 1]]) + else: + runs[-1].append(clips[i + 1]) + + print("\nContinuous runs (copy-paste to re-run on just that subset):", file=sys.stderr) + for n, run in enumerate(runs, 1): + if len(run) >= 2: + print(f" Run {n} ({len(run)} clips): {' '.join(c.name for c in run)}", file=sys.stderr) + else: + print(f" Run {n} (1 clip): {run[0].name} -- nothing to concatenate on its own", file=sys.stderr) + print(file=sys.stderr) + + def detect_overlaps(clips, confidence_threshold, min_signal_rms, allow_discontinuous): """Measure the audio overlap at each clip boundary. Junctions with a confident match are trimmed accordingly. Junctions with a poor match @@ -120,33 +176,47 @@ def detect_overlaps(clips, confidence_threshold, min_signal_rms, allow_discontin raws.append(raw) audios = [load_audio(r) for r in raws] + creation_times = [fetch_creation_time(c) for c in clips] + overlaps = [] - discontinuities = [] + results = [] + has_discontinuity = False for i in range(len(audios) - 1): overlap_s, score, rms = best_overlap(audios[i], audios[i + 1]) pair = f"{clips[i].name} -> {clips[i + 1].name}" + gap = None + if creation_times[i] is not None and creation_times[i + 1] is not None: + gap = (creation_times[i + 1] - creation_times[i]).total_seconds() + if score >= confidence_threshold: + verdict = "match" print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated " f"start (confidence {score:.3f})", file=sys.stderr) elif rms < min_signal_rms: + verdict = "quiet" print(f"warning: {pair} is too quiet (rms={rms:.0f}) to verify overlap " f"via audio; assuming continuous with 0 overlap", file=sys.stderr) overlap_s = 0.0 else: + verdict = "no match" print(f"warning: {pair} audio does not match (confidence {score:.3f}, " - f"rms={rms:.0f}) -- these clips may not be a continuous sequence", - file=sys.stderr) - discontinuities.append(pair) + f"rms={rms:.0f}, gap {format_gap(gap)}) -- these clips may not be " + f"a continuous sequence", file=sys.stderr) + has_discontinuity = True overlap_s = 0.0 - overlaps.append(overlap_s) - if discontinuities and not allow_discontinuous: - sys.exit( - "error: audio doesn't match at " + ", ".join(discontinuities) + " -- " - "these clips don't look like a continuous sequence (wrong order, " - "missing clip, or unrelated files?). Re-check the input, or pass " - "--allow-discontinuous to concatenate them anyway." - ) + overlaps.append(overlap_s) + results.append((gap, score, rms, verdict)) + + if has_discontinuity: + print_continuity_report(clips, results) + if not allow_discontinuous: + sys.exit( + "error: these clips don't look like a single continuous sequence " + "(wrong order, a missing clip, or unrelated files?). See the " + "continuity report above -- re-run on one of the listed runs, or " + "pass --allow-discontinuous to concatenate everything anyway." + ) return overlaps