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:
@@ -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
|
||||
```
|
||||
|
||||
+80
-10
@@ -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,32 +176,46 @@ 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:
|
||||
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: 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."
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user