Compare commits
25
Commits
0c8f662bee
..
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 |
@@ -1503,9 +1503,12 @@
|
|||||||
},
|
},
|
||||||
"Logo": {
|
"Logo": {
|
||||||
"source_file": "Logo.mov",
|
"source_file": "Logo.mov",
|
||||||
"duration": 14.0,
|
"duration": 18.0,
|
||||||
"has_audio": true,
|
"has_audio": true,
|
||||||
"is_shared": true
|
"is_shared": true,
|
||||||
|
"cutout": "fullscreen",
|
||||||
|
"layer": "above",
|
||||||
|
"pause_narration": 14.0
|
||||||
},
|
},
|
||||||
"MontageZoom": {
|
"MontageZoom": {
|
||||||
"source_file": "MontageZoom.mp4",
|
"source_file": "MontageZoom.mp4",
|
||||||
|
|||||||
@@ -1501,12 +1501,13 @@
|
|||||||
"has_audio": false,
|
"has_audio": false,
|
||||||
"is_shared": true
|
"is_shared": true
|
||||||
},
|
},
|
||||||
"Logo": {
|
|
||||||
"source_file": "Logo.mov",
|
"source_file": "Logo.mov",
|
||||||
"duration": 14.0,
|
"duration": 18.0,
|
||||||
"has_audio": true,
|
"has_audio": true,
|
||||||
"is_shared": true
|
"is_shared": true,
|
||||||
},
|
"cutout": "fullscreen",
|
||||||
|
"layer": "above",
|
||||||
|
"pause_narration": 14.0
|
||||||
"MontageZoom": {
|
"MontageZoom": {
|
||||||
"source_file": "MontageZoom.mp4",
|
"source_file": "MontageZoom.mp4",
|
||||||
"duration": 17.0,
|
"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
|
||||||
|
|
||||||
|
|
||||||
+12
-6
@@ -11,14 +11,20 @@ Implemented:
|
|||||||
take`; `events_to_marker_timings` round-trips them as overrides.
|
take`; `events_to_marker_timings` round-trips them as overrides.
|
||||||
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
|
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
|
||||||
alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on,
|
alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on,
|
||||||
take** (the fully-wired per-event fields). Unknown keys are ignored.
|
take, volume** (numeric keys `take`/`volume` coerced to float). Unknown keys are ignored.
|
||||||
- The key-reuse collision validator hard-error was removed (reuse is legal now).
|
- The key-reuse collision validator hard-error was removed (reuse is legal now).
|
||||||
|
|
||||||
Deferred (follow-ups): inline override of the *global* params (skip/zoom/volume/
|
`volume` is **sparse/overridable**: the renderer reads `VideoEvent.volume` (line ~1571),
|
||||||
use_audio_channels/pause_narration) — the renderer reads these from `video_source` in ~13
|
which is the events.json override if present else the videos.json default — so a videos.json
|
||||||
places, so wiring them per-event is a separate change; stripping the moved fields from
|
change keeps propagating, and events.json only stores `volume` when it's actually overridden
|
||||||
videos.json (kept as fallback defaults for now); a validator warning for unknown/unwired
|
(inline/GUI/manual). `derive_events` materializes it only when overridden; `_EVENT_OVERRIDE_KEYS`
|
||||||
inline keys.
|
round-trips it. This is the template for the remaining globals.
|
||||||
|
|
||||||
|
Deferred (follow-ups): inline/per-event override of the *other* globals (skip/zoom/
|
||||||
|
use_audio_channels/pause_narration) — same renderer-plumbing pattern as volume, per site;
|
||||||
|
per-segment narration voiceover volume (render currently uses `narration_videos[0].volume`
|
||||||
|
only); stripping the moved fields from videos.json (kept as fallback defaults); a validator
|
||||||
|
warning for unknown inline keys.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
|
|||||||
+86
-20
@@ -11,6 +11,7 @@ Files are looked up first locally, then in the cache at:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
@@ -18,36 +19,103 @@ from typing import Optional, Tuple
|
|||||||
_cache_config: Optional[dict] = None
|
_cache_config: Optional[dict] = None
|
||||||
_assets_config: Optional[dict] = None
|
_assets_config: Optional[dict] = None
|
||||||
_perf_config: Optional[dict] = None
|
_perf_config: Optional[dict] = None
|
||||||
|
# Per-project performance overrides (project.json "performance" block). Set at the
|
||||||
|
# start of preprocess/render via set_active_project(). These OVERRIDE ~/.gnommo.conf
|
||||||
|
# and — unlike that per-machine file — travel with the project over up/down, so the
|
||||||
|
# render rig's chunk size / CPU limits can be tuned remotely by editing project.json.
|
||||||
|
_active_project_perf: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def get_ffmpeg_thread_count() -> int:
|
def _load_perf_config() -> dict:
|
||||||
"""Return FFmpeg thread count based on [performance] cpu_limit in ~/.gnommo.conf.
|
"""Read and cache the [performance] section of ~/.gnommo.conf."""
|
||||||
|
|
||||||
cpu_limit is a fraction of logical CPUs (e.g. 0.8 = 80%).
|
|
||||||
Defaults to 1 when not configured, which is safe on memory-constrained machines.
|
|
||||||
|
|
||||||
Example ~/.gnommo.conf:
|
|
||||||
[performance]
|
|
||||||
cpu_limit = 0.8
|
|
||||||
"""
|
|
||||||
global _perf_config
|
global _perf_config
|
||||||
if _perf_config is None:
|
if _perf_config is not None:
|
||||||
config_path = Path.home() / ".gnommo.conf"
|
return _perf_config
|
||||||
|
|
||||||
_perf_config = {}
|
_perf_config = {}
|
||||||
|
config_path = Path.home() / ".gnommo.conf"
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(config_path)
|
cfg.read(config_path)
|
||||||
if cfg.has_option("performance", "cpu_limit"):
|
for key in ("cpu_limit_preprocess", "cpu_limit_render", "cpu_limit"):
|
||||||
|
if cfg.has_option("performance", key):
|
||||||
try:
|
try:
|
||||||
_perf_config["cpu_limit"] = float(
|
_perf_config[key] = float(cfg.get("performance", key))
|
||||||
cfg.get("performance", "cpu_limit")
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if cfg.has_option("performance", "render_chunk_slides"):
|
||||||
|
try:
|
||||||
|
_perf_config["render_chunk_slides"] = int(
|
||||||
|
cfg.get("performance", "render_chunk_slides")
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
return _perf_config
|
||||||
|
|
||||||
cpu_limit = _perf_config.get("cpu_limit")
|
|
||||||
|
def set_active_project(project_path) -> None:
|
||||||
|
"""Load a project's optional "performance" overrides from its project.json.
|
||||||
|
|
||||||
|
project.json syncs via up/down and isn't secret, so its "performance" block is
|
||||||
|
the remotely-editable home for the render rig's tunables (render_chunk_slides,
|
||||||
|
cpu_limit_preprocess, cpu_limit_render). Keys present here override
|
||||||
|
~/.gnommo.conf; anything absent falls back to the machine config. Call once at
|
||||||
|
the start of a per-project preprocess/render.
|
||||||
|
"""
|
||||||
|
global _active_project_perf
|
||||||
|
_active_project_perf = {}
|
||||||
|
if project_path is None:
|
||||||
|
return
|
||||||
|
pj = Path(project_path) / "project.json"
|
||||||
|
if not pj.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
perf = json.loads(pj.read_text(encoding="utf-8")).get("performance")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return
|
||||||
|
if isinstance(perf, dict):
|
||||||
|
_active_project_perf = perf
|
||||||
|
|
||||||
|
|
||||||
|
def _perf_get(key: str, default_key: Optional[str] = None):
|
||||||
|
"""Resolve a performance value: active project.json overrides ~/.gnommo.conf.
|
||||||
|
|
||||||
|
Within each source the stage-specific `key` wins over the legacy `default_key`
|
||||||
|
(e.g. `cpu_limit`); the project source is consulted before the machine config.
|
||||||
|
"""
|
||||||
|
conf = _load_perf_config()
|
||||||
|
for src in (_active_project_perf, conf):
|
||||||
|
for k in (key, default_key):
|
||||||
|
if k and src.get(k) is not None:
|
||||||
|
return src[k]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ffmpeg_thread_count(stage: str = "preprocess") -> int:
|
||||||
|
"""Return the FFmpeg thread count for a pipeline stage from ~/.gnommo.conf.
|
||||||
|
|
||||||
|
Preprocessing and rendering scale differently, so they read separate CPU
|
||||||
|
fractions of the logical core count:
|
||||||
|
|
||||||
|
[performance]
|
||||||
|
cpu_limit_preprocess = 0.8 # throughput-bound; safe at high parallelism
|
||||||
|
cpu_limit_render = 0.15 # -filter_complex spawns swscaler threads per
|
||||||
|
# layer and OOMs at high core counts
|
||||||
|
|
||||||
|
`stage` is "preprocess" or "render". The legacy single `cpu_limit` key is the
|
||||||
|
fallback for either stage when its specific key is absent. A project.json
|
||||||
|
"performance" block (see set_active_project) overrides these per project. Each
|
||||||
|
value is a fraction of logical CPUs (0.8 = 80%); defaults to 1 thread when
|
||||||
|
nothing is configured, which is safe on memory-constrained machines.
|
||||||
|
"""
|
||||||
|
key = "cpu_limit_render" if stage == "render" else "cpu_limit_preprocess"
|
||||||
|
cpu_limit = _perf_get(key, "cpu_limit")
|
||||||
if cpu_limit is None:
|
if cpu_limit is None:
|
||||||
return 1
|
return 1
|
||||||
|
try:
|
||||||
|
cpu_limit = float(cpu_limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
cpu_count = os.cpu_count() or 1
|
cpu_count = os.cpu_count() or 1
|
||||||
return max(1, int(cpu_count * cpu_limit))
|
return max(1, int(cpu_count * cpu_limit))
|
||||||
|
|
||||||
@@ -57,15 +125,13 @@ def get_render_chunk_size() -> Optional[int]:
|
|||||||
|
|
||||||
When set, cmd_render splits the filter graph into chunks of this many slides
|
When set, cmd_render splits the filter graph into chunks of this many slides
|
||||||
to avoid OOM from allocating filter buffers for the entire video at once.
|
to avoid OOM from allocating filter buffers for the entire video at once.
|
||||||
|
A project.json "performance" block overrides ~/.gnommo.conf per project.
|
||||||
|
|
||||||
Example ~/.gnommo.conf:
|
Example ~/.gnommo.conf:
|
||||||
[performance]
|
[performance]
|
||||||
render_chunk_slides = 15
|
render_chunk_slides = 15
|
||||||
"""
|
"""
|
||||||
global _perf_config
|
val = _perf_get("render_chunk_slides")
|
||||||
if _perf_config is None:
|
|
||||||
get_ffmpeg_thread_count() # populates _perf_config
|
|
||||||
val = _perf_config.get("render_chunk_slides")
|
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
+568
-322
File diff suppressed because it is too large
Load Diff
@@ -328,12 +328,23 @@ class VideoSource:
|
|||||||
float
|
float
|
||||||
] = None # Max duration to play (seconds). Default: until next slide or end of clip
|
] = None # Max duration to play (seconds). Default: until next slide or end of clip
|
||||||
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
|
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
|
||||||
|
loop: bool = False # If True, loop the [skip, skip+take] window to fill the display
|
||||||
|
# window (end_on). Without take, loops the whole clip from skip. Distinct from
|
||||||
|
# end_on="loop" (which loops to the render end); loop rides on any end_on.
|
||||||
zoom: float = (
|
zoom: float = (
|
||||||
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
|
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
|
||||||
)
|
)
|
||||||
cutout: Optional[
|
cutout: Optional[
|
||||||
str
|
str
|
||||||
] = None # Name of cutout to place video in (from project.json cutouts)
|
] = None # Name of cutout to place video in (from project.json cutouts)
|
||||||
|
# CSS-like placement when the video's aspect ratio differs from the cutout:
|
||||||
|
# object_fit: "cover" (default) fills the cutout and crops the overflow
|
||||||
|
# (zoomed by `zoom`); "contain" shrinks the whole video to fit
|
||||||
|
# inside and pads the remainder transparently (no cropping).
|
||||||
|
# object_position: which edge to anchor to — "center" (default) | "top" |
|
||||||
|
# "bottom" | "left" | "right".
|
||||||
|
object_fit: str = "cover"
|
||||||
|
object_position: str = "center"
|
||||||
always_visible: bool = False # If True, video is always shown (like talking head)
|
always_visible: bool = False # If True, video is always shown (like talking head)
|
||||||
is_shared: bool = False # If True, source_file is relative to shared_assets/
|
is_shared: bool = False # If True, source_file is relative to shared_assets/
|
||||||
pause_narration: float = (
|
pause_narration: float = (
|
||||||
@@ -421,6 +432,14 @@ class AudioEvent:
|
|||||||
# position (seconds) into the source stream to begin at — the loop phase for
|
# position (seconds) into the source stream to begin at — the loop phase for
|
||||||
# looping music, or a linear seek for one-shots. 0.0 = play from the start (v1).
|
# looping music, or a linear seek for one-shots. 0.0 = play from the start (v1).
|
||||||
src_offset: float = 0.0
|
src_offset: float = 0.0
|
||||||
|
# Loop phase for the CROSSFADE loop path specifically. That stream is periodic
|
||||||
|
# with period (duration - overlap), not `duration`, so its seam-resume phase is
|
||||||
|
# `elapsed % (duration - overlap)` — a different modulus than src_offset. Only
|
||||||
|
# set for looping clips that define an overlap; 0.0 otherwise.
|
||||||
|
crossfade_offset: float = 0.0
|
||||||
|
# Explicit stop time (output timeline) from an [end:handle] marker; None means
|
||||||
|
# play to the render/window end (loop) or the clip's natural length (one-shot).
|
||||||
|
end_time: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -434,6 +453,22 @@ class VideoEvent:
|
|||||||
cutout: "CutoutDefinition"
|
cutout: "CutoutDefinition"
|
||||||
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
||||||
layer: str = "above" # "above" = on top of slides; "below" = behind slides
|
layer: str = "above" # "above" = on top of slides; "below" = behind slides
|
||||||
|
# Resolved per-occurrence end policy (next_slide/next_video/end/loop/…). Kept so
|
||||||
|
# the render-plan listing can report the ACTUAL end rule (incl. inline overrides),
|
||||||
|
# not just the videos.json default. Purely informational; end_time is authoritative.
|
||||||
|
end_on: str = ""
|
||||||
|
# Resolved PER-OCCURRENCE playback controls (inline override > videos.json). The
|
||||||
|
# renderer reads these, not video_source.*, so the same handle can e.g. seek to a
|
||||||
|
# different point or loop in one place but not another.
|
||||||
|
skip: float = 0.0 # seek point into the source (seconds)
|
||||||
|
take: Optional[float] = None # display duration / loop period (seconds); None = window
|
||||||
|
loop: bool = False # loop the [skip, skip+take] window across the display window
|
||||||
|
# Resolved per-occurrence CSS-like cutout placement (see VideoSource).
|
||||||
|
object_fit: str = "cover"
|
||||||
|
object_position: str = "center"
|
||||||
|
# Effective audio volume for THIS occurrence: an events.json override if present,
|
||||||
|
# else the videos.json default. The renderer reads this (not video_source.volume).
|
||||||
|
volume: float = 1.0
|
||||||
# Chunking v2 seam (see docs/chunking_v2.md): when a clip began before this
|
# Chunking v2 seam (see docs/chunking_v2.md): when a clip began before this
|
||||||
# chunk's window, the render must seek into it so it resumes mid-clip instead of
|
# chunk's window, the render must seek into it so it resumes mid-clip instead of
|
||||||
# restarting at the boundary. None = play from video_source.skip (the v1/default).
|
# restarting at the boundary. None = play from video_source.skip (the v1/default).
|
||||||
|
|||||||
@@ -118,3 +118,42 @@ def build_narration_schedule(
|
|||||||
offset += eff
|
offset += eff
|
||||||
|
|
||||||
return segments, merged
|
return segments, merged
|
||||||
|
|
||||||
|
|
||||||
|
def slice_schedule(
|
||||||
|
schedule: list[NarrationSegment],
|
||||||
|
window_start: float,
|
||||||
|
window_end: float,
|
||||||
|
) -> list[NarrationSegment]:
|
||||||
|
"""Return the sub-schedule covering ``[window_start, window_end]`` of the
|
||||||
|
combined narration timeline — for a partial (chunked) render.
|
||||||
|
|
||||||
|
Each kept segment's ``skip``/``take``/``offset`` is adjusted so it seeks within
|
||||||
|
its OWN file: segments entirely outside the window are dropped, the first/last
|
||||||
|
kept segments are trimmed to the window edges, and offsets are re-based so the
|
||||||
|
sliced narration starts at 0. ``input_seek_time`` therefore stays 0 in concat
|
||||||
|
mode instead of a combined-timeline offset being (wrongly) applied to every
|
||||||
|
segment file.
|
||||||
|
|
||||||
|
This is what makes chunking bulletproof: because each chunk seeks its first
|
||||||
|
segment to the true source sample, ``render(A:B) ++ render(B:C)`` lands on the
|
||||||
|
exact same narration as ``render(A:C)``. Slicing to the full ``[0, total]`` is a
|
||||||
|
no-op, so full renders are unaffected.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
|
||||||
|
out: list[NarrationSegment] = []
|
||||||
|
for seg in schedule:
|
||||||
|
seg_start = seg.offset
|
||||||
|
seg_end = seg.offset + seg.duration
|
||||||
|
keep_start = max(window_start, seg_start)
|
||||||
|
keep_end = min(window_end, seg_end)
|
||||||
|
if keep_end - keep_start <= 1e-6:
|
||||||
|
continue # segment lies entirely outside the window
|
||||||
|
new = copy.copy(seg)
|
||||||
|
new.skip = round(seg.skip + (keep_start - seg_start), 6)
|
||||||
|
new.take = round(keep_end - keep_start, 6)
|
||||||
|
new.duration = new.take
|
||||||
|
new.offset = round(keep_start - window_start, 6)
|
||||||
|
out.append(new)
|
||||||
|
return out
|
||||||
|
|||||||
+47
-6
@@ -58,17 +58,36 @@ def _resolve_case_insensitive(path: Path) -> Path:
|
|||||||
# presentation fields materialized onto events.json and resolved per-event
|
# presentation fields materialized onto events.json and resolved per-event
|
||||||
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
|
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
|
||||||
# yet overridable inline; unknown keys are ignored.
|
# yet overridable inline; unknown keys are ignored.
|
||||||
_MARKER_OVERRIDE_KEYS = frozenset({"cutout", "layer", "end_on", "take"})
|
_MARKER_OVERRIDE_KEYS = frozenset(
|
||||||
|
{"cutout", "layer", "end_on", "take", "skip", "loop", "volume",
|
||||||
|
"object-fit", "object-position"}
|
||||||
|
)
|
||||||
|
# Override keys that are numeric (coerced to float).
|
||||||
|
_MARKER_NUMERIC_KEYS = frozenset({"take", "skip", "volume"})
|
||||||
|
# Override keys that are booleans (true/false/1/0/yes/no).
|
||||||
|
_MARKER_BOOL_KEYS = frozenset({"loop"})
|
||||||
|
# Inline aliases → canonical override key. `duration` reads naturally in the
|
||||||
|
# manuscript but means the same as `take` (how long the clip plays / the loop
|
||||||
|
# period), so it maps to take and is stored/rendered as take.
|
||||||
|
_MARKER_KEY_ALIASES = {"duration": "take"}
|
||||||
|
|
||||||
|
|
||||||
def _coerce_marker_value(key: str, raw: str):
|
def _coerce_marker_value(key: str, raw: str):
|
||||||
"""Coerce an inline override value: `take` → float; everything else → string."""
|
"""Coerce an inline override value: numeric keys → float, bool keys → bool,
|
||||||
|
everything else → string. Returns None if a typed value can't be parsed."""
|
||||||
v = raw.strip().strip('"').strip("'")
|
v = raw.strip().strip('"').strip("'")
|
||||||
if key == "take":
|
if key in _MARKER_NUMERIC_KEYS:
|
||||||
try:
|
try:
|
||||||
return float(v)
|
return float(v)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
if key in _MARKER_BOOL_KEYS:
|
||||||
|
lv = v.lower()
|
||||||
|
if lv in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if lv in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +115,7 @@ def parse_marker(raw: str) -> "tuple[str, Optional[dict]]":
|
|||||||
continue
|
continue
|
||||||
key, _, val = tok.partition("=")
|
key, _, val = tok.partition("=")
|
||||||
key = key.strip().lower()
|
key = key.strip().lower()
|
||||||
|
key = _MARKER_KEY_ALIASES.get(key, key) # duration → take, etc.
|
||||||
if key in _MARKER_OVERRIDE_KEYS:
|
if key in _MARKER_OVERRIDE_KEYS:
|
||||||
coerced = _coerce_marker_value(key, val)
|
coerced = _coerce_marker_value(key, val)
|
||||||
if coerced is not None:
|
if coerced is not None:
|
||||||
@@ -232,6 +252,23 @@ def load_citations(path: Path) -> list[Citation]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _lc_handle(value):
|
||||||
|
"""Lowercase a video handle (or list of handles) referenced in project.json.
|
||||||
|
|
||||||
|
Video handles are stored lowercased as videos.json keys (import lowercases them),
|
||||||
|
so project.json references — outro, main_video, background — must be normalised to
|
||||||
|
match; otherwise a mixed-case entry like "OutroVideo6" fails the case-sensitive
|
||||||
|
lookup against key "outrovideo6" and the render reports it "not found" even though
|
||||||
|
it's there. The actual file path comes from the entry's source_file, so its case
|
||||||
|
is untouched. None / non-strings pass through unchanged.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower()
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [x.lower() if isinstance(x, str) else x for x in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def parse_project_config(project_path: Path) -> ProjectConfig:
|
def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||||
"""Parse project.json into ProjectConfig."""
|
"""Parse project.json into ProjectConfig."""
|
||||||
config_path = project_path / "project.json"
|
config_path = project_path / "project.json"
|
||||||
@@ -325,18 +362,18 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
|||||||
default_slide_type=data.get("defaultSlideType", "square"),
|
default_slide_type=data.get("defaultSlideType", "square"),
|
||||||
cutouts=cutouts,
|
cutouts=cutouts,
|
||||||
default_filters=default_filters,
|
default_filters=default_filters,
|
||||||
background=data.get("background", ""),
|
background=_lc_handle(data.get("background", "")),
|
||||||
background_video=data.get("background_video", ""), # Deprecated
|
background_video=data.get("background_video", ""), # Deprecated
|
||||||
slides_path=data.get("slides", "slides.json"),
|
slides_path=data.get("slides", "slides.json"),
|
||||||
videos_path=data.get("videos", "videos.json"),
|
videos_path=data.get("videos", "videos.json"),
|
||||||
audio_path=data.get("audio", "audio.json"),
|
audio_path=data.get("audio", "audio.json"),
|
||||||
transcript_path=data.get("transcript"),
|
transcript_path=data.get("transcript"),
|
||||||
audio_source=data.get("audio_source"),
|
audio_source=data.get("audio_source"),
|
||||||
main_video=data.get("main_video"),
|
main_video=_lc_handle(data.get("main_video")),
|
||||||
process_cache=data.get("process_cache"),
|
process_cache=data.get("process_cache"),
|
||||||
default_begin=float(data.get("default_begin", 0.0)),
|
default_begin=float(data.get("default_begin", 0.0)),
|
||||||
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
||||||
outro=data.get("outro", []),
|
outro=_lc_handle(data.get("outro", [])),
|
||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
footer=data.get("footer", ""),
|
footer=data.get("footer", ""),
|
||||||
output_video=data.get("output_video", ""),
|
output_video=data.get("output_video", ""),
|
||||||
@@ -615,8 +652,11 @@ def parse_videos(
|
|||||||
output_file=video_data.get("output_file"),
|
output_file=video_data.get("output_file"),
|
||||||
take=take,
|
take=take,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
|
loop=bool(video_data.get("loop", False)),
|
||||||
zoom=video_data.get("zoom", 1.0),
|
zoom=video_data.get("zoom", 1.0),
|
||||||
cutout=video_data.get("cutout"),
|
cutout=video_data.get("cutout"),
|
||||||
|
object_fit=video_data.get("object-fit", "cover"),
|
||||||
|
object_position=video_data.get("object-position", "center"),
|
||||||
always_visible=video_data.get("always_visible", False),
|
always_visible=video_data.get("always_visible", False),
|
||||||
is_shared=video_data.get("is_shared", False),
|
is_shared=video_data.get("is_shared", False),
|
||||||
pause_narration=float(video_data.get("pause_narration", 0)),
|
pause_narration=float(video_data.get("pause_narration", 0)),
|
||||||
@@ -931,6 +971,7 @@ def resolve_missing_videos(
|
|||||||
output_file=entry.get("output_file"),
|
output_file=entry.get("output_file"),
|
||||||
take=entry.get("take"),
|
take=entry.get("take"),
|
||||||
skip=float(entry.get("skip", 0.0)),
|
skip=float(entry.get("skip", 0.0)),
|
||||||
|
loop=bool(entry.get("loop", False)),
|
||||||
zoom=float(entry.get("zoom", 1.0)),
|
zoom=float(entry.get("zoom", 1.0)),
|
||||||
cutout=entry.get("cutout"),
|
cutout=entry.get("cutout"),
|
||||||
always_visible=bool(entry.get("always_visible", False)),
|
always_visible=bool(entry.get("always_visible", False)),
|
||||||
|
|||||||
+76
-3
@@ -21,10 +21,10 @@ from typing import Union, Optional
|
|||||||
|
|
||||||
|
|
||||||
def _tc() -> str:
|
def _tc() -> str:
|
||||||
"""Return FFmpeg thread count string from ~/.gnommo.conf [performance] cpu_limit."""
|
"""FFmpeg thread count for preprocessing (~/.gnommo.conf cpu_limit_preprocess)."""
|
||||||
from .cache import get_ffmpeg_thread_count
|
from .cache import get_ffmpeg_thread_count
|
||||||
|
|
||||||
return str(get_ffmpeg_thread_count())
|
return str(get_ffmpeg_thread_count("preprocess"))
|
||||||
|
|
||||||
|
|
||||||
# Number of parallel workers for chunk processing
|
# Number of parallel workers for chunk processing
|
||||||
@@ -769,6 +769,7 @@ def preprocess_video(
|
|||||||
force: bool = False,
|
force: bool = False,
|
||||||
custom_gnommo_scratch: Optional[Path] = None,
|
custom_gnommo_scratch: Optional[Path] = None,
|
||||||
res: str = "full",
|
res: str = "full",
|
||||||
|
shared_loudnorm_stats: Optional[dict] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""
|
||||||
Apply preprocessing filters to a video source.
|
Apply preprocessing filters to a video source.
|
||||||
@@ -921,6 +922,7 @@ def preprocess_video(
|
|||||||
take=None,
|
take=None,
|
||||||
use_audio_channels=channel,
|
use_audio_channels=channel,
|
||||||
skip_loudnorm=video_source.defer_loudnorm,
|
skip_loudnorm=video_source.defer_loudnorm,
|
||||||
|
shared_loudnorm_stats=shared_loudnorm_stats,
|
||||||
)
|
)
|
||||||
current_input = step_output
|
current_input = step_output
|
||||||
batch_num += 1
|
batch_num += 1
|
||||||
@@ -2292,6 +2294,62 @@ def apply_transcribe(
|
|||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def measure_loudnorm_stats(
|
||||||
|
input_path: Path,
|
||||||
|
config: dict[str, Any],
|
||||||
|
use_audio_channels: str = "both",
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Run loudnorm's analysis (first) pass on one file and return its measured values.
|
||||||
|
|
||||||
|
Used to derive ONE shared loudness reference from a single narration take. Feeding
|
||||||
|
those measured values into `loudnorm ... linear=true` on EVERY take makes them all
|
||||||
|
receive the same gain, so per-segment loudnorm can't drift their levels apart (the
|
||||||
|
s1-9-louder-than-s10-39 bug). The channel mapping is applied so the measurement
|
||||||
|
matches the channel the render will actually use. Returns None on any failure — the
|
||||||
|
caller then falls back to ordinary per-segment loudnorm.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import re as _re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
cfg = parse_audio_normalize_config(config)
|
||||||
|
pan = ""
|
||||||
|
if use_audio_channels == "left":
|
||||||
|
pan = "pan=stereo|c0=c0|c1=c0,"
|
||||||
|
elif use_audio_channels == "right":
|
||||||
|
pan = "pan=stereo|c0=c1|c1=c1,"
|
||||||
|
af = (
|
||||||
|
f"{pan}loudnorm=I={cfg.target_lufs:.1f}:LRA={cfg.target_lra:.1f}"
|
||||||
|
f":TP={cfg.target_tp:.1f}:print_format=json"
|
||||||
|
)
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-hide_banner", "-nostats",
|
||||||
|
"-i", str(input_path), "-af", af, "-f", "null", "-",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
# loudnorm prints a JSON object near the end of stderr.
|
||||||
|
match = _re.search(r'\{[^{}]*"input_i"[^{}]*\}', proc.stderr, _re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = _json.loads(match.group(0))
|
||||||
|
stats = {
|
||||||
|
"measured_I": data["input_i"],
|
||||||
|
"measured_TP": data["input_tp"],
|
||||||
|
"measured_LRA": data["input_lra"],
|
||||||
|
"measured_thresh": data["input_thresh"],
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if verbose:
|
||||||
|
print(f" Loudness reference: {stats}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def apply_audio_normalize(
|
def apply_audio_normalize(
|
||||||
input_path: Path,
|
input_path: Path,
|
||||||
output_path: Path,
|
output_path: Path,
|
||||||
@@ -2300,6 +2358,7 @@ def apply_audio_normalize(
|
|||||||
take: float = None,
|
take: float = None,
|
||||||
use_audio_channels: str = "both",
|
use_audio_channels: str = "both",
|
||||||
skip_loudnorm: bool = False,
|
skip_loudnorm: bool = False,
|
||||||
|
shared_loudnorm_stats: Optional[dict] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Apply audio normalization: denoise, compress, and loudness normalize.
|
Apply audio normalization: denoise, compress, and loudness normalize.
|
||||||
@@ -2439,11 +2498,25 @@ def apply_audio_normalize(
|
|||||||
# 8. Loudness normalization (loudnorm - EBU R128)
|
# 8. Loudness normalization (loudnorm - EBU R128)
|
||||||
# Skip if skip_loudnorm=True (for segments that will be concatenated)
|
# Skip if skip_loudnorm=True (for segments that will be concatenated)
|
||||||
if cfg.normalize and not skip_loudnorm:
|
if cfg.normalize and not skip_loudnorm:
|
||||||
audio_filters.append(
|
loudnorm = (
|
||||||
f"loudnorm=I={cfg.target_lufs:.1f}"
|
f"loudnorm=I={cfg.target_lufs:.1f}"
|
||||||
f":LRA={cfg.target_lra:.1f}"
|
f":LRA={cfg.target_lra:.1f}"
|
||||||
f":TP={cfg.target_tp:.1f}"
|
f":TP={cfg.target_tp:.1f}"
|
||||||
)
|
)
|
||||||
|
# With a shared reference (from measure_loudnorm_stats on the first narration
|
||||||
|
# take), use linear mode so EVERY take gets the SAME gain — otherwise loudnorm's
|
||||||
|
# default dynamic pass re-measures each take independently and a pausier/quieter
|
||||||
|
# segment gets boosted louder than the next.
|
||||||
|
if shared_loudnorm_stats:
|
||||||
|
s = shared_loudnorm_stats
|
||||||
|
loudnorm += (
|
||||||
|
f":measured_I={s['measured_I']}"
|
||||||
|
f":measured_TP={s['measured_TP']}"
|
||||||
|
f":measured_LRA={s['measured_LRA']}"
|
||||||
|
f":measured_thresh={s['measured_thresh']}"
|
||||||
|
f":linear=true"
|
||||||
|
)
|
||||||
|
audio_filters.append(loudnorm)
|
||||||
|
|
||||||
if not audio_filters:
|
if not audio_filters:
|
||||||
# No filters enabled, just copy
|
# No filters enabled, just copy
|
||||||
|
|||||||
+260
-58
@@ -78,6 +78,7 @@ def _build_crossfade_loop_filter(
|
|||||||
needed_duration: float,
|
needed_duration: float,
|
||||||
volume: float,
|
volume: float,
|
||||||
delay_ms: int,
|
delay_ms: int,
|
||||||
|
start_offset: float = 0.0,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Build FFmpeg filter chain for crossfade looping.
|
Build FFmpeg filter chain for crossfade looping.
|
||||||
@@ -85,6 +86,12 @@ def _build_crossfade_loop_filter(
|
|||||||
Creates a seamless loop by overlapping copies of the audio with fade in/out.
|
Creates a seamless loop by overlapping copies of the audio with fade in/out.
|
||||||
Each loop iteration crossfades with the next for `overlap` seconds.
|
Each loop iteration crossfades with the next for `overlap` seconds.
|
||||||
|
|
||||||
|
The crossfaded stream is periodic with period ``loop_len = audio_duration -
|
||||||
|
overlap``. ``start_offset`` seeks into that continuous stream, so a chunk
|
||||||
|
that begins mid-loop (e.g. the second half of a partial/chunked render)
|
||||||
|
resumes at the correct loop phase instead of restarting from the top. This
|
||||||
|
is what keeps background music seamless across chunk seams.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_label: Input stream label (e.g., "[0:a]")
|
input_label: Input stream label (e.g., "[0:a]")
|
||||||
output_label: Output stream label (e.g., "[aud0]")
|
output_label: Output stream label (e.g., "[aud0]")
|
||||||
@@ -93,6 +100,7 @@ def _build_crossfade_loop_filter(
|
|||||||
needed_duration: Total duration needed
|
needed_duration: Total duration needed
|
||||||
volume: Volume multiplier
|
volume: Volume multiplier
|
||||||
delay_ms: Initial delay in milliseconds
|
delay_ms: Initial delay in milliseconds
|
||||||
|
start_offset: Phase (seconds) into the crossfade loop stream to start at
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of filter strings to append to the filter_complex
|
List of filter strings to append to the filter_complex
|
||||||
@@ -100,8 +108,12 @@ def _build_crossfade_loop_filter(
|
|||||||
filters = []
|
filters = []
|
||||||
loop_len = audio_duration - overlap
|
loop_len = audio_duration - overlap
|
||||||
|
|
||||||
|
# Build the crossfade stream from phase 0, long enough to cover the phase we
|
||||||
|
# seek past plus the duration we actually need, then trim [start_offset ...].
|
||||||
|
build_duration = start_offset + needed_duration
|
||||||
|
|
||||||
# Calculate number of loop iterations needed (add 1 extra for safety)
|
# Calculate number of loop iterations needed (add 1 extra for safety)
|
||||||
n_loops = math.ceil(needed_duration / loop_len) + 1
|
n_loops = math.ceil(build_duration / loop_len) + 1
|
||||||
|
|
||||||
# Limit to reasonable number of loops to avoid filter complexity explosion
|
# Limit to reasonable number of loops to avoid filter complexity explosion
|
||||||
n_loops = min(n_loops, 100)
|
n_loops = min(n_loops, 100)
|
||||||
@@ -109,7 +121,7 @@ def _build_crossfade_loop_filter(
|
|||||||
if n_loops <= 1:
|
if n_loops <= 1:
|
||||||
# Single play, no looping needed
|
# Single play, no looping needed
|
||||||
filters.append(
|
filters.append(
|
||||||
f"{input_label}atrim=0:{needed_duration:.3f},"
|
f"{input_label}atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
|
||||||
f"asetpts=PTS-STARTPTS,"
|
f"asetpts=PTS-STARTPTS,"
|
||||||
f"adelay={delay_ms}|{delay_ms},"
|
f"adelay={delay_ms}|{delay_ms},"
|
||||||
f"volume={volume:.2f}{output_label}"
|
f"volume={volume:.2f}{output_label}"
|
||||||
@@ -120,15 +132,16 @@ def _build_crossfade_loop_filter(
|
|||||||
split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)]
|
split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)]
|
||||||
filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}")
|
filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}")
|
||||||
|
|
||||||
# Process each copy with appropriate delay and fades
|
# Process each copy with appropriate delay and fades. Copies are laid out in
|
||||||
|
# loop time (no output delay yet); the output delay/phase-trim is applied
|
||||||
|
# once after mixing so the phase seek is straightforward.
|
||||||
mix_labels = []
|
mix_labels = []
|
||||||
for i in range(n_loops):
|
for i in range(n_loops):
|
||||||
copy_label = split_labels[i]
|
copy_label = split_labels[i]
|
||||||
out_label = f"[xfl_{output_label[1:-1]}_{i}]"
|
out_label = f"[xfl_{output_label[1:-1]}_{i}]"
|
||||||
mix_labels.append(out_label)
|
mix_labels.append(out_label)
|
||||||
|
|
||||||
loop_delay = i * loop_len
|
loop_delay_ms = int(i * loop_len * 1000)
|
||||||
total_delay_ms = delay_ms + int(loop_delay * 1000)
|
|
||||||
|
|
||||||
# Build filter chain for this copy
|
# Build filter chain for this copy
|
||||||
chain_parts = []
|
chain_parts = []
|
||||||
@@ -143,17 +156,20 @@ def _build_crossfade_loop_filter(
|
|||||||
if fade_out_start > 0:
|
if fade_out_start > 0:
|
||||||
chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}")
|
chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}")
|
||||||
|
|
||||||
chain_parts.append(f"adelay={total_delay_ms}|{total_delay_ms}")
|
if loop_delay_ms > 0:
|
||||||
chain_parts.append(f"volume={volume:.2f}")
|
chain_parts.append(f"adelay={loop_delay_ms}|{loop_delay_ms}")
|
||||||
|
|
||||||
filter_chain = ",".join(chain_parts)
|
filter_chain = ",".join(chain_parts)
|
||||||
filters.append(f"{copy_label}{filter_chain}{out_label}")
|
filters.append(f"{copy_label}{filter_chain}{out_label}")
|
||||||
|
|
||||||
# Mix all copies together, then trim to needed duration
|
# Mix all copies into the continuous loop stream, seek to the loop phase
|
||||||
|
# (start_offset), apply volume, then the output delay.
|
||||||
filters.append(
|
filters.append(
|
||||||
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
|
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
|
||||||
f"atrim=0:{needed_duration + delay_ms/1000:.3f},"
|
f"atrim={start_offset:.3f}:{start_offset + needed_duration:.3f},"
|
||||||
f"asetpts=PTS-STARTPTS{output_label}"
|
f"asetpts=PTS-STARTPTS,"
|
||||||
|
f"volume={volume:.2f},"
|
||||||
|
f"adelay={delay_ms}|{delay_ms}{output_label}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
@@ -262,6 +278,85 @@ def render(plan: RenderPlan, output_path: Path, verbose: bool = False, log=None)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ci_resolve(path: Path) -> Path:
|
||||||
|
"""Case-insensitive fallback for a file path.
|
||||||
|
|
||||||
|
If `path` doesn't exist but a sibling with the same name in a different case
|
||||||
|
does, return that sibling. macOS's default filesystem is case-INsensitive, so a
|
||||||
|
`source_file` whose stored case drifted from the real file (e.g. "outrovideo2.mov"
|
||||||
|
vs "OutroVideo2.mov") still resolves locally — but on the case-SENSITIVE Linux of
|
||||||
|
the render rig / WSL it would otherwise hard-fail with "not found". This makes the
|
||||||
|
two behave the same. Returns `path` unchanged if no match (caller handles absence).
|
||||||
|
"""
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
parent = path.parent
|
||||||
|
if not parent.is_dir():
|
||||||
|
return path
|
||||||
|
target = path.name.lower()
|
||||||
|
for entry in parent.iterdir():
|
||||||
|
if entry.name.lower() == target:
|
||||||
|
return entry
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _video_playback(event, fps: int) -> tuple[float, float, int]:
|
||||||
|
"""Per-occurrence playback geometry for a triggered video overlay.
|
||||||
|
|
||||||
|
Returns (skip, display_duration, loop_frames):
|
||||||
|
skip seek into the source — the chunk-seam override if present, else
|
||||||
|
the resolved per-occurrence skip (inline > videos.json).
|
||||||
|
display_duration how long the overlay is shown, in OUTPUT seconds. Normally the
|
||||||
|
clip's window (end-start), capped by `take` when set. With
|
||||||
|
loop=true, `take` is the loop PERIOD, not a display cap, so the
|
||||||
|
overlay fills the whole window.
|
||||||
|
loop_frames >0 → loop this many source frames (a filtergraph `loop` over the
|
||||||
|
[skip, skip+take] sub-window); 0 → no filtergraph loop. Only set
|
||||||
|
when loop=true AND take is given; whole-clip looping is handled by
|
||||||
|
the input-level -stream_loop auto-loop instead.
|
||||||
|
|
||||||
|
Single source of truth so the input builder and every overlay layer agree.
|
||||||
|
"""
|
||||||
|
# event.skip is the already-resolved per-occurrence value (inline > videos.json),
|
||||||
|
# so trust it verbatim — don't `or` it against video_source.skip, or an explicit
|
||||||
|
# skip=0 override (falsy) would wrongly fall back to the videos.json skip. The
|
||||||
|
# chunk-seam override, when present, wins over both.
|
||||||
|
if getattr(event, "skip_override", None) is not None:
|
||||||
|
skip = event.skip_override
|
||||||
|
elif hasattr(event, "skip"):
|
||||||
|
skip = event.skip or 0.0
|
||||||
|
else:
|
||||||
|
skip = event.video_source.skip or 0.0
|
||||||
|
window = event.end_time - event.start_time
|
||||||
|
take = getattr(event, "take", None)
|
||||||
|
if take is None:
|
||||||
|
take = event.video_source.take
|
||||||
|
loop = bool(getattr(event, "loop", False))
|
||||||
|
if loop:
|
||||||
|
display = window
|
||||||
|
loop_frames = int(round(take * fps)) if (take and take > 0) else 0
|
||||||
|
else:
|
||||||
|
display = window if take is None else min(window, take)
|
||||||
|
loop_frames = 0
|
||||||
|
return skip, display, loop_frames
|
||||||
|
|
||||||
|
|
||||||
|
def _trig_video_pts(event, fps: int, loop_frames: int) -> tuple[str, str]:
|
||||||
|
"""(loop_prefix, setpts_expr) for a triggered-video overlay source chain.
|
||||||
|
|
||||||
|
When loop_frames>0 the source is a `loop` filter repeating a `loop_frames`-frame
|
||||||
|
window forever; loop can leave non-monotonic PTS, so re-time from the frame index
|
||||||
|
(N/fps) plus the clip's output start. Otherwise use the normal PTS rebase+offset.
|
||||||
|
"""
|
||||||
|
start = event.start_time
|
||||||
|
if loop_frames > 0:
|
||||||
|
return (
|
||||||
|
f"loop=loop=-1:size={loop_frames}:start=0,",
|
||||||
|
f"setpts=N/({fps}*TB)+{start:.3f}/TB",
|
||||||
|
)
|
||||||
|
return "", f"setpts=PTS-STARTPTS+{start:.3f}/TB"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_video_path(
|
def _resolve_video_path(
|
||||||
videos_dir: Path,
|
videos_dir: Path,
|
||||||
video_source: VideoSource,
|
video_source: VideoSource,
|
||||||
@@ -310,6 +405,11 @@ def _resolve_video_path(
|
|||||||
else:
|
else:
|
||||||
resolved = source_path
|
resolved = source_path
|
||||||
|
|
||||||
|
# Tolerate case drift between the stored source_file and the real file — a no-op
|
||||||
|
# on macOS (case-insensitive) but the difference between working and "not found"
|
||||||
|
# on the case-sensitive render rig / WSL.
|
||||||
|
resolved = _ci_resolve(resolved)
|
||||||
|
|
||||||
if not resolved.exists():
|
if not resolved.exists():
|
||||||
# File not found anywhere — substitute PlaceholderVideo so FFmpeg doesn't crash
|
# File not found anywhere — substitute PlaceholderVideo so FFmpeg doesn't crash
|
||||||
placeholder = None
|
placeholder = None
|
||||||
@@ -447,7 +547,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
# thread per core no matter what — the real cause of the render-stage memory blowup.
|
# thread per core no matter what — the real cause of the render-stage memory blowup.
|
||||||
from .cache import get_ffmpeg_thread_count
|
from .cache import get_ffmpeg_thread_count
|
||||||
|
|
||||||
_tc = str(get_ffmpeg_thread_count())
|
_tc = str(get_ffmpeg_thread_count("render"))
|
||||||
cmd.extend(
|
cmd.extend(
|
||||||
["-threads", _tc, "-filter_threads", _tc, "-filter_complex_threads", _tc]
|
["-threads", _tc, "-filter_threads", _tc, "-filter_complex_threads", _tc]
|
||||||
)
|
)
|
||||||
@@ -589,15 +689,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
video_path = _resolve_video_path(
|
video_path = _resolve_video_path(
|
||||||
videos_dir, event.video_source, shared_assets_dir, project_path
|
videos_dir, event.video_source, shared_assets_dir, project_path
|
||||||
)
|
)
|
||||||
# Chunking v2 (docs/chunking_v2.md): a clip that began before this chunk
|
# Per-occurrence geometry (chunk-seam skip_override, per-event skip/take/loop).
|
||||||
# resumes mid-clip via skip_override. None today (v1) → the source's own skip.
|
skip, clip_duration, loop_frames = _video_playback(event, plan.config.fps)
|
||||||
skip = event.skip_override if getattr(event, "skip_override", None) is not None \
|
|
||||||
else (event.video_source.skip or 0.0)
|
|
||||||
|
|
||||||
# How long this clip needs to play in the output
|
|
||||||
clip_duration = event.end_time - event.start_time
|
|
||||||
if event.video_source.take is not None:
|
|
||||||
clip_duration = min(clip_duration, event.video_source.take)
|
|
||||||
|
|
||||||
# Loop the clip if the file is shorter than the display window.
|
# Loop the clip if the file is shorter than the display window.
|
||||||
# Don't loop pause-narration videos — they intentionally play once and stop.
|
# Don't loop pause-narration videos — they intentionally play once and stop.
|
||||||
@@ -611,6 +704,21 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
if has_audio:
|
if has_audio:
|
||||||
video_events_with_audio.add(i)
|
video_events_with_audio.add(i)
|
||||||
|
|
||||||
|
if loop_frames > 0:
|
||||||
|
# Explicit loop=true of a bounded [skip, skip+take] sub-window: read ONLY
|
||||||
|
# that window here (a filtergraph `loop` filter repeats it — see the overlay
|
||||||
|
# layers). No -stream_loop; the filter does the repeating.
|
||||||
|
_take_secs = loop_frames / plan.config.fps
|
||||||
|
if skip > 0:
|
||||||
|
cmd.extend(["-ss", f"{skip:.3f}"])
|
||||||
|
probesize = "1000000" if has_audio else "1000"
|
||||||
|
cmd.extend(["-analyzeduration", "0", "-probesize", probesize])
|
||||||
|
cmd.extend(["-t", f"{_take_secs:.3f}"])
|
||||||
|
cmd.extend(["-i", str(video_path)])
|
||||||
|
video_inputs[i] = input_idx
|
||||||
|
input_idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if needs_loop:
|
if needs_loop:
|
||||||
cmd.extend(["-stream_loop", "-1"])
|
cmd.extend(["-stream_loop", "-1"])
|
||||||
if skip > 0:
|
if skip > 0:
|
||||||
@@ -756,6 +864,38 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_filter(
|
||||||
|
w: int, h: int, zoom: float, object_fit: str = "cover", object_position: str = "center"
|
||||||
|
) -> str:
|
||||||
|
"""Scale+crop/pad chain that places a source into a w×h cutout, CSS-style.
|
||||||
|
|
||||||
|
object_fit "cover" (default): fill the cutout (scaled by `zoom`) and crop the
|
||||||
|
overflow — object-fit: cover. "contain": shrink the whole video to fit inside and
|
||||||
|
pad the remainder transparently — object-fit: contain (`zoom` is not applied,
|
||||||
|
since nothing is cropped). object_position anchors the crop (cover) or the padded
|
||||||
|
video (contain): center (default) | top | bottom | left | right.
|
||||||
|
|
||||||
|
With the defaults (cover/center) this is byte-identical to the long-standing
|
||||||
|
`scale=…increase,crop=W:H:(iw-W)/2:(ih-H)/2` used everywhere, so callers that pass
|
||||||
|
defaults render exactly as before.
|
||||||
|
"""
|
||||||
|
pos = (object_position or "center").lower()
|
||||||
|
if (object_fit or "cover").lower() == "contain":
|
||||||
|
px = "0" if pos == "left" else (f"(ow-iw)" if pos == "right" else "(ow-iw)/2")
|
||||||
|
py = "0" if pos == "top" else (f"(oh-ih)" if pos == "bottom" else "(oh-ih)/2")
|
||||||
|
return (
|
||||||
|
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
||||||
|
f"pad={w}:{h}:{px}:{py}:color=0x00000000"
|
||||||
|
)
|
||||||
|
zw, zh = int(w * zoom), int(h * zoom)
|
||||||
|
cx = "0" if pos == "left" else (f"(iw-{w})" if pos == "right" else f"(iw-{w})/2")
|
||||||
|
cy = "0" if pos == "top" else (f"(ih-{h})" if pos == "bottom" else f"(ih-{h})/2")
|
||||||
|
return (
|
||||||
|
f"scale={zw}:{zh}:force_original_aspect_ratio=increase,"
|
||||||
|
f"crop={w}:{h}:{cx}:{cy}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _calculate_cutout_position(
|
def _calculate_cutout_position(
|
||||||
cutout: CutoutDefinition, frame_width: int, frame_height: int
|
cutout: CutoutDefinition, frame_width: int, frame_height: int
|
||||||
) -> tuple[int, int, int, int]:
|
) -> tuple[int, int, int, int]:
|
||||||
@@ -1089,22 +1229,19 @@ def build_filter_complex(
|
|||||||
event.cutout, width, height
|
event.cutout, width, height
|
||||||
)
|
)
|
||||||
|
|
||||||
duration = event.end_time - event.start_time
|
_skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
|
||||||
if event.video_source.take is not None:
|
|
||||||
duration = min(duration, event.video_source.take)
|
|
||||||
effective_end = event.start_time + duration
|
effective_end = event.start_time + duration
|
||||||
|
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
|
||||||
|
|
||||||
zoom = event.video_source.zoom
|
zoom = event.video_source.zoom
|
||||||
zoomed_width = int(cut_width * zoom)
|
zoomed_width = int(cut_width * zoom)
|
||||||
zoomed_height = int(cut_height * zoom)
|
zoomed_height = int(cut_height * zoom)
|
||||||
|
|
||||||
video_label = f"tvb{i}"
|
video_label = f"tvb{i}"
|
||||||
start_pts = event.start_time
|
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{video_idx}:v]format=yuva444p10le,"
|
f"[{video_idx}:v]{_loop_pre}format=yuva444p10le,"
|
||||||
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
|
f"{_pts},"
|
||||||
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
|
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
|
||||||
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
|
|
||||||
f"format=rgba[{video_label}]"
|
f"format=rgba[{video_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1135,8 +1272,7 @@ def build_filter_complex(
|
|||||||
filters.append(
|
filters.append(
|
||||||
f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS,"
|
f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS,"
|
||||||
f"format=yuva444p10le,"
|
f"format=yuva444p10le,"
|
||||||
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
|
f"{_fit_filter(cut_width, cut_height, zoom)},"
|
||||||
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
|
|
||||||
f"format=rgba[{video_label}]"
|
f"format=rgba[{video_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1169,8 +1305,7 @@ def build_filter_complex(
|
|||||||
f"[{split_labels[seg_idx]}]trim={src_start:.3f}:{src_end:.3f},"
|
f"[{split_labels[seg_idx]}]trim={src_start:.3f}:{src_end:.3f},"
|
||||||
f"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB,"
|
f"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB,"
|
||||||
f"format=yuva444p10le,"
|
f"format=yuva444p10le,"
|
||||||
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
|
f"{_fit_filter(cut_width, cut_height, zoom)},"
|
||||||
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
|
|
||||||
f"format=rgba[{seg_label}]"
|
f"format=rgba[{seg_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1192,22 +1327,19 @@ def build_filter_complex(
|
|||||||
event.cutout, width, height
|
event.cutout, width, height
|
||||||
)
|
)
|
||||||
|
|
||||||
duration = event.end_time - event.start_time
|
_skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
|
||||||
if event.video_source.take is not None:
|
|
||||||
duration = min(duration, event.video_source.take)
|
|
||||||
effective_end = event.start_time + duration
|
effective_end = event.start_time + duration
|
||||||
|
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
|
||||||
|
|
||||||
zoom = event.video_source.zoom
|
zoom = event.video_source.zoom
|
||||||
zoomed_width = int(cut_width * zoom)
|
zoomed_width = int(cut_width * zoom)
|
||||||
zoomed_height = int(cut_height * zoom)
|
zoomed_height = int(cut_height * zoom)
|
||||||
|
|
||||||
video_label = f"tvm{i}"
|
video_label = f"tvm{i}"
|
||||||
start_pts = event.start_time
|
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{video_idx}:v]format=yuva444p10le,"
|
f"[{video_idx}:v]{_loop_pre}format=yuva444p10le,"
|
||||||
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
|
f"{_pts},"
|
||||||
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
|
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)},"
|
||||||
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
|
|
||||||
f"format=rgba[{video_label}]"
|
f"format=rgba[{video_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1249,22 +1381,19 @@ def build_filter_complex(
|
|||||||
event.cutout, width, height
|
event.cutout, width, height
|
||||||
)
|
)
|
||||||
|
|
||||||
duration = event.end_time - event.start_time
|
_skip, duration, _loop_frames = _video_playback(event, plan.config.fps)
|
||||||
if event.video_source.take is not None:
|
|
||||||
duration = min(duration, event.video_source.take)
|
|
||||||
effective_end = event.start_time + duration
|
effective_end = event.start_time + duration
|
||||||
|
_loop_pre, _pts = _trig_video_pts(event, plan.config.fps, _loop_frames)
|
||||||
|
|
||||||
zoom = event.video_source.zoom
|
zoom = event.video_source.zoom
|
||||||
zoomed_width = int(cut_width * zoom)
|
zoomed_width = int(cut_width * zoom)
|
||||||
zoomed_height = int(cut_height * zoom)
|
zoomed_height = int(cut_height * zoom)
|
||||||
|
|
||||||
video_label = f"tv{i}"
|
video_label = f"tv{i}"
|
||||||
start_pts = event.start_time
|
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{video_idx}:v]format=rgba,"
|
f"[{video_idx}:v]{_loop_pre}format=rgba,"
|
||||||
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
|
f"{_pts},"
|
||||||
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
|
f"{_fit_filter(cut_width, cut_height, zoom, event.object_fit, event.object_position)}"
|
||||||
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2"
|
|
||||||
f"[{video_label}]"
|
f"[{video_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1336,11 +1465,12 @@ def build_filter_complex(
|
|||||||
# Scale and crop video
|
# Scale and crop video
|
||||||
video_label = f"outro{i}"
|
video_label = f"outro{i}"
|
||||||
start_pts = event.start_time
|
start_pts = event.start_time
|
||||||
|
# OutroEvent carries no per-occurrence overrides, so read placement off
|
||||||
|
# its VideoSource (the videos.json defaults).
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{video_idx}:v]format=yuva444p10le,"
|
f"[{video_idx}:v]format=yuva444p10le,"
|
||||||
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
|
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
|
||||||
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
|
f"{_fit_filter(cut_width, cut_height, zoom, event.video_source.object_fit, event.video_source.object_position)},"
|
||||||
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
|
|
||||||
f"format=rgba[{video_label}]"
|
f"format=rgba[{video_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1459,11 +1589,16 @@ def build_filter_complex(
|
|||||||
for i, event in enumerate(plan.audio_events):
|
for i, event in enumerate(plan.audio_events):
|
||||||
audio_idx = audio_inputs[event.audio_id]
|
audio_idx = audio_inputs[event.audio_id]
|
||||||
volume = event.audio_def.volume
|
volume = event.audio_def.volume
|
||||||
|
# An [end:handle] marker caps this clip's stop time; otherwise a loop
|
||||||
|
# fills to the render/window end and a one-shot plays its natural length.
|
||||||
|
_clip_end = getattr(event, "end_time", None)
|
||||||
|
|
||||||
if event.audio_def.loop:
|
if event.audio_def.loop:
|
||||||
# Looping audio: loop source, then trim/segment
|
# Looping audio: loop source, then trim/segment. Stop at the end
|
||||||
# Stop at narration end if there's an outro
|
# marker if set, else at narration end / outro.
|
||||||
loop_end_time = audio_end_time
|
loop_end_time = (
|
||||||
|
audio_end_time if _clip_end is None else min(audio_end_time, _clip_end)
|
||||||
|
)
|
||||||
remaining = loop_end_time - event.start_time
|
remaining = loop_end_time - event.start_time
|
||||||
|
|
||||||
if plan.narration_pauses and not event.audio_def.ignore_pauses:
|
if plan.narration_pauses and not event.audio_def.ignore_pauses:
|
||||||
@@ -1529,6 +1664,10 @@ def build_filter_complex(
|
|||||||
needed_duration=remaining,
|
needed_duration=remaining,
|
||||||
volume=volume,
|
volume=volume,
|
||||||
delay_ms=delay_ms,
|
delay_ms=delay_ms,
|
||||||
|
# Chunking v2: resume at the loop phase so background
|
||||||
|
# music continues across chunk seams instead of
|
||||||
|
# restarting from the top.
|
||||||
|
start_offset=getattr(event, "crossfade_offset", 0.0),
|
||||||
)
|
)
|
||||||
filters.extend(crossfade_filters)
|
filters.extend(crossfade_filters)
|
||||||
else:
|
else:
|
||||||
@@ -1544,16 +1683,78 @@ def build_filter_complex(
|
|||||||
)
|
)
|
||||||
audio_labels_to_mix.append(f"[{label}]")
|
audio_labels_to_mix.append(f"[{label}]")
|
||||||
else:
|
else:
|
||||||
# One-shot audio: delay to trigger time. Chunking v2: seek in if
|
# One-shot audio. Freeze it through narration pauses too (like the
|
||||||
# the clip began in an earlier chunk (docs/chunking_v2.md).
|
# looping branch above): split the source at each pause and delay
|
||||||
|
# the remainder, so the clip resumes on the exact sample it stopped
|
||||||
|
# on when the cutscene ends — it is never restarted. A pause set on
|
||||||
|
# a cutscene video therefore silences background one-shots for its
|
||||||
|
# duration. `ignore_pauses` opts a clip out (e.g. a stinger meant to
|
||||||
|
# keep playing under the freeze). Chunking v2: src_offset seeks in
|
||||||
|
# when the clip began in an earlier chunk (docs/chunking_v2.md).
|
||||||
label = f"aud{i}"
|
label = f"aud{i}"
|
||||||
delay_ms = int(event.start_time * 1000)
|
|
||||||
_off = getattr(event, "src_offset", 0.0)
|
_off = getattr(event, "src_offset", 0.0)
|
||||||
_seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS," if _off > 0 else ""
|
relevant_pauses = (
|
||||||
|
[]
|
||||||
|
if event.audio_def.ignore_pauses
|
||||||
|
else [
|
||||||
|
p
|
||||||
|
for p in (plan.narration_pauses or [])
|
||||||
|
if p.output_time > event.start_time
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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(
|
filters.append(
|
||||||
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
|
f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]"
|
||||||
)
|
)
|
||||||
audio_labels_to_mix.append(f"[{label}]")
|
audio_labels_to_mix.append(f"[{label}]")
|
||||||
|
else:
|
||||||
|
# Play [seg_start, pause) of source, freeze during the pause,
|
||||||
|
# then resume — source position (src_pos) never advances across
|
||||||
|
# the gap. Final segment runs to the [end:handle] stop (if set),
|
||||||
|
# otherwise the source's natural end.
|
||||||
|
_end = _clip_end if _clip_end is not None else float("inf")
|
||||||
|
src_pos = _off
|
||||||
|
seg_start = event.start_time
|
||||||
|
seg_count = 0
|
||||||
|
for pause in relevant_pauses:
|
||||||
|
if pause.output_time >= _end:
|
||||||
|
break
|
||||||
|
if pause.output_time > seg_start:
|
||||||
|
seg_dur = pause.output_time - seg_start
|
||||||
|
seg_label = f"{label}_seg{seg_count}"
|
||||||
|
d_ms = int(seg_start * 1000)
|
||||||
|
filters.append(
|
||||||
|
f"[{audio_idx}:a]atrim={src_pos:.3f}:{src_pos + seg_dur:.3f},"
|
||||||
|
f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms},"
|
||||||
|
f"volume={volume:.2f}[{seg_label}]"
|
||||||
|
)
|
||||||
|
audio_labels_to_mix.append(f"[{seg_label}]")
|
||||||
|
src_pos += seg_dur
|
||||||
|
seg_count += 1
|
||||||
|
seg_start = pause.output_time + pause.duration
|
||||||
|
if seg_start < _end:
|
||||||
|
seg_label = f"{label}_seg{seg_count}"
|
||||||
|
d_ms = int(seg_start * 1000)
|
||||||
|
_atrim = (
|
||||||
|
f"atrim={src_pos:.3f}:{src_pos + (_end - seg_start):.3f}"
|
||||||
|
if _clip_end is not None
|
||||||
|
else f"atrim={src_pos:.3f}"
|
||||||
|
)
|
||||||
|
filters.append(
|
||||||
|
f"[{audio_idx}:a]{_atrim},"
|
||||||
|
f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms},"
|
||||||
|
f"volume={volume:.2f}[{seg_label}]"
|
||||||
|
)
|
||||||
|
audio_labels_to_mix.append(f"[{seg_label}]")
|
||||||
|
|
||||||
# Extract and mix audio from triggered video events
|
# Extract and mix audio from triggered video events
|
||||||
_have_audio = video_events_with_audio or set()
|
_have_audio = video_events_with_audio or set()
|
||||||
@@ -1568,7 +1769,8 @@ def build_filter_complex(
|
|||||||
delay_ms = int(event.start_time * 1000)
|
delay_ms = int(event.start_time * 1000)
|
||||||
label = f"tvaud{i}"
|
label = f"tvaud{i}"
|
||||||
|
|
||||||
vol = event.video_source.volume
|
# event.volume = events.json override if set, else the videos.json default.
|
||||||
|
vol = event.volume
|
||||||
vol_filter = f",volume={vol:.2f}" if vol != 1.0 else ""
|
vol_filter = f",volume={vol:.2f}" if vol != 1.0 else ""
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{video_idx}:a]atrim=0:{duration:.3f},"
|
f"[{video_idx}:a]atrim=0:{duration:.3f},"
|
||||||
|
|||||||
+112
-5
@@ -30,9 +30,13 @@ from typing import Optional
|
|||||||
from .models import CAMERA_PRESETS
|
from .models import CAMERA_PRESETS
|
||||||
from .transformer import MarkerTiming, resolve_video_presentation
|
from .transformer import MarkerTiming, resolve_video_presentation
|
||||||
|
|
||||||
# Per-occurrence presentation fields materialized onto video events (atomic events.json,
|
# Per-occurrence presentation fields ALWAYS materialized onto video events (atomic
|
||||||
# GUI-ready). Round-tripped as overrides so a stored value drives render over the default.
|
# events.json, GUI-ready). Round-tripped as overrides so a stored value drives render.
|
||||||
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take")
|
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take", "skip", "loop", "object-fit", "object-position")
|
||||||
|
# `volume` is materialized SPARSELY — only when actually overridden (inline/GUI/manual),
|
||||||
|
# so the videos.json value keeps flowing as the default and only a real override pins it.
|
||||||
|
# Round-tripping still carries it whenever present in the event dict.
|
||||||
|
_EVENT_OVERRIDE_KEYS = _PRESENTATION_KEYS + ("volume",)
|
||||||
|
|
||||||
EVENTS_FILE = "events.json"
|
EVENTS_FILE = "events.json"
|
||||||
SCAFFOLD_FILE = "scaffold.json"
|
SCAFFOLD_FILE = "scaffold.json"
|
||||||
@@ -90,6 +94,8 @@ def marker_type(marker_id: str, slides: dict, videos: dict, audio: dict) -> str:
|
|||||||
return "audio"
|
return "audio"
|
||||||
if _ci_contains(CAMERA_PRESETS, marker_id):
|
if _ci_contains(CAMERA_PRESETS, marker_id):
|
||||||
return "camera"
|
return "camera"
|
||||||
|
if marker_id.startswith("end:"):
|
||||||
|
return "end"
|
||||||
return "other"
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
@@ -175,7 +181,7 @@ def derive_events(
|
|||||||
vs,
|
vs,
|
||||||
t.overrides,
|
t.overrides,
|
||||||
default_end_on=(
|
default_end_on=(
|
||||||
None if t.marker_id.startswith("narration:") else "next_video"
|
None if t.marker_id.startswith("narration:") else "next_slide"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
e["handle"] = pres["handle"]
|
e["handle"] = pres["handle"]
|
||||||
@@ -184,6 +190,24 @@ def derive_events(
|
|||||||
e["end_on"] = pres["end_on"]
|
e["end_on"] = pres["end_on"]
|
||||||
if pres["take"] is not None:
|
if pres["take"] is not None:
|
||||||
e["take"] = pres["take"]
|
e["take"] = pres["take"]
|
||||||
|
# skip/loop are sparse: materialized only when explicitly overridden
|
||||||
|
# inline (like volume), so a plain video keeps a clean events.json and
|
||||||
|
# the videos.json default flows via render's fallback. Keyed on override
|
||||||
|
# PRESENCE, not truthiness, so an explicit skip=0 / loop=false survives.
|
||||||
|
if t.overrides and "skip" in t.overrides:
|
||||||
|
e["skip"] = round(pres["skip"], 3)
|
||||||
|
if t.overrides and "loop" in t.overrides:
|
||||||
|
e["loop"] = bool(pres["loop"])
|
||||||
|
# volume is sparse: written only when actually overridden, so the
|
||||||
|
# videos.json default keeps flowing until someone pins it here.
|
||||||
|
if t.overrides and "volume" in t.overrides:
|
||||||
|
e["volume"] = pres["volume"]
|
||||||
|
# object-fit/position are sparse too: materialize only when non-default
|
||||||
|
# so plain center-cover videos keep a clean events.json.
|
||||||
|
if pres["object_fit"] != "cover":
|
||||||
|
e["object-fit"] = pres["object_fit"]
|
||||||
|
if pres["object_position"] != "center":
|
||||||
|
e["object-position"] = pres["object_position"]
|
||||||
pd = _pause_duration(t.marker_id, videos)
|
pd = _pause_duration(t.marker_id, videos)
|
||||||
if pd:
|
if pd:
|
||||||
e["pause_duration"] = pd
|
e["pause_duration"] = pd
|
||||||
@@ -193,6 +217,85 @@ def derive_events(
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def derive_narration_events(
|
||||||
|
narration_schedule: list,
|
||||||
|
narration_videos: list,
|
||||||
|
pauses: list,
|
||||||
|
total_duration: float,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Representation-only events mirroring the always-visible talking-head track.
|
||||||
|
|
||||||
|
One event per narration segment (or a single event for legacy single-file
|
||||||
|
narration), shaped like a `video` event — handle, cutout, layer, source_file,
|
||||||
|
skip/take, timing — so a GUI can render and lay out every track uniformly
|
||||||
|
instead of treating the narration backbone as invisible.
|
||||||
|
|
||||||
|
These are NOT read back into the render: events_to_marker_timings skips
|
||||||
|
`type == "narration"`, because narration timing is owned by the aligner and the
|
||||||
|
schedule (it is the clock, and it is multi-file). So this is a read-only mirror,
|
||||||
|
added purely for the editing surface — it never changes what renders.
|
||||||
|
"""
|
||||||
|
# Cutout/layer come from the resolved narration video source (defaults match the
|
||||||
|
# talking-head convention used by import/transformer).
|
||||||
|
cutout = "talkinghead"
|
||||||
|
layer = "below"
|
||||||
|
if narration_videos:
|
||||||
|
_vs = narration_videos[0][1]
|
||||||
|
cutout = getattr(_vs, "cutout", None) or cutout
|
||||||
|
layer = getattr(_vs, "layer", None) or layer
|
||||||
|
|
||||||
|
pause_list = [
|
||||||
|
(float(p.narration_time), float(p.duration)) for p in (pauses or [])
|
||||||
|
]
|
||||||
|
|
||||||
|
def _final(offset: float) -> float:
|
||||||
|
# Same shift compute_final_times applies: push forward by every pause at or
|
||||||
|
# before this point on the narration timeline.
|
||||||
|
return round(offset + sum(d for pn, d in pause_list if pn <= offset), 3)
|
||||||
|
|
||||||
|
def _base(seg_id, source_name, offset, duration, skip, take):
|
||||||
|
return {
|
||||||
|
"type": "narration",
|
||||||
|
"id": seg_id,
|
||||||
|
"narration_time": round(offset, 3),
|
||||||
|
"adjustment": 0.0,
|
||||||
|
"final_time": _final(offset),
|
||||||
|
"mapping": MAPPING_EXACT,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"context": "(talking-head narration)",
|
||||||
|
"handle": seg_id,
|
||||||
|
"source_file": source_name,
|
||||||
|
"cutout": cutout,
|
||||||
|
"layer": layer,
|
||||||
|
"always_visible": True,
|
||||||
|
"end_on": "next_video",
|
||||||
|
"skip": round(skip or 0.0, 3),
|
||||||
|
"take": (round(take, 3) if take is not None else None),
|
||||||
|
"duration": round(duration, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
if narration_schedule:
|
||||||
|
for seg in narration_schedule:
|
||||||
|
src = getattr(seg.source_path, "name", None) or str(seg.source_path)
|
||||||
|
events.append(
|
||||||
|
_base(seg.seg_id, src, seg.offset, seg.duration, seg.skip, seg.take)
|
||||||
|
)
|
||||||
|
elif narration_videos:
|
||||||
|
_id, _vs, _ = narration_videos[0]
|
||||||
|
events.append(
|
||||||
|
_base(
|
||||||
|
_id,
|
||||||
|
getattr(_vs, "source_file", "") or _id,
|
||||||
|
0.0,
|
||||||
|
total_duration,
|
||||||
|
getattr(_vs, "skip", 0.0),
|
||||||
|
getattr(_vs, "take", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
def _interpolate_narration(events: list[dict]) -> None:
|
def _interpolate_narration(events: list[dict]) -> None:
|
||||||
"""Fill `narration_time: None` entries by linear interpolation between placed
|
"""Fill `narration_time: None` entries by linear interpolation between placed
|
||||||
neighbours. Head/tail runs spread at +1s steps from the nearest known time (or
|
neighbours. Head/tail runs spread at +1s steps from the nearest known time (or
|
||||||
@@ -323,11 +426,15 @@ def events_to_marker_timings(events: list[dict]) -> list[MarkerTiming]:
|
|||||||
"""
|
"""
|
||||||
timings: list[MarkerTiming] = []
|
timings: list[MarkerTiming] = []
|
||||||
for e in events:
|
for e in events:
|
||||||
|
# Representation-only base-track events (derive_narration_events) are a
|
||||||
|
# read-only mirror of the narration backbone — never fed back as markers.
|
||||||
|
if e.get("type") == "narration":
|
||||||
|
continue
|
||||||
n = e.get("narration_time")
|
n = e.get("narration_time")
|
||||||
eff = (n + e.get("adjustment", 0.0)) if n is not None else -1.0
|
eff = (n + e.get("adjustment", 0.0)) if n is not None else -1.0
|
||||||
# Carry any stored presentation as overrides so render honors the atomic event
|
# Carry any stored presentation as overrides so render honors the atomic event
|
||||||
# (e.g. a GUI edit) over the shorthand/videos.json default. Absent on old events.
|
# (e.g. a GUI edit) over the shorthand/videos.json default. Absent on old events.
|
||||||
overrides = {k: e[k] for k in _PRESENTATION_KEYS if k in e} or None
|
overrides = {k: e[k] for k in _EVENT_OVERRIDE_KEYS if k in e} or None
|
||||||
timings.append(
|
timings.append(
|
||||||
MarkerTiming(
|
MarkerTiming(
|
||||||
marker_id=e["id"],
|
marker_id=e["id"],
|
||||||
|
|||||||
+19
-3
@@ -18,6 +18,15 @@ Design:
|
|||||||
Sync model: move everything, exclude a small denylist. The exclusions are large
|
Sync model: move everything, exclude a small denylist. The exclusions are large
|
||||||
derived artifacts each side regenerates or ships on its own (rendered output,
|
derived artifacts each side regenerates or ships on its own (rendered output,
|
||||||
preprocessed segments, downscales, chunk scratch), so they never travel over SSH.
|
preprocessed segments, downscales, chunk scratch), so they never travel over SSH.
|
||||||
|
|
||||||
|
Master = the machine's local tree. The PROJECT sync runs with rsync --delete, so a
|
||||||
|
file deleted locally and then `up`'d is removed on the server, and `down` mirrors the
|
||||||
|
server onto the local tree (deletions included). The Mac is the intended master: it
|
||||||
|
`up`s, the rig `down`s. Excluded paths are PROTECTED from --delete (rsync does not
|
||||||
|
touch excluded files on the receiver), so rig-only artifacts — above all the big
|
||||||
|
`*_processed.*` preprocess outputs — are never deleted by a mirror from the Mac.
|
||||||
|
Shared assets are NOT --deleted (a cross-project library; blind deletion could wipe
|
||||||
|
pexels downloaded on the other side), so that pass stays purely additive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -52,6 +61,7 @@ _SYNC_EXCLUDES = [
|
|||||||
"media/narration/proxy/",
|
"media/narration/proxy/",
|
||||||
"media/videos/proxy/",
|
"media/videos/proxy/",
|
||||||
"**/chunks/",
|
"**/chunks/",
|
||||||
|
"*_processed.*", # preprocess outputs — rig-only, never transfer OR --delete them
|
||||||
"*.tmp",
|
"*.tmp",
|
||||||
".*", # rsync in-progress temp files (.filename.XXXXXX) and .DS_Store
|
".*", # rsync in-progress temp files (.filename.XXXXXX) and .DS_Store
|
||||||
]
|
]
|
||||||
@@ -147,9 +157,11 @@ def cmd_up(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
|||||||
shared_root = _find_shared_assets_root(project_path)
|
shared_root = _find_shared_assets_root(project_path)
|
||||||
remote_shared = f"{server['path']}/shared_assets"
|
remote_shared = f"{server['path']}/shared_assets"
|
||||||
|
|
||||||
# Pass 1: project files — whole tree, denylist excludes.
|
# Pass 1: project files — whole tree, denylist excludes. --delete mirrors the local
|
||||||
|
# (master) tree onto the server: files deleted locally are removed there too.
|
||||||
|
# Excluded paths (processed outputs, out/, chunks, …) are protected from deletion.
|
||||||
rsync_cmd = [
|
rsync_cmd = [
|
||||||
"rsync", "-av", "--progress",
|
"rsync", "-av", "--progress", "--delete",
|
||||||
"-e", f"ssh -p {server['port']}",
|
"-e", f"ssh -p {server['port']}",
|
||||||
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
||||||
f"{project_path}/",
|
f"{project_path}/",
|
||||||
@@ -228,8 +240,12 @@ def cmd_down(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
|||||||
|
|
||||||
project_path.mkdir(parents=True, exist_ok=True)
|
project_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# --delete mirrors the server (which the master `up`'d) onto this local tree:
|
||||||
|
# files gone from the server are removed here too. Excluded paths — above all the
|
||||||
|
# rig-only *_processed.* outputs — are protected, so a `down` on the rig never
|
||||||
|
# deletes its own preprocess artifacts.
|
||||||
rsync_cmd = [
|
rsync_cmd = [
|
||||||
"rsync", "-av", "--progress",
|
"rsync", "-av", "--progress", "--delete",
|
||||||
"-e", f"ssh -p {server['port']}",
|
"-e", f"ssh -p {server['port']}",
|
||||||
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
||||||
f"{server['user']}@{server['host']}:{remote_project}/",
|
f"{server['user']}@{server['host']}:{remote_project}/",
|
||||||
|
|||||||
+145
-14
@@ -63,7 +63,7 @@ def resolve_video_presentation(
|
|||||||
marker_id: str,
|
marker_id: str,
|
||||||
video_source,
|
video_source,
|
||||||
overrides: Optional[dict] = None,
|
overrides: Optional[dict] = None,
|
||||||
default_end_on: Optional[str] = "next_video",
|
default_end_on: Optional[str] = "next_slide",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Resolve a video marker's per-occurrence presentation to an atomic dict.
|
"""Resolve a video marker's per-occurrence presentation to an atomic dict.
|
||||||
|
|
||||||
@@ -74,8 +74,9 @@ def resolve_video_presentation(
|
|||||||
videos.json collision.
|
videos.json collision.
|
||||||
|
|
||||||
Precedence per field: explicit event override > shorthand prefix > videos.json
|
Precedence per field: explicit event override > shorthand prefix > videos.json
|
||||||
default > built-in default. `default_end_on` is "next_video" for video triggers and
|
default > built-in default. `default_end_on` is "next_slide" for video triggers
|
||||||
None for [narration:] (which runs to the end).
|
(so an untagged clip can't overstay and obscure later content) and None for
|
||||||
|
[narration:] (which runs to the end).
|
||||||
|
|
||||||
Returns {handle, cutout, layer, end_on, take, pause_narration}.
|
Returns {handle, cutout, layer, end_on, take, pause_narration}.
|
||||||
"""
|
"""
|
||||||
@@ -94,9 +95,20 @@ def resolve_video_presentation(
|
|||||||
layer = overrides.get("layer") or impl_layer or video_source.layer
|
layer = overrides.get("layer") or impl_layer or video_source.layer
|
||||||
end_on = overrides.get("end_on") or video_source.end_on or default_end_on
|
end_on = overrides.get("end_on") or video_source.end_on or default_end_on
|
||||||
take = overrides["take"] if "take" in overrides else video_source.take
|
take = overrides["take"] if "take" in overrides else video_source.take
|
||||||
|
# Per-occurrence playback controls: inline override wins, else videos.json default.
|
||||||
|
skip = overrides["skip"] if "skip" in overrides else (video_source.skip or 0.0)
|
||||||
|
loop = overrides["loop"] if "loop" in overrides else bool(video_source.loop)
|
||||||
pause_narration = overrides.get(
|
pause_narration = overrides.get(
|
||||||
"pause_narration", video_source.pause_narration or 0.0
|
"pause_narration", video_source.pause_narration or 0.0
|
||||||
)
|
)
|
||||||
|
# Volume defaults to the videos.json value; an events.json/inline override wins.
|
||||||
|
volume = overrides["volume"] if "volume" in overrides else video_source.volume
|
||||||
|
# CSS-like cutout placement (hyphenated keys mirror CSS; inline/events override
|
||||||
|
# the videos.json default, which defaults to cover/center).
|
||||||
|
object_fit = overrides.get("object-fit") or video_source.object_fit or "cover"
|
||||||
|
object_position = (
|
||||||
|
overrides.get("object-position") or video_source.object_position or "center"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"handle": handle,
|
"handle": handle,
|
||||||
@@ -104,7 +116,12 @@ def resolve_video_presentation(
|
|||||||
"layer": layer,
|
"layer": layer,
|
||||||
"end_on": end_on,
|
"end_on": end_on,
|
||||||
"take": take,
|
"take": take,
|
||||||
|
"skip": float(skip or 0.0),
|
||||||
|
"loop": bool(loop),
|
||||||
"pause_narration": float(pause_narration or 0.0),
|
"pause_narration": float(pause_narration or 0.0),
|
||||||
|
"volume": float(volume if volume is not None else 1.0),
|
||||||
|
"object_fit": object_fit,
|
||||||
|
"object_position": object_position,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +260,11 @@ def _is_known_marker(
|
|||||||
if audio_id in audio:
|
if audio_id in audio:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Explicit end markers: [end:handle] stops a video started with end_on=end_marker.
|
||||||
|
# Known so it aligns to its spoken position (and isn't stripped as filler).
|
||||||
|
if marker_id.startswith("end:"):
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -948,6 +970,8 @@ def build_render_plan(
|
|||||||
event.end_time -= time_offset
|
event.end_time -= time_offset
|
||||||
for event in audio_events:
|
for event in audio_events:
|
||||||
event.start_time = max(0, event.start_time - time_offset)
|
event.start_time = max(0, event.start_time - time_offset)
|
||||||
|
if event.end_time is not None:
|
||||||
|
event.end_time = max(0.0, event.end_time - time_offset)
|
||||||
for event in camera_events:
|
for event in camera_events:
|
||||||
event.time -= time_offset
|
event.time -= time_offset
|
||||||
|
|
||||||
@@ -991,14 +1015,27 @@ def build_render_plan(
|
|||||||
if vid_event is event:
|
if vid_event is event:
|
||||||
# Don't shift the pause event by its own pause
|
# Don't shift the pause event by its own pause
|
||||||
continue
|
continue
|
||||||
|
# A clip whose window STRADDLES the freeze (starts before, ends after)
|
||||||
|
# would otherwise be stretched across the whole cutscene. Its overlay
|
||||||
|
# source isn't freeze-spliced like the narration is, so it'd keep
|
||||||
|
# advancing under the cutscene and surface the wrong frame in the sliver
|
||||||
|
# between the cutscene ending and its own (shifted) end — the video7
|
||||||
|
# "appears at the start, vanishes when it should show" artifact. Instead
|
||||||
|
# end it right at the freeze onset: it plays its pre-pause content, then
|
||||||
|
# the cutscene cleanly takes over.
|
||||||
|
straddles = vid_event.start_time < narration_time < vid_event.end_time
|
||||||
if vid_event.start_time >= narration_time:
|
if vid_event.start_time >= narration_time:
|
||||||
vid_event.start_time += pause_duration
|
vid_event.start_time += pause_duration
|
||||||
if vid_event.end_time > narration_time:
|
if straddles:
|
||||||
|
vid_event.end_time = narration_time
|
||||||
|
elif vid_event.end_time > narration_time:
|
||||||
vid_event.end_time += pause_duration
|
vid_event.end_time += pause_duration
|
||||||
|
|
||||||
for aud_event in audio_events:
|
for aud_event in audio_events:
|
||||||
if aud_event.start_time > narration_time:
|
if aud_event.start_time > narration_time:
|
||||||
aud_event.start_time += pause_duration
|
aud_event.start_time += pause_duration
|
||||||
|
if aud_event.end_time is not None and aud_event.end_time > narration_time:
|
||||||
|
aud_event.end_time += pause_duration
|
||||||
|
|
||||||
for cam_event in camera_events:
|
for cam_event in camera_events:
|
||||||
if cam_event.time > narration_time:
|
if cam_event.time > narration_time:
|
||||||
@@ -1050,6 +1087,23 @@ def build_render_plan(
|
|||||||
slides_json_path = project_path / config.slides_path.lower()
|
slides_json_path = project_path / config.slides_path.lower()
|
||||||
slides_dir = slides_json_path.parent
|
slides_dir = slides_json_path.parent
|
||||||
|
|
||||||
|
# Concat-narration partial render: slice the schedule to the render window so
|
||||||
|
# each segment seeks within its OWN file (not by the combined-timeline offset,
|
||||||
|
# which over-seeks every file past the first). The offset is baked into each
|
||||||
|
# segment's skip, so narration input_seek_time stays 0. This runs on the shared
|
||||||
|
# slide_range path, so _chunked_render's per-chunk cmd_render and a hand-typed
|
||||||
|
# --slides use identical logic → render(A:B)++render(B:C) == render(A:C).
|
||||||
|
# Single-file narration keeps input_seek_time = time_offset (seeks that one file).
|
||||||
|
if narration_schedule:
|
||||||
|
from .narration import slice_schedule
|
||||||
|
|
||||||
|
narration_schedule = slice_schedule(
|
||||||
|
narration_schedule, time_offset, render_end_time
|
||||||
|
)
|
||||||
|
narration_input_seek = 0.0
|
||||||
|
else:
|
||||||
|
narration_input_seek = time_offset
|
||||||
|
|
||||||
plan = RenderPlan(
|
plan = RenderPlan(
|
||||||
project_path=project_path,
|
project_path=project_path,
|
||||||
config=config,
|
config=config,
|
||||||
@@ -1067,7 +1121,7 @@ def build_render_plan(
|
|||||||
camera_events=camera_events,
|
camera_events=camera_events,
|
||||||
time_offset=time_offset,
|
time_offset=time_offset,
|
||||||
initial_camera_state=initial_camera_state,
|
initial_camera_state=initial_camera_state,
|
||||||
input_seek_time=time_offset,
|
input_seek_time=narration_input_seek,
|
||||||
shared_assets_dir=shared_assets_dir,
|
shared_assets_dir=shared_assets_dir,
|
||||||
narration_pauses=narration_pauses,
|
narration_pauses=narration_pauses,
|
||||||
narration_segments=narration_schedule or [],
|
narration_segments=narration_schedule or [],
|
||||||
@@ -1349,6 +1403,16 @@ def _extract_video_events(
|
|||||||
# a clip when the next video begins, so videos never overlap.
|
# a clip when the next video begins, so videos never overlap.
|
||||||
video_start_times = sorted(t for t, *_ in video_markers)
|
video_start_times = sorted(t for t, *_ in video_markers)
|
||||||
|
|
||||||
|
# [end:handle] control markers: explicit end points for videos started with
|
||||||
|
# end_on=end_marker. Collected as {handle: sorted[timestamps]}. They are not a
|
||||||
|
# video prefix, so they never become video events themselves.
|
||||||
|
end_markers: dict[str, list[float]] = {}
|
||||||
|
for timing in marker_timings:
|
||||||
|
if timing.timestamp is not None and timing.timestamp >= 0 and timing.marker_id.startswith("end:"):
|
||||||
|
end_markers.setdefault(timing.marker_id[4:].lower(), []).append(timing.timestamp)
|
||||||
|
for _h in end_markers:
|
||||||
|
end_markers[_h].sort()
|
||||||
|
|
||||||
events: list[VideoEvent] = []
|
events: list[VideoEvent] = []
|
||||||
for start_time, marker_id, video_id, trigger_type, overrides in video_markers:
|
for start_time, marker_id, video_id, trigger_type, overrides in video_markers:
|
||||||
video_source = videos[video_id]
|
video_source = videos[video_id]
|
||||||
@@ -1360,14 +1424,19 @@ def _extract_video_events(
|
|||||||
marker_id,
|
marker_id,
|
||||||
video_source,
|
video_source,
|
||||||
overrides,
|
overrides,
|
||||||
default_end_on=(None if trigger_type == "narration" else "next_video"),
|
default_end_on=(None if trigger_type == "narration" else "next_slide"),
|
||||||
)
|
)
|
||||||
cutout_name = pres["cutout"]
|
cutout_name = pres["cutout"]
|
||||||
cutout = cutouts[cutout_name]
|
cutout = cutouts[cutout_name]
|
||||||
layer = pres["layer"]
|
layer = pres["layer"]
|
||||||
end_on = pres["end_on"]
|
end_on = pres["end_on"]
|
||||||
take = pres["take"]
|
take = pres["take"]
|
||||||
|
skip = pres["skip"]
|
||||||
|
loop = pres["loop"]
|
||||||
pause_narration = pres["pause_narration"]
|
pause_narration = pres["pause_narration"]
|
||||||
|
volume = pres["volume"]
|
||||||
|
object_fit = pres["object_fit"]
|
||||||
|
object_position = pres["object_position"]
|
||||||
|
|
||||||
if end_on == "take" and take is not None:
|
if end_on == "take" and take is not None:
|
||||||
end_time = start_time + take
|
end_time = start_time + take
|
||||||
@@ -1392,9 +1461,12 @@ def _extract_video_events(
|
|||||||
if vt > start_time:
|
if vt > start_time:
|
||||||
end_time = vt
|
end_time = vt
|
||||||
break
|
break
|
||||||
# A pause-narration video must stay for at least the pause it holds.
|
# A pause-narration cutscene fills EXACTLY the freeze it creates (its
|
||||||
|
# content length == pause_narration), so it ends when the freeze ends —
|
||||||
|
# not stretched to the next video, which would keep it overlaying the
|
||||||
|
# resumed narration afterwards.
|
||||||
if pause_narration:
|
if pause_narration:
|
||||||
end_time = max(end_time, start_time + pause_narration)
|
end_time = start_time + pause_narration
|
||||||
elif end_on in ("next_slide", "slide"):
|
elif end_on in ("next_slide", "slide"):
|
||||||
# End at next slide marker ("slide" is a recognised alias for "next_slide")
|
# End at next slide marker ("slide" is a recognised alias for "next_slide")
|
||||||
end_time = total_duration
|
end_time = total_duration
|
||||||
@@ -1402,10 +1474,29 @@ def _extract_video_events(
|
|||||||
if slide_time > start_time:
|
if slide_time > start_time:
|
||||||
end_time = slide_time
|
end_time = slide_time
|
||||||
break
|
break
|
||||||
# pause_narration videos must stay visible for the full pause duration —
|
# pause_narration cutscene: end exactly with the freeze (see above).
|
||||||
# the narration is held for that long, so the overlay should match.
|
|
||||||
if pause_narration:
|
if pause_narration:
|
||||||
end_time = max(end_time, start_time + pause_narration)
|
end_time = start_time + pause_narration
|
||||||
|
elif end_on == "end_marker":
|
||||||
|
# Explicit end: stop at the first [end:handle] placed after this clip
|
||||||
|
# starts (so the same handle can be reused in different sections).
|
||||||
|
ends = [t for t in end_markers.get(video_id, ()) if t > start_time]
|
||||||
|
if ends:
|
||||||
|
end_time = ends[0]
|
||||||
|
else:
|
||||||
|
# No matching [end:handle] — fall back to next_video and warn rather
|
||||||
|
# than silently running to the end of the render.
|
||||||
|
end_time = total_duration
|
||||||
|
for vt in video_start_times:
|
||||||
|
if vt > start_time:
|
||||||
|
end_time = vt
|
||||||
|
break
|
||||||
|
warnings.append(
|
||||||
|
f"[{marker_id}] end_on=end_marker but no [end:{video_id}] found "
|
||||||
|
f"after it — ending at the next video instead."
|
||||||
|
)
|
||||||
|
if pause_narration:
|
||||||
|
end_time = start_time + pause_narration
|
||||||
else:
|
else:
|
||||||
# end_on None ([narration:] with no explicit end) — runs to end.
|
# end_on None ([narration:] with no explicit end) — runs to end.
|
||||||
end_time = total_duration
|
end_time = total_duration
|
||||||
@@ -1415,12 +1506,23 @@ def _extract_video_events(
|
|||||||
# it), so a clip spanning a chunk boundary survives into the later chunk.
|
# it), so a clip spanning a chunk boundary survives into the later chunk.
|
||||||
if end_time <= range_start or start_time >= range_end:
|
if end_time <= range_start or start_time >= range_end:
|
||||||
continue
|
continue
|
||||||
|
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;
|
# 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.
|
# seek into it so it resumes at the right frame instead of restarting.
|
||||||
skip_override = None
|
|
||||||
if start_time < range_start:
|
if start_time < range_start:
|
||||||
into = range_start - start_time # elapsed since the clip started
|
into = range_start - start_time # elapsed since the clip started
|
||||||
base = video_source.skip or 0.0
|
base = skip # resolved per-occurrence skip
|
||||||
playable = (video_source.duration - base) if video_source.duration else None
|
playable = (video_source.duration - base) if video_source.duration else None
|
||||||
if playable and playable > 0 and into >= playable:
|
if playable and playable > 0 and into >= playable:
|
||||||
# the clip has looped by the window start → resume at the loop phase
|
# the clip has looped by the window start → resume at the loop phase
|
||||||
@@ -1440,6 +1542,13 @@ def _extract_video_events(
|
|||||||
cutout=cutout,
|
cutout=cutout,
|
||||||
cutout_name=cutout_name,
|
cutout_name=cutout_name,
|
||||||
layer=layer,
|
layer=layer,
|
||||||
|
end_on=end_on or "",
|
||||||
|
skip=skip,
|
||||||
|
take=take,
|
||||||
|
loop=loop,
|
||||||
|
volume=volume,
|
||||||
|
object_fit=object_fit,
|
||||||
|
object_position=object_position,
|
||||||
skip_override=skip_override,
|
skip_override=skip_override,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1456,6 +1565,15 @@ def _extract_audio_events(
|
|||||||
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
||||||
events: list[AudioEvent] = []
|
events: list[AudioEvent] = []
|
||||||
|
|
||||||
|
# [end:handle] markers stop an audio clip early (parallel to the video end_marker,
|
||||||
|
# but audio opts in automatically — there is no per-clip end_on to set).
|
||||||
|
audio_end_markers: dict[str, list[float]] = {}
|
||||||
|
for timing in marker_timings:
|
||||||
|
if timing.timestamp is not None and timing.timestamp >= 0 and timing.marker_id.startswith("end:"):
|
||||||
|
audio_end_markers.setdefault(timing.marker_id[4:].lower(), []).append(timing.timestamp)
|
||||||
|
for _h in audio_end_markers:
|
||||||
|
audio_end_markers[_h].sort()
|
||||||
|
|
||||||
for timing in marker_timings:
|
for timing in marker_timings:
|
||||||
if timing.timestamp < 0:
|
if timing.timestamp < 0:
|
||||||
continue
|
continue
|
||||||
@@ -1469,8 +1587,13 @@ def _extract_audio_events(
|
|||||||
if audio_id is not None and audio_id in audio:
|
if audio_id is not None and audio_id in audio:
|
||||||
adef = audio[audio_id]
|
adef = audio[audio_id]
|
||||||
astart = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
|
astart = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
|
||||||
|
# Explicit stop from the first [end:audio_id] placed after this clip starts.
|
||||||
|
_ends = [t for t in audio_end_markers.get(audio_id.lower(), ()) if t > timing.timestamp]
|
||||||
|
clip_end = _ends[0] if _ends else None
|
||||||
# Effective end of this clip on the output timeline.
|
# Effective end of this clip on the output timeline.
|
||||||
if adef.loop:
|
if clip_end is not None:
|
||||||
|
aend = clip_end
|
||||||
|
elif adef.loop:
|
||||||
aend = range_end # a loop fills to the window/render end
|
aend = range_end # a loop fills to the window/render end
|
||||||
elif adef.duration is not None:
|
elif adef.duration is not None:
|
||||||
aend = astart + adef.duration
|
aend = astart + adef.duration
|
||||||
@@ -1483,10 +1606,16 @@ def _extract_audio_events(
|
|||||||
if aend <= range_start or astart >= range_end:
|
if aend <= range_start or astart >= range_end:
|
||||||
continue
|
continue
|
||||||
src_offset = 0.0
|
src_offset = 0.0
|
||||||
|
crossfade_offset = 0.0
|
||||||
if astart < range_start:
|
if astart < range_start:
|
||||||
into = range_start - astart
|
into = range_start - astart
|
||||||
if adef.loop and adef.duration:
|
if adef.loop and adef.duration:
|
||||||
src_offset = into % adef.duration
|
src_offset = into % adef.duration
|
||||||
|
# The crossfade loop stream repeats every (duration - overlap),
|
||||||
|
# so it resumes at a different phase than the hard aloop path.
|
||||||
|
if adef.overlap:
|
||||||
|
loop_len = max(1e-6, adef.duration - adef.overlap)
|
||||||
|
crossfade_offset = into % loop_len
|
||||||
else:
|
else:
|
||||||
src_offset = into
|
src_offset = into
|
||||||
astart = range_start
|
astart = range_start
|
||||||
@@ -1496,6 +1625,8 @@ def _extract_audio_events(
|
|||||||
start_time=astart,
|
start_time=astart,
|
||||||
audio_def=adef,
|
audio_def=adef,
|
||||||
src_offset=src_offset,
|
src_offset=src_offset,
|
||||||
|
crossfade_offset=crossfade_offset,
|
||||||
|
end_time=clip_end,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,19 @@ def validate_project(
|
|||||||
if marker in ("pause", "stop"):
|
if marker in ("pause", "stop"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Explicit end markers: [end:handle] stops a video (end_on=end_marker) OR an
|
||||||
|
# audio clip. Valid if the handle is defined in either videos.json or audio.json.
|
||||||
|
if marker.startswith("end:"):
|
||||||
|
handle = marker[4:].lower()
|
||||||
|
if handle not in videos and handle not in (audio or {}):
|
||||||
|
warnings.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"[{marker}] ends a clip, but '{handle}' isn't defined in videos.json or audio.json.",
|
||||||
|
project_path / "manuscript.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning
|
# Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning
|
||||||
if ":" in marker:
|
if ":" in marker:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
@@ -155,6 +168,13 @@ def validate_project(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Only slide-shaped ids (S1, S54, …) are slide references. Other bare
|
||||||
|
# bracketed tokens are prose the author wrote, not markers — e.g. a vector
|
||||||
|
# "[1, 1, 1]" or "[2, 2, 2]" in the narration (the ", …" tail makes the regex
|
||||||
|
# read the leading number as a marker id). Don't flag those as missing slides.
|
||||||
|
if not (len(marker) > 1 and marker[0] in "Ss" and marker[1:].isdigit()):
|
||||||
|
continue
|
||||||
|
|
||||||
if marker not in slides:
|
if marker not in slides:
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
@@ -350,6 +370,55 @@ def validate_project(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate presentation override VALUES so a typo — [vsb:x, object-fit=covfer],
|
||||||
|
# end_on=nextslide, layer=beneath — fails HERE instead of silently mis-rendering
|
||||||
|
# (or crashing) at render time. Checked case-insensitively against the value sets
|
||||||
|
# the renderer/transformer accept. Sources: inline manuscript overrides and the
|
||||||
|
# project's own videos.json entries. Keep end_on in sync with _extract_video_events.
|
||||||
|
import re as _re
|
||||||
|
from .parser import parse_marker
|
||||||
|
|
||||||
|
_VALID_VALUES = {
|
||||||
|
"object-fit": {"cover", "contain"},
|
||||||
|
"object-position": {"center", "top", "bottom", "left", "right"},
|
||||||
|
"layer": {"above", "mid", "below"},
|
||||||
|
"end_on": {"end", "loop", "next_slide", "slide", "next_video",
|
||||||
|
"video", "take", "end_marker"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check_value(where: str, key: str, value, src_path: Path) -> None:
|
||||||
|
allowed = _VALID_VALUES.get(key)
|
||||||
|
if allowed is not None and value is not None and str(value).lower() not in allowed:
|
||||||
|
issues.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"{where}: invalid {key}={value!r} — valid values: {sorted(allowed)}",
|
||||||
|
src_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# (a) inline manuscript overrides: [prefix:handle, key=value, …]
|
||||||
|
_mpath = project_path / "manuscript.txt"
|
||||||
|
if _mpath.exists():
|
||||||
|
_mtext = _mpath.read_text(encoding="utf-8")
|
||||||
|
for _raw in _re.findall(r"\[([A-Za-z0-9_:./\-]+(?:,[^\]\n]*)?)\]", _mtext):
|
||||||
|
_mid, _overrides = parse_marker(_raw)
|
||||||
|
for _k, _v in (_overrides or {}).items():
|
||||||
|
_check_value(f"[{_raw}]", _k, _v, _mpath)
|
||||||
|
|
||||||
|
# (b) project videos.json entries (JSON keys mirror CSS: object-fit/object-position)
|
||||||
|
_vjson = project_path / config.videos_path
|
||||||
|
if _vjson.exists():
|
||||||
|
try:
|
||||||
|
_raw_videos = _read_json(_vjson)
|
||||||
|
except Exception:
|
||||||
|
_raw_videos = {}
|
||||||
|
if isinstance(_raw_videos, dict):
|
||||||
|
for _vid, _entry in _raw_videos.items():
|
||||||
|
if isinstance(_entry, dict):
|
||||||
|
for _k in ("object-fit", "object-position", "end_on", "layer"):
|
||||||
|
if _k in _entry:
|
||||||
|
_check_value(f"videos.json[{_vid}]", _k, _entry[_k], _vjson)
|
||||||
|
|
||||||
# If any issues, raise ValidationError
|
# If any issues, raise ValidationError
|
||||||
if issues:
|
if issues:
|
||||||
raise ValidationError(issues)
|
raise ValidationError(issues)
|
||||||
|
|||||||
+11
-15
@@ -8,15 +8,18 @@
|
|||||||
./gnommo.sh -p video4 grade --stage key
|
./gnommo.sh -p video4 grade --stage key
|
||||||
./gnommo.sh -p video5 grade --stage key
|
./gnommo.sh -p video5 grade --stage key
|
||||||
./gnommo.sh -p video6 grade --stage key
|
./gnommo.sh -p video6 grade --stage key
|
||||||
|
./gnommo.sh -p video7 grade --stage key
|
||||||
|
|
||||||
|
|
||||||
./gnommo.sh -p video0 grade --pick key_5
|
./gnommo.sh -p video0 grade --stage despill
|
||||||
./gnommo.sh -p video1 grade --pick key_5
|
./gnommo.sh -p video1 grade --stage despill
|
||||||
./gnommo.sh -p video2 grade --pick key_5
|
./gnommo.sh -p video2 grade --stage despill
|
||||||
./gnommo.sh -p video3 grade --pick key_5
|
./gnommo.sh -p video3 grade --stage despill
|
||||||
./gnommo.sh -p video4 grade --pick key_5
|
./gnommo.sh -p video4 grade --stage despill
|
||||||
./gnommo.sh -p video5 grade --pick key_5
|
./gnommo.sh -p video5 grade --stage despill
|
||||||
./gnommo.sh -p video6 grade --pick key_5
|
./gnommo.sh -p video6 grade --stage despill
|
||||||
|
./gnommo.sh -p video7 grade --stage despill
|
||||||
|
|
||||||
|
|
||||||
./gnommo.sh -p video0 grade --stage grade
|
./gnommo.sh -p video0 grade --stage grade
|
||||||
./gnommo.sh -p video1 grade --stage grade
|
./gnommo.sh -p video1 grade --stage grade
|
||||||
@@ -25,11 +28,4 @@
|
|||||||
./gnommo.sh -p video4 grade --stage grade
|
./gnommo.sh -p video4 grade --stage grade
|
||||||
./gnommo.sh -p video5 grade --stage grade
|
./gnommo.sh -p video5 grade --stage grade
|
||||||
./gnommo.sh -p video6 grade --stage grade
|
./gnommo.sh -p video6 grade --stage grade
|
||||||
|
./gnommo.sh -p video7 grade --stage grade
|
||||||
./gnommo.sh -p video0 grade --pick grade_5
|
|
||||||
./gnommo.sh -p video1 grade --pick grade_5
|
|
||||||
./gnommo.sh -p video2 grade --pick grade_5
|
|
||||||
./gnommo.sh -p video3 grade --pick grade_5
|
|
||||||
./gnommo.sh -p video4 grade --pick grade_5
|
|
||||||
./gnommo.sh -p video5 grade --pick grade_5
|
|
||||||
./gnommo.sh -p video6 grade --pick grade_5
|
|
||||||
|
|||||||
@@ -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
|
#!/bin/sh
|
||||||
|
|
||||||
|
./gnommo.sh -p video0 render --force
|
||||||
./gnommo.sh -p video1 all
|
./gnommo.sh -p video1 render --force
|
||||||
./gnommo.sh -p video2 all
|
./gnommo.sh -p video2 render --force
|
||||||
./gnommo.sh -p video3 all
|
./gnommo.sh -p video3 render --force
|
||||||
./gnommo.sh -p video4 all
|
./gnommo.sh -p video4 render --force
|
||||||
./gnommo.sh -p video5 all
|
./gnommo.sh -p video5 render --force
|
||||||
./gnommo.sh -p video6 all
|
./gnommo.sh -p video6 render --force
|
||||||
|
./gnommo.sh -p video7 render --force
|
||||||
|
./gnommo.sh -p video0 handoff --prod
|
||||||
|
./gnommo.sh -p video1 handoff --prod
|
||||||
|
./gnommo.sh -p video2 handoff --prod
|
||||||
|
./gnommo.sh -p video3 handoff --prod
|
||||||
|
./gnommo.sh -p video4 handoff --prod
|
||||||
|
./gnommo.sh -p video5 handoff --prod
|
||||||
|
./gnommo.sh -p video6 handoff --prod
|
||||||
|
./gnommo.sh -p video7 handoff --prod
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,27 @@ def test_audio():
|
|||||||
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossfade_phase():
|
||||||
|
print("crossfade loop phase:")
|
||||||
|
# Looping pad with a 15s crossfade overlap: the crossfade stream repeats every
|
||||||
|
# (duration - overlap) = 60 - 15 = 45s, so a chunk starting at into=300 resumes
|
||||||
|
# at crossfade phase 300 % 45 = 30, while the hard-loop phase is 300 % 60 = 60→0.
|
||||||
|
audio = {"pad": AudioDefinition(file="pad.wav", loop=True, duration=60.0, overlap=15.0)}
|
||||||
|
markers = [MarkerTiming(marker_id="Apad", timestamp=0.0, context="", confidence=1.0)]
|
||||||
|
evs = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=(300.0, 600.0))}
|
||||||
|
check("looping pad with overlap is included", "pad" in evs)
|
||||||
|
if "pad" in evs:
|
||||||
|
p = evs["pad"]
|
||||||
|
check("crossfade seeks to loop_len phase 30.0",
|
||||||
|
abs(p.crossfade_offset - 30.0) < 1e-6, f"crossfade_offset={p.crossfade_offset}")
|
||||||
|
check("src_offset still uses full-duration phase 0.0",
|
||||||
|
abs(p.src_offset - 0.0) < 1e-6, f"src_offset={p.src_offset}")
|
||||||
|
# Full render: no phase seek on either.
|
||||||
|
full = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=None)}
|
||||||
|
check("full render pad has no crossfade seek",
|
||||||
|
"pad" in full and full["pad"].crossfade_offset == 0.0)
|
||||||
|
|
||||||
|
|
||||||
# ── video ────────────────────────────────────────────────────────────────────
|
# ── video ────────────────────────────────────────────────────────────────────
|
||||||
def test_video():
|
def test_video():
|
||||||
print("video events:")
|
print("video events:")
|
||||||
@@ -113,9 +134,44 @@ def test_video():
|
|||||||
check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None)
|
check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_cutscene_chunk_ownership():
|
||||||
|
# A pause_narration cutscene must live WHOLLY in the chunk where it starts: its
|
||||||
|
# end = start + pause_narration overshoots the pre-pause timeline, so it must not be
|
||||||
|
# truncated by range_end nor duplicated into the next chunk (the video6 double-freeze
|
||||||
|
# / one-frame-logo bug).
|
||||||
|
print("pause cutscene chunk ownership:")
|
||||||
|
cutouts = {"fullscreen": CutoutDefinition(x=0, y=0, height=1080, width=1920)}
|
||||||
|
videos = {
|
||||||
|
"logo": VideoSource(source_file="logo.mov", cutout="fullscreen", layer="above",
|
||||||
|
duration=18.0, pause_narration=18.0),
|
||||||
|
}
|
||||||
|
slides = {f"S{i}": SlideDefinition(image=f"S{i}.png", type="slide") for i in range(1, 6)}
|
||||||
|
markers = [MarkerTiming(marker_id=f"S{i}", timestamp=(i - 1) * 30.0, context="", confidence=1.0)
|
||||||
|
for i in range(1, 6)]
|
||||||
|
# Cutscene triggers at t=100 (between S4=90 and S5=120). Its end = 100+18 = 118.
|
||||||
|
markers.append(MarkerTiming(marker_id="vftp:logo", timestamp=100.0, context="", confidence=1.0))
|
||||||
|
total = 200.0
|
||||||
|
|
||||||
|
# Owning chunk [90, 110): starts inside it. End must NOT be clamped to 110 → full 18s.
|
||||||
|
evs, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(90.0, 110.0))
|
||||||
|
lg = next((e for e in evs if e.video_id == "logo"), None)
|
||||||
|
check("cutscene INCLUDED in the chunk it starts in", lg is not None)
|
||||||
|
if lg is not None:
|
||||||
|
check("cutscene end NOT clamped to range_end (full pause length)",
|
||||||
|
abs((lg.end_time - lg.start_time) - 18.0) < 1e-6,
|
||||||
|
f"duration={lg.end_time - lg.start_time}")
|
||||||
|
|
||||||
|
# Next chunk [110, 200): the cutscene started earlier → must be EXCLUDED (no dupe freeze).
|
||||||
|
evs2, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(110.0, 200.0))
|
||||||
|
check("cutscene EXCLUDED from the next chunk (no duplicate freeze)",
|
||||||
|
not any(e.video_id == "logo" for e in evs2))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_audio()
|
test_audio()
|
||||||
|
test_crossfade_phase()
|
||||||
test_video()
|
test_video()
|
||||||
|
test_pause_cutscene_chunk_ownership()
|
||||||
print()
|
print()
|
||||||
if _fails:
|
if _fails:
|
||||||
print(f"FAILED: {len(_fails)} check(s): {', '.join(_fails)}")
|
print(f"FAILED: {len(_fails)} check(s): {', '.join(_fails)}")
|
||||||
|
|||||||
@@ -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 video4 import
|
||||||
./gnommo.sh -p video5 import
|
./gnommo.sh -p video5 import
|
||||||
./gnommo.sh -p video6 import
|
./gnommo.sh -p video6 import
|
||||||
|
./gnommo.sh -p video7 import
|
||||||
|
|
||||||
./gnommo.sh -p video0 prune
|
./gnommo.sh -p video0 prune
|
||||||
./gnommo.sh -p video1 prune
|
./gnommo.sh -p video1 prune
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
./gnommo.sh -p video4 prune
|
./gnommo.sh -p video4 prune
|
||||||
./gnommo.sh -p video5 prune
|
./gnommo.sh -p video5 prune
|
||||||
./gnommo.sh -p video6 prune
|
./gnommo.sh -p video6 prune
|
||||||
|
./gnommo.sh -p video7 prune
|
||||||
|
|
||||||
|
|
||||||
./gnommo.sh -p video0 up
|
./gnommo.sh -p video0 up
|
||||||
@@ -24,4 +26,5 @@
|
|||||||
./gnommo.sh -p video4 up
|
./gnommo.sh -p video4 up
|
||||||
./gnommo.sh -p video5 up
|
./gnommo.sh -p video5 up
|
||||||
./gnommo.sh -p video6 up
|
./gnommo.sh -p video6 up
|
||||||
|
./gnommo.sh -p video7 up
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user