Compare commits
28
Commits
4e1bfe03e2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a550ee8b6b | ||
|
|
3b0439a00c | ||
|
|
fd83dd546f | ||
|
|
70a9b23810 | ||
|
|
7303d820e3 | ||
|
|
7748e78712 | ||
|
|
2b2174c176 | ||
|
|
eac1d7968d | ||
|
|
06d2d27dad | ||
|
|
d9dc9baa51 | ||
|
|
f328759eab | ||
|
|
d2550d9432 | ||
|
|
0b2ebf84e4 | ||
|
|
0f3c595c04 | ||
|
|
f852b36291 | ||
|
|
a218e1cc9f | ||
|
|
be5173aeb1 | ||
|
|
eeaada77d4 | ||
|
|
4470246ebb | ||
|
|
9d29d2e2ed | ||
|
|
4fbb6425df | ||
|
|
cbdc22cc16 | ||
|
|
ec08e945e5 | ||
|
|
4c6c9b8569 | ||
|
|
c72c118d76 | ||
|
|
0c8f662bee | ||
|
|
cdda2e9024 | ||
|
|
a8aab55bd2 |
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Atomic Events — Design Spec
|
||||
|
||||
Status: **Stage A + Stage B implemented (2026-07-27).** Motivated by a future **Glitch
|
||||
Studio GUI** that edits each video occurrence as a self-contained object.
|
||||
|
||||
Implemented:
|
||||
- Per-occurrence presentation resolves via `transformer.resolve_video_presentation`
|
||||
(precedence: inline/GUI override > shorthand prefix > videos.json > default; video
|
||||
`end_on` default = `next_video`, `[narration:]` runs to end).
|
||||
- events.json is materialized/atomic: `derive_events` writes `handle/cutout/layer/end_on/
|
||||
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, 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).
|
||||
|
||||
`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
|
||||
|
||||
Presentation/timing properties (`cutout`, `layer`, `end_on`, `take`, `pause_narration`)
|
||||
live on the **videos.json handle**, but they are really properties of *where a clip is
|
||||
used*, not of the file. The shorthand prefix (`vst:` = square/above, `vsb:` =
|
||||
square/below) is per-marker, but `_project_markers_to_videos` collapses it onto the
|
||||
single handle record (last-wins). So one handle used two ways collides:
|
||||
|
||||
- `[vst:glitch_ccd_binning]` (above) and `[vsb:glitch_ccd_binning]` (below) → videos.json
|
||||
can only store `layer: below`, so the first occurrence renders under the slide (hidden).
|
||||
- video5 has 5 such collisions today (glitch_ccd_binning, pexels/12471039…,
|
||||
mainvideopart1, shotnoiseacc, slide_periodogram).
|
||||
|
||||
A stopgap validator hard-error (`validate_project`, gnommo/validator.py) currently blocks
|
||||
render on these. This spec removes the *cause* so that guard is no longer needed.
|
||||
|
||||
The naive fixes are both rejected: copying the file/handle (duplication on disk), and a
|
||||
"hybrid override + materialize" layer (too much indirection). Instead: **the per-occurrence
|
||||
properties move onto the event.**
|
||||
|
||||
## Field homes
|
||||
|
||||
**videos.json — asset + global defaults (one value per handle):**
|
||||
`source_file`, `output_file`/`processed_file`, `filter`, `has_audio`, `is_shared`,
|
||||
`src_mtime`, `duration` (probed; asset-only, never per-event), and the globals
|
||||
`zoom`, `skip`, `volume`, `use_audio_channels`.
|
||||
|
||||
**events.json — per-occurrence (one value per event):**
|
||||
`handle` (the video id, **prefix-free**), `cutout`, `layer`, `end_on`, `take`,
|
||||
`pause_narration`.
|
||||
|
||||
**Resolution order for a rendered clip:** event field (if set) → videos.json value (for the
|
||||
globals) → config default. The per-occurrence fields have no videos.json fallback — they
|
||||
are always materialized onto the event at build time.
|
||||
|
||||
Notes:
|
||||
- `end_on` **defaults to `next_video`** for videos when unspecified (was implicitly
|
||||
`next_slide`). Existing videos.json `end_on` values are migrated onto events explicitly,
|
||||
so current projects keep their behavior; only *new* unspecified markers get the new default.
|
||||
- `take` is the event-level cut length, only meaningful when `end_on=take`; otherwise the
|
||||
end is implicit from `end_on` and `take` stays null.
|
||||
- `skip` stays a global (asset trim-in) while `take` is per-event — a deliberate asymmetry:
|
||||
"where this asset generally starts" vs. "how long this occurrence plays."
|
||||
- `zoom`/`volume`/`use_audio_channels` stay global but are inline-overridable per event
|
||||
(below), so they can diverge without a videos.json copy.
|
||||
|
||||
## Authoring: shorthand + inline overloads
|
||||
|
||||
The manuscript stays the compact authoring surface. The shorthand letters encode
|
||||
`cutout`+`layer` (and `pause_narration` via the `…p:` variants). Anything the letters
|
||||
don't encode — chiefly `end_on`, and any per-event override of a global — is given as
|
||||
inline **`key=value`** pairs (simplified from the earlier `{"json":"form"}`):
|
||||
|
||||
```
|
||||
[vsb:glitch_ccd_binning2] # square/below, end_on defaults to next_video
|
||||
[vsb:glitch_ccd_binning2, end_on=next_video] # + explicit end_on
|
||||
[vsb:glitch_ccd_binning2, take=5, volume=0.5] # + per-event overrides of globals
|
||||
[video:glitch_ccd_binning2, cutout=square, layer=below] # generic; equivalent to [vsb:…]
|
||||
```
|
||||
|
||||
Rules:
|
||||
- The first token inside `[]` is `prefix:handle` (handle may contain `/`, e.g. `pexels/123`).
|
||||
- Remaining comma-separated tokens are `key=value`. Values are type-inferred: numeric →
|
||||
float, `true`/`false` → bool, else string. Allowed keys: `cutout`, `layer`, `end_on`,
|
||||
`take`, `skip`, `zoom`, `volume`, `use_audio_channels`, `pause_narration`,
|
||||
`always_visible`.
|
||||
- An inline key overrides whatever the shorthand implied (e.g. `[vst:x, layer=below]` →
|
||||
above from the prefix, then below from the override). Last-writer-wins, prefix first.
|
||||
- `[video:handle, …]` is the fully-explicit form the GUI round-trips: no prefix magic, every
|
||||
presentation field named.
|
||||
|
||||
Why `key=value` over JSON: no braces/quotes to escape inside `[]`, one obvious separator,
|
||||
and it reads cleanly in a script. The GUI still stores the resolved values as real JSON
|
||||
fields on the event — the manuscript form is just sugar that populates them.
|
||||
|
||||
## Build-time materialization
|
||||
|
||||
At build (`build_render_plan` / scaffold construction), each video marker resolves to an
|
||||
atomic event dict:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "video",
|
||||
"handle": "glitch_ccd_binning",
|
||||
"cutout": "square",
|
||||
"layer": "above",
|
||||
"end_on": "next_video",
|
||||
"take": null,
|
||||
"pause_narration": 0.0,
|
||||
"narration_time": 0.0, "adjustment": 0.0, "final_time": 0.0,
|
||||
"mapping": "exact", "confidence": 1.0, "context": "…"
|
||||
}
|
||||
```
|
||||
|
||||
`id` (currently `"vst:glitch_ccd_binning"`) is replaced by `handle` + explicit fields. The
|
||||
render pass reads presentation straight off the event and no longer consults the prefix or
|
||||
the videos.json presentation fields. `merge_events` must preserve manual event edits (the
|
||||
GUI's writes) across rebuilds, the same way it preserves `adjustment` today.
|
||||
|
||||
## Code touchpoints
|
||||
|
||||
- **models.py** — `VideoSource` sheds `cutout`/`layer`/`end_on`/`take`/`pause_narration`
|
||||
(or they become defaults-only); `VideoEvent` already carries `cutout`/`layer`/`end_on` —
|
||||
extend to `take`/`pause_narration` sourced from the event, not the handle.
|
||||
- **parser.py `parse_manuscript`** — extend the marker grammar to accept
|
||||
`prefix:handle, key=value, …`; update the malformed-marker detector (which today flags
|
||||
spaces/commas inside `[]`).
|
||||
- **transformer.py `_extract_video_events`** — resolve `cutout/layer/end_on/take/
|
||||
pause_narration` from (prefix ∪ inline overrides), not from `video_source`.
|
||||
- **scaffold.py** — event schema: `handle` + presentation fields; `merge_events` preserves
|
||||
GUI edits; migration for existing events.json.
|
||||
- **cli.py** — retire `_project_markers_to_videos` and `_writeback_video_metadata` (they
|
||||
project/writeback per-handle presentation) in favor of seeding event fields.
|
||||
- **validator.py** — **remove** the key-reuse collision hard-error (reuse is legal now).
|
||||
- **renderer.py** — read presentation from the event (mostly already does via `VideoEvent`).
|
||||
|
||||
## Migration
|
||||
|
||||
Existing projects (video0–video6, …) have presentation on the handle and prefixed `id`s in
|
||||
events.json. A one-shot migration, run on build:
|
||||
|
||||
1. For each video event, split the prefixed `id` into `handle` + implied `cutout`/`layer`.
|
||||
2. Fill `end_on`/`take`/`pause_narration` from the handle's current videos.json values
|
||||
(preserving today's behavior — including handles that explicitly set `next_slide`).
|
||||
3. Strip the moved fields from videos.json handles (leave the globals).
|
||||
4. Idempotent: a second run is a no-op once events carry `handle`.
|
||||
|
||||
## Staging
|
||||
|
||||
- **Stage A** — schema split + per-event resolution from the shorthand prefix, migration,
|
||||
remove the collision guard. Shorthand-only authoring keeps working; the 5 video5
|
||||
collisions resolve. (This is the part that fixes the bug.)
|
||||
- **Stage B** — the inline `key=value` overload grammar + malformed-marker updates.
|
||||
|
||||
Keep the validator collision hard-error in place **until Stage A lands** — removing it
|
||||
earlier would let the hidden-overlay bug back in on video5.
|
||||
|
||||
## Open questions
|
||||
|
||||
- `always_visible`, `use_audio_channels`: confirmed as inline-overridable globals — do any
|
||||
need to become fully per-event?
|
||||
- Does the GUI want events fully flattened (every field present) or sparse (only overrides,
|
||||
inherit the rest)? Affects whether the build writes defaults explicitly.
|
||||
+25
-12
@@ -1,6 +1,9 @@
|
||||
# Chunked Rendering v2 — Design Spec
|
||||
|
||||
Status: **planned** (v1 shipped; v1 boundary limitation is detected + warned, not yet fixed)
|
||||
Status: **implemented on branch `chunking-v2`, pending render-seam validation on the rig.**
|
||||
Plan-level logic is covered by `tests/test_chunking_v2.py` (all green). What remains
|
||||
is confirming the ffmpeg concat seam is frame/phase-accurate on a real render — see
|
||||
"Concat-seam correctness" below.
|
||||
|
||||
## Why chunking exists
|
||||
|
||||
@@ -113,16 +116,26 @@ via `skip_override`. Validation plan:
|
||||
|
||||
## Work items
|
||||
|
||||
- [ ] `_extract_video_events`: overlap test + `skip_override` (loop-aware).
|
||||
- [ ] `_extract_audio_events`: add `end_time`/`skip_override`, overlap + seek.
|
||||
- [ ] `AudioEvent`: `skip_override` field; renderer audio path honors it.
|
||||
- [ ] `OutroEvent`: same treatment.
|
||||
- [ ] Frame/audio seam-diff test (chunked vs full) in the test suite.
|
||||
- [ ] Flip `_chunk_boundary_span_warnings` from "will be dropped" to a debug-only
|
||||
assertion once v2 is the default.
|
||||
- [x] `_extract_video_events`: overlap test + `skip_override` (loop-aware).
|
||||
- [x] `_extract_audio_events`: overlap + `src_offset` seek (loop phase / linear).
|
||||
- [x] `AudioEvent`: `src_offset` field; renderer audio paths (loop-with-pauses,
|
||||
standard loop, one-shot) honor it.
|
||||
- [x] `VideoEvent.skip_override`: renderer video input `-ss` honors it; the clip's
|
||||
embedded audio (`tvaud`) is seeked automatically by the same input seek.
|
||||
- [x] `_chunk_boundary_span_warnings`: downgraded from the "will be dropped" v1
|
||||
warning to an informational note (logged; terminal only under `--verbose`).
|
||||
- [x] Plan-level tests: `tests/test_chunking_v2.py`.
|
||||
- [ ] **Render-seam validation on the rig** (chunked-vs-full frame/audio diff) — the
|
||||
remaining gate before making v2 the trusted default.
|
||||
- [ ] Crossfade-loop audio (`_build_crossfade_loop_filter`) does not yet apply
|
||||
`src_offset` — a crossfaded looping bed restarts phase at the seam. Standard
|
||||
(non-crossfade) loops and one-shots are handled. Low priority.
|
||||
- [ ] `OutroEvent`: not needed — outros are extracted for the last chunk only
|
||||
(`config.outro if is_last_chunk`), so they never split across a seam.
|
||||
|
||||
## Seams already in place (v2 preparations, shipped)
|
||||
## Not affected (verified)
|
||||
|
||||
- `VideoEvent.skip_override` (models.py) — inert; renderer honors it when set.
|
||||
- `cli._chunk_boundary_span_warnings` — detection + warning.
|
||||
- v1-limitation comments at both filter sites in `transformer.py`.
|
||||
- Slides: `_extract_slide_events` already used overlap+clamp — no change.
|
||||
- Full-screen `plan.background`: a separate always-included input.
|
||||
- Full (non-chunked) render: `time_range=None` path leaves `skip_override`/
|
||||
`src_offset` at their defaults, so output is byte-identical to before.
|
||||
|
||||
+91
-25
@@ -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:
|
||||
|
||||
+622
-386
File diff suppressed because it is too large
Load Diff
@@ -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 = (
|
||||
@@ -416,6 +427,19 @@ class AudioEvent:
|
||||
audio_id: str
|
||||
start_time: float # When to start playing (marker time - offset)
|
||||
audio_def: AudioDefinition
|
||||
# Chunking v2 (docs/chunking_v2.md): when a clip began before this chunk's
|
||||
# window it must resume mid-track, not restart at the seam. src_offset is the
|
||||
# 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
|
||||
@@ -429,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
+127
-11
@@ -54,6 +54,75 @@ def _resolve_case_insensitive(path: Path) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
# Inline marker-override keys honored at build time. These are the per-occurrence
|
||||
# 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", "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: numeric keys → float, bool keys → bool,
|
||||
everything else → string. Returns None if a typed value can't be parsed."""
|
||||
v = raw.strip().strip('"').strip("'")
|
||||
if key in _MARKER_NUMERIC_KEYS:
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
if key in _MARKER_BOOL_KEYS:
|
||||
lv = v.lower()
|
||||
if lv in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if lv in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def parse_marker(raw: str) -> "tuple[str, Optional[dict]]":
|
||||
"""Split a bracket's contents into (marker_id, overrides).
|
||||
|
||||
Supports the inline-overload grammar `[prefix:handle, key=value, key=value]`:
|
||||
the text before the first comma is the marker id (prefix:handle); the rest are
|
||||
comma-separated key=value overrides. Only _MARKER_OVERRIDE_KEYS are kept (others
|
||||
ignored). A plain marker (no comma) returns (marker_id, None).
|
||||
|
||||
Examples:
|
||||
"vsb:clip" -> ("vsb:clip", None)
|
||||
"vsb:clip, end_on=next_video" -> ("vsb:clip", {"end_on": "next_video"})
|
||||
"video:clip, cutout=square, layer=below"
|
||||
-> ("video:clip", {"cutout": "square", "layer": "below"})
|
||||
"""
|
||||
if "," not in raw:
|
||||
return raw.strip(), None
|
||||
head, _, tail = raw.partition(",")
|
||||
marker_id = head.strip()
|
||||
overrides: dict = {}
|
||||
for tok in tail.split(","):
|
||||
if "=" not in tok:
|
||||
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:
|
||||
overrides[key] = coerced
|
||||
return marker_id, (overrides or None)
|
||||
|
||||
|
||||
def parse_manuscript(
|
||||
project_path: Path,
|
||||
) -> tuple[str, list[str], list[tuple[int, str]], list[Citation]]:
|
||||
@@ -86,9 +155,12 @@ def parse_manuscript(
|
||||
text = re.sub(r"\[pause\]", "", text)
|
||||
text = re.sub(r"\[stop\]", "", text)
|
||||
|
||||
# Extract all valid markers like [S1], [video:demo], [vf2m:pexels/clip-name], etc.
|
||||
# Include / and - to capture pexels/library video IDs; . to catch file extensions in markers.
|
||||
markers = re.findall(r"\[([A-Za-z0-9_:./\-]+)\]", text)
|
||||
# Extract all valid markers like [S1], [video:demo], [vf2m:pexels/clip-name], and
|
||||
# inline-override forms like [vsb:clip, end_on=next_video]. Include / and - for
|
||||
# pexels/library video IDs; . for file extensions; an optional ",…" tail carries
|
||||
# per-event overrides (parsed out by parse_marker, so `markers` holds bare ids).
|
||||
raw_markers = re.findall(r"\[([A-Za-z0-9_:./\-]+(?:,[^\]\n]*)?)\]", text)
|
||||
markers = [parse_marker(m)[0] for m in raw_markers]
|
||||
|
||||
# Find malformed markers (missing brackets, extra spaces, etc.)
|
||||
malformed: list[tuple[int, str]] = []
|
||||
@@ -180,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"
|
||||
@@ -273,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", ""),
|
||||
@@ -563,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)),
|
||||
@@ -625,12 +717,35 @@ def parse_narration(
|
||||
default_filters = config.default_filters if config else {}
|
||||
|
||||
narration = {}
|
||||
_narr_video_exts = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
||||
for segment_id, segment_data in data.items():
|
||||
if "source_file" not in segment_data:
|
||||
raise ParseError(
|
||||
f"Narration segment '{segment_id}' missing required field 'source_file'",
|
||||
narration_path,
|
||||
)
|
||||
if not segment_data.get("source_file"):
|
||||
# source_file can drift out of an entry (case/sync churn between
|
||||
# machines). Recover the way import/prune/trim do: find the raw
|
||||
# recording whose stem matches the segment id, case-insensitively.
|
||||
recovered = None
|
||||
for sub in ("raw_mov", "processed"):
|
||||
sub_dir = narration_dir / sub
|
||||
if not sub_dir.is_dir():
|
||||
continue
|
||||
for f in sorted(sub_dir.iterdir()):
|
||||
if (
|
||||
f.is_file()
|
||||
and f.suffix.lower() in _narr_video_exts
|
||||
and f.stem.lower() == segment_id.lower()
|
||||
):
|
||||
recovered = f"{sub}/{f.name}"
|
||||
break
|
||||
if recovered:
|
||||
break
|
||||
if recovered is None:
|
||||
raise ParseError(
|
||||
f"Narration segment '{segment_id}' missing required field 'source_file' "
|
||||
f"and no matching recording was found in raw_mov/ or processed/. "
|
||||
f"Add a 'source_file' or run 'import' to repair narration.json.",
|
||||
narration_path,
|
||||
)
|
||||
segment_data = {**segment_data, "source_file": recovered}
|
||||
|
||||
# Resolve filter - can be a list or a string reference to default_filters
|
||||
filter_value = segment_data.get("filter", [])
|
||||
@@ -856,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
@@ -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
|
||||
|
||||
+271
-62
@@ -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:
|
||||
@@ -1473,7 +1608,9 @@ def build_filter_complex(
|
||||
for p in plan.narration_pauses
|
||||
if p.output_time > event.start_time
|
||||
]
|
||||
src_pos = 0.0
|
||||
# Chunking v2: start partway into the looped stream when the
|
||||
# clip began in an earlier chunk (docs/chunking_v2.md).
|
||||
src_pos = getattr(event, "src_offset", 0.0)
|
||||
seg_start = event.start_time
|
||||
seg_count = 0
|
||||
|
||||
@@ -1527,26 +1664,97 @@ 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:
|
||||
# Standard loop without crossfade
|
||||
# Standard loop without crossfade. Chunking v2: seek to
|
||||
# the loop phase when the clip began in an earlier chunk.
|
||||
_off = getattr(event, "src_offset", 0.0)
|
||||
filters.append(
|
||||
f"[{audio_idx}:a]aloop=loop=-1:size=2e+09,"
|
||||
f"atrim=0:{remaining:.3f},"
|
||||
f"atrim={_off:.3f}:{_off + remaining:.3f},"
|
||||
f"asetpts=PTS-STARTPTS,"
|
||||
f"adelay={delay_ms}|{delay_ms},"
|
||||
f"volume={volume:.2f}[{label}]"
|
||||
)
|
||||
audio_labels_to_mix.append(f"[{label}]")
|
||||
else:
|
||||
# One-shot audio: delay to trigger time
|
||||
# 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)
|
||||
filters.append(
|
||||
f"[{audio_idx}:a]adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
|
||||
_off = getattr(event, "src_offset", 0.0)
|
||||
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()
|
||||
@@ -1561,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},"
|
||||
|
||||
+149
-2
@@ -28,7 +28,15 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import CAMERA_PRESETS
|
||||
from .transformer import MarkerTiming
|
||||
from .transformer import MarkerTiming, resolve_video_presentation
|
||||
|
||||
# Per-occurrence presentation fields ALWAYS materialized onto video events (atomic
|
||||
# events.json, GUI-ready). Round-tripped as overrides so a stored value drives render.
|
||||
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take", "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"
|
||||
@@ -86,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"
|
||||
|
||||
|
||||
@@ -97,6 +107,16 @@ _PAUSE_MARKER_PREFIXES = (
|
||||
)
|
||||
|
||||
|
||||
def _lookup_video(marker_id: str, videos: dict):
|
||||
"""Case-insensitive videos.json lookup for a video marker (prefix stripped)."""
|
||||
if not videos:
|
||||
return None
|
||||
handle = marker_id.split(":", 1)[1].lower() if ":" in marker_id else marker_id.lower()
|
||||
return videos.get(handle) or next(
|
||||
(v for k, v in videos.items() if k.lower() == handle), None
|
||||
)
|
||||
|
||||
|
||||
def _pause_duration(marker_id: str, videos: dict) -> float:
|
||||
"""Seconds a pause-variant video marker freezes the narration for, else 0."""
|
||||
if not marker_id.startswith(_PAUSE_MARKER_PREFIXES):
|
||||
@@ -138,8 +158,9 @@ def derive_events(
|
||||
if (placed and t.confidence >= _EXACT_THRESHOLD and not after_prev)
|
||||
else MAPPING_INTERPOLATED
|
||||
)
|
||||
etype = marker_type(t.marker_id, slides, videos, audio)
|
||||
e = {
|
||||
"type": marker_type(t.marker_id, slides, videos, audio),
|
||||
"type": etype,
|
||||
"id": t.marker_id,
|
||||
"narration_time": round(t.timestamp, 3) if placed else None,
|
||||
"adjustment": 0.0,
|
||||
@@ -148,6 +169,45 @@ def derive_events(
|
||||
"confidence": round(t.confidence, 3),
|
||||
"context": (t.context or "")[:80],
|
||||
}
|
||||
# Materialize per-occurrence presentation onto video events so events.json is
|
||||
# atomic (each occurrence self-contained, GUI-editable) rather than depending on
|
||||
# the handle's videos.json entry. Resolved from the shorthand prefix + any prior
|
||||
# override the marker carried.
|
||||
if etype == "video":
|
||||
vs = _lookup_video(t.marker_id, videos)
|
||||
if vs is not None:
|
||||
pres = resolve_video_presentation(
|
||||
t.marker_id,
|
||||
vs,
|
||||
t.overrides,
|
||||
default_end_on=(
|
||||
None if t.marker_id.startswith("narration:") else "next_slide"
|
||||
),
|
||||
)
|
||||
e["handle"] = pres["handle"]
|
||||
e["cutout"] = pres["cutout"]
|
||||
e["layer"] = pres["layer"]
|
||||
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
|
||||
@@ -157,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
|
||||
@@ -287,14 +426,22 @@ 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 _EVENT_OVERRIDE_KEYS if k in e} or None
|
||||
timings.append(
|
||||
MarkerTiming(
|
||||
marker_id=e["id"],
|
||||
timestamp=eff,
|
||||
context=e.get("context", ""),
|
||||
confidence=float(e.get("confidence", 1.0)),
|
||||
overrides=overrides,
|
||||
)
|
||||
)
|
||||
return timings
|
||||
|
||||
+19
-3
@@ -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}/",
|
||||
|
||||
+312
-63
@@ -22,7 +22,7 @@ from .models import (
|
||||
VideoEvent,
|
||||
VideoSource,
|
||||
)
|
||||
from .parser import get_video_duration, resolve_missing_videos
|
||||
from .parser import get_video_duration, resolve_missing_videos, parse_marker
|
||||
from .transcriber import TranscribedWord
|
||||
|
||||
# Audio trigger offset: play sound this many seconds before the marker
|
||||
@@ -54,6 +54,76 @@ _SHORTHAND_PREFIXES: dict[str, tuple] = {
|
||||
"vsmp:": ("square", "mid"),
|
||||
}
|
||||
|
||||
# Cutout zone the narration talking-head defaults to when a segment/source has none.
|
||||
# Matches the convention import uses (cli._import_narration_segments writes this).
|
||||
_NARRATION_CUTOUT = "talkinghead"
|
||||
|
||||
|
||||
def resolve_video_presentation(
|
||||
marker_id: str,
|
||||
video_source,
|
||||
overrides: Optional[dict] = None,
|
||||
default_end_on: Optional[str] = "next_slide",
|
||||
) -> dict:
|
||||
"""Resolve a video marker's per-occurrence presentation to an atomic dict.
|
||||
|
||||
Single source of truth for how a marker becomes an event, shared by the render
|
||||
transformer (_extract_video_events) and the scaffold that materializes events.json.
|
||||
Presentation is per-occurrence: the shorthand prefix (vst: → square/above) decides
|
||||
cutout+layer for THIS marker, so one handle can appear as vst: and vsb: without a
|
||||
videos.json collision.
|
||||
|
||||
Precedence per field: explicit event override > shorthand prefix > videos.json
|
||||
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}.
|
||||
"""
|
||||
overrides = overrides or {}
|
||||
|
||||
prefix = next((p for p in _SHORTHAND_PREFIXES if marker_id.startswith(p)), None)
|
||||
if prefix is not None:
|
||||
handle = marker_id[len(prefix):].lower()
|
||||
impl_cutout, impl_layer = _SHORTHAND_PREFIXES[prefix]
|
||||
else:
|
||||
# Legacy [video:X] / [narration:X] — strip the generic prefix if present.
|
||||
handle = (marker_id.split(":", 1)[1] if ":" in marker_id else marker_id).lower()
|
||||
impl_cutout = impl_layer = None
|
||||
|
||||
cutout = overrides.get("cutout") or impl_cutout or video_source.cutout
|
||||
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,
|
||||
"cutout": cutout,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkerTiming:
|
||||
@@ -63,6 +133,9 @@ class MarkerTiming:
|
||||
timestamp: float # -1 if not found
|
||||
context: str # the text following the marker
|
||||
confidence: float # 0-1, how confident the match is
|
||||
# Per-occurrence presentation overrides carried from events.json (GUI edits) so
|
||||
# render honors them over the shorthand/videos.json defaults. None on fresh align.
|
||||
overrides: Optional[dict] = None
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
@@ -187,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
|
||||
|
||||
|
||||
@@ -226,7 +304,10 @@ def _extract_marker_contexts(
|
||||
|
||||
raw_contexts = []
|
||||
for i in range(1, len(parts), 2):
|
||||
marker_id = parts[i]
|
||||
# Split the bracket into a bare marker id and any inline overrides
|
||||
# ([vsb:clip, end_on=next_video]); the id drives alignment, the overrides ride
|
||||
# along to the event.
|
||||
marker_id, overrides = parse_marker(parts[i])
|
||||
|
||||
if not _is_known_marker(marker_id, slides, videos, audio):
|
||||
continue
|
||||
@@ -240,7 +321,7 @@ def _extract_marker_contexts(
|
||||
j += 1
|
||||
if j >= len(parts):
|
||||
break
|
||||
if _is_known_marker(parts[j], slides, videos, audio):
|
||||
if _is_known_marker(parse_marker(parts[j])[0], slides, videos, audio):
|
||||
break
|
||||
j += 1
|
||||
|
||||
@@ -248,22 +329,22 @@ def _extract_marker_contexts(
|
||||
following_text = " ".join(following_text.split())
|
||||
following_text = _strip_unknown_markers(following_text, slides, videos, audio)
|
||||
following_text = " ".join(following_text.split())
|
||||
raw_contexts.append((marker_id, following_text))
|
||||
raw_contexts.append((marker_id, following_text, overrides))
|
||||
|
||||
contexts = []
|
||||
for i, (marker_id, following_text) in enumerate(raw_contexts):
|
||||
for i, (marker_id, following_text, overrides) in enumerate(raw_contexts):
|
||||
if following_text:
|
||||
words = following_text.split()[:10]
|
||||
contexts.append((marker_id, " ".join(words), False, "before"))
|
||||
contexts.append((marker_id, " ".join(words), False, "before", overrides))
|
||||
else:
|
||||
borrowed = False
|
||||
for j in range(i + 1, len(raw_contexts)):
|
||||
next_marker_id, next_text = raw_contexts[j]
|
||||
next_marker_id, next_text, _ = raw_contexts[j]
|
||||
if next_text:
|
||||
if next_marker_id in (slides or {}):
|
||||
break
|
||||
words = next_text.split()[:10]
|
||||
contexts.append((marker_id, " ".join(words), True, "before"))
|
||||
contexts.append((marker_id, " ".join(words), True, "before", overrides))
|
||||
borrowed = True
|
||||
break
|
||||
if not borrowed:
|
||||
@@ -278,9 +359,9 @@ def _extract_marker_contexts(
|
||||
if preceding_text:
|
||||
words = preceding_text.split()
|
||||
tail = " ".join(words[-6:])
|
||||
contexts.append((marker_id, tail, False, "after"))
|
||||
contexts.append((marker_id, tail, False, "after", overrides))
|
||||
else:
|
||||
contexts.append((marker_id, "", False, "before"))
|
||||
contexts.append((marker_id, "", False, "before", overrides))
|
||||
|
||||
return contexts
|
||||
|
||||
@@ -484,7 +565,7 @@ def align_markers_to_transcription(
|
||||
last_idx = 0
|
||||
last_end_time = 0.0
|
||||
|
||||
for marker_id, anchor_text, is_borrowed, anchor_type in contexts:
|
||||
for marker_id, anchor_text, is_borrowed, anchor_type, overrides in contexts:
|
||||
if not anchor_text.strip():
|
||||
marker_time = last_end_time + 1.0
|
||||
timings.append(
|
||||
@@ -493,6 +574,7 @@ def align_markers_to_transcription(
|
||||
timestamp=marker_time,
|
||||
context="(after previous)",
|
||||
confidence=1.0,
|
||||
overrides=overrides,
|
||||
)
|
||||
)
|
||||
last_end_time = marker_time
|
||||
@@ -517,6 +599,7 @@ def align_markers_to_transcription(
|
||||
timestamp=marker_time,
|
||||
context=f"(end of: {anchor_text[:40]})",
|
||||
confidence=confidence,
|
||||
overrides=overrides,
|
||||
)
|
||||
)
|
||||
last_idx = match_end_idx
|
||||
@@ -529,6 +612,7 @@ def align_markers_to_transcription(
|
||||
timestamp=adjusted_time,
|
||||
context=anchor_text[:50],
|
||||
confidence=confidence,
|
||||
overrides=overrides,
|
||||
)
|
||||
)
|
||||
if not is_borrowed:
|
||||
@@ -544,6 +628,7 @@ def align_markers_to_transcription(
|
||||
timestamp=-1.0,
|
||||
context=anchor_text[:50],
|
||||
confidence=0.0,
|
||||
overrides=overrides,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -557,7 +642,7 @@ def align_markers_to_transcription(
|
||||
if timing.timestamp >= 0:
|
||||
continue
|
||||
|
||||
marker_id, anchor_text, is_borrowed, anchor_type = contexts[i]
|
||||
marker_id, anchor_text, is_borrowed, anchor_type, overrides = contexts[i]
|
||||
if not anchor_text.strip():
|
||||
continue
|
||||
|
||||
@@ -612,6 +697,7 @@ def align_markers_to_transcription(
|
||||
timestamp=marker_time,
|
||||
context=f"(repaired: {anchor_text[:40]})",
|
||||
confidence=confidence,
|
||||
overrides=overrides,
|
||||
)
|
||||
|
||||
# Deduplicate slide markers. The manuscript pattern [SN]\n\n[SN] text... is
|
||||
@@ -733,8 +819,9 @@ def build_render_plan(
|
||||
# sum of the segment durations and skip is already baked into each segment.
|
||||
if narration_schedule:
|
||||
narration_video_id = "narration"
|
||||
# cutout left unset — resolved to the talkinghead zone below (single source).
|
||||
narration_video = narration_source or VideoSource(
|
||||
source_file="", cutout=config.default_slide_type, always_visible=True
|
||||
source_file="", always_visible=True
|
||||
)
|
||||
narration_skip = 0.0
|
||||
full_duration = sum(seg.duration for seg in narration_schedule)
|
||||
@@ -770,7 +857,20 @@ def build_render_plan(
|
||||
if timing.timestamp >= 0:
|
||||
marker_times[timing.marker_id] = timing.timestamp
|
||||
|
||||
cutout = config.cutouts[narration_video.cutout]
|
||||
# Narration talking-head cutout. Narration segments carry no cutout of their own,
|
||||
# so default to the dedicated "talkinghead" zone (the convention import uses when it
|
||||
# creates narration entries — see cli._import_narration_segments), falling back to
|
||||
# the project's default slide type only if that zone isn't defined. Guard the lookup
|
||||
# so a misconfigured cutout gives a clear error instead of a bare KeyError.
|
||||
narration_cutout_name = narration_video.cutout or (
|
||||
_NARRATION_CUTOUT if _NARRATION_CUTOUT in config.cutouts else config.default_slide_type
|
||||
)
|
||||
if narration_cutout_name not in config.cutouts:
|
||||
raise ValueError(
|
||||
f"Narration cutout '{narration_cutout_name}' not found in project cutouts "
|
||||
f"{list(config.cutouts)}"
|
||||
)
|
||||
cutout = config.cutouts[narration_cutout_name]
|
||||
# Adjust duration for skip (content starts at skip, so effective duration is less)
|
||||
effective_duration = full_duration - narration_skip
|
||||
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = [
|
||||
@@ -870,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
|
||||
|
||||
@@ -913,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:
|
||||
@@ -972,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,
|
||||
@@ -989,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 [],
|
||||
@@ -1194,14 +1326,10 @@ def _extract_video_events(
|
||||
marker_timings, slides, total_duration
|
||||
)
|
||||
|
||||
# Pause-variant prefixes — the only thing the render pass still needs from
|
||||
# shorthand markers at event-build time (pause_narration is per-event, not stored in videos.json).
|
||||
_PAUSE_PREFIXES = {"vftp:", "vfbp:", "vfmp:", "vf2tp:", "vf2bp:", "vf2mp:", "vstp:", "vsbp:", "vsmp:"}
|
||||
|
||||
# Collect video markers: (time, video_id, event_type, pause_narration)
|
||||
# video_markers: (timestamp, video_id, marker_type, pause_narration)
|
||||
# cutout and layer are read from videos.json (projected there by _project_markers_to_videos)
|
||||
video_markers: list[tuple[float, str, str, bool]] = []
|
||||
# Collect video markers. Carry the full marker_id (so presentation resolves from
|
||||
# its shorthand prefix PER occurrence) and any per-event overrides from events.json.
|
||||
# video_markers: (timestamp, marker_id, handle, trigger_type, overrides)
|
||||
video_markers: list[tuple[float, str, str, str, Optional[dict]]] = []
|
||||
|
||||
for timing in marker_timings:
|
||||
if timing.timestamp < 0:
|
||||
@@ -1229,8 +1357,9 @@ def _extract_video_events(
|
||||
f"run render once to project values, or set cutout manually."
|
||||
)
|
||||
continue
|
||||
pause_narration = shorthand_match in _PAUSE_PREFIXES
|
||||
video_markers.append((timing.timestamp, video_id, "video", pause_narration))
|
||||
video_markers.append(
|
||||
(timing.timestamp, mid, video_id, "video", timing.overrides)
|
||||
)
|
||||
continue
|
||||
|
||||
# --- legacy [video:xxx] ---
|
||||
@@ -1247,7 +1376,9 @@ def _extract_video_events(
|
||||
f"[video:{video_id}] has no valid cutout in videos.json — skipped."
|
||||
)
|
||||
continue
|
||||
video_markers.append((timing.timestamp, video_id, "video", False))
|
||||
video_markers.append(
|
||||
(timing.timestamp, mid, video_id, "video", timing.overrides)
|
||||
)
|
||||
continue
|
||||
|
||||
# --- [narration:xxx] ---
|
||||
@@ -1264,29 +1395,56 @@ def _extract_video_events(
|
||||
f"[narration:{video_id}] has no valid cutout in videos.json — skipped."
|
||||
)
|
||||
continue
|
||||
video_markers.append((timing.timestamp, video_id, "narration", False))
|
||||
video_markers.append(
|
||||
(timing.timestamp, mid, video_id, "narration", timing.overrides)
|
||||
)
|
||||
|
||||
# Sorted start times of all video markers — used by end_on="next_video" to cap
|
||||
# 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] = []
|
||||
for start_time, video_id, marker_type, pause_narration in video_markers:
|
||||
for start_time, marker_id, video_id, trigger_type, overrides in video_markers:
|
||||
video_source = videos[video_id]
|
||||
|
||||
# Read cutout and layer directly from videos.json (projected by ETL)
|
||||
cutout_name = video_source.cutout
|
||||
# Resolve presentation per-occurrence: shorthand prefix (and any events.json
|
||||
# override) wins over the videos.json default, so one handle can render above
|
||||
# in one place and below in another. [narration:] runs to the end by default.
|
||||
pres = resolve_video_presentation(
|
||||
marker_id,
|
||||
video_source,
|
||||
overrides,
|
||||
default_end_on=(None if trigger_type == "narration" else "next_slide"),
|
||||
)
|
||||
cutout_name = pres["cutout"]
|
||||
cutout = cutouts[cutout_name]
|
||||
layer = video_source.layer
|
||||
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"]
|
||||
|
||||
end_on = video_source.end_on
|
||||
if end_on == "take" and video_source.take is not None:
|
||||
end_time = start_time + video_source.take
|
||||
if end_on == "take" and take is not None:
|
||||
end_time = start_time + take
|
||||
elif end_on == "end":
|
||||
# Play the clip once through its natural length, then stop — no looping.
|
||||
# Natural length = explicit take, else the file's own duration past skip.
|
||||
if video_source.take is not None:
|
||||
natural = video_source.take
|
||||
if take is not None:
|
||||
natural = take
|
||||
elif video_source.duration is not None:
|
||||
natural = max(0.0, video_source.duration - (video_source.skip or 0.0))
|
||||
else:
|
||||
@@ -1303,34 +1461,77 @@ 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.
|
||||
if video_source.pause_narration:
|
||||
end_time = max(end_time, start_time + video_source.pause_narration)
|
||||
elif end_on in ("next_slide", "slide") or (end_on is None and marker_type == "video"):
|
||||
# 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 = 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
|
||||
for slide_time in slide_times:
|
||||
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.
|
||||
if video_source.pause_narration:
|
||||
end_time = max(end_time, start_time + video_source.pause_narration)
|
||||
# pause_narration cutscene: end exactly with the freeze (see above).
|
||||
if 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 is None and marker_type == "narration": runs to end
|
||||
# end_on None ([narration:] with no explicit end) — runs to end.
|
||||
end_time = total_duration
|
||||
|
||||
# Filter by time range.
|
||||
# CHUNKING v1 LIMITATION: this keeps only clips whose START is inside the
|
||||
# window, so a clip that began in an earlier chunk and is still playing
|
||||
# across the boundary is DROPPED here — the chunked output then differs from
|
||||
# a full render. cli._chunk_boundary_span_warnings surfaces this. The v2 fix
|
||||
# is overlap-inclusion + a per-event seek (skip_override); see
|
||||
# docs/chunking_v2.md.
|
||||
if start_time < range_start or start_time >= range_end:
|
||||
# Filter by time range — CHUNKING v2 (docs/chunking_v2.md).
|
||||
# Include any clip that OVERLAPS the window (not just those starting inside
|
||||
# it), so a clip spanning a chunk boundary survives into the later chunk.
|
||||
if end_time <= range_start or start_time >= range_end:
|
||||
continue
|
||||
end_time = min(end_time, range_end)
|
||||
skip_override = None
|
||||
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(
|
||||
@@ -1341,6 +1542,14 @@ 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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1356,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
|
||||
@@ -1367,17 +1585,48 @@ def _extract_audio_events(
|
||||
elif marker_id.startswith("audio:"):
|
||||
audio_id = marker_id[6:]
|
||||
if audio_id is not None and audio_id in audio:
|
||||
# CHUNKING v1 LIMITATION (same as video, worse for looping background
|
||||
# music): a clip started before the window is dropped from later chunks.
|
||||
# v2 = overlap-inclusion + audio seek; see docs/chunking_v2.md.
|
||||
if timing.timestamp < range_start or timing.timestamp >= range_end:
|
||||
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 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
|
||||
else:
|
||||
aend = float("inf") # unknown one-shot length — assume it may span
|
||||
# CHUNKING v2 (docs/chunking_v2.md): include if it OVERLAPS the window,
|
||||
# and seek into clips that began earlier so they resume mid-track — the
|
||||
# loop phase for looping music, a linear seek for one-shots. v1 dropped
|
||||
# these, silencing looping background music in every chunk but the first.
|
||||
if aend <= range_start or astart >= range_end:
|
||||
continue
|
||||
start_time = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
|
||||
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
|
||||
events.append(
|
||||
AudioEvent(
|
||||
audio_id=audio_id,
|
||||
start_time=start_time,
|
||||
audio_def=audio[audio_id],
|
||||
start_time=astart,
|
||||
audio_def=adef,
|
||||
src_offset=src_offset,
|
||||
crossfade_offset=crossfade_offset,
|
||||
end_time=clip_end,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ def validate_project(
|
||||
elif marker.startswith("narration:"):
|
||||
referenced_video_ids.add(marker[10:].lower())
|
||||
|
||||
# (Key-reuse across cutout/layer is legal: presentation is resolved per-occurrence
|
||||
# from the shorthand prefix now — see transformer.resolve_video_presentation — so
|
||||
# one handle can appear as vst: (above) and vsb: (below) without colliding.)
|
||||
|
||||
# Check for malformed markers first (these are likely typos)
|
||||
if malformed_markers:
|
||||
for line_num, marker_text in malformed_markers:
|
||||
@@ -141,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(
|
||||
@@ -151,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(
|
||||
@@ -346,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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan-level validation for chunking v2 (docs/chunking_v2.md).
|
||||
|
||||
Verifies the transformer now INCLUDES clips that span a chunk boundary and seeks
|
||||
into them (skip_override / src_offset), instead of the v1 behaviour that dropped
|
||||
them. This is a pure plan-level check — the ffmpeg concat-seam (frame alignment via
|
||||
-c copy) still needs a real render on the rig to confirm.
|
||||
|
||||
Run: ./venv/bin/python tests/test_chunking_v2.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from gnommo.transformer import (
|
||||
MarkerTiming,
|
||||
_extract_audio_events,
|
||||
_extract_video_events,
|
||||
AUDIO_OFFSET_SECONDS,
|
||||
)
|
||||
from gnommo.models import AudioDefinition, VideoSource, CutoutDefinition, SlideDefinition
|
||||
|
||||
_fails = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}" + (f" — {detail}" if detail and not cond else ""))
|
||||
if not cond:
|
||||
_fails.append(name)
|
||||
|
||||
|
||||
# ── audio ────────────────────────────────────────────────────────────────────
|
||||
def test_audio():
|
||||
print("audio events:")
|
||||
audio = {
|
||||
"music": AudioDefinition(file="music.mp3", loop=True, duration=90.0),
|
||||
"sfx": AudioDefinition(file="sfx.wav", loop=False, duration=500.0),
|
||||
"blip": AudioDefinition(file="blip.wav", loop=False, duration=50.0),
|
||||
}
|
||||
# music triggers at t=0, sfx at t=10, blip at t=10
|
||||
markers = [
|
||||
MarkerTiming(marker_id="Amusic", timestamp=0.0, context="", confidence=1.0),
|
||||
MarkerTiming(marker_id="Asfx", timestamp=10.0, context="", confidence=1.0),
|
||||
MarkerTiming(marker_id="Ablip", timestamp=10.0, context="", confidence=1.0),
|
||||
]
|
||||
|
||||
# Chunk window [300, 600): all three started earlier.
|
||||
evs = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=(300.0, 600.0))}
|
||||
|
||||
# Looping music: included, resumes at loop phase. astart = max(0, 0-1)=0; into=300;
|
||||
# phase = 300 % 90 = 30.
|
||||
check("looping music spanning boundary is INCLUDED (v1 dropped it)", "music" in evs)
|
||||
if "music" in evs:
|
||||
m = evs["music"]
|
||||
check("music clamped to window start", abs(m.start_time - 300.0) < 1e-6, f"start={m.start_time}")
|
||||
check("music seeks to loop phase 30.0", abs(m.src_offset - 30.0) < 1e-6, f"src_offset={m.src_offset}")
|
||||
|
||||
# One-shot still playing at the window: included, linear seek.
|
||||
# astart = max(0,10-1)=9; aend=9+500=509 > 300 → spans. into=300-9=291.
|
||||
check("one-shot still playing is INCLUDED", "sfx" in evs)
|
||||
if "sfx" in evs:
|
||||
s = evs["sfx"]
|
||||
check("sfx linear seek 291.0", abs(s.src_offset - 291.0) < 1e-6, f"src_offset={s.src_offset}")
|
||||
|
||||
# One-shot that ended before the window: excluded (aend=9+50=59 < 300).
|
||||
check("one-shot ended before window is EXCLUDED", "blip" not in evs)
|
||||
|
||||
# Full render (no range): everything from the start, no seek.
|
||||
full = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=None)}
|
||||
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
||||
|
||||
|
||||
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:")
|
||||
cutouts = {"fullscreen": CutoutDefinition(x=0, y=0, height=1080, width=1920)}
|
||||
videos = {
|
||||
"bg": VideoSource(
|
||||
source_file="bg.mp4", cutout="fullscreen", layer="below",
|
||||
duration=90.0, skip=0.0, end_on="next_video",
|
||||
)
|
||||
}
|
||||
slides = {f"S{i}": SlideDefinition(image=f"S{i}.png", type="slide") for i in range(1, 11)}
|
||||
markers = [MarkerTiming(marker_id=f"S{i}", timestamp=(i - 1) * 60.0, context="", confidence=1.0)
|
||||
for i in range(1, 11)]
|
||||
# Background overlay starts at slide-7 time (360) and, as the only video with
|
||||
# end_on next_video, runs to total_duration (600).
|
||||
markers.append(MarkerTiming(marker_id="vfm:bg", timestamp=360.0, context="", confidence=1.0))
|
||||
|
||||
total = 600.0
|
||||
# Chunk window that STARTS AFTER the video began: [420, 600) (slides 8-10).
|
||||
evs, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(420.0, 600.0))
|
||||
bg = next((e for e in evs if e.video_id == "bg"), None)
|
||||
|
||||
check("spanning background video is INCLUDED in the later chunk (v1 dropped it)", bg is not None)
|
||||
if bg is not None:
|
||||
check("bg clamped to window start", abs(bg.start_time - 420.0) < 1e-6, f"start={bg.start_time}")
|
||||
# into = 420-360 = 60; playable = 90-0 = 90; 60 < 90 → linear seek 60.
|
||||
check("bg linear seek 60.0 (first play-through)", abs((bg.skip_override or 0) - 60.0) < 1e-6,
|
||||
f"skip_override={bg.skip_override}")
|
||||
|
||||
# Window starting deep enough that the 90s clip has looped once: [480, 600).
|
||||
# into = 480-360 = 120; 120 >= 90 → phase = 120 % 90 = 30.
|
||||
evs2, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(480.0, 600.0))
|
||||
bg2 = next((e for e in evs2 if e.video_id == "bg"), None)
|
||||
check("looped background resumes at phase 30.0", bg2 is not None and abs((bg2.skip_override or 0) - 30.0) < 1e-6,
|
||||
f"skip_override={getattr(bg2, 'skip_override', None)}")
|
||||
|
||||
# Full render: no seek.
|
||||
evs3, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=None)
|
||||
bg3 = next((e for e in evs3 if e.video_id == "bg"), None)
|
||||
check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None)
|
||||
|
||||
|
||||
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)}")
|
||||
sys.exit(1)
|
||||
print("All chunking-v2 plan-level checks passed.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user