Compare commits

25 Commits
Author SHA1 Message Date
gitprov a550ee8b6b Adding fix to the logo pause narration 2026-08-08 11:19:57 +02:00
gitprov 3b0439a00c Adding render_all 2026-08-07 13:17:08 +02:00
gitprov fd83dd546f Fixing a few things about the videoZ 2026-08-07 13:14:37 +02:00
gitprov 70a9b23810 Improved loudnorm 2026-08-07 10:47:09 +02:00
gitprov 7303d820e3 Adding hadnoff all 2026-08-06 16:21:47 +02:00
gitprov 7748e78712 Case insensitivity bite 2026-08-06 12:59:15 +02:00
gitprov 2b2174c176 Adding straddle fix 2026-08-05 22:02:06 +02:00
gitprov eac1d7968d Fix to the placement of 2026-08-05 21:53:54 +02:00
gitprov 06d2d27dad Fixing the up files 2026-08-05 13:39:24 +02:00
gitprov d9dc9baa51 Fixing the audio looping and pause narration issue 2026-08-05 12:46:51 +02:00
gitprov f328759eab Fixes to video6 2026-08-04 16:37:21 +02:00
gitprov d2550d9432 Fixing tasks.md 2026-08-04 15:10:04 +02:00
gitprov 0b2ebf84e4 Adding fix to objet_fit 2026-08-04 14:31:18 +02:00
gitprov 0f3c595c04 Make outro file case ingorant 2026-08-03 16:25:46 +02:00
gitprov f852b36291 Adding fixes to renderer 2026-08-03 14:38:33 +02:00
gitprov a218e1cc9f Adding improved scaffolding of videos and hopefully a fix of the narration 2026-08-02 21:34:30 +02:00
gitprov be5173aeb1 Fixing audio that doesn't silence 2026-08-02 21:12:43 +02:00
gitprov eeaada77d4 Adding 2026-07-30 23:04:50 +02:00
gitprov 4470246ebb Adding autorender 2026-07-30 20:42:25 +02:00
gitprov 9d29d2e2ed Adding autorender 2026-07-30 20:34:01 +02:00
gitprov 4fbb6425df Add render -n command 2026-07-30 16:09:05 +02:00
gitprov cbdc22cc16 Improved CPU knobs 2026-07-30 10:34:10 +02:00
gitprov ec08e945e5 Adding a few fixes to the gnommo pipeline 2026-07-29 17:27:48 +02:00
gitprov 4c6c9b8569 Fixing volume propagation settings through events.json, and fixed bug where failure to match the text on the end slide of a segment now issues a warning, and uses the last word + 2 seconds, rather than the full duration of the clip 2026-07-29 14:55:28 +02:00
gitprov c72c118d76 Fix to transfer 2026-07-29 12:04:08 +02:00
26 changed files with 1905 additions and 503 deletions
+5 -2
View File
@@ -1503,9 +1503,12 @@
}, },
"Logo": { "Logo": {
"source_file": "Logo.mov", "source_file": "Logo.mov",
"duration": 14.0, "duration": 18.0,
"has_audio": true, "has_audio": true,
"is_shared": true "is_shared": true,
"cutout": "fullscreen",
"layer": "above",
"pause_narration": 14.0
}, },
"MontageZoom": { "MontageZoom": {
"source_file": "MontageZoom.mp4", "source_file": "MontageZoom.mp4",
+6 -5
View File
@@ -1501,12 +1501,13 @@
"has_audio": false, "has_audio": false,
"is_shared": true "is_shared": true
}, },
"Logo": { "source_file": "Logo.mov",
"source_file": "Logo.mov", "duration": 18.0,
"duration": 14.0,
"has_audio": true, "has_audio": true,
"is_shared": true "is_shared": true,
}, "cutout": "fullscreen",
"layer": "above",
"pause_narration": 14.0
"MontageZoom": { "MontageZoom": {
"source_file": "MontageZoom.mp4", "source_file": "MontageZoom.mp4",
"duration": 17.0, "duration": 17.0,
Executable
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# autorender.sh — self-updating nightly render driver for the render rig.
#
# Invoked by Windows Task Scheduler (run only when user is logged on):
# wsl.exe -d Ubuntu -u glitchhunter -- bash -lc "/home/glitchhunter/Projects/gnommo/autorender.sh"
#
# Flow:
# 1. DEPLOY CODE — git fetch + reset --hard origin/<branch>, then re-exec the
# freshly pulled script once. This is how code changes ship from the Mac:
# push to origin, and the next run picks them up. SAFE — reset --hard only
# rewrites TRACKED files; gitignored project data (video*/) and secrets
# (.env) are left untouched. This script NEVER runs `git clean`.
# 2. RENDER — (pending) `gnommo auto`: per-project down -> gated render -> handoff.
#
# Config via environment (set once in ~/.profile / ~/.bash_profile on the rig,
# so a login shell — bash -lc — picks them up):
# GNOMMO_DIR repo clone on the rig (default: this script's own dir)
# BRANCH branch to track (default: main)
# NTFY_URL failure ping endpoint, e.g. https://ntfy.sh/your-secret-topic
#
set -uo pipefail
# Default GNOMMO_DIR to the repo this script lives in, so it works regardless of
# username or checkout path (rig: /home/glitchhunter/Projects/gnommo).
GNOMMO_DIR="${GNOMMO_DIR:-$(cd "$(dirname "$(readlink -f "$0")")" && pwd)}"
BRANCH="${AUTORENDER_BRANCH:-main}"
LOG="${AUTORENDER_LOG:-$GNOMMO_DIR/autorender.log}"
LOCK="${AUTORENDER_LOCK:-/tmp/gnommo-autorender.lock}"
log() { printf '%s | %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG"; }
notify() { [ -n "${NTFY_URL:-}" ] && curl -fsS -m 10 -d "$*" "$NTFY_URL" >/dev/null 2>&1 || true; }
# ── Step 1: deploy latest code from origin, then re-exec the fresh script once ──
# The re-exec is essential: reset --hard rewrites this very file mid-run, so we
# must restart from the updated copy rather than keep executing the old bytes.
if [ -z "${AUTORENDER_UPDATED:-}" ]; then
if ! cd "$GNOMMO_DIR" 2>/dev/null; then
log "FATAL: GNOMMO_DIR not found: $GNOMMO_DIR"
notify "autorender: GNOMMO_DIR missing ($GNOMMO_DIR)"
exit 1
fi
if git fetch --quiet origin 2>>"$LOG"; then
before="$(git rev-parse --short HEAD 2>/dev/null || echo '?')"
git reset --hard "origin/$BRANCH" >>"$LOG" 2>&1
after="$(git rev-parse --short HEAD 2>/dev/null || echo '?')"
./venv/bin/pip install -e . -q >>"$LOG" 2>&1 || true # catch new deps/entry points
[ "$before" != "$after" ] && log "code deployed: $before -> $after"
else
log "git fetch failed — running existing code"
notify "autorender: git fetch failed on the rig"
fi
export AUTORENDER_UPDATED=1
exec "$0" "$@"
fi
# ── everything below runs on the freshly-deployed code ─────────────────────────
# Single-run lock — renders take hours; a second scheduled firing must bail.
exec 9>"$LOCK"
if ! flock -n 9; then
log "another autorender run is active — exiting"
exit 0
fi
log "autorender start (HEAD $(git -C "$GNOMMO_DIR" rev-parse --short HEAD 2>/dev/null), user $(whoami))"
# ── Step 2: render loop — per-project down → gated render → handoff ────────────
# `gnommo auto` scans video* under the cwd, so cd into the repo first. It returns
# non-zero if any project failed; we ping on that.
cd "$GNOMMO_DIR"
if ./venv/bin/python -m gnommo auto 2>&1 | tee -a "$LOG"; then
log "autorender done (all projects ok)"
else
log "autorender done WITH FAILURES (see log above)"
notify "autorender: one or more projects failed on the rig — check autorender.log"
fi
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
./gnommo.sh -p video0 build --force
./gnommo.sh -p video1 build --force
./gnommo.sh -p video2 build --force
./gnommo.sh -p video3 build --force
./gnommo.sh -p video4 build --force
./gnommo.sh -p video5 build --force
./gnommo.sh -p video6 build --force
./gnommo.sh -p video7 build --force
+12 -6
View File
@@ -11,14 +11,20 @@ Implemented:
take`; `events_to_marker_timings` round-trips them as overrides. take`; `events_to_marker_timings` round-trips them as overrides.
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through - Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on, alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on,
take** (the fully-wired per-event fields). Unknown keys are ignored. take, volume** (numeric keys `take`/`volume` coerced to float). Unknown keys are ignored.
- The key-reuse collision validator hard-error was removed (reuse is legal now). - The key-reuse collision validator hard-error was removed (reuse is legal now).
Deferred (follow-ups): inline override of the *global* params (skip/zoom/volume/ `volume` is **sparse/overridable**: the renderer reads `VideoEvent.volume` (line ~1571),
use_audio_channels/pause_narration) — the renderer reads these from `video_source` in ~13 which is the events.json override if present else the videos.json default — so a videos.json
places, so wiring them per-event is a separate change; stripping the moved fields from change keeps propagating, and events.json only stores `volume` when it's actually overridden
videos.json (kept as fallback defaults for now); a validator warning for unknown/unwired (inline/GUI/manual). `derive_events` materializes it only when overridden; `_EVENT_OVERRIDE_KEYS`
inline keys. round-trips it. This is the template for the remaining globals.
Deferred (follow-ups): inline/per-event override of the *other* globals (skip/zoom/
use_audio_channels/pause_narration) — same renderer-plumbing pattern as volume, per site;
per-segment narration voiceover volume (render currently uses `narration_videos[0].volume`
only); stripping the moved fields from videos.json (kept as fallback defaults); a validator
warning for unknown inline keys.
## Problem ## Problem
+91 -25
View File
@@ -11,6 +11,7 @@ Files are looked up first locally, then in the cache at:
""" """
import configparser import configparser
import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -18,36 +19,103 @@ from typing import Optional, Tuple
_cache_config: Optional[dict] = None _cache_config: Optional[dict] = None
_assets_config: Optional[dict] = None _assets_config: Optional[dict] = None
_perf_config: Optional[dict] = None _perf_config: Optional[dict] = None
# Per-project performance overrides (project.json "performance" block). Set at the
# start of preprocess/render via set_active_project(). These OVERRIDE ~/.gnommo.conf
# and — unlike that per-machine file — travel with the project over up/down, so the
# render rig's chunk size / CPU limits can be tuned remotely by editing project.json.
_active_project_perf: dict = {}
def get_ffmpeg_thread_count() -> int: def _load_perf_config() -> dict:
"""Return FFmpeg thread count based on [performance] cpu_limit in ~/.gnommo.conf. """Read and cache the [performance] section of ~/.gnommo.conf."""
cpu_limit is a fraction of logical CPUs (e.g. 0.8 = 80%).
Defaults to 1 when not configured, which is safe on memory-constrained machines.
Example ~/.gnommo.conf:
[performance]
cpu_limit = 0.8
"""
global _perf_config global _perf_config
if _perf_config is None: if _perf_config is not None:
config_path = Path.home() / ".gnommo.conf" return _perf_config
_perf_config = {}
if config_path.exists(): _perf_config = {}
cfg = configparser.ConfigParser() config_path = Path.home() / ".gnommo.conf"
cfg.read(config_path) if config_path.exists():
if cfg.has_option("performance", "cpu_limit"): cfg = configparser.ConfigParser()
cfg.read(config_path)
for key in ("cpu_limit_preprocess", "cpu_limit_render", "cpu_limit"):
if cfg.has_option("performance", key):
try: try:
_perf_config["cpu_limit"] = float( _perf_config[key] = float(cfg.get("performance", key))
cfg.get("performance", "cpu_limit")
)
except ValueError: except ValueError:
pass pass
if cfg.has_option("performance", "render_chunk_slides"):
try:
_perf_config["render_chunk_slides"] = int(
cfg.get("performance", "render_chunk_slides")
)
except ValueError:
pass
return _perf_config
cpu_limit = _perf_config.get("cpu_limit")
def set_active_project(project_path) -> None:
"""Load a project's optional "performance" overrides from its project.json.
project.json syncs via up/down and isn't secret, so its "performance" block is
the remotely-editable home for the render rig's tunables (render_chunk_slides,
cpu_limit_preprocess, cpu_limit_render). Keys present here override
~/.gnommo.conf; anything absent falls back to the machine config. Call once at
the start of a per-project preprocess/render.
"""
global _active_project_perf
_active_project_perf = {}
if project_path is None:
return
pj = Path(project_path) / "project.json"
if not pj.exists():
return
try:
perf = json.loads(pj.read_text(encoding="utf-8")).get("performance")
except (ValueError, OSError):
return
if isinstance(perf, dict):
_active_project_perf = perf
def _perf_get(key: str, default_key: Optional[str] = None):
"""Resolve a performance value: active project.json overrides ~/.gnommo.conf.
Within each source the stage-specific `key` wins over the legacy `default_key`
(e.g. `cpu_limit`); the project source is consulted before the machine config.
"""
conf = _load_perf_config()
for src in (_active_project_perf, conf):
for k in (key, default_key):
if k and src.get(k) is not None:
return src[k]
return None
def get_ffmpeg_thread_count(stage: str = "preprocess") -> int:
"""Return the FFmpeg thread count for a pipeline stage from ~/.gnommo.conf.
Preprocessing and rendering scale differently, so they read separate CPU
fractions of the logical core count:
[performance]
cpu_limit_preprocess = 0.8 # throughput-bound; safe at high parallelism
cpu_limit_render = 0.15 # -filter_complex spawns swscaler threads per
# layer and OOMs at high core counts
`stage` is "preprocess" or "render". The legacy single `cpu_limit` key is the
fallback for either stage when its specific key is absent. A project.json
"performance" block (see set_active_project) overrides these per project. Each
value is a fraction of logical CPUs (0.8 = 80%); defaults to 1 thread when
nothing is configured, which is safe on memory-constrained machines.
"""
key = "cpu_limit_render" if stage == "render" else "cpu_limit_preprocess"
cpu_limit = _perf_get(key, "cpu_limit")
if cpu_limit is None: if cpu_limit is None:
return 1 return 1
try:
cpu_limit = float(cpu_limit)
except (TypeError, ValueError):
return 1
cpu_count = os.cpu_count() or 1 cpu_count = os.cpu_count() or 1
return max(1, int(cpu_count * cpu_limit)) return max(1, int(cpu_count * cpu_limit))
@@ -57,15 +125,13 @@ def get_render_chunk_size() -> Optional[int]:
When set, cmd_render splits the filter graph into chunks of this many slides When set, cmd_render splits the filter graph into chunks of this many slides
to avoid OOM from allocating filter buffers for the entire video at once. to avoid OOM from allocating filter buffers for the entire video at once.
A project.json "performance" block overrides ~/.gnommo.conf per project.
Example ~/.gnommo.conf: Example ~/.gnommo.conf:
[performance] [performance]
render_chunk_slides = 15 render_chunk_slides = 15
""" """
global _perf_config val = _perf_get("render_chunk_slides")
if _perf_config is None:
get_ffmpeg_thread_count() # populates _perf_config
val = _perf_config.get("render_chunk_slides")
if val is None: if val is None:
return None return None
try: try:
+585 -339
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -328,12 +328,23 @@ 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)
) )
cutout: Optional[ cutout: Optional[
str str
] = None # Name of cutout to place video in (from project.json cutouts) ] = None # Name of cutout to place video in (from project.json cutouts)
# CSS-like placement when the video's aspect ratio differs from the cutout:
# object_fit: "cover" (default) fills the cutout and crops the overflow
# (zoomed by `zoom`); "contain" shrinks the whole video to fit
# inside and pads the remainder transparently (no cropping).
# object_position: which edge to anchor to — "center" (default) | "top" |
# "bottom" | "left" | "right".
object_fit: str = "cover"
object_position: str = "center"
always_visible: bool = False # If True, video is always shown (like talking head) always_visible: bool = False # If True, video is always shown (like talking head)
is_shared: bool = False # If True, source_file is relative to shared_assets/ is_shared: bool = False # If True, source_file is relative to shared_assets/
pause_narration: float = ( pause_narration: float = (
@@ -421,6 +432,14 @@ class AudioEvent:
# position (seconds) into the source stream to begin at — the loop phase for # position (seconds) into the source stream to begin at — the loop phase for
# looping music, or a linear seek for one-shots. 0.0 = play from the start (v1). # looping music, or a linear seek for one-shots. 0.0 = play from the start (v1).
src_offset: float = 0.0 src_offset: float = 0.0
# Loop phase for the CROSSFADE loop path specifically. That stream is periodic
# with period (duration - overlap), not `duration`, so its seam-resume phase is
# `elapsed % (duration - overlap)` — a different modulus than src_offset. Only
# set for looping clips that define an overlap; 0.0 otherwise.
crossfade_offset: float = 0.0
# Explicit stop time (output timeline) from an [end:handle] marker; None means
# play to the render/window end (loop) or the clip's natural length (one-shot).
end_time: Optional[float] = None
@dataclass @dataclass
@@ -434,6 +453,22 @@ class VideoEvent:
cutout: "CutoutDefinition" cutout: "CutoutDefinition"
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
layer: str = "above" # "above" = on top of slides; "below" = behind slides layer: str = "above" # "above" = on top of slides; "below" = behind slides
# Resolved per-occurrence end policy (next_slide/next_video/end/loop/…). Kept so
# 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"
# Effective audio volume for THIS occurrence: an events.json override if present,
# else the videos.json default. The renderer reads this (not video_source.volume).
volume: float = 1.0
# Chunking v2 seam (see docs/chunking_v2.md): when a clip began before this # Chunking v2 seam (see docs/chunking_v2.md): when a clip began before this
# chunk's window, the render must seek into it so it resumes mid-clip instead of # chunk's window, the render must seek into it so it resumes mid-clip instead of
# restarting at the boundary. None = play from video_source.skip (the v1/default). # restarting at the boundary. None = play from video_source.skip (the v1/default).
+39
View File
@@ -118,3 +118,42 @@ def build_narration_schedule(
offset += eff offset += eff
return segments, merged return segments, merged
def slice_schedule(
schedule: list[NarrationSegment],
window_start: float,
window_end: float,
) -> list[NarrationSegment]:
"""Return the sub-schedule covering ``[window_start, window_end]`` of the
combined narration timeline for a partial (chunked) render.
Each kept segment's ``skip``/``take``/``offset`` is adjusted so it seeks within
its OWN file: segments entirely outside the window are dropped, the first/last
kept segments are trimmed to the window edges, and offsets are re-based so the
sliced narration starts at 0. ``input_seek_time`` therefore stays 0 in concat
mode instead of a combined-timeline offset being (wrongly) applied to every
segment file.
This is what makes chunking bulletproof: because each chunk seeks its first
segment to the true source sample, ``render(A:B) ++ render(B:C)`` lands on the
exact same narration as ``render(A:C)``. Slicing to the full ``[0, total]`` is a
no-op, so full renders are unaffected.
"""
import copy
out: list[NarrationSegment] = []
for seg in schedule:
seg_start = seg.offset
seg_end = seg.offset + seg.duration
keep_start = max(window_start, seg_start)
keep_end = min(window_end, seg_end)
if keep_end - keep_start <= 1e-6:
continue # segment lies entirely outside the window
new = copy.copy(seg)
new.skip = round(seg.skip + (keep_start - seg_start), 6)
new.take = round(keep_end - keep_start, 6)
new.duration = new.take
new.offset = round(keep_start - window_start, 6)
out.append(new)
return out
+47 -6
View File
@@ -58,17 +58,36 @@ def _resolve_case_insensitive(path: Path) -> Path:
# presentation fields materialized onto events.json and resolved per-event # presentation fields materialized onto events.json and resolved per-event
# (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({"cutout", "layer", "end_on", "take"}) _MARKER_OVERRIDE_KEYS = frozenset(
{"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", "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: `take` → 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 == "take": 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
@@ -96,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:
@@ -232,6 +252,23 @@ def load_citations(path: Path) -> list[Citation]:
] ]
def _lc_handle(value):
"""Lowercase a video handle (or list of handles) referenced in project.json.
Video handles are stored lowercased as videos.json keys (import lowercases them),
so project.json references outro, main_video, background must be normalised to
match; otherwise a mixed-case entry like "OutroVideo6" fails the case-sensitive
lookup against key "outrovideo6" and the render reports it "not found" even though
it's there. The actual file path comes from the entry's source_file, so its case
is untouched. None / non-strings pass through unchanged.
"""
if isinstance(value, str):
return value.lower()
if isinstance(value, list):
return [x.lower() if isinstance(x, str) else x for x in value]
return value
def parse_project_config(project_path: Path) -> ProjectConfig: def parse_project_config(project_path: Path) -> ProjectConfig:
"""Parse project.json into ProjectConfig.""" """Parse project.json into ProjectConfig."""
config_path = project_path / "project.json" config_path = project_path / "project.json"
@@ -325,18 +362,18 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
default_slide_type=data.get("defaultSlideType", "square"), default_slide_type=data.get("defaultSlideType", "square"),
cutouts=cutouts, cutouts=cutouts,
default_filters=default_filters, default_filters=default_filters,
background=data.get("background", ""), background=_lc_handle(data.get("background", "")),
background_video=data.get("background_video", ""), # Deprecated background_video=data.get("background_video", ""), # Deprecated
slides_path=data.get("slides", "slides.json"), slides_path=data.get("slides", "slides.json"),
videos_path=data.get("videos", "videos.json"), videos_path=data.get("videos", "videos.json"),
audio_path=data.get("audio", "audio.json"), audio_path=data.get("audio", "audio.json"),
transcript_path=data.get("transcript"), transcript_path=data.get("transcript"),
audio_source=data.get("audio_source"), audio_source=data.get("audio_source"),
main_video=data.get("main_video"), main_video=_lc_handle(data.get("main_video")),
process_cache=data.get("process_cache"), process_cache=data.get("process_cache"),
default_begin=float(data.get("default_begin", 0.0)), default_begin=float(data.get("default_begin", 0.0)),
default_end_trim=float(data.get("default_end_trim", 0.0)), default_end_trim=float(data.get("default_end_trim", 0.0)),
outro=data.get("outro", []), outro=_lc_handle(data.get("outro", [])),
description=data.get("description", ""), description=data.get("description", ""),
footer=data.get("footer", ""), footer=data.get("footer", ""),
output_video=data.get("output_video", ""), output_video=data.get("output_video", ""),
@@ -615,8 +652,11 @@ 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_position=video_data.get("object-position", "center"),
always_visible=video_data.get("always_visible", False), always_visible=video_data.get("always_visible", False),
is_shared=video_data.get("is_shared", False), is_shared=video_data.get("is_shared", False),
pause_narration=float(video_data.get("pause_narration", 0)), pause_narration=float(video_data.get("pause_narration", 0)),
@@ -931,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)),
+76 -3
View File
@@ -21,10 +21,10 @@ from typing import Union, Optional
def _tc() -> str: def _tc() -> str:
"""Return FFmpeg thread count string from ~/.gnommo.conf [performance] cpu_limit.""" """FFmpeg thread count for preprocessing (~/.gnommo.conf cpu_limit_preprocess)."""
from .cache import get_ffmpeg_thread_count from .cache import get_ffmpeg_thread_count
return str(get_ffmpeg_thread_count()) return str(get_ffmpeg_thread_count("preprocess"))
# Number of parallel workers for chunk processing # Number of parallel workers for chunk processing
@@ -769,6 +769,7 @@ def preprocess_video(
force: bool = False, force: bool = False,
custom_gnommo_scratch: Optional[Path] = None, custom_gnommo_scratch: Optional[Path] = None,
res: str = "full", res: str = "full",
shared_loudnorm_stats: Optional[dict] = None,
) -> Path: ) -> Path:
""" """
Apply preprocessing filters to a video source. Apply preprocessing filters to a video source.
@@ -921,6 +922,7 @@ def preprocess_video(
take=None, take=None,
use_audio_channels=channel, use_audio_channels=channel,
skip_loudnorm=video_source.defer_loudnorm, skip_loudnorm=video_source.defer_loudnorm,
shared_loudnorm_stats=shared_loudnorm_stats,
) )
current_input = step_output current_input = step_output
batch_num += 1 batch_num += 1
@@ -2292,6 +2294,62 @@ def apply_transcribe(
return output_path return output_path
def measure_loudnorm_stats(
input_path: Path,
config: dict[str, Any],
use_audio_channels: str = "both",
verbose: bool = False,
) -> Optional[dict]:
"""Run loudnorm's analysis (first) pass on one file and return its measured values.
Used to derive ONE shared loudness reference from a single narration take. Feeding
those measured values into `loudnorm ... linear=true` on EVERY take makes them all
receive the same gain, so per-segment loudnorm can't drift their levels apart (the
s1-9-louder-than-s10-39 bug). The channel mapping is applied so the measurement
matches the channel the render will actually use. Returns None on any failure the
caller then falls back to ordinary per-segment loudnorm.
"""
import json as _json
import re as _re
import subprocess
cfg = parse_audio_normalize_config(config)
pan = ""
if use_audio_channels == "left":
pan = "pan=stereo|c0=c0|c1=c0,"
elif use_audio_channels == "right":
pan = "pan=stereo|c0=c1|c1=c1,"
af = (
f"{pan}loudnorm=I={cfg.target_lufs:.1f}:LRA={cfg.target_lra:.1f}"
f":TP={cfg.target_tp:.1f}:print_format=json"
)
cmd = [
"ffmpeg", "-hide_banner", "-nostats",
"-i", str(input_path), "-af", af, "-f", "null", "-",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True)
except Exception:
return None
# loudnorm prints a JSON object near the end of stderr.
match = _re.search(r'\{[^{}]*"input_i"[^{}]*\}', proc.stderr, _re.DOTALL)
if not match:
return None
try:
data = _json.loads(match.group(0))
stats = {
"measured_I": data["input_i"],
"measured_TP": data["input_tp"],
"measured_LRA": data["input_lra"],
"measured_thresh": data["input_thresh"],
}
except Exception:
return None
if verbose:
print(f" Loudness reference: {stats}")
return stats
def apply_audio_normalize( def apply_audio_normalize(
input_path: Path, input_path: Path,
output_path: Path, output_path: Path,
@@ -2300,6 +2358,7 @@ def apply_audio_normalize(
take: float = None, take: float = None,
use_audio_channels: str = "both", use_audio_channels: str = "both",
skip_loudnorm: bool = False, skip_loudnorm: bool = False,
shared_loudnorm_stats: Optional[dict] = None,
) -> None: ) -> None:
""" """
Apply audio normalization: denoise, compress, and loudness normalize. Apply audio normalization: denoise, compress, and loudness normalize.
@@ -2439,11 +2498,25 @@ def apply_audio_normalize(
# 8. Loudness normalization (loudnorm - EBU R128) # 8. Loudness normalization (loudnorm - EBU R128)
# Skip if skip_loudnorm=True (for segments that will be concatenated) # Skip if skip_loudnorm=True (for segments that will be concatenated)
if cfg.normalize and not skip_loudnorm: if cfg.normalize and not skip_loudnorm:
audio_filters.append( loudnorm = (
f"loudnorm=I={cfg.target_lufs:.1f}" f"loudnorm=I={cfg.target_lufs:.1f}"
f":LRA={cfg.target_lra:.1f}" f":LRA={cfg.target_lra:.1f}"
f":TP={cfg.target_tp:.1f}" f":TP={cfg.target_tp:.1f}"
) )
# With a shared reference (from measure_loudnorm_stats on the first narration
# take), use linear mode so EVERY take gets the SAME gain — otherwise loudnorm's
# default dynamic pass re-measures each take independently and a pausier/quieter
# segment gets boosted louder than the next.
if shared_loudnorm_stats:
s = shared_loudnorm_stats
loudnorm += (
f":measured_I={s['measured_I']}"
f":measured_TP={s['measured_TP']}"
f":measured_LRA={s['measured_LRA']}"
f":measured_thresh={s['measured_thresh']}"
f":linear=true"
)
audio_filters.append(loudnorm)
if not audio_filters: if not audio_filters:
# No filters enabled, just copy # No filters enabled, just copy
+263 -61
View File
@@ -78,6 +78,7 @@ def _build_crossfade_loop_filter(
needed_duration: float, needed_duration: float,
volume: float, volume: float,
delay_ms: int, delay_ms: int,
start_offset: float = 0.0,
) -> list[str]: ) -> list[str]:
""" """
Build FFmpeg filter chain for crossfade looping. Build FFmpeg filter chain for crossfade looping.
@@ -85,6 +86,12 @@ def _build_crossfade_loop_filter(
Creates a seamless loop by overlapping copies of the audio with fade in/out. Creates a seamless loop by overlapping copies of the audio with fade in/out.
Each loop iteration crossfades with the next for `overlap` seconds. Each loop iteration crossfades with the next for `overlap` seconds.
The crossfaded stream is periodic with period ``loop_len = audio_duration -
overlap``. ``start_offset`` seeks into that continuous stream, so a chunk
that begins mid-loop (e.g. the second half of a partial/chunked render)
resumes at the correct loop phase instead of restarting from the top. This
is what keeps background music seamless across chunk seams.
Args: Args:
input_label: Input stream label (e.g., "[0:a]") input_label: Input stream label (e.g., "[0:a]")
output_label: Output stream label (e.g., "[aud0]") output_label: Output stream label (e.g., "[aud0]")
@@ -93,6 +100,7 @@ def _build_crossfade_loop_filter(
needed_duration: Total duration needed needed_duration: Total duration needed
volume: Volume multiplier volume: Volume multiplier
delay_ms: Initial delay in milliseconds delay_ms: Initial delay in milliseconds
start_offset: Phase (seconds) into the crossfade loop stream to start at
Returns: Returns:
List of filter strings to append to the filter_complex List of filter strings to append to the filter_complex
@@ -100,8 +108,12 @@ def _build_crossfade_loop_filter(
filters = [] filters = []
loop_len = audio_duration - overlap loop_len = audio_duration - overlap
# Build the crossfade stream from phase 0, long enough to cover the phase we
# seek past plus the duration we actually need, then trim [start_offset ...].
build_duration = start_offset + needed_duration
# Calculate number of loop iterations needed (add 1 extra for safety) # Calculate number of loop iterations needed (add 1 extra for safety)
n_loops = math.ceil(needed_duration / loop_len) + 1 n_loops = math.ceil(build_duration / loop_len) + 1
# Limit to reasonable number of loops to avoid filter complexity explosion # Limit to reasonable number of loops to avoid filter complexity explosion
n_loops = min(n_loops, 100) n_loops = min(n_loops, 100)
@@ -109,7 +121,7 @@ def _build_crossfade_loop_filter(
if n_loops <= 1: if n_loops <= 1:
# Single play, no looping needed # Single play, no looping needed
filters.append( filters.append(
f"{input_label}atrim=0:{needed_duration:.3f}," f"{input_label}atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
f"asetpts=PTS-STARTPTS," f"asetpts=PTS-STARTPTS,"
f"adelay={delay_ms}|{delay_ms}," f"adelay={delay_ms}|{delay_ms},"
f"volume={volume:.2f}{output_label}" f"volume={volume:.2f}{output_label}"
@@ -120,15 +132,16 @@ def _build_crossfade_loop_filter(
split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)] split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)]
filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}") filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}")
# Process each copy with appropriate delay and fades # Process each copy with appropriate delay and fades. Copies are laid out in
# loop time (no output delay yet); the output delay/phase-trim is applied
# once after mixing so the phase seek is straightforward.
mix_labels = [] mix_labels = []
for i in range(n_loops): for i in range(n_loops):
copy_label = split_labels[i] copy_label = split_labels[i]
out_label = f"[xfl_{output_label[1:-1]}_{i}]" out_label = f"[xfl_{output_label[1:-1]}_{i}]"
mix_labels.append(out_label) mix_labels.append(out_label)
loop_delay = i * loop_len loop_delay_ms = int(i * loop_len * 1000)
total_delay_ms = delay_ms + int(loop_delay * 1000)
# Build filter chain for this copy # Build filter chain for this copy
chain_parts = [] chain_parts = []
@@ -143,17 +156,20 @@ def _build_crossfade_loop_filter(
if fade_out_start > 0: if fade_out_start > 0:
chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}") chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}")
chain_parts.append(f"adelay={total_delay_ms}|{total_delay_ms}") if loop_delay_ms > 0:
chain_parts.append(f"volume={volume:.2f}") chain_parts.append(f"adelay={loop_delay_ms}|{loop_delay_ms}")
filter_chain = ",".join(chain_parts) filter_chain = ",".join(chain_parts)
filters.append(f"{copy_label}{filter_chain}{out_label}") filters.append(f"{copy_label}{filter_chain}{out_label}")
# Mix all copies together, then trim to needed duration # Mix all copies into the continuous loop stream, seek to the loop phase
# (start_offset), apply volume, then the output delay.
filters.append( filters.append(
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0," f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
f"atrim=0:{needed_duration + delay_ms/1000:.3f}," f"atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
f"asetpts=PTS-STARTPTS{output_label}" f"asetpts=PTS-STARTPTS,"
f"volume={volume:.2f},"
f"adelay={delay_ms}|{delay_ms}{output_label}"
) )
return filters return filters
@@ -262,6 +278,85 @@ def render(plan: RenderPlan, output_path: Path, verbose: bool = False, log=None)
) )
def _ci_resolve(path: Path) -> Path:
"""Case-insensitive fallback for a file path.
If `path` doesn't exist but a sibling with the same name in a different case
does, return that sibling. macOS's default filesystem is case-INsensitive, so a
`source_file` whose stored case drifted from the real file (e.g. "outrovideo2.mov"
vs "OutroVideo2.mov") still resolves locally but on the case-SENSITIVE Linux of
the render rig / WSL it would otherwise hard-fail with "not found". This makes the
two behave the same. Returns `path` unchanged if no match (caller handles absence).
"""
if path.exists():
return path
parent = path.parent
if not parent.is_dir():
return path
target = path.name.lower()
for entry in parent.iterdir():
if entry.name.lower() == target:
return entry
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,
@@ -310,6 +405,11 @@ def _resolve_video_path(
else: else:
resolved = source_path resolved = source_path
# Tolerate case drift between the stored source_file and the real file — a no-op
# on macOS (case-insensitive) but the difference between working and "not found"
# on the case-sensitive render rig / WSL.
resolved = _ci_resolve(resolved)
if not resolved.exists(): if not resolved.exists():
# File not found anywhere — substitute PlaceholderVideo so FFmpeg doesn't crash # File not found anywhere — substitute PlaceholderVideo so FFmpeg doesn't crash
placeholder = None placeholder = None
@@ -447,7 +547,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
# thread per core no matter what — the real cause of the render-stage memory blowup. # thread per core no matter what — the real cause of the render-stage memory blowup.
from .cache import get_ffmpeg_thread_count from .cache import get_ffmpeg_thread_count
_tc = str(get_ffmpeg_thread_count()) _tc = str(get_ffmpeg_thread_count("render"))
cmd.extend( cmd.extend(
["-threads", _tc, "-filter_threads", _tc, "-filter_complex_threads", _tc] ["-threads", _tc, "-filter_threads", _tc, "-filter_complex_threads", _tc]
) )
@@ -589,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.
@@ -611,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:
@@ -756,6 +864,38 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
return cmd return cmd
def _fit_filter(
w: int, h: int, zoom: float, object_fit: str = "cover", object_position: str = "center"
) -> str:
"""Scale+crop/pad chain that places a source into a w×h cutout, CSS-style.
object_fit "cover" (default): fill the cutout (scaled by `zoom`) and crop the
overflow object-fit: cover. "contain": shrink the whole video to fit inside and
pad the remainder transparently object-fit: contain (`zoom` is not applied,
since nothing is cropped). object_position anchors the crop (cover) or the padded
video (contain): center (default) | top | bottom | left | right.
With the defaults (cover/center) this is byte-identical to the long-standing
`scale=increase,crop=W:H:(iw-W)/2:(ih-H)/2` used everywhere, so callers that pass
defaults render exactly as before.
"""
pos = (object_position or "center").lower()
if (object_fit or "cover").lower() == "contain":
px = "0" if pos == "left" else (f"(ow-iw)" if pos == "right" else "(ow-iw)/2")
py = "0" if pos == "top" else (f"(oh-ih)" if pos == "bottom" else "(oh-ih)/2")
return (
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
f"pad={w}:{h}:{px}:{py}:color=0x00000000"
)
zw, zh = int(w * zoom), int(h * zoom)
cx = "0" if pos == "left" else (f"(iw-{w})" if pos == "right" else f"(iw-{w})/2")
cy = "0" if pos == "top" else (f"(ih-{h})" if pos == "bottom" else f"(ih-{h})/2")
return (
f"scale={zw}:{zh}:force_original_aspect_ratio=increase,"
f"crop={w}:{h}:{cx}:{cy}"
)
def _calculate_cutout_position( def _calculate_cutout_position(
cutout: CutoutDefinition, frame_width: int, frame_height: int cutout: CutoutDefinition, frame_width: int, frame_height: int
) -> tuple[int, int, int, int]: ) -> tuple[int, int, int, int]:
@@ -1089,22 +1229,19 @@ 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"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"format=rgba[{video_label}]" f"format=rgba[{video_label}]"
) )
@@ -1135,8 +1272,7 @@ def build_filter_complex(
filters.append( filters.append(
f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS," f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS,"
f"format=yuva444p10le," f"format=yuva444p10le,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"{_fit_filter(cut_width, cut_height, zoom)},"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"format=rgba[{video_label}]" f"format=rgba[{video_label}]"
) )
@@ -1169,8 +1305,7 @@ def build_filter_complex(
f"[{split_labels[seg_idx]}]trim={src_start:.3f}:{src_end:.3f}," f"[{split_labels[seg_idx]}]trim={src_start:.3f}:{src_end:.3f},"
f"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB," f"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB,"
f"format=yuva444p10le," f"format=yuva444p10le,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"{_fit_filter(cut_width, cut_height, zoom)},"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"format=rgba[{seg_label}]" f"format=rgba[{seg_label}]"
) )
@@ -1192,22 +1327,19 @@ 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"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"format=rgba[{video_label}]" f"format=rgba[{video_label}]"
) )
@@ -1249,22 +1381,19 @@ 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"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2"
f"[{video_label}]" f"[{video_label}]"
) )
@@ -1336,11 +1465,12 @@ def build_filter_complex(
# Scale and crop video # Scale and crop video
video_label = f"outro{i}" video_label = f"outro{i}"
start_pts = event.start_time start_pts = event.start_time
# OutroEvent carries no per-occurrence overrides, so read placement off
# its VideoSource (the videos.json defaults).
filters.append( filters.append(
f"[{video_idx}:v]format=yuva444p10le," f"[{video_idx}:v]format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB," f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"{_fit_filter(cut_width, cut_height, zoom, event.video_source.object_fit, event.video_source.object_position)},"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"format=rgba[{video_label}]" f"format=rgba[{video_label}]"
) )
@@ -1459,11 +1589,16 @@ def build_filter_complex(
for i, event in enumerate(plan.audio_events): for i, event in enumerate(plan.audio_events):
audio_idx = audio_inputs[event.audio_id] audio_idx = audio_inputs[event.audio_id]
volume = event.audio_def.volume volume = event.audio_def.volume
# An [end:handle] marker caps this clip's stop time; otherwise a loop
# fills to the render/window end and a one-shot plays its natural length.
_clip_end = getattr(event, "end_time", None)
if event.audio_def.loop: if event.audio_def.loop:
# Looping audio: loop source, then trim/segment # Looping audio: loop source, then trim/segment. Stop at the end
# Stop at narration end if there's an outro # marker if set, else at narration end / outro.
loop_end_time = audio_end_time loop_end_time = (
audio_end_time if _clip_end is None else min(audio_end_time, _clip_end)
)
remaining = loop_end_time - event.start_time remaining = loop_end_time - event.start_time
if plan.narration_pauses and not event.audio_def.ignore_pauses: if plan.narration_pauses and not event.audio_def.ignore_pauses:
@@ -1529,6 +1664,10 @@ def build_filter_complex(
needed_duration=remaining, needed_duration=remaining,
volume=volume, volume=volume,
delay_ms=delay_ms, delay_ms=delay_ms,
# Chunking v2: resume at the loop phase so background
# music continues across chunk seams instead of
# restarting from the top.
start_offset=getattr(event, "crossfade_offset", 0.0),
) )
filters.extend(crossfade_filters) filters.extend(crossfade_filters)
else: else:
@@ -1544,16 +1683,78 @@ def build_filter_complex(
) )
audio_labels_to_mix.append(f"[{label}]") audio_labels_to_mix.append(f"[{label}]")
else: else:
# One-shot audio: delay to trigger time. Chunking v2: seek in if # One-shot audio. Freeze it through narration pauses too (like the
# the clip began in an earlier chunk (docs/chunking_v2.md). # looping branch above): split the source at each pause and delay
# the remainder, so the clip resumes on the exact sample it stopped
# on when the cutscene ends — it is never restarted. A pause set on
# a cutscene video therefore silences background one-shots for its
# duration. `ignore_pauses` opts a clip out (e.g. a stinger meant to
# keep playing under the freeze). Chunking v2: src_offset seeks in
# when the clip began in an earlier chunk (docs/chunking_v2.md).
label = f"aud{i}" label = f"aud{i}"
delay_ms = int(event.start_time * 1000)
_off = getattr(event, "src_offset", 0.0) _off = getattr(event, "src_offset", 0.0)
_seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS," if _off > 0 else "" relevant_pauses = (
filters.append( []
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]" if event.audio_def.ignore_pauses
else [
p
for p in (plan.narration_pauses or [])
if p.output_time > event.start_time
]
) )
audio_labels_to_mix.append(f"[{label}]") if not relevant_pauses:
delay_ms = int(event.start_time * 1000)
if _clip_end is not None:
# [end:handle] → play only up to the stop time, then trim.
_dur = max(0.0, _clip_end - event.start_time)
_seek = f"atrim={_off:.3f}:{_off + _dur:.3f},asetpts=PTS-STARTPTS,"
elif _off > 0:
_seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS,"
else:
_seek = ""
filters.append(
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
)
audio_labels_to_mix.append(f"[{label}]")
else:
# Play [seg_start, pause) of source, freeze during the pause,
# then resume — source position (src_pos) never advances across
# the gap. Final segment runs to the [end:handle] stop (if set),
# otherwise the source's natural end.
_end = _clip_end if _clip_end is not None else float("inf")
src_pos = _off
seg_start = event.start_time
seg_count = 0
for pause in relevant_pauses:
if pause.output_time >= _end:
break
if pause.output_time > seg_start:
seg_dur = pause.output_time - seg_start
seg_label = f"{label}_seg{seg_count}"
d_ms = int(seg_start * 1000)
filters.append(
f"[{audio_idx}:a]atrim={src_pos:.3f}:{src_pos + seg_dur:.3f},"
f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms},"
f"volume={volume:.2f}[{seg_label}]"
)
audio_labels_to_mix.append(f"[{seg_label}]")
src_pos += seg_dur
seg_count += 1
seg_start = pause.output_time + pause.duration
if seg_start < _end:
seg_label = f"{label}_seg{seg_count}"
d_ms = int(seg_start * 1000)
_atrim = (
f"atrim={src_pos:.3f}:{src_pos + (_end - seg_start):.3f}"
if _clip_end is not None
else f"atrim={src_pos:.3f}"
)
filters.append(
f"[{audio_idx}:a]{_atrim},"
f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms},"
f"volume={volume:.2f}[{seg_label}]"
)
audio_labels_to_mix.append(f"[{seg_label}]")
# Extract and mix audio from triggered video events # Extract and mix audio from triggered video events
_have_audio = video_events_with_audio or set() _have_audio = video_events_with_audio or set()
@@ -1568,7 +1769,8 @@ def build_filter_complex(
delay_ms = int(event.start_time * 1000) delay_ms = int(event.start_time * 1000)
label = f"tvaud{i}" label = f"tvaud{i}"
vol = event.video_source.volume # event.volume = events.json override if set, else the videos.json default.
vol = event.volume
vol_filter = f",volume={vol:.2f}" if vol != 1.0 else "" vol_filter = f",volume={vol:.2f}" if vol != 1.0 else ""
filters.append( filters.append(
f"[{video_idx}:a]atrim=0:{duration:.3f}," f"[{video_idx}:a]atrim=0:{duration:.3f},"
+112 -5
View File
@@ -30,9 +30,13 @@ from typing import Optional
from .models import CAMERA_PRESETS from .models import CAMERA_PRESETS
from .transformer import MarkerTiming, resolve_video_presentation from .transformer import MarkerTiming, resolve_video_presentation
# Per-occurrence presentation fields materialized onto video events (atomic events.json, # Per-occurrence presentation fields ALWAYS materialized onto video events (atomic
# GUI-ready). Round-tripped as overrides so a stored value drives render over the default. # events.json, GUI-ready). Round-tripped as overrides so a stored value drives render.
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take") _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.
_EVENT_OVERRIDE_KEYS = _PRESENTATION_KEYS + ("volume",)
EVENTS_FILE = "events.json" EVENTS_FILE = "events.json"
SCAFFOLD_FILE = "scaffold.json" SCAFFOLD_FILE = "scaffold.json"
@@ -90,6 +94,8 @@ def marker_type(marker_id: str, slides: dict, videos: dict, audio: dict) -> str:
return "audio" return "audio"
if _ci_contains(CAMERA_PRESETS, marker_id): if _ci_contains(CAMERA_PRESETS, marker_id):
return "camera" return "camera"
if marker_id.startswith("end:"):
return "end"
return "other" return "other"
@@ -175,7 +181,7 @@ def derive_events(
vs, vs,
t.overrides, t.overrides,
default_end_on=( default_end_on=(
None if t.marker_id.startswith("narration:") else "next_video" None if t.marker_id.startswith("narration:") else "next_slide"
), ),
) )
e["handle"] = pres["handle"] e["handle"] = pres["handle"]
@@ -184,6 +190,24 @@ 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
# videos.json default keeps flowing until someone pins it here.
if t.overrides and "volume" in t.overrides:
e["volume"] = pres["volume"]
# object-fit/position are sparse too: materialize only when non-default
# so plain center-cover videos keep a clean events.json.
if pres["object_fit"] != "cover":
e["object-fit"] = pres["object_fit"]
if pres["object_position"] != "center":
e["object-position"] = pres["object_position"]
pd = _pause_duration(t.marker_id, videos) pd = _pause_duration(t.marker_id, videos)
if pd: if pd:
e["pause_duration"] = pd e["pause_duration"] = pd
@@ -193,6 +217,85 @@ def derive_events(
return events return events
def derive_narration_events(
narration_schedule: list,
narration_videos: list,
pauses: list,
total_duration: float,
) -> list[dict]:
"""Representation-only events mirroring the always-visible talking-head track.
One event per narration segment (or a single event for legacy single-file
narration), shaped like a `video` event handle, cutout, layer, source_file,
skip/take, timing so a GUI can render and lay out every track uniformly
instead of treating the narration backbone as invisible.
These are NOT read back into the render: events_to_marker_timings skips
`type == "narration"`, because narration timing is owned by the aligner and the
schedule (it is the clock, and it is multi-file). So this is a read-only mirror,
added purely for the editing surface it never changes what renders.
"""
# Cutout/layer come from the resolved narration video source (defaults match the
# talking-head convention used by import/transformer).
cutout = "talkinghead"
layer = "below"
if narration_videos:
_vs = narration_videos[0][1]
cutout = getattr(_vs, "cutout", None) or cutout
layer = getattr(_vs, "layer", None) or layer
pause_list = [
(float(p.narration_time), float(p.duration)) for p in (pauses or [])
]
def _final(offset: float) -> float:
# Same shift compute_final_times applies: push forward by every pause at or
# before this point on the narration timeline.
return round(offset + sum(d for pn, d in pause_list if pn <= offset), 3)
def _base(seg_id, source_name, offset, duration, skip, take):
return {
"type": "narration",
"id": seg_id,
"narration_time": round(offset, 3),
"adjustment": 0.0,
"final_time": _final(offset),
"mapping": MAPPING_EXACT,
"confidence": 1.0,
"context": "(talking-head narration)",
"handle": seg_id,
"source_file": source_name,
"cutout": cutout,
"layer": layer,
"always_visible": True,
"end_on": "next_video",
"skip": round(skip or 0.0, 3),
"take": (round(take, 3) if take is not None else None),
"duration": round(duration, 3),
}
events: list[dict] = []
if narration_schedule:
for seg in narration_schedule:
src = getattr(seg.source_path, "name", None) or str(seg.source_path)
events.append(
_base(seg.seg_id, src, seg.offset, seg.duration, seg.skip, seg.take)
)
elif narration_videos:
_id, _vs, _ = narration_videos[0]
events.append(
_base(
_id,
getattr(_vs, "source_file", "") or _id,
0.0,
total_duration,
getattr(_vs, "skip", 0.0),
getattr(_vs, "take", None),
)
)
return events
def _interpolate_narration(events: list[dict]) -> None: def _interpolate_narration(events: list[dict]) -> None:
"""Fill `narration_time: None` entries by linear interpolation between placed """Fill `narration_time: None` entries by linear interpolation between placed
neighbours. Head/tail runs spread at +1s steps from the nearest known time (or neighbours. Head/tail runs spread at +1s steps from the nearest known time (or
@@ -323,11 +426,15 @@ def events_to_marker_timings(events: list[dict]) -> list[MarkerTiming]:
""" """
timings: list[MarkerTiming] = [] timings: list[MarkerTiming] = []
for e in events: for e in events:
# Representation-only base-track events (derive_narration_events) are a
# read-only mirror of the narration backbone — never fed back as markers.
if e.get("type") == "narration":
continue
n = e.get("narration_time") n = e.get("narration_time")
eff = (n + e.get("adjustment", 0.0)) if n is not None else -1.0 eff = (n + e.get("adjustment", 0.0)) if n is not None else -1.0
# Carry any stored presentation as overrides so render honors the atomic event # Carry any stored presentation as overrides so render honors the atomic event
# (e.g. a GUI edit) over the shorthand/videos.json default. Absent on old events. # (e.g. a GUI edit) over the shorthand/videos.json default. Absent on old events.
overrides = {k: e[k] for k in _PRESENTATION_KEYS if k in e} or None overrides = {k: e[k] for k in _EVENT_OVERRIDE_KEYS if k in e} or None
timings.append( timings.append(
MarkerTiming( MarkerTiming(
marker_id=e["id"], marker_id=e["id"],
+19 -3
View File
@@ -18,6 +18,15 @@ Design:
Sync model: move everything, exclude a small denylist. The exclusions are large Sync model: move everything, exclude a small denylist. The exclusions are large
derived artifacts each side regenerates or ships on its own (rendered output, derived artifacts each side regenerates or ships on its own (rendered output,
preprocessed segments, downscales, chunk scratch), so they never travel over SSH. preprocessed segments, downscales, chunk scratch), so they never travel over SSH.
Master = the machine's local tree. The PROJECT sync runs with rsync --delete, so a
file deleted locally and then `up`'d is removed on the server, and `down` mirrors the
server onto the local tree (deletions included). The Mac is the intended master: it
`up`s, the rig `down`s. Excluded paths are PROTECTED from --delete (rsync does not
touch excluded files on the receiver), so rig-only artifacts above all the big
`*_processed.*` preprocess outputs are never deleted by a mirror from the Mac.
Shared assets are NOT --deleted (a cross-project library; blind deletion could wipe
pexels downloaded on the other side), so that pass stays purely additive.
""" """
import json import json
@@ -52,6 +61,7 @@ _SYNC_EXCLUDES = [
"media/narration/proxy/", "media/narration/proxy/",
"media/videos/proxy/", "media/videos/proxy/",
"**/chunks/", "**/chunks/",
"*_processed.*", # preprocess outputs — rig-only, never transfer OR --delete them
"*.tmp", "*.tmp",
".*", # rsync in-progress temp files (.filename.XXXXXX) and .DS_Store ".*", # rsync in-progress temp files (.filename.XXXXXX) and .DS_Store
] ]
@@ -147,9 +157,11 @@ def cmd_up(project_path: Path, verbose: bool, dry_run: bool) -> int:
shared_root = _find_shared_assets_root(project_path) shared_root = _find_shared_assets_root(project_path)
remote_shared = f"{server['path']}/shared_assets" remote_shared = f"{server['path']}/shared_assets"
# Pass 1: project files — whole tree, denylist excludes. # Pass 1: project files — whole tree, denylist excludes. --delete mirrors the local
# (master) tree onto the server: files deleted locally are removed there too.
# Excluded paths (processed outputs, out/, chunks, …) are protected from deletion.
rsync_cmd = [ rsync_cmd = [
"rsync", "-av", "--progress", "rsync", "-av", "--progress", "--delete",
"-e", f"ssh -p {server['port']}", "-e", f"ssh -p {server['port']}",
*[f"--exclude={p}" for p in _SYNC_EXCLUDES], *[f"--exclude={p}" for p in _SYNC_EXCLUDES],
f"{project_path}/", f"{project_path}/",
@@ -228,8 +240,12 @@ def cmd_down(project_path: Path, verbose: bool, dry_run: bool) -> int:
project_path.mkdir(parents=True, exist_ok=True) project_path.mkdir(parents=True, exist_ok=True)
# --delete mirrors the server (which the master `up`'d) onto this local tree:
# files gone from the server are removed here too. Excluded paths — above all the
# rig-only *_processed.* outputs — are protected, so a `down` on the rig never
# deletes its own preprocess artifacts.
rsync_cmd = [ rsync_cmd = [
"rsync", "-av", "--progress", "rsync", "-av", "--progress", "--delete",
"-e", f"ssh -p {server['port']}", "-e", f"ssh -p {server['port']}",
*[f"--exclude={p}" for p in _SYNC_EXCLUDES], *[f"--exclude={p}" for p in _SYNC_EXCLUDES],
f"{server['user']}@{server['host']}:{remote_project}/", f"{server['user']}@{server['host']}:{remote_project}/",
+157 -26
View File
@@ -63,7 +63,7 @@ def resolve_video_presentation(
marker_id: str, marker_id: str,
video_source, video_source,
overrides: Optional[dict] = None, overrides: Optional[dict] = None,
default_end_on: Optional[str] = "next_video", default_end_on: Optional[str] = "next_slide",
) -> dict: ) -> dict:
"""Resolve a video marker's per-occurrence presentation to an atomic dict. """Resolve a video marker's per-occurrence presentation to an atomic dict.
@@ -74,8 +74,9 @@ def resolve_video_presentation(
videos.json collision. videos.json collision.
Precedence per field: explicit event override > shorthand prefix > videos.json Precedence per field: explicit event override > shorthand prefix > videos.json
default > built-in default. `default_end_on` is "next_video" for video triggers and default > built-in default. `default_end_on` is "next_slide" for video triggers
None for [narration:] (which runs to the end). (so an untagged clip can't overstay and obscure later content) and None for
[narration:] (which runs to the end).
Returns {handle, cutout, layer, end_on, take, pause_narration}. Returns {handle, cutout, layer, end_on, take, pause_narration}.
""" """
@@ -94,9 +95,20 @@ 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
) )
# Volume defaults to the videos.json value; an events.json/inline override wins.
volume = overrides["volume"] if "volume" in overrides else video_source.volume
# CSS-like cutout placement (hyphenated keys mirror CSS; inline/events override
# the videos.json default, which defaults to cover/center).
object_fit = overrides.get("object-fit") or video_source.object_fit or "cover"
object_position = (
overrides.get("object-position") or video_source.object_position or "center"
)
return { return {
"handle": handle, "handle": handle,
@@ -104,7 +116,12 @@ 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),
"object_fit": object_fit,
"object_position": object_position,
} }
@@ -243,6 +260,11 @@ def _is_known_marker(
if audio_id in audio: if audio_id in audio:
return True return True
# Explicit end markers: [end:handle] stops a video started with end_on=end_marker.
# Known so it aligns to its spoken position (and isn't stripped as filler).
if marker_id.startswith("end:"):
return True
return False return False
@@ -948,6 +970,8 @@ def build_render_plan(
event.end_time -= time_offset event.end_time -= time_offset
for event in audio_events: for event in audio_events:
event.start_time = max(0, event.start_time - time_offset) event.start_time = max(0, event.start_time - time_offset)
if event.end_time is not None:
event.end_time = max(0.0, event.end_time - time_offset)
for event in camera_events: for event in camera_events:
event.time -= time_offset event.time -= time_offset
@@ -991,14 +1015,27 @@ def build_render_plan(
if vid_event is event: if vid_event is event:
# Don't shift the pause event by its own pause # Don't shift the pause event by its own pause
continue continue
# A clip whose window STRADDLES the freeze (starts before, ends after)
# would otherwise be stretched across the whole cutscene. Its overlay
# source isn't freeze-spliced like the narration is, so it'd keep
# advancing under the cutscene and surface the wrong frame in the sliver
# between the cutscene ending and its own (shifted) end — the video7
# "appears at the start, vanishes when it should show" artifact. Instead
# end it right at the freeze onset: it plays its pre-pause content, then
# the cutscene cleanly takes over.
straddles = vid_event.start_time < narration_time < vid_event.end_time
if vid_event.start_time >= narration_time: if vid_event.start_time >= narration_time:
vid_event.start_time += pause_duration vid_event.start_time += pause_duration
if vid_event.end_time > narration_time: if straddles:
vid_event.end_time = narration_time
elif vid_event.end_time > narration_time:
vid_event.end_time += pause_duration vid_event.end_time += pause_duration
for aud_event in audio_events: for aud_event in audio_events:
if aud_event.start_time > narration_time: if aud_event.start_time > narration_time:
aud_event.start_time += pause_duration aud_event.start_time += pause_duration
if aud_event.end_time is not None and aud_event.end_time > narration_time:
aud_event.end_time += pause_duration
for cam_event in camera_events: for cam_event in camera_events:
if cam_event.time > narration_time: if cam_event.time > narration_time:
@@ -1050,6 +1087,23 @@ def build_render_plan(
slides_json_path = project_path / config.slides_path.lower() slides_json_path = project_path / config.slides_path.lower()
slides_dir = slides_json_path.parent slides_dir = slides_json_path.parent
# Concat-narration partial render: slice the schedule to the render window so
# each segment seeks within its OWN file (not by the combined-timeline offset,
# which over-seeks every file past the first). The offset is baked into each
# segment's skip, so narration input_seek_time stays 0. This runs on the shared
# slide_range path, so _chunked_render's per-chunk cmd_render and a hand-typed
# --slides use identical logic → render(A:B)++render(B:C) == render(A:C).
# Single-file narration keeps input_seek_time = time_offset (seeks that one file).
if narration_schedule:
from .narration import slice_schedule
narration_schedule = slice_schedule(
narration_schedule, time_offset, render_end_time
)
narration_input_seek = 0.0
else:
narration_input_seek = time_offset
plan = RenderPlan( plan = RenderPlan(
project_path=project_path, project_path=project_path,
config=config, config=config,
@@ -1067,7 +1121,7 @@ def build_render_plan(
camera_events=camera_events, camera_events=camera_events,
time_offset=time_offset, time_offset=time_offset,
initial_camera_state=initial_camera_state, initial_camera_state=initial_camera_state,
input_seek_time=time_offset, input_seek_time=narration_input_seek,
shared_assets_dir=shared_assets_dir, shared_assets_dir=shared_assets_dir,
narration_pauses=narration_pauses, narration_pauses=narration_pauses,
narration_segments=narration_schedule or [], narration_segments=narration_schedule or [],
@@ -1349,6 +1403,16 @@ def _extract_video_events(
# a clip when the next video begins, so videos never overlap. # a clip when the next video begins, so videos never overlap.
video_start_times = sorted(t for t, *_ in video_markers) video_start_times = sorted(t for t, *_ in video_markers)
# [end:handle] control markers: explicit end points for videos started with
# end_on=end_marker. Collected as {handle: sorted[timestamps]}. They are not a
# video prefix, so they never become video events themselves.
end_markers: dict[str, list[float]] = {}
for timing in marker_timings:
if timing.timestamp is not None and timing.timestamp >= 0 and timing.marker_id.startswith("end:"):
end_markers.setdefault(timing.marker_id[4:].lower(), []).append(timing.timestamp)
for _h in end_markers:
end_markers[_h].sort()
events: list[VideoEvent] = [] events: list[VideoEvent] = []
for start_time, marker_id, video_id, trigger_type, overrides in video_markers: for start_time, marker_id, video_id, trigger_type, overrides in video_markers:
video_source = videos[video_id] video_source = videos[video_id]
@@ -1360,14 +1424,19 @@ def _extract_video_events(
marker_id, marker_id,
video_source, video_source,
overrides, overrides,
default_end_on=(None if trigger_type == "narration" else "next_video"), default_end_on=(None if trigger_type == "narration" else "next_slide"),
) )
cutout_name = pres["cutout"] cutout_name = pres["cutout"]
cutout = cutouts[cutout_name] cutout = cutouts[cutout_name]
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"]
object_fit = pres["object_fit"]
object_position = pres["object_position"]
if end_on == "take" and take is not None: if end_on == "take" and take is not None:
end_time = start_time + take end_time = start_time + take
@@ -1392,9 +1461,12 @@ def _extract_video_events(
if vt > start_time: if vt > start_time:
end_time = vt end_time = vt
break break
# A pause-narration video must stay for at least the pause it holds. # A pause-narration cutscene fills EXACTLY the freeze it creates (its
# content length == pause_narration), so it ends when the freeze ends —
# not stretched to the next video, which would keep it overlaying the
# resumed narration afterwards.
if pause_narration: if pause_narration:
end_time = max(end_time, start_time + pause_narration) end_time = start_time + pause_narration
elif end_on in ("next_slide", "slide"): elif end_on in ("next_slide", "slide"):
# End at next slide marker ("slide" is a recognised alias for "next_slide") # End at next slide marker ("slide" is a recognised alias for "next_slide")
end_time = total_duration end_time = total_duration
@@ -1402,10 +1474,29 @@ def _extract_video_events(
if slide_time > start_time: if slide_time > start_time:
end_time = slide_time end_time = slide_time
break break
# pause_narration videos must stay visible for the full pause duration — # pause_narration cutscene: end exactly with the freeze (see above).
# the narration is held for that long, so the overlay should match.
if pause_narration: if pause_narration:
end_time = max(end_time, start_time + pause_narration) end_time = start_time + pause_narration
elif end_on == "end_marker":
# Explicit end: stop at the first [end:handle] placed after this clip
# starts (so the same handle can be reused in different sections).
ends = [t for t in end_markers.get(video_id, ()) if t > start_time]
if ends:
end_time = ends[0]
else:
# No matching [end:handle] — fall back to next_video and warn rather
# than silently running to the end of the render.
end_time = total_duration
for vt in video_start_times:
if vt > start_time:
end_time = vt
break
warnings.append(
f"[{marker_id}] end_on=end_marker but no [end:{video_id}] found "
f"after it — ending at the next video instead."
)
if pause_narration:
end_time = start_time + pause_narration
else: else:
# end_on None ([narration:] with no explicit end) — runs to end. # end_on None ([narration:] with no explicit end) — runs to end.
end_time = total_duration end_time = total_duration
@@ -1415,21 +1506,32 @@ def _extract_video_events(
# it), so a clip spanning a chunk boundary survives into the later chunk. # it), so a clip spanning a chunk boundary survives into the later chunk.
if end_time <= range_start or start_time >= range_end: if end_time <= range_start or start_time >= range_end:
continue continue
# A clip that began before this window is already mid-playback at the seam;
# seek into it so it resumes at the right frame instead of restarting.
skip_override = None skip_override = None
if start_time < range_start: if pause_narration:
into = range_start - start_time # elapsed since the clip started # A pause-narration CUTSCENE must render WHOLE in the single chunk where it
base = video_source.skip or 0.0 # starts. Its end = start + pause_narration deliberately overshoots the
playable = (video_source.duration - base) if video_source.duration else None # pre-pause timeline (the 18s freeze doesn't exist yet at extraction), so
if playable and playable > 0 and into >= playable: # clamping it to this chunk's pre-pause range_end would truncate the freeze,
# the clip has looped by the window start → resume at the loop phase # AND it would otherwise ALSO be pulled into the next chunk as a "spanning"
skip_override = base + (into % playable) # clip — duplicating the freeze (the video6 "two 18s pauses / logo shows one
else: # frame" bug). Own it only where it starts; never seek or clamp it.
# still within the first play-through (or unknown length) → linear seek if start_time < range_start:
skip_override = base + into continue # earlier chunk owns this cutscene
start_time = range_start # -> 0 after time_offset subtraction else:
end_time = min(end_time, range_end) # A clip that began before this window is already mid-playback at the seam;
# seek into it so it resumes at the right frame instead of restarting.
if start_time < range_start:
into = range_start - start_time # elapsed since the clip started
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
skip_override = base + (into % playable)
else:
# still within the first play-through (or unknown length) → linear seek
skip_override = base + into
start_time = range_start # -> 0 after time_offset subtraction
end_time = min(end_time, range_end)
events.append( events.append(
VideoEvent( VideoEvent(
@@ -1440,6 +1542,13 @@ def _extract_video_events(
cutout=cutout, cutout=cutout,
cutout_name=cutout_name, cutout_name=cutout_name,
layer=layer, layer=layer,
end_on=end_on or "",
skip=skip,
take=take,
loop=loop,
volume=volume,
object_fit=object_fit,
object_position=object_position,
skip_override=skip_override, skip_override=skip_override,
) )
) )
@@ -1456,6 +1565,15 @@ def _extract_audio_events(
range_start, range_end = time_range if time_range else (0.0, float("inf")) range_start, range_end = time_range if time_range else (0.0, float("inf"))
events: list[AudioEvent] = [] events: list[AudioEvent] = []
# [end:handle] markers stop an audio clip early (parallel to the video end_marker,
# but audio opts in automatically — there is no per-clip end_on to set).
audio_end_markers: dict[str, list[float]] = {}
for timing in marker_timings:
if timing.timestamp is not None and timing.timestamp >= 0 and timing.marker_id.startswith("end:"):
audio_end_markers.setdefault(timing.marker_id[4:].lower(), []).append(timing.timestamp)
for _h in audio_end_markers:
audio_end_markers[_h].sort()
for timing in marker_timings: for timing in marker_timings:
if timing.timestamp < 0: if timing.timestamp < 0:
continue continue
@@ -1469,8 +1587,13 @@ def _extract_audio_events(
if audio_id is not None and audio_id in audio: if audio_id is not None and audio_id in audio:
adef = audio[audio_id] adef = audio[audio_id]
astart = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS) astart = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
# Explicit stop from the first [end:audio_id] placed after this clip starts.
_ends = [t for t in audio_end_markers.get(audio_id.lower(), ()) if t > timing.timestamp]
clip_end = _ends[0] if _ends else None
# Effective end of this clip on the output timeline. # Effective end of this clip on the output timeline.
if adef.loop: if clip_end is not None:
aend = clip_end
elif adef.loop:
aend = range_end # a loop fills to the window/render end aend = range_end # a loop fills to the window/render end
elif adef.duration is not None: elif adef.duration is not None:
aend = astart + adef.duration aend = astart + adef.duration
@@ -1483,10 +1606,16 @@ def _extract_audio_events(
if aend <= range_start or astart >= range_end: if aend <= range_start or astart >= range_end:
continue continue
src_offset = 0.0 src_offset = 0.0
crossfade_offset = 0.0
if astart < range_start: if astart < range_start:
into = range_start - astart into = range_start - astart
if adef.loop and adef.duration: if adef.loop and adef.duration:
src_offset = into % adef.duration src_offset = into % adef.duration
# The crossfade loop stream repeats every (duration - overlap),
# so it resumes at a different phase than the hard aloop path.
if adef.overlap:
loop_len = max(1e-6, adef.duration - adef.overlap)
crossfade_offset = into % loop_len
else: else:
src_offset = into src_offset = into
astart = range_start astart = range_start
@@ -1496,6 +1625,8 @@ def _extract_audio_events(
start_time=astart, start_time=astart,
audio_def=adef, audio_def=adef,
src_offset=src_offset, src_offset=src_offset,
crossfade_offset=crossfade_offset,
end_time=clip_end,
) )
) )
+69
View File
@@ -145,6 +145,19 @@ def validate_project(
if marker in ("pause", "stop"): if marker in ("pause", "stop"):
continue continue
# Explicit end markers: [end:handle] stops a video (end_on=end_marker) OR an
# audio clip. Valid if the handle is defined in either videos.json or audio.json.
if marker.startswith("end:"):
handle = marker[4:].lower()
if handle not in videos and handle not in (audio or {}):
warnings.append(
ValidationIssue(
f"[{marker}] ends a clip, but '{handle}' isn't defined in videos.json or audio.json.",
project_path / "manuscript.txt",
)
)
continue
# Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning # Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning
if ":" in marker: if ":" in marker:
warnings.append( warnings.append(
@@ -155,6 +168,13 @@ def validate_project(
) )
continue continue
# Only slide-shaped ids (S1, S54, …) are slide references. Other bare
# bracketed tokens are prose the author wrote, not markers — e.g. a vector
# "[1, 1, 1]" or "[2, 2, 2]" in the narration (the ", …" tail makes the regex
# read the leading number as a marker id). Don't flag those as missing slides.
if not (len(marker) > 1 and marker[0] in "Ss" and marker[1:].isdigit()):
continue
if marker not in slides: if marker not in slides:
issues.append( issues.append(
ValidationIssue( ValidationIssue(
@@ -350,6 +370,55 @@ def validate_project(
) )
) )
# Validate presentation override VALUES so a typo — [vsb:x, object-fit=covfer],
# end_on=nextslide, layer=beneath — fails HERE instead of silently mis-rendering
# (or crashing) at render time. Checked case-insensitively against the value sets
# the renderer/transformer accept. Sources: inline manuscript overrides and the
# project's own videos.json entries. Keep end_on in sync with _extract_video_events.
import re as _re
from .parser import parse_marker
_VALID_VALUES = {
"object-fit": {"cover", "contain"},
"object-position": {"center", "top", "bottom", "left", "right"},
"layer": {"above", "mid", "below"},
"end_on": {"end", "loop", "next_slide", "slide", "next_video",
"video", "take", "end_marker"},
}
def _check_value(where: str, key: str, value, src_path: Path) -> None:
allowed = _VALID_VALUES.get(key)
if allowed is not None and value is not None and str(value).lower() not in allowed:
issues.append(
ValidationIssue(
f"{where}: invalid {key}={value!r} — valid values: {sorted(allowed)}",
src_path,
)
)
# (a) inline manuscript overrides: [prefix:handle, key=value, …]
_mpath = project_path / "manuscript.txt"
if _mpath.exists():
_mtext = _mpath.read_text(encoding="utf-8")
for _raw in _re.findall(r"\[([A-Za-z0-9_:./\-]+(?:,[^\]\n]*)?)\]", _mtext):
_mid, _overrides = parse_marker(_raw)
for _k, _v in (_overrides or {}).items():
_check_value(f"[{_raw}]", _k, _v, _mpath)
# (b) project videos.json entries (JSON keys mirror CSS: object-fit/object-position)
_vjson = project_path / config.videos_path
if _vjson.exists():
try:
_raw_videos = _read_json(_vjson)
except Exception:
_raw_videos = {}
if isinstance(_raw_videos, dict):
for _vid, _entry in _raw_videos.items():
if isinstance(_entry, dict):
for _k in ("object-fit", "object-position", "end_on", "layer"):
if _k in _entry:
_check_value(f"videos.json[{_vid}]", _k, _entry[_k], _vjson)
# If any issues, raise ValidationError # If any issues, raise ValidationError
if issues: if issues:
raise ValidationError(issues) raise ValidationError(issues)
+11 -15
View File
@@ -8,15 +8,18 @@
./gnommo.sh -p video4 grade --stage key ./gnommo.sh -p video4 grade --stage key
./gnommo.sh -p video5 grade --stage key ./gnommo.sh -p video5 grade --stage key
./gnommo.sh -p video6 grade --stage key ./gnommo.sh -p video6 grade --stage key
./gnommo.sh -p video7 grade --stage key
./gnommo.sh -p video0 grade --pick key_5 ./gnommo.sh -p video0 grade --stage despill
./gnommo.sh -p video1 grade --pick key_5 ./gnommo.sh -p video1 grade --stage despill
./gnommo.sh -p video2 grade --pick key_5 ./gnommo.sh -p video2 grade --stage despill
./gnommo.sh -p video3 grade --pick key_5 ./gnommo.sh -p video3 grade --stage despill
./gnommo.sh -p video4 grade --pick key_5 ./gnommo.sh -p video4 grade --stage despill
./gnommo.sh -p video5 grade --pick key_5 ./gnommo.sh -p video5 grade --stage despill
./gnommo.sh -p video6 grade --pick key_5 ./gnommo.sh -p video6 grade --stage despill
./gnommo.sh -p video7 grade --stage despill
./gnommo.sh -p video0 grade --stage grade ./gnommo.sh -p video0 grade --stage grade
./gnommo.sh -p video1 grade --stage grade ./gnommo.sh -p video1 grade --stage grade
@@ -25,11 +28,4 @@
./gnommo.sh -p video4 grade --stage grade ./gnommo.sh -p video4 grade --stage grade
./gnommo.sh -p video5 grade --stage grade ./gnommo.sh -p video5 grade --stage grade
./gnommo.sh -p video6 grade --stage grade ./gnommo.sh -p video6 grade --stage grade
./gnommo.sh -p video7 grade --stage grade
./gnommo.sh -p video0 grade --pick grade_5
./gnommo.sh -p video1 grade --pick grade_5
./gnommo.sh -p video2 grade --pick grade_5
./gnommo.sh -p video3 grade --pick grade_5
./gnommo.sh -p video4 grade --pick grade_5
./gnommo.sh -p video5 grade --pick grade_5
./gnommo.sh -p video6 grade --pick grade_5
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
./gnommo.sh -p video0 handoff
./gnommo.sh -p video1 handoff
./gnommo.sh -p video2 handoff
./gnommo.sh -p video3 handoff
./gnommo.sh -p video4 handoff
./gnommo.sh -p video5 handoff
./gnommo.sh -p video6 handoff
./gnommo.sh -p video7 handoff
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
./gnommo.sh -p video0 import
./gnommo.sh -p video1 import
./gnommo.sh -p video2 import
./gnommo.sh -p video3 import
./gnommo.sh -p video4 import
./gnommo.sh -p video5 import
./gnommo.sh -p video6 import
./gnommo.sh -p video7 import
+16 -7
View File
@@ -1,10 +1,19 @@
#!/bin/sh #!/bin/sh
./gnommo.sh -p video0 render --force
./gnommo.sh -p video1 all ./gnommo.sh -p video1 render --force
./gnommo.sh -p video2 all ./gnommo.sh -p video2 render --force
./gnommo.sh -p video3 all ./gnommo.sh -p video3 render --force
./gnommo.sh -p video4 all ./gnommo.sh -p video4 render --force
./gnommo.sh -p video5 all ./gnommo.sh -p video5 render --force
./gnommo.sh -p video6 all ./gnommo.sh -p video6 render --force
./gnommo.sh -p video7 render --force
./gnommo.sh -p video0 handoff --prod
./gnommo.sh -p video1 handoff --prod
./gnommo.sh -p video2 handoff --prod
./gnommo.sh -p video3 handoff --prod
./gnommo.sh -p video4 handoff --prod
./gnommo.sh -p video5 handoff --prod
./gnommo.sh -p video6 handoff --prod
./gnommo.sh -p video7 handoff --prod
+56
View File
@@ -71,6 +71,27 @@ def test_audio():
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0) check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
def test_crossfade_phase():
print("crossfade loop phase:")
# Looping pad with a 15s crossfade overlap: the crossfade stream repeats every
# (duration - overlap) = 60 - 15 = 45s, so a chunk starting at into=300 resumes
# at crossfade phase 300 % 45 = 30, while the hard-loop phase is 300 % 60 = 60→0.
audio = {"pad": AudioDefinition(file="pad.wav", loop=True, duration=60.0, overlap=15.0)}
markers = [MarkerTiming(marker_id="Apad", timestamp=0.0, context="", confidence=1.0)]
evs = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=(300.0, 600.0))}
check("looping pad with overlap is included", "pad" in evs)
if "pad" in evs:
p = evs["pad"]
check("crossfade seeks to loop_len phase 30.0",
abs(p.crossfade_offset - 30.0) < 1e-6, f"crossfade_offset={p.crossfade_offset}")
check("src_offset still uses full-duration phase 0.0",
abs(p.src_offset - 0.0) < 1e-6, f"src_offset={p.src_offset}")
# Full render: no phase seek on either.
full = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=None)}
check("full render pad has no crossfade seek",
"pad" in full and full["pad"].crossfade_offset == 0.0)
# ── video ──────────────────────────────────────────────────────────────────── # ── video ────────────────────────────────────────────────────────────────────
def test_video(): def test_video():
print("video events:") print("video events:")
@@ -113,9 +134,44 @@ def test_video():
check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None) check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None)
def test_pause_cutscene_chunk_ownership():
# A pause_narration cutscene must live WHOLLY in the chunk where it starts: its
# end = start + pause_narration overshoots the pre-pause timeline, so it must not be
# truncated by range_end nor duplicated into the next chunk (the video6 double-freeze
# / one-frame-logo bug).
print("pause cutscene chunk ownership:")
cutouts = {"fullscreen": CutoutDefinition(x=0, y=0, height=1080, width=1920)}
videos = {
"logo": VideoSource(source_file="logo.mov", cutout="fullscreen", layer="above",
duration=18.0, pause_narration=18.0),
}
slides = {f"S{i}": SlideDefinition(image=f"S{i}.png", type="slide") for i in range(1, 6)}
markers = [MarkerTiming(marker_id=f"S{i}", timestamp=(i - 1) * 30.0, context="", confidence=1.0)
for i in range(1, 6)]
# Cutscene triggers at t=100 (between S4=90 and S5=120). Its end = 100+18 = 118.
markers.append(MarkerTiming(marker_id="vftp:logo", timestamp=100.0, context="", confidence=1.0))
total = 200.0
# Owning chunk [90, 110): starts inside it. End must NOT be clamped to 110 → full 18s.
evs, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(90.0, 110.0))
lg = next((e for e in evs if e.video_id == "logo"), None)
check("cutscene INCLUDED in the chunk it starts in", lg is not None)
if lg is not None:
check("cutscene end NOT clamped to range_end (full pause length)",
abs((lg.end_time - lg.start_time) - 18.0) < 1e-6,
f"duration={lg.end_time - lg.start_time}")
# Next chunk [110, 200): the cutscene started earlier → must be EXCLUDED (no dupe freeze).
evs2, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(110.0, 200.0))
check("cutscene EXCLUDED from the next chunk (no duplicate freeze)",
not any(e.video_id == "logo" for e in evs2))
if __name__ == "__main__": if __name__ == "__main__":
test_audio() test_audio()
test_crossfade_phase()
test_video() test_video()
test_pause_cutscene_chunk_ownership()
print() print()
if _fails: if _fails:
print(f"FAILED: {len(_fails)} check(s): {', '.join(_fails)}") print(f"FAILED: {len(_fails)} check(s): {', '.join(_fails)}")
+58
View File
@@ -0,0 +1,58 @@
"""[end:handle] explicit end markers: a video started with end_on=end_marker
stops at the first [end:handle] placed after it."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gnommo.transformer import _extract_video_events, MarkerTiming
from gnommo.models import VideoSource, CutoutDefinition
def check(name, cond):
print(f" {'PASS' if cond else 'FAIL'} {name}")
assert cond, name
VIDEOS = {"fart": VideoSource(source_file="fart.mp4", cutout="fullscreen", layer="below")}
CUTOUTS = {"fullscreen": CutoutDefinition(x=0, y=0, height=1080, width=1920)}
def mt(mid, t, ov=None):
return MarkerTiming(mid, t, "text", 1.0, ov)
# 1. [vfb:fart, end_on=end_marker] @10 ; [end:fart] @25 → ends at 25
events, warns = _extract_video_events(
[mt("vfb:fart", 10.0, {"end_on": "end_marker"}), mt("end:fart", 25.0)],
VIDEOS, CUTOUTS, {}, 100.0,
)
check("[end:fart] is not itself a video event", len(events) == 1)
check("video starts at 10.0", abs(events[0].start_time - 10.0) < 1e-6)
check("video ends at the [end:fart] marker (25.0)", abs(events[0].end_time - 25.0) < 1e-6)
check("no warnings", not warns)
# 2. earliest [end:fart] AFTER the start wins; an earlier one is ignored (reuse handle)
events2, _ = _extract_video_events(
[
mt("end:fart", 5.0), # before start → ignored
mt("vfb:fart", 10.0, {"end_on": "end_marker"}),
mt("end:fart", 20.0), # first after start
mt("end:fart", 40.0),
],
VIDEOS, CUTOUTS, {}, 100.0,
)
check("uses first end marker after start (20.0)", abs(events2[0].end_time - 20.0) < 1e-6)
# 3. fallback: end_on=end_marker but no [end:fart] → next video + warning
videos3 = {**VIDEOS, "other": VideoSource(source_file="o.mp4", cutout="square")}
cutouts3 = {**CUTOUTS, "square": CutoutDefinition(x=0, y=0, height=864, width=864)}
events3, warns3 = _extract_video_events(
[mt("vfb:fart", 10.0, {"end_on": "end_marker"}), mt("vst:other", 30.0)],
videos3, cutouts3, {}, 100.0,
)
fart_ev = next(e for e in events3 if e.video_id == "fart")
check("fallback ends at next video (30.0)", abs(fart_ev.end_time - 30.0) < 1e-6)
check("warns about the missing [end:fart]", any("end_on=end_marker" in w for w in warns3))
print("\nAll [end:handle] tests passed.")
+42
View File
@@ -0,0 +1,42 @@
"""CSS-like cutout placement: object-fit (cover/contain) + object-position."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gnommo.renderer import _fit_filter
def check(name, cond):
print(f" {'PASS' if cond else 'FAIL'} {name}")
assert cond, name
# Defaults reproduce the long-standing cover+center string exactly (no render churn).
check(
"cover/center == legacy scale+crop",
_fit_filter(864, 864, 1.0, "cover", "center")
== "scale=864:864:force_original_aspect_ratio=increase,crop=864:864:(iw-864)/2:(ih-864)/2",
)
check(
"cover applies zoom",
_fit_filter(864, 864, 1.5, "cover", "center").startswith("scale=1296:1296:"),
)
# cover anchors the crop by position.
check("cover/top crops from bottom (y=0)", ":(iw-864)/2:0" in _fit_filter(864, 864, 1.0, "cover", "top"))
check("cover/bottom (y=ih-H)", ":(iw-864)/2:(ih-864)" in _fit_filter(864, 864, 1.0, "cover", "bottom"))
check("cover/left (x=0)", "crop=864:864:0:(ih-864)/2" in _fit_filter(864, 864, 1.0, "cover", "left"))
check("cover/right (x=iw-W)", "crop=864:864:(iw-864):(ih-864)/2" in _fit_filter(864, 864, 1.0, "cover", "right"))
# contain shrinks to fit and pads; position places the padded video.
check(
"contain/top fits inside, pads to top",
_fit_filter(864, 864, 1.0, "contain", "top")
== "scale=864:864:force_original_aspect_ratio=decrease,pad=864:864:(ow-iw)/2:0:color=0x00000000",
)
check("contain ignores zoom", "scale=864:864:" in _fit_filter(864, 864, 2.0, "contain", "center"))
check("contain/bottom pads to bottom", ":(ow-iw)/2:(oh-ih):" in _fit_filter(864, 864, 1.0, "contain", "bottom"))
check("contain/left pads to left", "pad=864:864:0:(oh-ih)/2" in _fit_filter(864, 864, 1.0, "contain", "left"))
print("\nAll object-fit/object-position tests passed.")
+84
View File
@@ -0,0 +1,84 @@
"""Invariant tests for narration schedule slicing (chunked-render correctness).
The property that must hold: render(A:C) uses the same per-segment source samples
as render(A:B) ++ render(B:C). Since the render seeks each narration segment by its
(sliced) skip, that reduces to: slice([A,C]) covers the same source spans as
slice([A,B]) followed by slice([B,C]).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gnommo.narration import NarrationSegment, slice_schedule
def seg(sid, skip, dur, offset):
return NarrationSegment(
seg_id=sid, source_path=Path(f"{sid}.mov"),
skip=skip, take=dur, duration=dur, offset=offset,
)
# Combined timeline:
# s1: source skip 5, plays 100 -> combined [0,100], source [5,105]
# s2: source skip 10, plays 200 -> combined [100,300], source [10,210]
# s3: source skip 2, plays 50 -> combined [300,350], source [2,52]
SCHED = [seg("s1", 5, 100, 0), seg("s2", 10, 200, 100), seg("s3", 2, 50, 300)]
def _spans(segs):
return [(s.seg_id, round(s.skip, 6), round(s.skip + s.take, 6)) for s in segs]
def _merge(spans):
"""Fuse adjacent spans of the same segment (the boundary segment split across
two chunks) so a chunked coverage can be compared to a single-window one."""
out = []
for sid, a, b in spans:
if out and out[-1][0] == sid and abs(out[-1][2] - a) < 1e-6:
out[-1] = (sid, out[-1][1], b)
else:
out.append((sid, a, b))
return out
def check(name, cond):
print(f" {'PASS' if cond else 'FAIL'} {name}")
assert cond, name
# Full-window slice is a no-op (full renders unaffected).
full = slice_schedule(SCHED, 0, 350)
check("full-window slice is identity", _spans(full) == _spans(SCHED))
# Window drops the out-of-range segment and trims the edges into the files.
w = slice_schedule(SCHED, 150, 320)
check("drops out-of-window segment s1", [s.seg_id for s in w] == ["s2", "s3"])
check("first kept seg seeks into its file (s2 skip 60, take 150, offset 0)",
(w[0].skip, w[0].take, w[0].offset) == (60, 150, 0))
check("last kept seg trimmed to window (s3 skip 2, take 20, offset 150)",
(w[1].skip, w[1].take, w[1].offset) == (2, 20, 150))
# Every seek stays within its own file's real bounds.
for orig, sl in ((SCHED[1], w[0]), (SCHED[2], w[1])):
check(f"{sl.seg_id} seek within file bounds",
sl.skip >= orig.skip and sl.skip + sl.take <= orig.skip + orig.duration + 1e-6)
# THE invariant: A:C == A:B ++ B:C in source coverage, for boundaries that land
# mid-segment, on a segment edge, and spanning multiple segments.
for A, B, C in [(150, 250, 340), (150, 300, 340), (50, 100, 350), (0, 300, 350)]:
whole = _spans(slice_schedule(SCHED, A, C))
joined = _merge(_spans(slice_schedule(SCHED, A, B)) + _spans(slice_schedule(SCHED, B, C)))
check(f"slice({A}:{C}) == slice({A}:{B})++slice({B}:{C})", joined == whole)
# Offsets are contiguous and cover the window with no gap/overlap.
for A, C in [(150, 320), (0, 350), (120, 340)]:
sl = slice_schedule(SCHED, A, C)
ok = abs(sl[0].offset) < 1e-6
for prev, cur in zip(sl, sl[1:]):
ok = ok and abs((prev.offset + prev.duration) - cur.offset) < 1e-6
ok = ok and abs((sl[-1].offset + sl[-1].duration) - (C - A)) < 1e-6
check(f"contiguous coverage of window [{A},{C}]", ok)
print("\nAll narration-slice invariants passed.")
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
./gnommo.sh -p video0 trim --force
./gnommo.sh -p video1 trim --force
./gnommo.sh -p video2 trim --force
./gnommo.sh -p video3 trim --force
./gnommo.sh -p video4 trim --force
./gnommo.sh -p video5 trim --force
./gnommo.sh -p video6 trim --force
./gnommo.sh -p video7 trim --force
+3
View File
@@ -7,6 +7,7 @@
./gnommo.sh -p video4 import ./gnommo.sh -p video4 import
./gnommo.sh -p video5 import ./gnommo.sh -p video5 import
./gnommo.sh -p video6 import ./gnommo.sh -p video6 import
./gnommo.sh -p video7 import
./gnommo.sh -p video0 prune ./gnommo.sh -p video0 prune
./gnommo.sh -p video1 prune ./gnommo.sh -p video1 prune
@@ -15,6 +16,7 @@
./gnommo.sh -p video4 prune ./gnommo.sh -p video4 prune
./gnommo.sh -p video5 prune ./gnommo.sh -p video5 prune
./gnommo.sh -p video6 prune ./gnommo.sh -p video6 prune
./gnommo.sh -p video7 prune
./gnommo.sh -p video0 up ./gnommo.sh -p video0 up
@@ -24,4 +26,5 @@
./gnommo.sh -p video4 up ./gnommo.sh -p video4 up
./gnommo.sh -p video5 up ./gnommo.sh -p video5 up
./gnommo.sh -p video6 up ./gnommo.sh -p video6 up
./gnommo.sh -p video7 up