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
|
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
|
||||||
|
```
|
||||||
|
|||||||
+82
-12
@@ -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,33 +176,47 @@ 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)
|
||||||
sys.exit(
|
results.append((gap, score, rms, verdict))
|
||||||
"error: audio doesn't match at " + ", ".join(discontinuities) + " -- "
|
|
||||||
"these clips don't look like a continuous sequence (wrong order, "
|
if has_discontinuity:
|
||||||
"missing clip, or unrelated files?). Re-check the input, or pass "
|
print_continuity_report(clips, results)
|
||||||
"--allow-discontinuous to concatenate them anyway."
|
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
|
return overlaps
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user