Setting the file. One moment. Render And Composite · Embedded Captions · heygen-com/hyperframes · Skills Docs14.42
Hershey Script1
scripts/render-and-composite.sh
scripts/render-and-composite.sh
Shell·469 lines·25 KB
> [hyperframes-repo]
}
"
13PROJECT="$(cd "$PROJECT" && pwd)"
14
15# Resolve the hyperframes checkout. Candidate order:
16# 1. arg 2 2. $HYPERFRAMES_ROOT 3. repo root if this skill ships INSIDE the
17# hyperframes repo (skills/embedded-captions/scripts → ../../..) 4. ~/Downloads/hyperframes
18SKILL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19HF=""
20for cand in "${2:-}" "${HYPERFRAMES_ROOT:-}" "$(cd "$SKILL_SCRIPT_DIR/../../.." 2>/dev/null && pwd)" "$HOME/Downloads/hyperframes"; do
21 if [[ -n "$cand" && -f "$cand/packages/cli/dist/cli.js" ]]; then HF="$cand"; break; fi
22done
23if [[ -z "$HF" ]]; then
24 echo "[render] hyperframes CLI not found. Set HYPERFRAMES_ROOT to your hyperframes" >&2
25 echo " checkout (needs packages/cli/dist/cli.js — 'bun install && bun run build')." >&2
26 exit 1
27fi
28export HYPERFRAMES_ROOT="$HF" # so the occlusion gate's measure-layout.cjs finds puppeteer too
29HF_CLI="$HF/packages/cli/dist/cli.js"
30if [[ ! -d "$PROJECT/frames_fg" ]]; then
31 echo "[render] missing matte frames at $PROJECT/frames_fg — run matte.cjs first" >&2
32 exit 1
33fi
34# Which compiler owns this project? Cinematic = make-composition.cjs (plan.json).
35# (Standard mode retired 2026-06-12 — rail-surface needs are served by theme
36# DNAs like "anchor"; legacy standard.json projects re-render from their
37# existing index.html or via the archived compiler in embedded-captions-archive.)
38compiler_for() {
39 echo "make-composition.cjs"
40}
41if [[ ! -f "$PROJECT/index.html" ]]; then
42 if [[ -f "$PROJECT/plan.json" ]]; then
43 C="$(compiler_for)"
44 echo "[render] no index.html — auto-compiling via $C"
45 node "$(dirname "$0")/$C" "$PROJECT"
46 elif [[ -f "$PROJECT/cinematic.json" ]]; then
47 # cinematic.json-only project: the full compiler lowers blocks → plan.json → html
48 echo "[render] no index.html — auto-compiling via make-cinematic.cjs"
49 node "$(dirname "$0")/make-cinematic.cjs" "$PROJECT"
50 else
51 echo "[render] missing $PROJECT/index.html and standard.json/plan.json/cinematic.json — author + compile first" >&2
52 exit 1
53 fi
54elif [[ -f "$PROJECT/cinematic.json" && "$PROJECT/cinematic.json" -nt "$PROJECT/index.html" ]]; then
55 echo "[render] cinematic.json newer than index.html — recompiling"
56 node "$(dirname "$0")/make-cinematic.cjs" "$PROJECT"
57elif [[ -f "$PROJECT/plan.json" && "$PROJECT/plan.json" -nt "$PROJECT/index.html" ]]; then
58 C="$(compiler_for)"
59 echo "[render] plan.json newer than index.html — recompiling via $C"
60 node "$(dirname "$0")/$C" "$PROJECT"
61else
62 # the COMPILERS themselves may have changed since this project last compiled —
63 # rendering stale HTML after a compiler fix silently re-ships the old bug
64 C="$(compiler_for)"
65 RECOMPILER="$C"
66 # for cinematic projects the FULL compiler is make-cinematic (compiler_for points
67 # at make-composition, which only re-emits html from an already-compiled plan)
68 if [[ -f "$PROJECT/cinematic.json" && "$C" == "make-composition.cjs" ]]; then RECOMPILER="make-cinematic.cjs"; fi
69 ENGINE="$(dirname "$0")/../modes/cinematic/engine.html"
70 if [[ "$(dirname "$0")/$RECOMPILER" -nt "$PROJECT/index.html" || "$(dirname "$0")/lib-dna.cjs" -nt "$PROJECT/index.html" || ( -f "$ENGINE" && "$ENGINE" -nt "$PROJECT/index.html" ) ]]; then
71 echo "[render] compiler/engine newer than index.html — recompiling via $RECOMPILER"
72 node "$(dirname "$0")/$RECOMPILER" "$PROJECT"
73 fi
74fi
75
76# Embed template fonts BEFORE the gates + render. hyperframes only auto-supplies
77# its ~18 canonical fonts; every other template family (Anton, Bangers, VT323,
78# Press Start 2P, …) silently falls back to a generic font on a clean/offline/CI
79# machine — it only "looks right" locally when that font happens to be installed
80# as a system font. inject-fonts inlines the @font-face (base64 woff2, from
81# modes/standard/fonts/fonts.css) for whatever non-canonical families each HTML
82# actually uses, so measure-layout AND the capture see the true glyph metrics.
83# Idempotent; a no-op when every font is canonical/system or already declared.
84node "$(dirname "$0")/inject-fonts.cjs" "$PROJECT" \
85 || echo "[render] (font embed skipped — inject-fonts.cjs/fonts.css unavailable)" >&2
86
87# gate ledger — each gate appends one line; echoed as a summary before "done"
88# (verdicts were drowning hundreds of lines up in ffmpeg logs)
89GATES="$PROJECT/_gates.txt"; : > "$GATES"
90
91# Gate: plan.json word timings must align with transcript.json within 80ms.
92# A caption whose animation fires 500ms before or after the word is spoken
93# breaks the "belongs to the scene" illusion — hard fail, not a warning.
94# Skip only if transcript.json is missing (custom mode without transcript).
95if [[ -f "$PROJECT/plan.json" && -f "$PROJECT/transcript.json" ]]; then
96 if ! node "$(dirname "$0")/check-timing.cjs" "$PROJECT" --strict; then
97 echo "[render] ABORTED — fix plan.json word timings to match transcript.json, then re-run." >&2
98 exit 2
99 fi
100 echo "timing PASS (strict)" >> "$GATES"
101fi
102
103# Gate: subject occlusion + frame-edge overflow — pixel-perfect via Chromium DOM
104# rects (measure-layout.cjs) × the subject-matte alpha (sharp). Template mode only
105# (skipped when plan.json is absent; custom mode uses check-overflow.cjs below).
106if [[ -f "$PROJECT/plan.json" && -d "$PROJECT/frames_fg" ]]; then
107 # check-occlusion prints its verdict, then can intermittently hang on native
108 # (sharp/libvips) teardown — which would wedge the whole render. Run it under a
109 # watchdog: once the verdict is printed, a stuck exit can't block us. We read the
110 # pass/fail from its OUTPUT, not its exit code, so the hang is harmless.
111 OCC_LOG="$PROJECT/_occlusion.log"
112 node "$(dirname "$0")/check-occlusion.cjs" "$PROJECT" --strict > "$OCC_LOG" 2>&1 &
113 OCC_PID=$!; occ_t0=$SECONDS; OCC_RC=""; verdict_at=""
114 while kill -0 "$OCC_PID" 2>/dev/null; do
115 # mark when the verdict header is printed (analysis done). if-form: a failed grep
116 # must NOT trip set -e (it silently killed the whole render on fresh projects).
117 if [[ -z "$verdict_at" ]] && grep -qE '\[v2\].*word-fail' "$OCC_LOG" 2>/dev/null; then verdict_at=$SECONDS; fi
118 # verdict printed but still alive 8s later → native (sharp) teardown hang; or no
119 # verdict after 150s → measure/analysis stuck. Either way: kill + read verdict.
120 if { [[ -n "$verdict_at" ]] && (( SECONDS - verdict_at > 8 )); } || (( SECONDS - occ_t0 > 150 )); then
121 kill -9 "$OCC_PID" 2>/dev/null
122 if grep -q 'cap(s) FAIL' "$OCC_LOG"; then OCC_RC=2; else OCC_RC=0; fi
123 echo "[render] occlusion gate hung after verdict (sharp teardown) — killed zombie; verdict rc=$OCC_RC" >&2
124 break
125 fi
126 sleep 2
127 done
128 if [[ -z "$OCC_RC" ]]; then OCC_RC=0; wait "$OCC_PID" 2>/dev/null || OCC_RC=$?; fi # capture rc without tripping set -e
129 cat "$OCC_LOG"
130 if (( OCC_RC != 0 )); then
131 echo "[render] ABORTED — fix plan.json layout to reduce subject occlusion / frame-edge overflow, then re-run." >&2
132 echo " Override: OCCLUSION_SKIP=1 bash render-and-composite.sh <project>" >&2
133 if [[ "${OCCLUSION_SKIP:-0}" != "1" ]]; then
134 exit 2
135 fi
136 echo "[render] OCCLUSION_SKIP=1 set — continuing despite occlusion/overflow warnings." >&2
137 echo "occlusion+overflow OVERRIDDEN (OCCLUSION_SKIP=1 — conscious accept)" >> "$GATES"
138 fi
139 if (( OCC_RC == 0 )); then echo "occlusion+overflow PASS" >> "$GATES"; fi
140fi
141
142# Custom mode (no plan.json) skips the template gates above. Run a lightweight,
143# mode-agnostic frame-overflow check as a WARNING only — custom designs may bleed
144# off-frame intentionally, so it never aborts, but it surfaces captions that fall
145# off the canvas (the failure we otherwise only catch by eye).
146if [[ ! -f "$PROJECT/plan.json" && -f "$PROJECT/index.html" && -f "$(dirname "$0")/check-overflow.cjs" ]]; then
147 node "$(dirname "$0")/check-overflow.cjs" "$PROJECT" \
148 || echo "[render] (overflow check skipped — Chromium/puppeteer unavailable)" >&2
149 echo "overflow(index) checked (custom mode, warning-only)" >> "$GATES"
150fi
151# rail.html gets NO other automated coverage (the occlusion gate only reads plan.json
152# caps) — run its overflow check whenever it exists. Was dead code inside the
153# no-plan.json branch: Standard always HAS a derived plan.json, so it never ran.
154if [[ -f "$PROJECT/rail.html" && -f "$(dirname "$0")/check-overflow.cjs" ]]; then
155 if node "$(dirname "$0")/check-overflow.cjs" "$PROJECT" rail.html; then
156 echo "overflow(rail) PASS" >> "$GATES"
157 else
158 echo "[render] (rail overflow check skipped — Chromium/puppeteer unavailable)" >&2
159 echo "overflow(rail) skipped (infra)" >> "$GATES"
160 fi
161fi
162
163# Standard hand-off gate: the PROMOTED climax word must NOT also be revealed in the
164# rail during the climax's on-screen window (PIPELINE.md "Rail ↔ climax hand-off").
165# Hard-fails on a CONFIRMED duplicate; infra issues (no puppeteer, etc.) exit 0 and
166# never block. Override with RAIL_CLIMAX_SKIP=1 for a deliberate exception.
167if [[ -f "$PROJECT/rail.html" && -f "$PROJECT/index.html" && -f "$(dirname "$0")/check-rail-climax.cjs" ]]; then
168 if ! node "$(dirname "$0")/check-rail-climax.cjs" "$PROJECT"; then
169 echo "[render] ABORTED — the promoted climax word is duplicated in the rail." >&2
170 echo " Apply the rail↔climax hand-off (PIPELINE.md), then re-run." >&2
171 echo " Override: RAIL_CLIMAX_SKIP=1 bash render-and-composite.sh <project>" >&2
172 if [[ "${RAIL_CLIMAX_SKIP:-0}" != "1" ]]; then
173 exit 2
174 fi
175 echo "[render] RAIL_CLIMAX_SKIP=1 — continuing despite the rail/climax duplicate." >&2
176 echo "rail-climax OVERRIDDEN (RAIL_CLIMAX_SKIP=1)" >> "$GATES"
177 else
178 echo "rail-climax PASS (no duplicate reveal)" >> "$GATES"
179 fi
180fi
181
182# FPS: matte.fps (written by matte.cjs at the source's NATIVE rate) is authoritative
183# so the matte overlay stays frame-aligned with the render. Falls back to plan.fps /
184# frame-count inference / 24. Warn if plan.json fps disagrees with the matte.
185FPS=""
186if [[ -f "$PROJECT/matte.fps" ]]; then
187 FPS="$(tr -dc '0-9' < "$PROJECT/matte.fps")"
188 if [[ -f "$PROJECT/plan.json" ]]; then
189 PFPS="$(node -e 'try{process.stdout.write(String(require(process.argv[1]).fps??""))}catch(e){}' "$PROJECT/plan.json" 2>/dev/null || true)"
190 if [[ -n "$PFPS" && "$PFPS" != "$FPS" ]]; then
191 echo "[render] WARN: plan.json fps=$PFPS != matte fps=$FPS — using matte fps to keep occlusion aligned" >&2
192 fi
193 fi
194fi
195if [[ -z "$FPS" || "$FPS" == "0" ]]; then
196 if [[ -f "$PROJECT/plan.json" ]]; then
197 FPS="$(node -e 'process.stdout.write(String(require(process.argv[1]).fps??24))' "$PROJECT/plan.json")"
198 elif [[ -d "$PROJECT/frames_fg" ]]; then
199 N="$(ls "$PROJECT/frames_fg" | wc -l | tr -d ' ')"
200 DUR="$(grep -oE 'data-duration="[0-9.]+"' "$PROJECT/index.html" | head -1 | grep -oE '[0-9.]+' || echo '')"
201 if [[ -n "$DUR" && "$N" -gt 0 ]]; then
202 FPS="$(awk "BEGIN{printf \"%d\", $N/$DUR + 0.5}")"
203 else
204 FPS=24
205 fi
206 else
207 FPS=24
208 fi
209fi
210
211# Caption layer: "bg" (classic embed — matte overlays subject on top of caps)
212# or "fg" (captions always on top — announcement feel, used for 9:16 portraits
213# where the subject fills the frame and bg mode loses too much to occlusion).
214# Precedence: CLI env flag > plan.json > HTML data attribute > "bg".
215CAPTION_LAYER="${CAPTION_LAYER_FLAG:-}"
216if [[ -z "$CAPTION_LAYER" && -f "$PROJECT/plan.json" ]]; then
217 CAPTION_LAYER="$(node -e 'process.stdout.write(String(require(process.argv[1]).caption_layer??"bg"))' "$PROJECT/plan.json")"
218fi
219if [[ -z "$CAPTION_LAYER" && -f "$PROJECT/index.html" ]]; then
220 ATTR="$(grep -oE 'data-caption-layer="(bg|fg)"' "$PROJECT/index.html" | head -1 | grep -oE '(bg|fg)' || true)"
221 if [[ -n "$ATTR" ]]; then CAPTION_LAYER="$ATTR"; fi
222fi
223CAPTION_LAYER="${CAPTION_LAYER:-bg}"
224echo "[render] caption_layer=$CAPTION_LAYER"
225
226BG="$PROJECT/bg_plus_caps.mp4"
227FINAL="$PROJECT/final.mp4"
228
229# Snapshot the current index.html + plan.json into history/ so the user
230# can recover a prior iteration's design after further edits overwrite it.
231HISTORY_DIR="$PROJECT/history"
232mkdir -p "$HISTORY_DIR"
233STAMP="$(date +%Y%m%d-%H%M%S)"
234cp "$PROJECT/index.html" "$HISTORY_DIR/index-${STAMP}.html"
235cp "$PROJECT/plan.json" "$HISTORY_DIR/plan-${STAMP}.json" 2>/dev/null || true
236echo "[render] snapshot → history/index-${STAMP}.html"
237
238echo "[render] hyperframes render @ ${FPS}fps"
239
240# Hyperframes occasionally hangs on Chromium shutdown *after* the output file
241# is successfully written (seen multiple times on 15–30s clips). Without a
242# guard the shell waits forever. This helper enforces a max wall-clock budget,
243# and if the output is already on disk when we hit it, treats the run as
244# successful and kills the zombie. Tune HF_TIMEOUT_S via env if needed.
245# Default SCALES with clip size: two parallel Chromium passes on a long clip
246# legitimately exceed a fixed 240s (a 38s/1151-frame render was killed at 244s
247# while healthy). ~1.5s per source frame, floor 240s.
248N_FRAMES="$(ls "$PROJECT/frames_fg" 2>/dev/null | wc -l | tr -d ' ')"
249HF_TIMEOUT_S="${HF_TIMEOUT_S:-$(( N_FRAMES * 3 / 2 > 240 ? N_FRAMES * 3 / 2 : 240 ))}"
250# hf_render_dir: render one hyperframes composition.
251# args: <output.mp4> <label> <project_dir>
252# watches for the Chromium-shutdown-hang; if output file exists and is >1MB
253# past timeout, treats as success and kills the zombie.
254hf_render_dir() {
255 local out="$1" label="$2" proj="$3" fmt="${4:-}"
256 # bash 3.2 (macOS) throws on empty-array expansion under `set -u`, so branch
257 # explicitly instead of splatting an optional --format array.
258 if [[ -n "$fmt" ]]; then
259 node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --format "$fmt" --crf 11 -o "$out" &
260 else
261 node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --crf 11 -o "$out" &
262 fi
263 local pid=$! start=$SECONDS elapsed
264 while kill -0 "$pid" 2>/dev/null; do
265 elapsed=$((SECONDS - start))
266 if (( elapsed > HF_TIMEOUT_S )); then
267 local sz=0
268 [[ -f "$out" ]] && sz=$(stat -f%z "$out" 2>/dev/null || echo 0)
269 if (( sz > 1000000 )); then
270 echo "[render] ${label}: node hung ${elapsed}s after shutdown (output ${sz}B exists, treating as success)"
271 kill -9 "$pid" 2>/dev/null
272 pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
273 return 0
274 fi
275 echo "[render] ${label}: hung ${elapsed}s with no output — killing and failing" >&2
276 kill -9 "$pid" 2>/dev/null
277 pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
278 return 2
279 fi
280 sleep 5
281 done
282 wait "$pid" 2>/dev/null
283 [[ -f "$out" ]]
284}
285
286# Link a project's assets into a shadow render dir EXCEPT the files we manage
287# (the HTML we override + render outputs/intermediates). Links every other entry by
288# its real name, so the shadow resolves whatever media the HTML references — including
289# the ORIGINAL video filename `hyperframes init` scaffolds (e.g. clip.mp4), not just a
290# fixed allow-list. Prevents the shadow-render 404 → silent/frozen output → abort.
291link_assets() { # <project> <shadow>
292 local proj="$1" sh="$2" b
293 for p in "$proj"/*; do
294 [[ -e "$p" ]] || continue
295 b="$(basename "$p")"
296 case "$b" in
297 index.html|rail.html|index_fg.html|final.mp4|bg_plus_caps.mp4|fg_caps.mp4|rail.webm|history|_*) continue;;
298 esac
299 ln -sf "$p" "$sh/$b"
300 done
301}
302
303# Hybrid renders need 2 independent hyperframes passes. They share no state, so
304# we run them in parallel (one in the main PROJECT, one in a shadow dir with
305# index_fg.html renamed to index.html). Saves ~half of the Chromium cost.
306FG_SHADOW=""
307if [[ -f "$PROJECT/index_fg.html" ]]; then
308 FG_SHADOW="$PROJECT/_fg_shadow"
309 rm -rf "$FG_SHADOW" && mkdir -p "$FG_SHADOW"
310 # Link shared assets (any media filename); copy index_fg.html into shadow as index.html.
311 link_assets "$PROJECT" "$FG_SHADOW"
312 cp "$PROJECT/index_fg.html" "$FG_SHADOW/index.html"
313
314 FG_CAPS="$PROJECT/fg_caps.mp4"
315 echo "[render] hybrid fg/bg — launching both passes in parallel"
316 hf_render_dir "$BG" "bg_plus_caps" "$PROJECT" &
317 BG_PID=$!
318 hf_render_dir "$FG_CAPS" "fg_caps" "$FG_SHADOW" &
319 FG_PID=$!
320 # Wait for both; fail if either fails.
321 BG_RC=0; wait "$BG_PID" || BG_RC=$?
322 FG_RC=0; wait "$FG_PID" || FG_RC=$?
323 rm -rf "$FG_SHADOW"
324 if (( BG_RC != 0 )); then echo "[render] bg render failed" >&2; exit 1; fi
325 if (( FG_RC != 0 )); then echo "[render] fg render failed" >&2; exit 1; fi
326elif [[ -f "$PROJECT/rail.html" ]]; then
327 # Standard mode: TWO independent hyperframes passes (base = index.html with the
328 # embed; rail = rail.html transparent). Each renders from its own shadow dir (the
329 # multiple-root ambiguity), and they share no state — run them IN PARALLEL like
330 # the fg-hybrid above (~halves the Chromium wall time). The rail webm is consumed
331 # by the composite stage below, which finds it already rendered.
332 BASE_SHADOW="$PROJECT/_base_shadow"; rm -rf "$BASE_SHADOW"; mkdir -p "$BASE_SHADOW"
333 link_assets "$PROJECT" "$BASE_SHADOW"
334 cp "$PROJECT/index.html" "$BASE_SHADOW/index.html"
335 RAIL_SHADOW="$PROJECT/_rail_shadow"; rm -rf "$RAIL_SHADOW"; mkdir -p "$RAIL_SHADOW"
336 link_assets "$PROJECT" "$RAIL_SHADOW"
337 cp "$PROJECT/rail.html" "$RAIL_SHADOW/index.html"
338 RAIL_WEBM="$PROJECT/rail.webm"
339 echo "[render] standard base + rail — launching both passes in parallel"
340 hf_render_dir "$BG" "bg_plus_caps" "$BASE_SHADOW" &
341 BASE_PID=$!
342 hf_render_dir "$RAIL_WEBM" "rail" "$RAIL_SHADOW" "webm" &
343 RAIL_PID=$!
344 BASE_RC=0; wait "$BASE_PID" || BASE_RC=$?
345 RAIL_RC=0; wait "$RAIL_PID" || RAIL_RC=$?
346 rm -rf "$BASE_SHADOW" "$RAIL_SHADOW"
347 if (( BASE_RC != 0 )); then echo "[render] bg render failed" >&2; exit 1; fi
348 if (( RAIL_RC != 0 )); then echo "[render] rail render failed" >&2; exit 1; fi
349else
350 hf_render_dir "$BG" "bg_plus_caps" "$PROJECT" \
351 || { echo "[render] bg render failed" >&2; exit 1; }
352fi
353
354# Probe render dims for ffmpeg scale
355W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=nw=1:nk=1 -- "$BG")"
356H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=nw=1:nk=1 -- "$BG")"
357
358# Clamp every composite to the matte (= source-video) length. The render uses
359# plan.duration / data-duration, which can exceed the source (e.g. Whisper word
360# timestamps overrun the clip). Past the source the a-roll is gone (black) but the
361# matte overlay repeats its last frame → the tail shows the subject floating on
362# black. frames_fg count/fps APPROXIMATES the source length, but fractional-rate
363# sources (29.97) get over-extracted by integer-fps rounding (e.g. 544 frames for
364# a 542-frame 18.085s clip -> 544/30 = 18.133s -> ~1.5 trailing BLACK frames after
365# the a-roll stream ends). The true a-roll duration is authoritative: clamp to
366# min(matte frames / fps, source duration).
367MATTE_DUR="$(awk "BEGIN{printf \"%.3f\", $(ls "$PROJECT/frames_fg" | wc -l)/$FPS}")"
368SRC_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 -- "$PROJECT/source.mp4" 2>/dev/null || true)"
369if [[ -n "${SRC_DUR:-}" ]]; then
370 MATTE_DUR="$(awk "BEGIN{m=$MATTE_DUR; s=$SRC_DUR; printf \"%.3f\", (s>0 && s<m) ? s : m}")"
371fi
372echo "[render] clamp output to source/matte length: ${MATTE_DUR}s"
373
374# Bug-1 guard: the background plate ($BG) must be at least the matte/source length,
375# else the tail (where the bg ran out but the matte continues) shows ONLY the
376# foreground subject on black. Cinematic auto-fixes this (make-composition sets the
377# canvas = source length); this catches a hand-authored Standard duration set to the
378# last-caption time instead of the clip length. Clamp to the bg length so we never
379# ship the only-foreground tail, and tell the author the real fix.
380if [[ -f "$BG" ]]; then
381 BG_DUR="$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 -- "$BG" 2>/dev/null | tr -dc '0-9.')"
382 if [[ -n "$BG_DUR" ]] && awk "BEGIN{exit !($BG_DUR < $MATTE_DUR - 0.3)}"; then
383 echo "[render] ⚠ background plate is ${BG_DUR}s but the clip is ${MATTE_DUR}s — the composition is shorter than the footage." >&2
384 echo " The tail would show ONLY the foreground subject on black. FIX: set the composition" >&2
385 echo " duration to the SOURCE clip length (data-duration on #root/#a-roll); captions may still" >&2
386 echo " end earlier. Clamping output to ${BG_DUR}s for now to avoid the broken tail." >&2
387 MATTE_DUR="$BG_DUR"
388 fi
389fi
390
391# ─────────────────────────────────────────────────────────────────────────────
392# STANDARD mode (rail + embed) — detected by rail.html.
393# $BG = index.html rendered (source video + the embed climax).
394# (1) overlay the subject matte so the subject occludes the climax (embed = behind).
395# (2) render rail.html → transparent WebM (the verbatim rail).
396# (3) alpha-composite the rail IN FRONT so it is never occluded (rail = on top).
397# The existing Cinematic paths below are untouched.
398if [[ -f "$PROJECT/rail.html" ]]; then
399 MATTED="$PROJECT/_matted.mp4"
400 if [[ "$CAPTION_LAYER" == "fg" ]]; then
401 # caption_layer:fg — the climax sits IN FRONT of the subject (no behind-subject
402 # embed possible, e.g. a frame-filling 9:16 subject). bg_plus_caps already has the
403 # climax drawn over the video, so we SKIP the matte overlay (which would push the
404 # subject back in front of it). The rail still overlays on top below.
405 echo "[render] STANDARD (rail + FRONT climax) — caption_layer=fg, matte overlay skipped (${W}x${H})"
406 cp "$BG" "$MATTED"
407 else
408 echo "[render] STANDARD (rail + embed) — embed behind subject, rail alpha-overlaid in front (${W}x${H})"
409 ffmpeg -y -i "$BG" \
410 -framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
411 -filter_complex "[1:v]scale=${W}:${H},format=yuva420p[m];[0:v][m]overlay=format=auto[v]" \
412 -map "[v]" -map 0:a -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 11 -preset medium -c:a copy "$MATTED"
413 fi
414
415 # rail.webm was already rendered IN PARALLEL with the base pass above.
416 RAIL_WEBM="$PROJECT/rail.webm"
417 [[ -f "$RAIL_WEBM" ]] || { echo "[render] rail.webm missing (parallel rail pass failed?)" >&2; exit 1; }
418
419 # alpha-overlay the transparent rail in front of the matted video (force vp9
420 # decode so the WebM alpha plane is honoured by overlay)
421 ffmpeg -y -i "$MATTED" -c:v libvpx-vp9 -i "$RAIL_WEBM" \
422 -filter_complex "[0:v][1:v]overlay=format=auto[v]" \
423 -map "[v]" -map 0:a -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 11 -preset medium -c:a copy "$FINAL"
424 rm -f "$MATTED"
425 if [[ -s "$GATES" ]]; then echo "[render] ── gates ──"; sed 's/^/[render] /' "$GATES"; fi
426echo "[render] done → $FINAL"
427 exit 0
428fi
429
430# Decide composite mode:
431# - If make-composition emitted index_fg.html (any group has layer:fg),
432# use hybrid regardless of plan-level caption_layer.
433# - Else if caption_layer=fg globally, skip matte.
434# - Else (default), matte embed.
435if [[ -f "$PROJECT/index_fg.html" ]]; then
436 # Hybrid: bg_plus_caps and fg_caps were both rendered above in parallel.
437 # Now composite: bg_plus_caps + matte (subject on top) + fg_caps (screen).
438 FG_CAPS="$PROJECT/fg_caps.mp4"
439 echo "[render] hybrid — composite (bg_plus_caps + matte + fg_caps[screen blend])"
440 # fg_caps is bright caption on pure black. blend=screen makes it behave
441 # like CSS mix-blend-mode: screen on the matted video — captions pick up
442 # scene luminance, NOT a flat opaque overlay (which looks sticker-like).
443 # Use rgb format to avoid YUV-space color drift, then convert back for libx264.
444 ffmpeg -y -i "$BG" \
445 -framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
446 -i "$FG_CAPS" \
447 -filter_complex "[1:v]scale=${W}:${H},format=yuva420p[matte];[0:v][matte]overlay=format=auto,format=gbrp[matted];[2:v]format=gbrp[fg];[matted][fg]blend=all_mode=screen,format=yuv420p[v]" \
448 -map "[v]" -map 0:a \
449 -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
450 "$FINAL"
451elif [[ "$CAPTION_LAYER" == "fg" ]]; then
452 # Global FG mode: skip matte overlay entirely. bg_plus_caps.mp4 already
453 # has captions on top of a-roll. Re-encode for consistency.
454 echo "[render] fg mode (global) — skipping matte, re-encoding ${W}x${H}"
455 ffmpeg -y -i "$BG" \
456 -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
457 "$FINAL"
458else
459 echo "[render] bg mode — overlay matte (${W}x${H})"
460 ffmpeg -y -i "$BG" \
461 -framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
462 -filter_complex "[1:v]scale=${W}:${H},format=yuva420p[fg];[0:v][fg]overlay=format=auto[v]" \
463 -map "[v]" -map 0:a \
464 -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
465 "$FINAL"
466fi
467
468if [[ -s "$GATES" ]]; then echo "[render] ── gates ──"; sed 's/^/[render] /' "$GATES"; fi
469echo "[render] done → $FINAL"