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": {
"source_file": "Logo.mov",
"duration": 14.0,
"duration": 18.0,
"has_audio": true,
"is_shared": true
"is_shared": true,
"cutout": "fullscreen",
"layer": "above",
"pause_narration": 14.0
},
"MontageZoom": {
"source_file": "MontageZoom.mp4",
+6 -5
View File
@@ -1501,12 +1501,13 @@
"has_audio": false,
"is_shared": true
},
"Logo": {
"source_file": "Logo.mov",
"duration": 14.0,
"source_file": "Logo.mov",
"duration": 18.0,
"has_audio": true,
"is_shared": true
},
"is_shared": true,
"cutout": "fullscreen",
"layer": "above",
"pause_narration": 14.0
"MontageZoom": {
"source_file": "MontageZoom.mp4",
"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.
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
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).
Deferred (follow-ups): inline override of the *global* params (skip/zoom/volume/
use_audio_channels/pause_narration) — the renderer reads these from `video_source` in ~13
places, so wiring them per-event is a separate change; stripping the moved fields from
videos.json (kept as fallback defaults for now); a validator warning for unknown/unwired
inline keys.
`volume` is **sparse/overridable**: the renderer reads `VideoEvent.volume` (line ~1571),
which is the events.json override if present else the videos.json default — so a videos.json
change keeps propagating, and events.json only stores `volume` when it's actually overridden
(inline/GUI/manual). `derive_events` materializes it only when overridden; `_EVENT_OVERRIDE_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
+91 -25
View File
@@ -11,6 +11,7 @@ Files are looked up first locally, then in the cache at:
"""
import configparser
import json
import os
from pathlib import Path
from typing import Optional, Tuple
@@ -18,36 +19,103 @@ from typing import Optional, Tuple
_cache_config: Optional[dict] = None
_assets_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:
"""Return FFmpeg thread count based on [performance] cpu_limit in ~/.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
"""
def _load_perf_config() -> dict:
"""Read and cache the [performance] section of ~/.gnommo.conf."""
global _perf_config
if _perf_config is None:
config_path = Path.home() / ".gnommo.conf"
_perf_config = {}
if config_path.exists():
cfg = configparser.ConfigParser()
cfg.read(config_path)
if cfg.has_option("performance", "cpu_limit"):
if _perf_config is not None:
return _perf_config
_perf_config = {}
config_path = Path.home() / ".gnommo.conf"
if config_path.exists():
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:
_perf_config["cpu_limit"] = float(
cfg.get("performance", "cpu_limit")
)
_perf_config[key] = float(cfg.get("performance", key))
except ValueError:
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:
return 1
try:
cpu_limit = float(cpu_limit)
except (TypeError, ValueError):
return 1
cpu_count = os.cpu_count() or 1
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
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:
[performance]
render_chunk_slides = 15
"""
global _perf_config
if _perf_config is None:
get_ffmpeg_thread_count() # populates _perf_config
val = _perf_config.get("render_chunk_slides")
val = _perf_get("render_chunk_slides")
if val is None:
return None
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
] = None # Max duration to play (seconds). Default: until next slide or end of clip
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
loop: bool = False # If True, loop the [skip, skip+take] window to fill the display
# window (end_on). Without take, loops the whole clip from skip. Distinct from
# end_on="loop" (which loops to the render end); loop rides on any end_on.
zoom: float = (
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
)
cutout: Optional[
str
] = 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)
is_shared: bool = False # If True, source_file is relative to shared_assets/
pause_narration: float = (
@@ -421,6 +432,14 @@ class AudioEvent:
# 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).
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
@@ -434,6 +453,22 @@ class VideoEvent:
cutout: "CutoutDefinition"
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
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
# 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).
+39
View File
@@ -118,3 +118,42 @@ def build_narration_schedule(
offset += eff
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
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
# yet overridable inline; unknown keys are ignored.
_MARKER_OVERRIDE_KEYS = frozenset({"cutout", "layer", "end_on", "take"})
_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):
"""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("'")
if key == "take":
if key in _MARKER_NUMERIC_KEYS:
try:
return float(v)
except ValueError:
return None
if key in _MARKER_BOOL_KEYS:
lv = v.lower()
if lv in ("true", "1", "yes", "on"):
return True
if lv in ("false", "0", "no", "off"):
return False
return None
return v
@@ -96,6 +115,7 @@ def parse_marker(raw: str) -> "tuple[str, Optional[dict]]":
continue
key, _, val = tok.partition("=")
key = key.strip().lower()
key = _MARKER_KEY_ALIASES.get(key, key) # duration → take, etc.
if key in _MARKER_OVERRIDE_KEYS:
coerced = _coerce_marker_value(key, val)
if coerced is not None:
@@ -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:
"""Parse project.json into ProjectConfig."""
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"),
cutouts=cutouts,
default_filters=default_filters,
background=data.get("background", ""),
background=_lc_handle(data.get("background", "")),
background_video=data.get("background_video", ""), # Deprecated
slides_path=data.get("slides", "slides.json"),
videos_path=data.get("videos", "videos.json"),
audio_path=data.get("audio", "audio.json"),
transcript_path=data.get("transcript"),
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"),
default_begin=float(data.get("default_begin", 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", ""),
footer=data.get("footer", ""),
output_video=data.get("output_video", ""),
@@ -615,8 +652,11 @@ def parse_videos(
output_file=video_data.get("output_file"),
take=take,
skip=skip,
loop=bool(video_data.get("loop", False)),
zoom=video_data.get("zoom", 1.0),
cutout=video_data.get("cutout"),
object_fit=video_data.get("object-fit", "cover"),
object_position=video_data.get("object-position", "center"),
always_visible=video_data.get("always_visible", False),
is_shared=video_data.get("is_shared", False),
pause_narration=float(video_data.get("pause_narration", 0)),
@@ -931,6 +971,7 @@ def resolve_missing_videos(
output_file=entry.get("output_file"),
take=entry.get("take"),
skip=float(entry.get("skip", 0.0)),
loop=bool(entry.get("loop", False)),
zoom=float(entry.get("zoom", 1.0)),
cutout=entry.get("cutout"),
always_visible=bool(entry.get("always_visible", False)),
+76 -3
View File
@@ -21,10 +21,10 @@ from typing import Union, Optional
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
return str(get_ffmpeg_thread_count())
return str(get_ffmpeg_thread_count("preprocess"))
# Number of parallel workers for chunk processing
@@ -769,6 +769,7 @@ def preprocess_video(
force: bool = False,
custom_gnommo_scratch: Optional[Path] = None,
res: str = "full",
shared_loudnorm_stats: Optional[dict] = None,
) -> Path:
"""
Apply preprocessing filters to a video source.
@@ -921,6 +922,7 @@ def preprocess_video(
take=None,
use_audio_channels=channel,
skip_loudnorm=video_source.defer_loudnorm,
shared_loudnorm_stats=shared_loudnorm_stats,
)
current_input = step_output
batch_num += 1
@@ -2292,6 +2294,62 @@ def apply_transcribe(
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(
input_path: Path,
output_path: Path,
@@ -2300,6 +2358,7 @@ def apply_audio_normalize(
take: float = None,
use_audio_channels: str = "both",
skip_loudnorm: bool = False,
shared_loudnorm_stats: Optional[dict] = None,
) -> None:
"""
Apply audio normalization: denoise, compress, and loudness normalize.
@@ -2439,11 +2498,25 @@ def apply_audio_normalize(
# 8. Loudness normalization (loudnorm - EBU R128)
# Skip if skip_loudnorm=True (for segments that will be concatenated)
if cfg.normalize and not skip_loudnorm:
audio_filters.append(
loudnorm = (
f"loudnorm=I={cfg.target_lufs:.1f}"
f":LRA={cfg.target_lra:.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:
# No filters enabled, just copy
+263 -61
View File
@@ -78,6 +78,7 @@ def _build_crossfade_loop_filter(
needed_duration: float,
volume: float,
delay_ms: int,
start_offset: float = 0.0,
) -> list[str]:
"""
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.
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:
input_label: Input stream label (e.g., "[0:a]")
output_label: Output stream label (e.g., "[aud0]")
@@ -93,6 +100,7 @@ def _build_crossfade_loop_filter(
needed_duration: Total duration needed
volume: Volume multiplier
delay_ms: Initial delay in milliseconds
start_offset: Phase (seconds) into the crossfade loop stream to start at
Returns:
List of filter strings to append to the filter_complex
@@ -100,8 +108,12 @@ def _build_crossfade_loop_filter(
filters = []
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)
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
n_loops = min(n_loops, 100)
@@ -109,7 +121,7 @@ def _build_crossfade_loop_filter(
if n_loops <= 1:
# Single play, no looping needed
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"adelay={delay_ms}|{delay_ms},"
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)]
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 = []
for i in range(n_loops):
copy_label = split_labels[i]
out_label = f"[xfl_{output_label[1:-1]}_{i}]"
mix_labels.append(out_label)
loop_delay = i * loop_len
total_delay_ms = delay_ms + int(loop_delay * 1000)
loop_delay_ms = int(i * loop_len * 1000)
# Build filter chain for this copy
chain_parts = []
@@ -143,17 +156,20 @@ def _build_crossfade_loop_filter(
if fade_out_start > 0:
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}")
chain_parts.append(f"volume={volume:.2f}")
if loop_delay_ms > 0:
chain_parts.append(f"adelay={loop_delay_ms}|{loop_delay_ms}")
filter_chain = ",".join(chain_parts)
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(
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
f"atrim=0:{needed_duration + delay_ms/1000:.3f},"
f"asetpts=PTS-STARTPTS{output_label}"
f"atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
f"asetpts=PTS-STARTPTS,"
f"volume={volume:.2f},"
f"adelay={delay_ms}|{delay_ms}{output_label}"
)
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(
videos_dir: Path,
video_source: VideoSource,
@@ -310,6 +405,11 @@ def _resolve_video_path(
else:
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():
# File not found anywhere — substitute PlaceholderVideo so FFmpeg doesn't crash
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.
from .cache import get_ffmpeg_thread_count
_tc = str(get_ffmpeg_thread_count())
_tc = str(get_ffmpeg_thread_count("render"))
cmd.extend(
["-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(
videos_dir, event.video_source, shared_assets_dir, project_path
)
# Chunking v2 (docs/chunking_v2.md): a clip that began before this chunk
# resumes mid-clip via skip_override. None today (v1) → the source's own skip.
skip = event.skip_override if getattr(event, "skip_override", None) is not None \
else (event.video_source.skip or 0.0)
# How long this clip needs to play in the output
clip_duration = event.end_time - event.start_time
if event.video_source.take is not None:
clip_duration = min(clip_duration, event.video_source.take)
# Per-occurrence geometry (chunk-seam skip_override, per-event skip/take/loop).
skip, clip_duration, loop_frames = _video_playback(event, plan.config.fps)
# Loop the clip if the file is shorter than the display window.
# Don't loop pause-narration videos — they intentionally play once and stop.
@@ -611,6 +704,21 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
if has_audio:
video_events_with_audio.add(i)
if loop_frames > 0:
# Explicit loop=true of a bounded [skip, skip+take] sub-window: read ONLY
# that window here (a filtergraph `loop` filter repeats it — see the overlay
# layers). No -stream_loop; the filter does the repeating.
_take_secs = loop_frames / plan.config.fps
if skip > 0:
cmd.extend(["-ss", f"{skip:.3f}"])
probesize = "1000000" if has_audio else "1000"
cmd.extend(["-analyzeduration", "0", "-probesize", probesize])
cmd.extend(["-t", f"{_take_secs:.3f}"])
cmd.extend(["-i", str(video_path)])
video_inputs[i] = input_idx
input_idx += 1
continue
if needs_loop:
cmd.extend(["-stream_loop", "-1"])
if skip > 0:
@@ -756,6 +864,38 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
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(
cutout: CutoutDefinition, frame_width: int, frame_height: int
) -> tuple[int, int, int, int]:
@@ -1089,22 +1229,19 @@ def build_filter_complex(
event.cutout, width, height
)
duration = event.end_time - event.start_time
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
_skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
effective_end = event.start_time + duration
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom)
video_label = f"tvb{i}"
start_pts = event.start_time
filters.append(
f"[{video_idx}:v]format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"[{video_idx}:v]{_loop_pre}format=yuva444p10le,"
f"{_pts},"
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
f"format=rgba[{video_label}]"
)
@@ -1135,8 +1272,7 @@ def build_filter_complex(
filters.append(
f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS,"
f"format=yuva444p10le,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"{_fit_filter(cut_width, cut_height, zoom)},"
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"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB,"
f"format=yuva444p10le,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"{_fit_filter(cut_width, cut_height, zoom)},"
f"format=rgba[{seg_label}]"
)
@@ -1192,22 +1327,19 @@ def build_filter_complex(
event.cutout, width, height
)
duration = event.end_time - event.start_time
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
_skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
effective_end = event.start_time + duration
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom)
video_label = f"tvm{i}"
start_pts = event.start_time
filters.append(
f"[{video_idx}:v]format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"[{video_idx}:v]{_loop_pre}format=yuva444p10le,"
f"{_pts},"
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
f"format=rgba[{video_label}]"
)
@@ -1249,22 +1381,19 @@ def build_filter_complex(
event.cutout, width, height
)
duration = event.end_time - event.start_time
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
_skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
effective_end = event.start_time + duration
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom)
video_label = f"tv{i}"
start_pts = event.start_time
filters.append(
f"[{video_idx}:v]format=rgba,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2"
f"[{video_idx}:v]{_loop_pre}format=rgba,"
f"{_pts},"
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}"
f"[{video_label}]"
)
@@ -1336,11 +1465,12 @@ def build_filter_complex(
# Scale and crop video
video_label = f"outro{i}"
start_pts = event.start_time
# OutroEvent carries no per-occurrence overrides, so read placement off
# its VideoSource (the videos.json defaults).
filters.append(
f"[{video_idx}:v]format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"{_fit_filter(cut_width, cut_height, zoom, event.video_source.object_fit, event.video_source.object_position)},"
f"format=rgba[{video_label}]"
)
@@ -1459,11 +1589,16 @@ def build_filter_complex(
for i, event in enumerate(plan.audio_events):
audio_idx = audio_inputs[event.audio_id]
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:
# Looping audio: loop source, then trim/segment
# Stop at narration end if there's an outro
loop_end_time = audio_end_time
# Looping audio: loop source, then trim/segment. Stop at the end
# marker if set, else at narration end / outro.
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
if plan.narration_pauses and not event.audio_def.ignore_pauses:
@@ -1529,6 +1664,10 @@ def build_filter_complex(
needed_duration=remaining,
volume=volume,
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)
else:
@@ -1544,16 +1683,78 @@ def build_filter_complex(
)
audio_labels_to_mix.append(f"[{label}]")
else:
# One-shot audio: delay to trigger time. Chunking v2: seek in if
# the clip began in an earlier chunk (docs/chunking_v2.md).
# One-shot audio. Freeze it through narration pauses too (like the
# 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}"
delay_ms = int(event.start_time * 1000)
_off = getattr(event, "src_offset", 0.0)
_seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS," if _off > 0 else ""
filters.append(
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
relevant_pauses = (
[]
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
_have_audio = video_events_with_audio or set()
@@ -1568,7 +1769,8 @@ def build_filter_complex(
delay_ms = int(event.start_time * 1000)
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 ""
filters.append(
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 .transformer import MarkerTiming, resolve_video_presentation
# Per-occurrence presentation fields materialized onto video events (atomic events.json,
# GUI-ready). Round-tripped as overrides so a stored value drives render over the default.
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take")
# Per-occurrence presentation fields ALWAYS materialized onto video events (atomic
# events.json, GUI-ready). Round-tripped as overrides so a stored value drives render.
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take", "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"
SCAFFOLD_FILE = "scaffold.json"
@@ -90,6 +94,8 @@ def marker_type(marker_id: str, slides: dict, videos: dict, audio: dict) -> str:
return "audio"
if _ci_contains(CAMERA_PRESETS, marker_id):
return "camera"
if marker_id.startswith("end:"):
return "end"
return "other"
@@ -175,7 +181,7 @@ def derive_events(
vs,
t.overrides,
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"]
@@ -184,6 +190,24 @@ def derive_events(
e["end_on"] = pres["end_on"]
if pres["take"] is not None:
e["take"] = pres["take"]
# skip/loop are sparse: materialized only when explicitly overridden
# inline (like volume), so a plain video keeps a clean events.json and
# the videos.json default flows via render's fallback. Keyed on override
# PRESENCE, not truthiness, so an explicit skip=0 / loop=false survives.
if t.overrides and "skip" in t.overrides:
e["skip"] = round(pres["skip"], 3)
if t.overrides and "loop" in t.overrides:
e["loop"] = bool(pres["loop"])
# volume is sparse: written only when actually overridden, so the
# videos.json default keeps flowing until someone pins it here.
if t.overrides and "volume" in t.overrides:
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)
if pd:
e["pause_duration"] = pd
@@ -193,6 +217,85 @@ def derive_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:
"""Fill `narration_time: None` entries by linear interpolation between placed
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] = []
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")
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
# (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(
MarkerTiming(
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
derived artifacts each side regenerates or ships on its own (rendered output,
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
@@ -52,6 +61,7 @@ _SYNC_EXCLUDES = [
"media/narration/proxy/",
"media/videos/proxy/",
"**/chunks/",
"*_processed.*", # preprocess outputs — rig-only, never transfer OR --delete them
"*.tmp",
".*", # 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)
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", "-av", "--progress",
"rsync", "-av", "--progress", "--delete",
"-e", f"ssh -p {server['port']}",
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
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)
# --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", "-av", "--progress",
"rsync", "-av", "--progress", "--delete",
"-e", f"ssh -p {server['port']}",
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
f"{server['user']}@{server['host']}:{remote_project}/",
+157 -26
View File
@@ -63,7 +63,7 @@ def resolve_video_presentation(
marker_id: str,
video_source,
overrides: Optional[dict] = None,
default_end_on: Optional[str] = "next_video",
default_end_on: Optional[str] = "next_slide",
) -> dict:
"""Resolve a video marker's per-occurrence presentation to an atomic dict.
@@ -74,8 +74,9 @@ def resolve_video_presentation(
videos.json collision.
Precedence per field: explicit event override > shorthand prefix > videos.json
default > built-in default. `default_end_on` is "next_video" for video triggers and
None for [narration:] (which runs to the end).
default > built-in default. `default_end_on` is "next_slide" for video triggers
(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}.
"""
@@ -94,9 +95,20 @@ def resolve_video_presentation(
layer = overrides.get("layer") or impl_layer or video_source.layer
end_on = overrides.get("end_on") or video_source.end_on or default_end_on
take = overrides["take"] if "take" in overrides else video_source.take
# Per-occurrence playback controls: inline override wins, else videos.json default.
skip = overrides["skip"] if "skip" in overrides else (video_source.skip or 0.0)
loop = overrides["loop"] if "loop" in overrides else bool(video_source.loop)
pause_narration = overrides.get(
"pause_narration", video_source.pause_narration or 0.0
)
# 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 {
"handle": handle,
@@ -104,7 +116,12 @@ def resolve_video_presentation(
"layer": layer,
"end_on": end_on,
"take": take,
"skip": float(skip or 0.0),
"loop": bool(loop),
"pause_narration": float(pause_narration or 0.0),
"volume": float(volume if volume is not None else 1.0),
"object_fit": object_fit,
"object_position": object_position,
}
@@ -243,6 +260,11 @@ def _is_known_marker(
if audio_id in audio:
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
@@ -948,6 +970,8 @@ def build_render_plan(
event.end_time -= time_offset
for event in audio_events:
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:
event.time -= time_offset
@@ -991,14 +1015,27 @@ def build_render_plan(
if vid_event is event:
# Don't shift the pause event by its own pause
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:
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
for aud_event in audio_events:
if aud_event.start_time > narration_time:
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:
if cam_event.time > narration_time:
@@ -1050,6 +1087,23 @@ def build_render_plan(
slides_json_path = project_path / config.slides_path.lower()
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(
project_path=project_path,
config=config,
@@ -1067,7 +1121,7 @@ def build_render_plan(
camera_events=camera_events,
time_offset=time_offset,
initial_camera_state=initial_camera_state,
input_seek_time=time_offset,
input_seek_time=narration_input_seek,
shared_assets_dir=shared_assets_dir,
narration_pauses=narration_pauses,
narration_segments=narration_schedule or [],
@@ -1349,6 +1403,16 @@ def _extract_video_events(
# a clip when the next video begins, so videos never overlap.
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] = []
for start_time, marker_id, video_id, trigger_type, overrides in video_markers:
video_source = videos[video_id]
@@ -1360,14 +1424,19 @@ def _extract_video_events(
marker_id,
video_source,
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 = cutouts[cutout_name]
layer = pres["layer"]
end_on = pres["end_on"]
take = pres["take"]
skip = pres["skip"]
loop = pres["loop"]
pause_narration = pres["pause_narration"]
volume = pres["volume"]
object_fit = pres["object_fit"]
object_position = pres["object_position"]
if end_on == "take" and take is not None:
end_time = start_time + take
@@ -1392,9 +1461,12 @@ def _extract_video_events(
if vt > start_time:
end_time = vt
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:
end_time = max(end_time, start_time + pause_narration)
end_time = start_time + pause_narration
elif end_on in ("next_slide", "slide"):
# End at next slide marker ("slide" is a recognised alias for "next_slide")
end_time = total_duration
@@ -1402,10 +1474,29 @@ def _extract_video_events(
if slide_time > start_time:
end_time = slide_time
break
# pause_narration videos must stay visible for the full pause duration —
# the narration is held for that long, so the overlay should match.
# pause_narration cutscene: end exactly with the freeze (see above).
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:
# end_on None ([narration:] with no explicit end) — runs to end.
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.
if end_time <= range_start or start_time >= range_end:
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
if start_time < range_start:
into = range_start - start_time # elapsed since the clip started
base = video_source.skip or 0.0
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)
if pause_narration:
# A pause-narration CUTSCENE must render WHOLE in the single chunk where it
# starts. Its end = start + pause_narration deliberately overshoots the
# pre-pause timeline (the 18s freeze doesn't exist yet at extraction), so
# clamping it to this chunk's pre-pause range_end would truncate the freeze,
# AND it would otherwise ALSO be pulled into the next chunk as a "spanning"
# clip — duplicating the freeze (the video6 "two 18s pauses / logo shows one
# frame" bug). Own it only where it starts; never seek or clamp it.
if start_time < range_start:
continue # earlier chunk owns this cutscene
else:
# 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(
VideoEvent(
@@ -1440,6 +1542,13 @@ def _extract_video_events(
cutout=cutout,
cutout_name=cutout_name,
layer=layer,
end_on=end_on or "",
skip=skip,
take=take,
loop=loop,
volume=volume,
object_fit=object_fit,
object_position=object_position,
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"))
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:
if timing.timestamp < 0:
continue
@@ -1469,8 +1587,13 @@ def _extract_audio_events(
if audio_id is not None and audio_id in audio:
adef = audio[audio_id]
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.
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
elif adef.duration is not None:
aend = astart + adef.duration
@@ -1483,10 +1606,16 @@ def _extract_audio_events(
if aend <= range_start or astart >= range_end:
continue
src_offset = 0.0
crossfade_offset = 0.0
if astart < range_start:
into = range_start - astart
if adef.loop and 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:
src_offset = into
astart = range_start
@@ -1496,6 +1625,8 @@ def _extract_audio_events(
start_time=astart,
audio_def=adef,
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"):
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
if ":" in marker:
warnings.append(
@@ -155,6 +168,13 @@ def validate_project(
)
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:
issues.append(
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 issues:
raise ValidationError(issues)
+11 -15
View File
@@ -8,15 +8,18 @@
./gnommo.sh -p video4 grade --stage key
./gnommo.sh -p video5 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 video1 grade --pick key_5
./gnommo.sh -p video2 grade --pick key_5
./gnommo.sh -p video3 grade --pick key_5
./gnommo.sh -p video4 grade --pick key_5
./gnommo.sh -p video5 grade --pick key_5
./gnommo.sh -p video6 grade --pick key_5
./gnommo.sh -p video0 grade --stage despill
./gnommo.sh -p video1 grade --stage despill
./gnommo.sh -p video2 grade --stage despill
./gnommo.sh -p video3 grade --stage despill
./gnommo.sh -p video4 grade --stage despill
./gnommo.sh -p video5 grade --stage despill
./gnommo.sh -p video6 grade --stage despill
./gnommo.sh -p video7 grade --stage despill
./gnommo.sh -p video0 grade --stage grade
./gnommo.sh -p video1 grade --stage grade
@@ -25,11 +28,4 @@
./gnommo.sh -p video4 grade --stage grade
./gnommo.sh -p video5 grade --stage grade
./gnommo.sh -p video6 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
./gnommo.sh -p video7 grade --stage grade
+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
./gnommo.sh -p video1 all
./gnommo.sh -p video2 all
./gnommo.sh -p video3 all
./gnommo.sh -p video4 all
./gnommo.sh -p video5 all
./gnommo.sh -p video6 all
./gnommo.sh -p video0 render --force
./gnommo.sh -p video1 render --force
./gnommo.sh -p video2 render --force
./gnommo.sh -p video3 render --force
./gnommo.sh -p video4 render --force
./gnommo.sh -p video5 render --force
./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)
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 ────────────────────────────────────────────────────────────────────
def test_video():
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)
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__":
test_audio()
test_crossfade_phase()
test_video()
test_pause_cutscene_chunk_ownership()
print()
if _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 video5 import
./gnommo.sh -p video6 import
./gnommo.sh -p video7 import
./gnommo.sh -p video0 prune
./gnommo.sh -p video1 prune
@@ -15,6 +16,7 @@
./gnommo.sh -p video4 prune
./gnommo.sh -p video5 prune
./gnommo.sh -p video6 prune
./gnommo.sh -p video7 prune
./gnommo.sh -p video0 up
@@ -24,4 +26,5 @@
./gnommo.sh -p video4 up
./gnommo.sh -p video5 up
./gnommo.sh -p video6 up
./gnommo.sh -p video7 up