The #f6f6f4 off-white background was showing through the 30px grid
gaps and behind translated cards during the zoom/unzoom transitions,
creating visible white strips in the rendered video. Changed to
#1a1a20 (near-black) so gaps read as intentional dark separators.
Also added a .zoom-backdrop div behind the grid in the zoom demo that
fades to the focus card's gold gradient during the transition, covering
any gaps left by sibling cards translating off-screen.
Co-authored-by: Kanyini <onebenson@gmail.com>
The renderer's Page.captureScreenshot can introduce a 1-2px offset
at viewport boundaries due to compositor subpixel rounding. Grid rows
are now 341px (3×341 + 2×30 = 1083), overflowing the 1080 viewport
by 3px. The overflow is clipped by overflow: hidden, but ensures any
capture offset still sees grid content rather than the body background.
Also removed flex centering from .demo-canvas — unnecessary now that
the grid fills the viewport, and it was a source of non-deterministic
positioning under multi-worker rendering.
Co-authored-by: Kanyini <onebenson@gmail.com>
Cards 360×340 with 30px gap = exactly 1920×1080. No body background
visible at any frame. Focus scale drops from 9 to 6 (360×6 = 2160,
still overshoots viewport by 240px).
Co-authored-by: Kanyini <onebenson@gmail.com>
- Increase --focus-scale from 8 to 9 — at scale 8 the card matched
the viewport width exactly (240×8=1920), leaving zero tolerance for
subpixel rounding. Scale 9 overshoots by 240px on each axis.
- Reduce grid gap from 40px to 24px — the 1040px grid in a 1080px
viewport left only 20px of padding, causing bottom-row cards and
their box-shadows to clip at the frame edge.
- Remove transform-style: preserve-3d from both snippets — no 3D
transforms are used, and preserve-3d can cause compositing artifacts
with overflow: hidden ancestors.
- Unzoom: set --pu-sibling-fade to 0 and drive sibling opacity via
GSAP (starting at t=2.0s). Previously, CSS-driven opacity made cards
70% visible when they first peeked into the viewport, creating
colored strips at the frame edge during the reveal.
Co-authored-by: Kanyini <onebenson@gmail.com>
Two companion components inspired by the eBay Playbook hero transition:
- parallax-zoom: center card scales up to fill the frame while siblings
parallax outward. Single CSS variable (--pz-progress 0→1), fully
seekable and deterministic.
- parallax-unzoom: the reverse — focus card starts at full-frame scale
and shrinks back into its grid position while siblings parallax inward.
Uses --pu-progress with the pu prefix to avoid variable collisions when
both components live in the same composition.
Designed to chain: zoom INTO a card in scene 1, unzoom OUT of it in
scene 2 to reveal a fresh grid underneath.
Includes demo compositions, registry manifests, and catalog pages.
Preview assets rendered and uploaded to CDN.
Co-authored-by: Kanyini <onebenson@gmail.com>
Caption components are overlays — their root element should not
intercept pointer events, allowing clicks to pass through to the
underlying composition content in Studio.
Replace opaque backgrounds (#0a0a0a, #000, #000000) with transparent
on both html/body and the composition root div for all 15 caption
components. Captions are overlays — opaque backgrounds cover the
underlying video when loaded as sub-compositions.
Timeline locking:
- Add data-timeline-locked attribute support — fully disables move,
trim-start, and trim-end in Studio for clips that carry this attr
- Runtime propagates the attribute from inner composition root to host
element so component authors control it from their HTML
- All 15 caption components in the registry now carry the attribute
Font fix:
- Extract <link rel="stylesheet"> and <link rel="preconnect"> from
sub-composition <head> alongside existing <style>/<script> extraction
- Fixes caption components (and any sub-comp using Google Fonts via
<link> tags) losing their font-family when loaded as sub-compositions
- Applied in both runtime (compositionLoader) and compiler
(inlineSubCompositions) paths
Catalog preview script (scripts/generate-catalog-previews.ts) uses demo.html
as the component entry point. Missing file caused the Render catalog previews
job to fail with "Item not found".
Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.
Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls
Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks
Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
Rename component to caption-texture and bundle 6 popular textures
(lava, marble, metal, wood, concrete, rock). The texture is configurable
via the texture composition variable (default: lava).
Usage: hyperframes render --variables '{"texture":"marble-012"}'
Tested: lava, marble-012, metal-046-b all render correctly with
distinct visual patterns.
- Replace from:"random" with from:"center" stagger in us-map,
world-map, spain-map — random stagger is non-deterministic across
parallel render workers, causing visual jumps at chunk boundaries.
- Exempt type="importmap" and type="module" inline scripts from the
invalid_inline_script_syntax lint rule. The rule used new Function()
to parse, which rejects import statements and JSON import maps.
Closes#929.
- Cache-bust all map MDX preview video URLs after re-rendering with
the deterministic stagger fix.
New block: spain-map — animated Spain choropleth by autonomous
community using D3 conic conformal projection with GDP per capita
data and red-to-amber color scale.
Also switches all map MDX preview URLs from S3 to local paths
so they render in mintlify dev without needing S3 upload first.
New blocks: world-map (D3 Natural Earth choropleth), us-map-bubble
(proportional city markers), us-map-hex (hexagonal tile grid),
us-map-flow (animated connection arcs between cities).
All blocks share the same dark theme and are composable — layer
bubble or flow on top of the choropleth via track indexes.
Adds "Maps" section to the catalog docs with MDX pages for all 5
map blocks (including the us-map from the previous commit).
Animated US population density choropleth using D3 geoAlbersUsa
projection with TopoJSON. Includes staggered state reveals, label
fade-ins, gradient legend, and highlight pulses on densest states.
Upload re-rendered videos with -v2 filenames to bypass CloudFront
immutable cache. Replace gradient-fill with improved version from
sandbox composition.
The deterministic font system now supplements its embedded font bundle
with Google Fonts fetches for any weights not in the pre-bundled set.
This fixes compositions that request font weights (e.g. Montserrat 300)
not included in the CANONICAL_FONTS faces array — previously those
weights were silently dropped, causing invisible text in renders.
Also replaces caption-glitch-rgb and caption-weight-shift with improved
versions from avatar preview compositions, adapted to 1920x1080 with
standard demo transcript.
- clip-wipe: slower reveal (0.3s), longer hold, smoother exit
- glitch-rgb: 2.5x larger RGB split, stronger scanlines, more dramatic jitter
- gradient-fill: smoother word transitions (0.15s), longer exit
- typewriter: extended group hold times so text lingers on screen
- weight-shift: per-word font-weight animation (200→900), was broken
with only line-level shift that never triggered on single-line groups
- Add caption-highlight: red background sweep behind active word (TikTok-style)
- Re-rendered and uploaded preview videos for all 6 components
- Fix timeline_id_mismatch on all 15 caption components: __timelines key
now matches data-composition-id (e.g. "caption-clip-wipe" not "clip-wipe")
- Regenerate docs/public/catalog-index.json with 15 new caption entries
- Add "Captions" group mapping to generate-catalog-pages.ts (priority 0)
- Regenerate docs.json nav and mdx pages via the catalog script
- Upload docs preview videos to docs/images CDN path
- Fix caption-texture-lava mask URL to use local lava.png instead of
/assets/texture-mask-text/masks/ absolute path that 404s on install
- Add lava.png to registry-item.json files array so it ships with
npx hyperframes add caption-texture-lava
- Remove unused mulberry32 function from caption-clip-wipe
- Add 15 .mdx doc pages under docs/catalog/components/ for all caption styles
- Add "Captions" group as first section in the Catalog tab navigation
- Add canvas-based fitFontSize to 14 caption components to prevent text overflow
- Fix parallax-layers vertical clipping by repositioning the behind safe zone
- Re-render all 15 preview videos at high quality and upload to CDN
Two surgical changes, both isolated to the catalog-previews flow:
1. `packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts`
PR #842 changed `seek()` to take `(time, { keepPlaying: true })` for the
A/E shortcuts. The keyboard-layout tests added by #839 still asserted the
single-arg form. Both landed on main without cross-checking, so `main`
itself has been failing Test/Windows since. Update the two assertions
to match the new signature. Same fix Miguel already authored on
`feat/studio-preview-pasteboard-bg`.
2. `registry/components/vignette/demo.html`
The original demo captured a frame where the vignette was at its
weakest point — the effect was nearly invisible in the static preview
used by docs. Reworked the demo so:
- The backdrop is a layered "cinematic still" (warm key + teal rim +
dark falloff) and includes a centered subject ("moon"), so the
vignette has a focal point to frame.
- Vignette starts soft (size 70%, alpha 0.35) and ramps to a dramatic
cinematic vignette (size 26%, alpha 0.92) over 1.6s.
- Peak intensity holds across t≈3.0s, which is exactly where the
catalog script samples the thumbnail (`Math.min(3.0, duration*0.6)`
with duration=5).
- Breathing motion in t=3.4–5.2s gives the video loop visible life
without disturbing the still frame.
Pure-CSS radial darkening overlay that fills its positioned parent and
pulls focus toward the center. Shape, size, and color are exposed as
CSS custom properties (--vignette-shape, --vignette-size,
--vignette-edge, --vignette-color), so a GSAP timeline can animate any
of them — the snippet's header comment shows the pattern for fading
the vignette in.
The Components section currently has four entries; this fills the
cinematic-cinematography gap a video editor expects out of the box
without bundling any asset (the effect is a single radial-gradient).
Default z-index 90 sits below grain-overlay (100) so grain reads on
top of the darkened corners.
Catalog page, registry index, and nav are regenerated via
scripts/generate-catalog-pages.ts.
* fix(player): replay from start when play is pressed after video ends
When a non-looping composition reaches its end, pressing play again
had no effect because the playhead stayed at the final frame. Now
play() detects the ended state and seeks to 0 before resuming.
* chore: fix pre-existing format issues in registry files
* docs: group VFX blocks under HTML-in-Canvas, captions under Captions in sidebar
* docs: add HTML-in-Canvas guide, Chrome flag disclaimer, remove captions
- Add docs/guides/html-in-canvas.mdx — comprehensive guide covering the
API, feature detection, re-capture patterns, and catalog blocks
- Add Chrome flag Warning banner to every HTML-in-Canvas block page
- Remove captions blocks from registry (will ship separately)
- Add html-in-canvas guide to docs navigation (top of Guides section)
* feat: update liquid glass, portal, shatter with HyperFrames branding
Replace generic placeholder content with HyperFrames-themed text:
- Liquid Glass: 'Ship videos 10x faster' with stats and gradient text
- Portal: 'Write HTML / Render Video' with HyperFrames nav
- Shatter: 'HTML is Video' with render speed/file size metrics
Re-rendered and uploaded preview videos to S3.
hyperframes add now auto-detects whether the argument is a single
block name or a tag. No --tag flag needed:
hyperframes add html-in-canvas # installs all 7 html-in-canvas blocks
hyperframes add captions # installs all 5 caption blocks
hyperframes add vfx-shatter # installs one block
When the name doesn't match a registry item, the CLI falls back to
tag-based resolution and bulk-installs all matching blocks.
Also fixes tag assignments:
- VFX blocks tagged "html-in-canvas" (not "vfx")
- Caption blocks tagged "captions" only (no html-in-canvas)
All 12 blocks now share the 'html-in-canvas' tag, enabling:
hyperframes add --tag html-in-canvas
This installs all VFX + caption blocks in one command.
Add 12 new registry blocks across two new categories:
**VFX Blocks (7):**
- vfx-text-cursor: dramatic text reveal with cursor glow and chromatic shadows
- vfx-liquid-background: organic liquid simulation with vertex displacement
- vfx-iphone-device: real GLTF iPhone + MacBook with live HTML screens
- vfx-magnetic: magnetic field particle visualization
- vfx-portal: dimension breach portal transition
- vfx-liquid-glass: Voronoi glass fracture with parallax reveal
- vfx-shatter: glass shatter with physics-driven fragments
**Caption Blocks (5):**
- captions-slam: bold uppercase scale-pop with back.out overshoot
- captions-karaoke: word-by-word color reveal on frosted glass pill
- captions-minimal: clean Netflix-subtitle style fade
- captions-bounce: playful elastic bounce with colorful word pills
- captions-cinematic: elegant slow fade with letter-spacing animation
All blocks include registry-item.json and are registered in registry.json.
VFX blocks use experimental stability, caption blocks use stable.
## Problem
The Blue Sweater intro HyperFrames project was only available as a standalone exported project zip. It was not installable from the public registry or visible in the Catalog Showcases group.
## What this fixes
Adds `blue-sweater-intro-video` as a registry block with its composition, avatar image, and sound mix asset. The block is exposed through the generated catalog page, `docs/public/catalog-index.json`, and the Showcases navigation.
The manifest and generated catalog page credit the creator as [Joe Sai](https://x.com/_blue_sweater_).
## Root cause
Catalog-visible blocks are driven by `registry/registry.json`, each block's `registry-item.json`, generated docs/catalog files, and CDN-hosted preview media. The exported project had a valid standalone composition, but it had not been converted into that registry/catalog contract or uploaded to the docs preview CDN.
## Verification
### Local checks
- `bun install`
- `bun run build`
- `bunx tsx scripts/generate-catalog-pages.ts`
- `bun run generate:catalog-previews -- --only blue-sweater-intro-video`
- `bun packages/cli/src/cli.ts add blue-sweater-intro-video --dir /tmp/hf-blue-sweater-install-test --no-clipboard --json` against a locally served registry
- `bun packages/cli/src/cli.ts lint /tmp/hf-blue-sweater-install-test` returned 0 errors and 3 static GSAP overlap warnings from the supplied timeline/parser path
- `bun packages/cli/src/cli.ts validate /tmp/hf-blue-sweater-install-test --timeout 5000` returned 0 runtime errors and 0 warnings, with contrast audit warnings only
- `bun packages/cli/src/cli.ts inspect /tmp/hf-blue-sweater-install-test --at 0.5,2.5,5.5,9.8,11.2 --json` returned 0 layout issues
- `bun packages/cli/src/cli.ts render /tmp/hf-blue-sweater-install-test --output /tmp/hf-blue-sweater-install-test/blue-sweater-intro-video-render.mp4 --fps 24 --quality draft --workers 3`
- `ffprobe` reported the installed render duration as `12.000000`
- `bunx oxfmt --check registry/registry.json registry/blocks/blue-sweater-intro-video/registry-item.json registry/blocks/blue-sweater-intro-video/blue-sweater-intro-video.html docs/docs.json docs/public/catalog-index.json docs/catalog/blocks/blue-sweater-intro-video.mdx`
- `git diff --check`
- `bunx vitest run packages/cli/src/commands/add.test.ts packages/core/src/registry/types.test.ts`
### Browser verification
- Started a real local HyperFrames preview for the installed test project.
- Used `agent-browser` to open `http://localhost:5198/api/projects/hf-blue-sweater-install-test/preview` at 1920x1080.
- Verified the runtime registered `install-test` and `blue-sweater-intro-video` timelines.
- Sought the block to the final card and verified `@_blue_sweater_` and the following state were visible.
- Recorded an `agent-browser`-driven full animation pass; `ffprobe` confirmed a 1920x1080 WebM with 110 video frames.
- Checked the fresh `agent-browser` session for page errors after the direct preview flow: `errors: []`.
- Used `agent-browser` to load an HTML page with the exact generated CDN `video`/`poster` URLs; the browser reported `readyState: 4`, `videoWidth: 1920`, `videoHeight: 1080`, and `paused: false`.
### CDN upload
Uploaded the generated preview media with AWS CLI to the existing docs image bucket path:
- `s3://heygen-public/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.mp4`
- `s3://heygen-public/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.png`
Verified both public CDN URLs return `HTTP 200` with correct content type and immutable cache headers:
- `https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.mp4` (`video/mp4`)
- `https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.png` (`image/png`)
## Notes
- Local-only browser proof artifacts:
- `/tmp/hf-blue-sweater-browser-proof/fresh-final-card.png`
- `/tmp/hf-blue-sweater-browser-proof/fresh-browser-flow.webm`
- `/tmp/hf-blue-sweater-cdn-check.png`
- Local-only installed render artifact:
- `/tmp/hf-blue-sweater-install-test/blue-sweater-intro-video-render.mp4`
## Problem
Two newly reported runtime issues break common local workflows:
- Fixes#615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping.
- Fixes#616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open.
The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items.
## What this fixes
- detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state
- wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame
- keeps non-looping players dispatching the existing `ended` flow
- lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render
- keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in
- adds regression coverage for the player loop end-state and successful render exit scheduling
- renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key
- keeps visible block/app copy friendly and named `VPN`
- updates catalog-preview CI to ignore deleted registry paths when computing changed preview items
## Root cause
The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped.
For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed.
The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths.
## Verification
### Local checks
- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts`
- `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts`
- `bun run --filter @hyperframes/player typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bun run --filter @hyperframes/player build`
- `bun run --filter @hyperframes/studio build`
- `bun run --filter @hyperframes/cli build`
- `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxfmt --check .github/workflows/catalog-previews.yml`
- `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot`
- `npx tsx scripts/sync-schemas.ts --check`
- `npx mint validate` from `docs/`
- `npx mint broken-links` from `docs/`
- `git diff --check`
- Lefthook pre-commit: format pass
- Lefthook commit-msg: commitlint pass
### Browser verification
- Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP.
- Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition.
- Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start.
- Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`.
- Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible.
### Composition verification
- `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning.
- `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling.
- `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully.
- `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`.
### Render verification
- Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`.
- Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`.
## Notes
- I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion.
- Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`.
- The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`.
- The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`.