Chunking v2: keep clips that span a chunk boundary (overlap + seek)
v1 dropped any overlay video or audio clip whose start fell outside a chunk's window, so a multi-slide background video — or looping background music — silently vanished from every chunk after the one it started in, diverging from a full render. v2 includes any clip that OVERLAPS the window and seeks into clips that began earlier so they resume mid-clip at the seam instead of restarting: - _extract_video_events: overlap test + VideoEvent.skip_override (loop-aware phase); the input -ss also seeks the clip's embedded audio (tvaud). - _extract_audio_events: overlap test + AudioEvent.src_offset (loop phase / linear); renderer honors it in the loop-with-pauses, standard-loop, and one-shot paths. - _chunk_boundary_span_warnings downgraded from "will be dropped" to informational. Full (non-chunked) render is unchanged: time_range=None leaves skip_override/ src_offset at defaults. Slides already used overlap; outros are last-chunk-only. Plan-level checks in tests/test_chunking_v2.py (all green). Remaining gate: render- seam frame/phase validation on the rig; crossfade-loop audio seek deferred. See docs/chunking_v2.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan-level validation for chunking v2 (docs/chunking_v2.md).
|
||||
|
||||
Verifies the transformer now INCLUDES clips that span a chunk boundary and seeks
|
||||
into them (skip_override / src_offset), instead of the v1 behaviour that dropped
|
||||
them. This is a pure plan-level check — the ffmpeg concat-seam (frame alignment via
|
||||
-c copy) still needs a real render on the rig to confirm.
|
||||
|
||||
Run: ./venv/bin/python tests/test_chunking_v2.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from gnommo.transformer import (
|
||||
MarkerTiming,
|
||||
_extract_audio_events,
|
||||
_extract_video_events,
|
||||
AUDIO_OFFSET_SECONDS,
|
||||
)
|
||||
from gnommo.models import AudioDefinition, VideoSource, CutoutDefinition, SlideDefinition
|
||||
|
||||
_fails = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}" + (f" — {detail}" if detail and not cond else ""))
|
||||
if not cond:
|
||||
_fails.append(name)
|
||||
|
||||
|
||||
# ── audio ────────────────────────────────────────────────────────────────────
|
||||
def test_audio():
|
||||
print("audio events:")
|
||||
audio = {
|
||||
"music": AudioDefinition(file="music.mp3", loop=True, duration=90.0),
|
||||
"sfx": AudioDefinition(file="sfx.wav", loop=False, duration=500.0),
|
||||
"blip": AudioDefinition(file="blip.wav", loop=False, duration=50.0),
|
||||
}
|
||||
# music triggers at t=0, sfx at t=10, blip at t=10
|
||||
markers = [
|
||||
MarkerTiming(marker_id="Amusic", timestamp=0.0, context="", confidence=1.0),
|
||||
MarkerTiming(marker_id="Asfx", timestamp=10.0, context="", confidence=1.0),
|
||||
MarkerTiming(marker_id="Ablip", timestamp=10.0, context="", confidence=1.0),
|
||||
]
|
||||
|
||||
# Chunk window [300, 600): all three started earlier.
|
||||
evs = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=(300.0, 600.0))}
|
||||
|
||||
# Looping music: included, resumes at loop phase. astart = max(0, 0-1)=0; into=300;
|
||||
# phase = 300 % 90 = 30.
|
||||
check("looping music spanning boundary is INCLUDED (v1 dropped it)", "music" in evs)
|
||||
if "music" in evs:
|
||||
m = evs["music"]
|
||||
check("music clamped to window start", abs(m.start_time - 300.0) < 1e-6, f"start={m.start_time}")
|
||||
check("music seeks to loop phase 30.0", abs(m.src_offset - 30.0) < 1e-6, f"src_offset={m.src_offset}")
|
||||
|
||||
# One-shot still playing at the window: included, linear seek.
|
||||
# astart = max(0,10-1)=9; aend=9+500=509 > 300 → spans. into=300-9=291.
|
||||
check("one-shot still playing is INCLUDED", "sfx" in evs)
|
||||
if "sfx" in evs:
|
||||
s = evs["sfx"]
|
||||
check("sfx linear seek 291.0", abs(s.src_offset - 291.0) < 1e-6, f"src_offset={s.src_offset}")
|
||||
|
||||
# One-shot that ended before the window: excluded (aend=9+50=59 < 300).
|
||||
check("one-shot ended before window is EXCLUDED", "blip" not in evs)
|
||||
|
||||
# Full render (no range): everything from the start, no seek.
|
||||
full = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=None)}
|
||||
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
||||
|
||||
|
||||
# ── video ────────────────────────────────────────────────────────────────────
|
||||
def test_video():
|
||||
print("video events:")
|
||||
cutouts = {"fullscreen": CutoutDefinition(x=0, y=0, height=1080, width=1920)}
|
||||
videos = {
|
||||
"bg": VideoSource(
|
||||
source_file="bg.mp4", cutout="fullscreen", layer="below",
|
||||
duration=90.0, skip=0.0, end_on="next_video",
|
||||
)
|
||||
}
|
||||
slides = {f"S{i}": SlideDefinition(image=f"S{i}.png", type="slide") for i in range(1, 11)}
|
||||
markers = [MarkerTiming(marker_id=f"S{i}", timestamp=(i - 1) * 60.0, context="", confidence=1.0)
|
||||
for i in range(1, 11)]
|
||||
# Background overlay starts at slide-7 time (360) and, as the only video with
|
||||
# end_on next_video, runs to total_duration (600).
|
||||
markers.append(MarkerTiming(marker_id="vfm:bg", timestamp=360.0, context="", confidence=1.0))
|
||||
|
||||
total = 600.0
|
||||
# Chunk window that STARTS AFTER the video began: [420, 600) (slides 8-10).
|
||||
evs, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(420.0, 600.0))
|
||||
bg = next((e for e in evs if e.video_id == "bg"), None)
|
||||
|
||||
check("spanning background video is INCLUDED in the later chunk (v1 dropped it)", bg is not None)
|
||||
if bg is not None:
|
||||
check("bg clamped to window start", abs(bg.start_time - 420.0) < 1e-6, f"start={bg.start_time}")
|
||||
# into = 420-360 = 60; playable = 90-0 = 90; 60 < 90 → linear seek 60.
|
||||
check("bg linear seek 60.0 (first play-through)", abs((bg.skip_override or 0) - 60.0) < 1e-6,
|
||||
f"skip_override={bg.skip_override}")
|
||||
|
||||
# Window starting deep enough that the 90s clip has looped once: [480, 600).
|
||||
# into = 480-360 = 120; 120 >= 90 → phase = 120 % 90 = 30.
|
||||
evs2, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(480.0, 600.0))
|
||||
bg2 = next((e for e in evs2 if e.video_id == "bg"), None)
|
||||
check("looped background resumes at phase 30.0", bg2 is not None and abs((bg2.skip_override or 0) - 30.0) < 1e-6,
|
||||
f"skip_override={getattr(bg2, 'skip_override', None)}")
|
||||
|
||||
# Full render: no seek.
|
||||
evs3, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=None)
|
||||
bg3 = next((e for e in evs3 if e.video_id == "bg"), None)
|
||||
check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_audio()
|
||||
test_video()
|
||||
print()
|
||||
if _fails:
|
||||
print(f"FAILED: {len(_fails)} check(s): {', '.join(_fails)}")
|
||||
sys.exit(1)
|
||||
print("All chunking-v2 plan-level checks passed.")
|
||||
Reference in New Issue
Block a user