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
+1 -1
View File
File diff suppressed because one or more lines are too long
+21 -2
View File
@@ -93,12 +93,18 @@ def _save_results(results: list, cfg: "AppConfig") -> None: # type: ignore[name
def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-defined]
"""Re-render CUTTER_REPORT.{md,html} after each cache write so they stay in sync."""
"""Re-render CUTTER_REPORT.{md,html} and output/report/match_report.html.
Called from every match-style command after the cache is written so all
cutter-facing artefacts stay in sync with the current `match_results.json`.
Failures are logged but never abort the run — the cache is the source of
truth, the reports can always be re-rendered manually later.
"""
try:
from scripts.generate_cutter_report import render_report
except Exception as exc:
logging.getLogger(__name__).warning("Cutter report regen skipped: %s", exc)
return
else:
try:
project_root = cfg.paths.cache_dir.parent
md, html = render_report(project_root, with_stills=True, with_clips=True)
@@ -108,6 +114,19 @@ def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-de
except Exception as exc:
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
# Also keep the legacy output/report/match_report.html in sync. It uses
# its own preview-clip pipeline (frame-locked compare videos) and is the
# heavier of the two reports — kept up-to-date so the cutter can choose
# whichever view they prefer.
try:
from src.pipeline.reporter import generate_report
all_beats = _load_beats(cfg)
all_results = _normalize_cached_results(all_beats, _load_results(cfg), cfg)
generate_report(all_beats, all_results, cfg)
logging.getLogger(__name__).info("Match report regenerated → output/report/match_report.html")
except Exception as exc:
logging.getLogger(__name__).warning("Match report regen failed: %s", exc)
def _load_results(cfg: "AppConfig") -> list: # type: ignore[name-defined]
from src.core.models import MatchResult, MatchSegment
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+78 -5
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,13 +331,30 @@ 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 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