Fixing a few things about the videoZ

This commit is contained in:
2026-08-07 13:14:37 +02:00
parent 70a9b23810
commit fd83dd546f
5 changed files with 136 additions and 32 deletions
+9
View File
@@ -328,6 +328,9 @@ class VideoSource:
float float
] = None # Max duration to play (seconds). Default: until next slide or end of clip ] = 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) 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 = ( zoom: float = (
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge) 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), # 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. # not just the videos.json default. Purely informational; end_time is authoritative.
end_on: str = "" 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). # Resolved per-occurrence CSS-like cutout placement (see VideoSource).
object_fit: str = "cover" object_fit: str = "cover"
object_position: str = "center" object_position: str = "center"
+21 -3
View File
@@ -59,20 +59,35 @@ def _resolve_case_insensitive(path: Path) -> Path:
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not # (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
# yet overridable inline; unknown keys are ignored. # yet overridable inline; unknown keys are ignored.
_MARKER_OVERRIDE_KEYS = frozenset( _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). # 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): 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("'") v = raw.strip().strip('"').strip("'")
if key in _MARKER_NUMERIC_KEYS: if key in _MARKER_NUMERIC_KEYS:
try: try:
return float(v) return float(v)
except ValueError: except ValueError:
return None 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 return v
@@ -100,6 +115,7 @@ def parse_marker(raw: str) -> "tuple[str, Optional[dict]]":
continue continue
key, _, val = tok.partition("=") key, _, val = tok.partition("=")
key = key.strip().lower() key = key.strip().lower()
key = _MARKER_KEY_ALIASES.get(key, key) # duration → take, etc.
if key in _MARKER_OVERRIDE_KEYS: if key in _MARKER_OVERRIDE_KEYS:
coerced = _coerce_marker_value(key, val) coerced = _coerce_marker_value(key, val)
if coerced is not None: if coerced is not None:
@@ -636,6 +652,7 @@ def parse_videos(
output_file=video_data.get("output_file"), output_file=video_data.get("output_file"),
take=take, take=take,
skip=skip, skip=skip,
loop=bool(video_data.get("loop", False)),
zoom=video_data.get("zoom", 1.0), zoom=video_data.get("zoom", 1.0),
cutout=video_data.get("cutout"), cutout=video_data.get("cutout"),
object_fit=video_data.get("object-fit", "cover"), object_fit=video_data.get("object-fit", "cover"),
@@ -954,6 +971,7 @@ def resolve_missing_videos(
output_file=entry.get("output_file"), output_file=entry.get("output_file"),
take=entry.get("take"), take=entry.get("take"),
skip=float(entry.get("skip", 0.0)), skip=float(entry.get("skip", 0.0)),
loop=bool(entry.get("loop", False)),
zoom=float(entry.get("zoom", 1.0)), zoom=float(entry.get("zoom", 1.0)),
cutout=entry.get("cutout"), cutout=entry.get("cutout"),
always_visible=bool(entry.get("always_visible", False)), always_visible=bool(entry.get("always_visible", False)),
+86 -27
View File
@@ -300,6 +300,63 @@ def _ci_resolve(path: Path) -> Path:
return 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( def _resolve_video_path(
videos_dir: Path, videos_dir: Path,
video_source: VideoSource, video_source: VideoSource,
@@ -632,15 +689,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
video_path = _resolve_video_path( video_path = _resolve_video_path(
videos_dir, event.video_source, shared_assets_dir, project_path videos_dir, event.video_source, shared_assets_dir, project_path
) )
# Chunking v2 (docs/chunking_v2.md): a clip that began before this chunk # Per-occurrence geometry (chunk-seam skip_override, per-event skip/take/loop).
# resumes mid-clip via skip_override. None today (v1) → the source's own skip. skip, clip_duration, loop_frames = _video_playback(event, plan.config.fps)
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)
# Loop the clip if the file is shorter than the display window. # Loop the clip if the file is shorter than the display window.
# Don't loop pause-narration videos — they intentionally play once and stop. # 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: if has_audio:
video_events_with_audio.add(i) 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: if needs_loop:
cmd.extend(["-stream_loop", "-1"]) cmd.extend(["-stream_loop", "-1"])
if skip > 0: if skip > 0:
@@ -1164,20 +1229,18 @@ def build_filter_complex(
event.cutout, width, height event.cutout, width, height
) )
duration = event.end_time - event.start_time _skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
effective_end = event.start_time + duration effective_end = event.start_time + duration
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
zoom = event.video_source.zoom zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom) zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom) zoomed_height = int(cut_height * zoom)
video_label = f"tvb{i}" video_label = f"tvb{i}"
start_pts = event.start_time
filters.append( filters.append(
f"[{video_idx}:v]format=yuva444p10le," f"[{video_idx}:v]{_loop_pre}format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," f"{_pts},"
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
f"format=rgba[{video_label}]" f"format=rgba[{video_label}]"
) )
@@ -1264,20 +1327,18 @@ def build_filter_complex(
event.cutout, width, height event.cutout, width, height
) )
duration = event.end_time - event.start_time _skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
effective_end = event.start_time + duration effective_end = event.start_time + duration
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
zoom = event.video_source.zoom zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom) zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom) zoomed_height = int(cut_height * zoom)
video_label = f"tvm{i}" video_label = f"tvm{i}"
start_pts = event.start_time
filters.append( filters.append(
f"[{video_idx}:v]format=yuva444p10le," f"[{video_idx}:v]{_loop_pre}format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," f"{_pts},"
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
f"format=rgba[{video_label}]" f"format=rgba[{video_label}]"
) )
@@ -1320,20 +1381,18 @@ def build_filter_complex(
event.cutout, width, height event.cutout, width, height
) )
duration = event.end_time - event.start_time _skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
effective_end = event.start_time + duration effective_end = event.start_time + duration
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
zoom = event.video_source.zoom zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom) zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom) zoomed_height = int(cut_height * zoom)
video_label = f"tv{i}" video_label = f"tv{i}"
start_pts = event.start_time
filters.append( filters.append(
f"[{video_idx}:v]format=rgba," f"[{video_idx}:v]{_loop_pre}format=rgba,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," f"{_pts},"
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}" f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}"
f"[{video_label}]" f"[{video_label}]"
) )
+9 -1
View File
@@ -32,7 +32,7 @@ from .transformer import MarkerTiming, resolve_video_presentation
# Per-occurrence presentation fields ALWAYS materialized onto video events (atomic # Per-occurrence presentation fields ALWAYS materialized onto video events (atomic
# events.json, GUI-ready). Round-tripped as overrides so a stored value drives render. # 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), # `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. # 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. # Round-tripping still carries it whenever present in the event dict.
@@ -190,6 +190,14 @@ def derive_events(
e["end_on"] = pres["end_on"] e["end_on"] = pres["end_on"]
if pres["take"] is not None: if pres["take"] is not None:
e["take"] = pres["take"] 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 # volume is sparse: written only when actually overridden, so the
# videos.json default keeps flowing until someone pins it here. # videos.json default keeps flowing until someone pins it here.
if t.overrides and "volume" in t.overrides: if t.overrides and "volume" in t.overrides:
+11 -1
View File
@@ -95,6 +95,9 @@ def resolve_video_presentation(
layer = overrides.get("layer") or impl_layer or video_source.layer 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 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 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 = overrides.get(
"pause_narration", video_source.pause_narration or 0.0 "pause_narration", video_source.pause_narration or 0.0
) )
@@ -113,6 +116,8 @@ def resolve_video_presentation(
"layer": layer, "layer": layer,
"end_on": end_on, "end_on": end_on,
"take": take, "take": take,
"skip": float(skip or 0.0),
"loop": bool(loop),
"pause_narration": float(pause_narration or 0.0), "pause_narration": float(pause_narration or 0.0),
"volume": float(volume if volume is not None else 1.0), "volume": float(volume if volume is not None else 1.0),
"object_fit": object_fit, "object_fit": object_fit,
@@ -1426,6 +1431,8 @@ def _extract_video_events(
layer = pres["layer"] layer = pres["layer"]
end_on = pres["end_on"] end_on = pres["end_on"]
take = pres["take"] take = pres["take"]
skip = pres["skip"]
loop = pres["loop"]
pause_narration = pres["pause_narration"] pause_narration = pres["pause_narration"]
volume = pres["volume"] volume = pres["volume"]
object_fit = pres["object_fit"] object_fit = pres["object_fit"]
@@ -1504,7 +1511,7 @@ def _extract_video_events(
skip_override = None skip_override = None
if start_time < range_start: if start_time < range_start:
into = range_start - start_time # elapsed since the clip started 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 playable = (video_source.duration - base) if video_source.duration else None
if playable and playable > 0 and into >= playable: if playable and playable > 0 and into >= playable:
# the clip has looped by the window start → resume at the loop phase # 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, cutout_name=cutout_name,
layer=layer, layer=layer,
end_on=end_on or "", end_on=end_on or "",
skip=skip,
take=take,
loop=loop,
volume=volume, volume=volume,
object_fit=object_fit, object_fit=object_fit,
object_position=object_position, object_position=object_position,