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.
This commit is contained in:
2026-07-26 12:53:54 -04:00
parent ac3bdbcfe9
commit ffafb46d78
2 changed files with 104 additions and 12 deletions
+22
View File
@@ -98,3 +98,25 @@ Three outcomes:
as evidence the clips aren't actually a continuous sequence, and the run as evidence the clips aren't actually a continuous sequence, and the run
aborts with an error before any encoding happens. Pass aborts with an error before any encoding happens. Pass
`--allow-discontinuous` to concatenate them anyway. `--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
```
+80 -10
View File
@@ -32,6 +32,7 @@ import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
try: 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 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): def detect_overlaps(clips, confidence_threshold, min_signal_rms, allow_discontinuous):
"""Measure the audio overlap at each clip boundary. Junctions with a """Measure the audio overlap at each clip boundary. Junctions with a
confident match are trimmed accordingly. Junctions with a poor match confident match are trimmed accordingly. Junctions with a poor match
@@ -120,32 +176,46 @@ def detect_overlaps(clips, confidence_threshold, min_signal_rms, allow_discontin
raws.append(raw) raws.append(raw)
audios = [load_audio(r) for r in raws] audios = [load_audio(r) for r in raws]
creation_times = [fetch_creation_time(c) for c in clips]
overlaps = [] overlaps = []
discontinuities = [] results = []
has_discontinuity = False
for i in range(len(audios) - 1): for i in range(len(audios) - 1):
overlap_s, score, rms = best_overlap(audios[i], audios[i + 1]) overlap_s, score, rms = best_overlap(audios[i], audios[i + 1])
pair = f"{clips[i].name} -> {clips[i + 1].name}" 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: if score >= confidence_threshold:
verdict = "match"
print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated " print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated "
f"start (confidence {score:.3f})", file=sys.stderr) f"start (confidence {score:.3f})", file=sys.stderr)
elif rms < min_signal_rms: elif rms < min_signal_rms:
verdict = "quiet"
print(f"warning: {pair} is too quiet (rms={rms:.0f}) to verify overlap " print(f"warning: {pair} is too quiet (rms={rms:.0f}) to verify overlap "
f"via audio; assuming continuous with 0 overlap", file=sys.stderr) f"via audio; assuming continuous with 0 overlap", file=sys.stderr)
overlap_s = 0.0 overlap_s = 0.0
else: else:
verdict = "no match"
print(f"warning: {pair} audio does not match (confidence {score:.3f}, " print(f"warning: {pair} audio does not match (confidence {score:.3f}, "
f"rms={rms:.0f}) -- these clips may not be a continuous sequence", f"rms={rms:.0f}, gap {format_gap(gap)}) -- these clips may not be "
file=sys.stderr) f"a continuous sequence", file=sys.stderr)
discontinuities.append(pair) has_discontinuity = True
overlap_s = 0.0 overlap_s = 0.0
overlaps.append(overlap_s)
if discontinuities and not allow_discontinuous: overlaps.append(overlap_s)
results.append((gap, score, rms, verdict))
if has_discontinuity:
print_continuity_report(clips, results)
if not allow_discontinuous:
sys.exit( sys.exit(
"error: audio doesn't match at " + ", ".join(discontinuities) + " -- " "error: these clips don't look like a single continuous sequence "
"these clips don't look like a continuous sequence (wrong order, " "(wrong order, a missing clip, or unrelated files?). See the "
"missing clip, or unrelated files?). Re-check the input, or pass " "continuity report above -- re-run on one of the listed runs, or "
"--allow-discontinuous to concatenate them anyway." "pass --allow-discontinuous to concatenate everything anyway."
) )
return overlaps return overlaps