From fd83dd546f02f7f47cd0f9f6223e1600ef1d535c Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 7 Aug 2026 13:14:37 +0200 Subject: [PATCH] Fixing a few things about the videoZ --- gnommo/models.py | 9 ++++ gnommo/parser.py | 24 +++++++-- gnommo/renderer.py | 113 ++++++++++++++++++++++++++++++++---------- gnommo/scaffold.py | 10 +++- gnommo/transformer.py | 12 ++++- 5 files changed, 136 insertions(+), 32 deletions(-) diff --git a/gnommo/models.py b/gnommo/models.py index a61ba13..8db71cf 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -328,6 +328,9 @@ class VideoSource: float ] = None # Max duration to play (seconds). Default: until next slide or end of clip skip: float = 0.0 # Skip this many seconds at start of video (seek point) + loop: bool = False # If True, loop the [skip, skip+take] window to fill the display + # window (end_on). Without take, loops the whole clip from skip. Distinct from + # end_on="loop" (which loops to the render end); loop rides on any end_on. zoom: float = ( 1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge) ) @@ -454,6 +457,12 @@ class VideoEvent: # the render-plan listing can report the ACTUAL end rule (incl. inline overrides), # not just the videos.json default. Purely informational; end_time is authoritative. end_on: str = "" + # Resolved PER-OCCURRENCE playback controls (inline override > videos.json). The + # renderer reads these, not video_source.*, so the same handle can e.g. seek to a + # different point or loop in one place but not another. + skip: float = 0.0 # seek point into the source (seconds) + take: Optional[float] = None # display duration / loop period (seconds); None = window + loop: bool = False # loop the [skip, skip+take] window across the display window # Resolved per-occurrence CSS-like cutout placement (see VideoSource). object_fit: str = "cover" object_position: str = "center" diff --git a/gnommo/parser.py b/gnommo/parser.py index 9e18f29..1516e83 100644 --- a/gnommo/parser.py +++ b/gnommo/parser.py @@ -59,20 +59,35 @@ def _resolve_case_insensitive(path: Path) -> Path: # (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not # yet overridable inline; unknown keys are ignored. _MARKER_OVERRIDE_KEYS = frozenset( - {"cutout", "layer", "end_on", "take", "volume", "object-fit", "object-position"} + {"cutout", "layer", "end_on", "take", "skip", "loop", "volume", + "object-fit", "object-position"} ) # Override keys that are numeric (coerced to float). -_MARKER_NUMERIC_KEYS = frozenset({"take", "volume"}) +_MARKER_NUMERIC_KEYS = frozenset({"take", "skip", "volume"}) +# Override keys that are booleans (true/false/1/0/yes/no). +_MARKER_BOOL_KEYS = frozenset({"loop"}) +# Inline aliases → canonical override key. `duration` reads naturally in the +# manuscript but means the same as `take` (how long the clip plays / the loop +# period), so it maps to take and is stored/rendered as take. +_MARKER_KEY_ALIASES = {"duration": "take"} def _coerce_marker_value(key: str, raw: str): - """Coerce an inline override value: numeric keys → float; everything else → string.""" + """Coerce an inline override value: numeric keys → float, bool keys → bool, + everything else → string. Returns None if a typed value can't be parsed.""" v = raw.strip().strip('"').strip("'") if key in _MARKER_NUMERIC_KEYS: try: return float(v) except ValueError: return None + if key in _MARKER_BOOL_KEYS: + lv = v.lower() + if lv in ("true", "1", "yes", "on"): + return True + if lv in ("false", "0", "no", "off"): + return False + return None return v @@ -100,6 +115,7 @@ def parse_marker(raw: str) -> "tuple[str, Optional[dict]]": continue key, _, val = tok.partition("=") key = key.strip().lower() + key = _MARKER_KEY_ALIASES.get(key, key) # duration → take, etc. if key in _MARKER_OVERRIDE_KEYS: coerced = _coerce_marker_value(key, val) if coerced is not None: @@ -636,6 +652,7 @@ def parse_videos( output_file=video_data.get("output_file"), take=take, skip=skip, + loop=bool(video_data.get("loop", False)), zoom=video_data.get("zoom", 1.0), cutout=video_data.get("cutout"), object_fit=video_data.get("object-fit", "cover"), @@ -954,6 +971,7 @@ def resolve_missing_videos( output_file=entry.get("output_file"), take=entry.get("take"), skip=float(entry.get("skip", 0.0)), + loop=bool(entry.get("loop", False)), zoom=float(entry.get("zoom", 1.0)), cutout=entry.get("cutout"), always_visible=bool(entry.get("always_visible", False)), diff --git a/gnommo/renderer.py b/gnommo/renderer.py index 84a4a7f..2ea11b1 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -300,6 +300,63 @@ def _ci_resolve(path: Path) -> Path: return path +def _video_playback(event, fps: int) -> tuple[float, float, int]: + """Per-occurrence playback geometry for a triggered video overlay. + + Returns (skip, display_duration, loop_frames): + skip seek into the source — the chunk-seam override if present, else + the resolved per-occurrence skip (inline > videos.json). + display_duration how long the overlay is shown, in OUTPUT seconds. Normally the + clip's window (end-start), capped by `take` when set. With + loop=true, `take` is the loop PERIOD, not a display cap, so the + overlay fills the whole window. + loop_frames >0 → loop this many source frames (a filtergraph `loop` over the + [skip, skip+take] sub-window); 0 → no filtergraph loop. Only set + when loop=true AND take is given; whole-clip looping is handled by + the input-level -stream_loop auto-loop instead. + + Single source of truth so the input builder and every overlay layer agree. + """ + # event.skip is the already-resolved per-occurrence value (inline > videos.json), + # so trust it verbatim — don't `or` it against video_source.skip, or an explicit + # skip=0 override (falsy) would wrongly fall back to the videos.json skip. The + # chunk-seam override, when present, wins over both. + if getattr(event, "skip_override", None) is not None: + skip = event.skip_override + elif hasattr(event, "skip"): + skip = event.skip or 0.0 + else: + skip = event.video_source.skip or 0.0 + window = event.end_time - event.start_time + take = getattr(event, "take", None) + if take is None: + take = event.video_source.take + loop = bool(getattr(event, "loop", False)) + if loop: + display = window + loop_frames = int(round(take * fps)) if (take and take > 0) else 0 + else: + display = window if take is None else min(window, take) + loop_frames = 0 + return skip, display, loop_frames + + +def _trig_video_pts(event, fps: int, loop_frames: int) -> tuple[str, str]: + """(loop_prefix, setpts_expr) for a triggered-video overlay source chain. + + When loop_frames>0 the source is a `loop` filter repeating a `loop_frames`-frame + window forever; loop can leave non-monotonic PTS, so re-time from the frame index + (N/fps) plus the clip's output start. Otherwise use the normal PTS rebase+offset. + """ + start = event.start_time + if loop_frames > 0: + return ( + f"loop=loop=-1:size={loop_frames}:start=0,", + f"setpts=N/({fps}*TB)+{start:.3f}/TB", + ) + return "", f"setpts=PTS-STARTPTS+{start:.3f}/TB" + + def _resolve_video_path( videos_dir: Path, video_source: VideoSource, @@ -632,15 +689,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: video_path = _resolve_video_path( videos_dir, event.video_source, shared_assets_dir, project_path ) - # Chunking v2 (docs/chunking_v2.md): a clip that began before this chunk - # resumes mid-clip via skip_override. None today (v1) → the source's own skip. - skip = event.skip_override if getattr(event, "skip_override", None) is not None \ - else (event.video_source.skip or 0.0) - - # How long this clip needs to play in the output - clip_duration = event.end_time - event.start_time - if event.video_source.take is not None: - clip_duration = min(clip_duration, event.video_source.take) + # Per-occurrence geometry (chunk-seam skip_override, per-event skip/take/loop). + skip, clip_duration, loop_frames = _video_playback(event, plan.config.fps) # Loop the clip if the file is shorter than the display window. # Don't loop pause-narration videos — they intentionally play once and stop. @@ -654,6 +704,21 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: if has_audio: video_events_with_audio.add(i) + if loop_frames > 0: + # Explicit loop=true of a bounded [skip, skip+take] sub-window: read ONLY + # that window here (a filtergraph `loop` filter repeats it — see the overlay + # layers). No -stream_loop; the filter does the repeating. + _take_secs = loop_frames / plan.config.fps + if skip > 0: + cmd.extend(["-ss", f"{skip:.3f}"]) + probesize = "1000000" if has_audio else "1000" + cmd.extend(["-analyzeduration", "0", "-probesize", probesize]) + cmd.extend(["-t", f"{_take_secs:.3f}"]) + cmd.extend(["-i", str(video_path)]) + video_inputs[i] = input_idx + input_idx += 1 + continue + if needs_loop: cmd.extend(["-stream_loop", "-1"]) if skip > 0: @@ -1164,20 +1229,18 @@ def build_filter_complex( event.cutout, width, height ) - duration = event.end_time - event.start_time - if event.video_source.take is not None: - duration = min(duration, event.video_source.take) + _skip, duration, _loop_frames = _video_playback(event, plan.config.fps) effective_end = event.start_time + duration + _loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames) zoom = event.video_source.zoom zoomed_width = int(cut_width * zoom) zoomed_height = int(cut_height * zoom) video_label = f"tvb{i}" - start_pts = event.start_time filters.append( - f"[{video_idx}:v]format=yuva444p10le," - f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," + f"[{video_idx}:v]{_loop_pre}format=yuva444p10le," + f"{_pts}," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}," f"format=rgba[{video_label}]" ) @@ -1264,20 +1327,18 @@ def build_filter_complex( event.cutout, width, height ) - duration = event.end_time - event.start_time - if event.video_source.take is not None: - duration = min(duration, event.video_source.take) + _skip, duration, _loop_frames = _video_playback(event, plan.config.fps) effective_end = event.start_time + duration + _loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames) zoom = event.video_source.zoom zoomed_width = int(cut_width * zoom) zoomed_height = int(cut_height * zoom) video_label = f"tvm{i}" - start_pts = event.start_time filters.append( - f"[{video_idx}:v]format=yuva444p10le," - f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," + f"[{video_idx}:v]{_loop_pre}format=yuva444p10le," + f"{_pts}," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}," f"format=rgba[{video_label}]" ) @@ -1320,20 +1381,18 @@ def build_filter_complex( event.cutout, width, height ) - duration = event.end_time - event.start_time - if event.video_source.take is not None: - duration = min(duration, event.video_source.take) + _skip, duration, _loop_frames = _video_playback(event, plan.config.fps) effective_end = event.start_time + duration + _loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames) zoom = event.video_source.zoom zoomed_width = int(cut_width * zoom) zoomed_height = int(cut_height * zoom) video_label = f"tv{i}" - start_pts = event.start_time filters.append( - f"[{video_idx}:v]format=rgba," - f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," + f"[{video_idx}:v]{_loop_pre}format=rgba," + f"{_pts}," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}" f"[{video_label}]" ) diff --git a/gnommo/scaffold.py b/gnommo/scaffold.py index 5e346cc..64bc2d2 100644 --- a/gnommo/scaffold.py +++ b/gnommo/scaffold.py @@ -32,7 +32,7 @@ from .transformer import MarkerTiming, resolve_video_presentation # Per-occurrence presentation fields ALWAYS materialized onto video events (atomic # events.json, GUI-ready). Round-tripped as overrides so a stored value drives render. -_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take", "object-fit", "object-position") +_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take", "skip", "loop", "object-fit", "object-position") # `volume` is materialized SPARSELY — only when actually overridden (inline/GUI/manual), # so the videos.json value keeps flowing as the default and only a real override pins it. # Round-tripping still carries it whenever present in the event dict. @@ -190,6 +190,14 @@ def derive_events( e["end_on"] = pres["end_on"] if pres["take"] is not None: e["take"] = pres["take"] + # skip/loop are sparse: materialized only when explicitly overridden + # inline (like volume), so a plain video keeps a clean events.json and + # the videos.json default flows via render's fallback. Keyed on override + # PRESENCE, not truthiness, so an explicit skip=0 / loop=false survives. + if t.overrides and "skip" in t.overrides: + e["skip"] = round(pres["skip"], 3) + if t.overrides and "loop" in t.overrides: + e["loop"] = bool(pres["loop"]) # volume is sparse: written only when actually overridden, so the # videos.json default keeps flowing until someone pins it here. if t.overrides and "volume" in t.overrides: diff --git a/gnommo/transformer.py b/gnommo/transformer.py index c6af800..0a98181 100644 --- a/gnommo/transformer.py +++ b/gnommo/transformer.py @@ -95,6 +95,9 @@ def resolve_video_presentation( layer = overrides.get("layer") or impl_layer or video_source.layer end_on = overrides.get("end_on") or video_source.end_on or default_end_on take = overrides["take"] if "take" in overrides else video_source.take + # Per-occurrence playback controls: inline override wins, else videos.json default. + skip = overrides["skip"] if "skip" in overrides else (video_source.skip or 0.0) + loop = overrides["loop"] if "loop" in overrides else bool(video_source.loop) pause_narration = overrides.get( "pause_narration", video_source.pause_narration or 0.0 ) @@ -113,6 +116,8 @@ def resolve_video_presentation( "layer": layer, "end_on": end_on, "take": take, + "skip": float(skip or 0.0), + "loop": bool(loop), "pause_narration": float(pause_narration or 0.0), "volume": float(volume if volume is not None else 1.0), "object_fit": object_fit, @@ -1426,6 +1431,8 @@ def _extract_video_events( layer = pres["layer"] end_on = pres["end_on"] take = pres["take"] + skip = pres["skip"] + loop = pres["loop"] pause_narration = pres["pause_narration"] volume = pres["volume"] object_fit = pres["object_fit"] @@ -1504,7 +1511,7 @@ def _extract_video_events( skip_override = None if start_time < range_start: into = range_start - start_time # elapsed since the clip started - base = video_source.skip or 0.0 + base = skip # resolved per-occurrence skip playable = (video_source.duration - base) if video_source.duration else None if playable and playable > 0 and into >= playable: # the clip has looped by the window start → resume at the loop phase @@ -1525,6 +1532,9 @@ def _extract_video_events( cutout_name=cutout_name, layer=layer, end_on=end_on or "", + skip=skip, + take=take, + loop=loop, volume=volume, object_fit=object_fit, object_position=object_position,