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:
+1
-1
File diff suppressed because one or more lines are too long
@@ -93,20 +93,39 @@ 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)
|
||||
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
|
||||
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
|
||||
logging.getLogger(__name__).info("Cutter report regenerated (md + html)")
|
||||
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:
|
||||
project_root = cfg.paths.cache_dir.parent
|
||||
md, html = render_report(project_root, with_stills=True, with_clips=True)
|
||||
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
|
||||
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
|
||||
logging.getLogger(__name__).info("Cutter report regenerated (md + html)")
|
||||
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("Cutter report regen failed: %s", exc)
|
||||
logging.getLogger(__name__).warning("Match report regen failed: %s", exc)
|
||||
|
||||
|
||||
def _load_results(cfg: "AppConfig") -> list: # type: ignore[name-defined]
|
||||
|
||||
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.
@@ -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