Concat multi-shot source clips and auto-regen match_report.html

1. Cutter-report source clip for multi-segment beats was using only the
   primary in/out, which equals the FIRST segment's range. Beat 10 with
   3 shots therefore showed only ~0.88 s of source instead of all 3.32 s.
   Added extract_concat_clip(): renders each segment as its own MP4 and
   concatenates them via ffmpeg's concat demuxer into one continuous
   source clip the same length as the trailer beat.

   Per-segment intermediate clips (beat_NN_source_seg00.mp4 etc.) are
   kept too so individual shots stay inspectable.

2. _regenerate_cutter_report now also regenerates the legacy
   output/report/match_report.html via src.pipeline.reporter.generate_report.
   Both reports stay in sync after every match command.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Melbar
2026-05-05 04:40:01 +02:00
parent cc27208d2a
commit b70d7e11be
10 changed files with 108 additions and 16 deletions
+80 -7
View File
@@ -190,6 +190,62 @@ def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path)
return out.exists() and out.stat().st_size > 0
def extract_concat_clip(
video_path: Path,
segments: list[tuple[float, float]],
out: Path,
) -> bool:
"""Render each (start_s, duration_s) segment then concat into one MP4.
Used for multi-shot source matches so the cutter sees the assembled
source side-by-side with the trailer beat instead of just one segment.
"""
if not video_path.exists() or not segments:
return False
out.parent.mkdir(parents=True, exist_ok=True)
parts: list[Path] = []
for idx, (seg_start, seg_dur) in enumerate(segments):
part = out.with_name(f"{out.stem}_seg{idx:02d}.mp4")
if not extract_clip(video_path, seg_start, seg_dur, part):
return False
parts.append(part)
if not parts:
return False
# Single segment: just rename / copy.
if len(parts) == 1:
if parts[0].resolve() != out.resolve():
try:
if out.exists():
out.unlink()
parts[0].rename(out)
except OSError:
return False
return out.exists() and out.stat().st_size > 0
# Multi-segment: concat via ffmpeg concat demuxer (codec params match
# because every segment is rendered through extract_clip with identical
# encoder settings).
list_file = out.with_name(f"{out.stem}_concat.txt")
list_file.write_text(
"\n".join(f"file '{part.as_posix()}'" for part in parts) + "\n",
encoding="utf-8",
)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(list_file),
"-c", "copy",
"-movflags", "+faststart",
str(out),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
finally:
try:
list_file.unlink()
except OSError:
pass
return out.exists() and out.stat().st_size > 0
def beat_still_time(start_s: float, end_s: float) -> float:
duration = max(0.04, end_s - start_s)
return start_s + min(0.4, duration * 0.3)
@@ -275,15 +331,32 @@ def collect_rows(
tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
if extract_clip(trailer_path, beat["start_s"], tdur, tmp4):
trailer_clip = tmp4
# Source clip: full matched duration. May be shorter than the beat
# when the match drops out before the beat ends (fade / shot
# change in the source); that's intentional — the cutter needs to
# see exactly the matched span.
# Source clip:
# - segmented match (multi-shot beat): concatenate each
# segment back-to-back so the cutter sees the assembled
# source (e.g. man-shot + reaction shot) at the same total
# length as the trailer beat.
# - single match: extract the full matched span.
# May be shorter than the beat when a match drops before the
# beat ends (fade / shot change in source) — intentional.
if rec is not None:
sdur = max(0.5, min(CLIP_MAX_DURATION_S, rec["out_point_s"] - rec["in_point_s"]))
segs = rec.get("segments") or []
smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
if extract_clip(source_path, rec["in_point_s"], sdur, smp4):
source_clip = smp4
if len(segs) >= 2:
seg_specs = [
(
float(s["in_point_s"]),
max(0.04, float(s["out_point_s"]) - float(s["in_point_s"])),
)
for s in segs
if float(s["out_point_s"]) > float(s["in_point_s"])
]
if seg_specs and extract_concat_clip(source_path, seg_specs, smp4):
source_clip = smp4
else:
sdur = max(0.5, min(CLIP_MAX_DURATION_S, rec["out_point_s"] - rec["in_point_s"]))
if extract_clip(source_path, rec["in_point_s"], sdur, smp4):
source_clip = smp4
rows.append(BeatRow(
bid=bid,