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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user