Four regression tests (font-variant-numeric, many-cuts, missing-host-comp-id,
variables-prod) failed on this PR with `Unable to parse PSNR output at <last
checkpoint>s`. Root cause: the harness derived all 100 checkpoints from the
*rendered* video's container duration, then asked ffmpeg's PSNR filter to
compare the same frame index from both videos.
The encoder changes earlier in this PR add `-avoid_negative_ts make_zero` to
the mux step. With AAC audio that shifts the first audio sample to t=0
instead of the encoder-delay offset, extending reported container duration
by ~20ms without changing video frame count. For the four failing tests,
the i=99 checkpoint then landed on a frame index that exists in the rendered
video but not in the snapshot baseline (e.g. round(2.98998 * 24) = 72 in a
72-frame baseline). ffmpeg's PSNR filter ran on zero matched frames and
emitted no `average:` line, so the parser threw.
Fix: probe both videos and use min(rendered, snapshot) duration when
spreading checkpoints. This is the correct semantics for symmetric PSNR
comparison anyway — both videos must have a frame at every sampled time.
The change is local to the harness; no encoder behavior changes, no
baselines regenerated.
Other regression tests with audio (chat, sub-composition-video,
vignelli-stacking) passed because their checkpoint-99 frame index landed
inside the baseline's frame range with several frames of slack. The four
failing tests had round-number durations where a 20ms drift was enough to
push the last checkpoint past `nb_frames - 1`.
- engine/chunkEncoder, engine/streamingEncoder: extend `-bf 0` to GPU h264
paths (nvenc, qsv, vaapi) and `-b_strategy 0` for qsv so GPU-encoded
outputs avoid negative-DTS freezes too — not just SW libx264.
- engine/videoFrameExtractor: detect mid-path traversal (e.g.
`assets/../../foo.mp4`) by normalizing first and re-anchoring at the
project root. Adds a regression test.
- engine/videoFrameExtractor: dedupe stderr "src not resolvable" warnings
by `video.src` so a comp with N broken sources logs once, not N times.
- engine/videoFrameExtractor.test: drop dynamic `require("node:fs")`,
use ES `import { writeFileSync } from "node:fs"`.
- engine/ffprobe: extract `readTagCI` helper for case-insensitive ffprobe
tag reads (will recur for other libavformat-versioned sidecar tags).
- cli/background-removal/pipeline: collapse Quality / QUALITIES /
QUALITY_CRF / DEFAULT_QUALITY / isQuality surface using
`Quality = keyof typeof QUALITY_CRF`.
- producer/renderOrchestrator: replace `v.src.startsWith("/")` with
`isAbsolute(v.src)` in the HDR probe path so Windows absolute paths
(`C:\...`) aren't treated as relative — matches the audioMixer guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A <video src='../assets/foo.mp4'> inside a sub-composition silently dropped
from extraction; the rendered output froze on the first decoded frame for
the entire clip, with no error in stdout.
Root cause: browser URL resolver clamps '..' at origin root (studio preview
loads fine), but path.join(projectDir, '../assets/foo.mp4') normalizes to
parent-of-project/assets/foo.mp4, which usually doesn't exist. existsSync
returns false, extraction is skipped, no frame lookup is built, the
per-frame injector has nothing to swap, and the <video> element's first
decoded frame paints every screenshot.
- Adds resolveProjectRelativeSrc in videoFrameExtractor that mirrors browser
clamping (literal join first, then leading '..' stripped).
- Surfaces a loud stderr warning when the resolver misses.
- Mirrors fix in audioMixer.ts (same bug for <audio src='../'>) and
renderOrchestrator HDR probe loop.
- +6 regression tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Align the no-op timeline duration with the root's data-duration. The
fixture's root has data-duration="3" but the placeholder timeline tween
was still { duration: 2 } — leftover from when I bumped the duration
from 2s to 3s to dodge the PSNR-checkpoint-at-1.99s parse edge case.
The tween is a no-op (no targets, no visible effect) so rendered pixels
don't change; baseline still passes Docker regression at 100/100
checkpoints.
Reuse + efficiency reviews otherwise clean. Two findings deferred:
silence.wav duplication is real but only 2 fixtures share it today —
worth extracting to tests/_shared/ when the third fixture lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Docker regression test that exercises the full variables
chain: meta.json renderConfig.variables → harness → createRenderJob →
RenderConfig → CaptureOptions → engine evaluateOnNewDocument →
window.__hfVariables → getVariables() → DOM text → rendered pixels.
Fixture (packages/producer/tests/variables-prod/):
- src/index.html: composition with three declared variables (title,
subtitle, bgColor) read via window.__hyperframes.getVariables() and
rendered as positioned text on a colored background. No animation —
keeps the regression frame-stable so it isolates "did the variables
flow through?" from motion concerns.
- meta.json: tags ["variables", "composition"] (runs in the existing
fast shard's tag filter), renderConfig.variables provides override
values the baseline reflects ("Override Title", "Override subtitle",
#0a3d62). Defaults would produce a visibly different frame, so a
failing baseline that reflects defaults means the variables didn't
propagate.
- output/output.mp4: Docker-generated baseline per the project's
CLAUDE.md golden-baseline rule.
Harness change (packages/producer/src/regression-harness.ts):
- TestMetadata.renderConfig gains an optional variables field,
validated as a JSON object in the meta.json validator.
- The createRenderJob call site forwards renderConfig.variables to
RenderConfig.variables, which the engine already consumes via
evaluateOnNewDocument (PR #600).
Verified: docker:test variables-prod passes 100/100 visual checkpoints
and audio correlation 1.000 against the committed baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## What
Adds the parametrized-render primitive from [hf#592](https://github.com/heygen-com/hyperframes/issues/592) by introducing a `getVariables()` runtime helper plus a CLI `--variables` / `--variables-file` flag. Compositions declare variables once on the root `<html>` element (the existing `data-composition-variables` attribute, which already drives Studio editing UI), read them at runtime via `window.__hyperframes.getVariables()`, and CLI users override them at render time without touching the composition source.
This is **PR 1 of a 4-PR stack**:
1. **PR 1 (this one)** — runtime helper + CLI flag + engine injection (top-level renders).
2. PR 2 — sub-comp per-instance scoping (carry the host's `data-variable-values` into the inlined sub-comp's `getVariables()`).
3. PR 3 — schema validation + lint rules (warn on undeclared variable IDs, optional `--strict-variables`).
4. PR 4 — skill / scaffold distribution (SKILL.md, AGENTS.md scaffolds, openai/plugins mirror).
## Why
The existing `data-composition-variables` schema declares variable types and defaults but isn't readable from composition scripts and can't be overridden at render time. To produce N variations of a composition today, an agent has to fork the composition or edit the source HTML before each render. `--variables` collapses that into one render call per variation, matching Editframe's `--data` UX without copying their `getRenderData` framing — `getVariables()` is named for the codebase's existing "variables" terminology and works equally in dev preview and at render time.
## How
- **Runtime helper** (`packages/core/src/runtime/getVariables.ts`): reads `data-composition-variables` from `document.documentElement`, extracts `{id: default}` defaults, merges `window.__hfVariables` (override) on top, returns `Partial<T>`. Same code path in dev preview (no override) and at render (with override). Generic parameter for typed editor ergonomics. Exposed both as a named export from `@hyperframes/core` and on `window.__hyperframes.getVariables` for vanilla compositions.
- **CLI flag** (`packages/cli/src/commands/render.ts`): `--variables '<json>'` and `--variables-file <path>`. `parseVariablesArg` is split out as a pure function (returns a discriminated `{ ok: true } | { ok: false }` union) so all validation paths are unit-testable; the side-effecting `resolveVariablesArg` wraps it with `errorBox` + `process.exit`. Mutually exclusive with `--variables-file`; fail-fast on conflicts, missing file, unparseable JSON, or non-object payloads (string, number, array, null).
- **Engine injection** (`packages/engine/src/services/frameCapture.ts`): added an `evaluateOnNewDocument` step right after the `__name` polyfill that sets `window.__hfVariables` to the parsed JSON before any page script runs. Skipped when payload is empty so we don't add pointless init scripts. Plumbed through `CaptureOptions.variables` and `RenderConfig.variables`. Docker mode forwards the flag to the in-container CLI via `dockerRunArgs`.
- **Why a separate `__hfVariables` global** instead of writing into `__hyperframes.getVariables()` directly: the helper is an IIFE that has to be defined before composition scripts execute, but the *override* needs to land before *that*. `evaluateOnNewDocument` is the only reliable hook that runs before the runtime IIFE evaluates. Storing the raw value on `__hfVariables` and merging in the helper keeps both paths order-independent.
## Test plan
- [x] Unit tests added/updated
- 9 jsdom tests for `getVariables()` covering empty state, declared defaults only, override merge, override-wins, declared-only, invalid JSON, non-array payloads, non-object overrides, typed generic.
- 7 tests for `parseVariablesArg` covering all validation paths.
- 2 integration tests for `renderLocal` confirming `variables` reach `createRenderJob`.
- 3 new `dockerRunArgs` assertions for `--variables` passthrough (set / not-set / empty-object).
- All existing tests green: core 611, cli 208, engine 519.
- [x] Manual testing performed
- `npx tsx packages/cli/src/cli.ts render --help` shows both flags + the two new examples.
- [x] Documentation updated
- `docs/packages/cli.mdx` — added flags to the table and a "Parametrized renders" section with a worked example.
- `docs/concepts/data-attributes.mdx` — added `data-composition-variables` row.
## Backwards compatibility
Fully backwards compatible. Compositions without `data-composition-variables` work unchanged; `getVariables()` returns `{}` and the engine skips the injection step.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture.
The manifest lives at:
```text
.hyperframes/studio-manual-edits.json
```
It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset.
## Architecture
- **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values.
- **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file.
- **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base.
- **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script.
- **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines.
- **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly.
## User Impact
Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state.
## Main Files
- `packages/studio/src/components/editor/manualEdits.ts`
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- `packages/studio/src/App.tsx`
- `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts`
- `packages/studio/vite.config.ts`
- `packages/cli/src/server/studioServer.ts`
- `packages/core/src/compiler/htmlBundler.ts`
- `packages/producer/src/services/htmlCompiler.ts`
- `packages/core/src/studio-api/routes/thumbnail.ts`
- `packages/producer/src/services/fileServer.ts`
- `packages/producer/src/services/renderOrchestrator.ts`
## Test Plan
```bash
volta run --node 22.20.0 bun run build
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
Adds the parametrized-render primitive from hf#592 by reusing the existing
data-composition-variables schema as the source of declared defaults.
- Runtime helper window.__hyperframes.getVariables() (also exported from
@hyperframes/core) reads data-composition-variables defaults from the
document root and merges window.__hfVariables (CLI override) on top.
Returns Partial<T> for typed access; supports a generic for editor
ergonomics. Same code path runs in dev preview and at render time.
- CLI render --variables '<json>' / --variables-file <path> populates the
override. Mutually exclusive; fail-fast on conflicting flags, missing
file, unparseable JSON, or non-object payloads. parseVariablesArg is
exported as a pure function so validation paths stay unit-testable.
- Engine injects window.__hfVariables via evaluateOnNewDocument before
any page script runs, so the helper sees the merged values on its
first call. Empty payloads are skipped to avoid pointless init scripts.
- Producer threads variables through RenderConfig and into the engine's
CaptureOptions; Docker mode forwards --variables to the in-container
CLI invocation via dockerRunArgs.
Composition authors declare variables once on the root <html> element:
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Hello"}
]'>
and read them in any composition script:
const { title } = window.__hyperframes.getVariables();
A render with `--variables '{"title":"Q4 Report"}'` overrides the default
without modifying the composition source. Missing keys fall through to
the declared defaults, so dev preview and CLI renders without --variables
behave identically.
This is PR 1 of a 4-PR stack. Sub-comp per-instance scoping (carrying
host data-variable-values through the inlined sub-comp's getVariables()
call) lands in PR 2; schema validation and lint in PR 3; skill / scaffold
distribution in PR 4.
Tests: 9 new unit tests for getVariables() (jsdom), 11 new CLI tests
covering parseVariablesArg validation paths and Docker passthrough,
2 new dockerRunArgs assertions for the --variables flag. All existing
tests green (core 611, cli 208, engine 519).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
Fixes three issues identified in the [post-merge review](https://github.com/heygen-com/hyperframes/pull/596#pullrequestreview-4214283515) of PR #596:
- **P1 (cache bypass):** When `extractCacheDir` is set, extracted frames live outside `compiledDir`, so `createCompiledFrameSrcResolver` rejects them and every frame falls back to base64 data URIs. Fix: symlink cached frame directories into `compiledDir/__hyperframes_video_frames/` after extraction and remap `framePaths` so the served-frame fast path works.
- **P2 (pooled browser stale state):** `closeCaptureSession` force-killed the Chrome process on timeout via raw `SIGKILL` without clearing `pooledBrowser` / `pooledBrowserRefCount`, leaving other sessions with a dead browser reference. Fix: add `forceReleaseBrowser()` in `browserManager` that atomically clears pool state before killing the process.
- **P3 (reserved chars in URLs):** `createCompiledFrameSrcResolver` encodes path segments with `encodeURIComponent`, but the file server used `c.req.path` (which only applies `decodeURI`) to look up files on disk. Video IDs containing `#`, `?`, or `%` produced 404s. Fix: apply `decodeURIComponent` per path segment in the file server's catch-all route.
## Test plan
- [x] `createCompiledFrameSrcResolver` tests: symlinked cache paths resolve to served URLs; cache-external paths return null; reserved characters encode correctly
- [x] `forceReleaseBrowser` tests: kills process + disconnects; tolerates already-killed process
- [x] `createFileServer` test: `video%231/frame.jpg` serves file from `video#1/frame.jpg` on disk
- [x] Typecheck: engine + producer pass
- [x] Lint + format: 0 warnings, 0 errors
## Problem
I reproduced the selected open issue batch one by one and confirmed the reports were valid. The fixes all touch the CLI/runtime capture boundary, then the follow-up regression run exposed one over-broad runtime change in sub-composition host visibility and one CI-only baseline trap.
Closes#590, #589, #588, #587, #586, and #584.
## What this fixes
### CLI/runtime edge cases
- Makes the GSAP infinite-repeat lint rule ignore JavaScript comments, so literal `repeat:-1` text in comments is not flagged.
- Lets the compositions CLI inspect `<template>` content, count visual-only template descendants, estimate simple GSAP durations, and suppress root `data-start` warnings in sub-composition lint mode.
- Preserves runtime bootstrap scripts when body scripts are coalesced, and injects the runtime into a real `<head>` when source HTML has no head.
- Keeps #589 fixed by loading and rendering template-wrapped sub-composition content, while restoring host visibility to the shorter of the authored parent clip window and the child composition live timeline.
- Resolves snapshot/validate viewport size from root `data-width` / `data-height` instead of falling back to 1920x1080.
- Skips fully off-frame text boxes during contrast sampling and bounds-checks ring samples so contrast output no longer emits `null:1` / `NaN:1`.
- Marks muted videos as `data-has-audio="false"` in the core timing compiler, which fixes the same-src muted `<video>` + separate `<audio>` StaticGuard case.
- Keeps user-authored `hf-seek` listeners reachable during capture by preventing author scripts from being merged into the runtime bootstrap path.
### Shared helper cleanup
- Removes the stale producer-local timing compiler duplicate; producer compilation now consumes the core timing compiler.
- Centralizes HTML document helpers in core: fragment parsing, embedded runtime stripping, head/body script injection, and early-head injection.
- Centralizes the CLI layout/snapshot static HTML server.
- Adds browser-safe core subpath helpers for Lottie readiness and CLI screenshot clip calculation; Studio's Vite config keeps the screenshot clip helper self-contained so clean-checkout test startup does not value-import core `.ts` source.
- Replaces the engine parity-contract copy with a core re-export.
- De-duplicates render-job cleanup and Studio static file-serving callbacks.
### Regression hardening
- Replaces the embedded-runtime script stripping regex with a script-tag scanner that handles closing tags like `</script >`.
- Escapes inline script bodies before wrapping them in `<script>` tags, so authored `</script` and `<!--` text cannot break out of the injected wrapper script.
- Shares media-duration clamping between core and producer, with a 50 ms tolerance for ffprobe precision drift between local and CI media stacks.
- Pins the affected style fixture SFX durations in source so style-1 and style-9 compile deterministically.
- Restores the `vfr-screen-recording` video golden to the CI-stable baseline; the current CI failure showed the Linux render matches the old golden, while the locally refreshed macOS golden was the mismatch.
## Root cause
The CLI paths had accumulated assumptions that held for simple direct-root landscape compositions but not for current composition patterns: DOM queries did not enter template content, snapshot/validate used a fixed viewport, runtime and author scripts shared a coalescing bucket, and timing compilation treated every video as audio-bearing unless authors manually overrode it.
The style shard failures were not product regressions. Local and CI media probing disagreed on the short SFX clip duration by about 45 ms, and the compiler was clamping authored durations to the locally probed value. The shared clamp tolerance preserves explicit author/source durations for small probe precision differences while still clamping real overflows.
The vfr fast-shard failure was a bad baseline refresh: CI actual frames matched the old `vfr-screen-recording` baseline at 40+ dB PSNR, but mismatched the macOS-refreshed golden at ~18-22 dB. The fix is to keep the Docker/Linux-stable video golden and only retain the deterministic compiled snapshot change.
The sub-composition regression came from treating a host's authored parent window as the only visibility boundary. That made settled child overlays stay visible after their own live GSAP timeline ended. The corrected runtime behavior respects both contracts: parent clips still bound where the host can appear, and the child live timeline can end the host earlier.
## Verification
### Local checks
- `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bun run --cwd packages/core test src/runtime/init.test.ts`
- `bun run --cwd packages/cli test src/commands/compositions.test.ts src/utils/compositionViewport.test.ts`
- `bun run build:hyperframes-runtime`
- `bun run --cwd packages/producer test --keep-temp --sequential style-12-prod style-5-prod`
- `bun run --cwd packages/producer test --sequential vfr-screen-recording hdr-hlg-regression style-7-prod`
- `bun run --cwd packages/core test src/compiler/htmlCompiler.test.ts src/compiler/timingCompiler.test.ts src/index.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bunx oxlint packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer test --sequential style-1-prod style-9-prod`
- `bun run --filter @hyperframes/studio test` with `packages/core/dist` temporarily hidden to simulate clean-checkout config loading
- `git diff --check`
### CI artifact checks
- Inspected failed run `25225854394` job `73969147096`: style-1 failed only on `click-sfx` `1.044898` vs `1` duration/end.
- Inspected failed run `25225854394` job `73969147061`: style-9 failed only on SFX `1.044898`-based duration/end mismatches.
- Inspected failed run `25225854394` job `73969147048`: `vfr-screen-recording` compilation/audio passed, visual failed after comparing against the macOS-refreshed golden.
- Compared the first 10 uploaded CI vfr failure frames against the restored old baseline; minimum PSNR was `40.444705`, above the fixture threshold of `28`.
### Repro checks
- `bun packages/cli/src/cli.ts lint /tmp/hf-590-repro` now passes without `gsap_infinite_repeat`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-587-repro --at 0.5 --timeout 1000` now writes a 1080x1920 PNG.
- `bun packages/cli/src/cli.ts validate /tmp/hf-588-repro --timeout 500` no longer emits `null:1` / `NaN:1` contrast output.
- `bun packages/cli/src/cli.ts validate /tmp/hf-586-repro --timeout 500 --contrast false` no longer emits the muted-video StaticGuard contract error.
- `bun packages/cli/src/cli.ts compositions /tmp/hf-589-gsap-repro` now reports `foo 0.5s 1920x1080 1 element`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-589-gsap-repro --at 0.25 --timeout 2000` captures the expected template-backed red frame.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-584-repro --at 0.5,1.5 --timeout 500` captures the expected post-seek green frame.
### Browser verification
- Refreshed the local side-by-side comparison page at `qa-artifacts/pr-591-video-compare/index.html`.
- Served the comparison page locally and used `agent-browser` to load `style-12-prod`, play both videos quickly to the failed window, pause, and inspect the side-by-side frame.
- Browser proof screenshot: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12-labeled.png`.
- Browser proof recording: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12.webm`.
- Earlier Studio proof artifacts remain local-only: `qa-artifacts/dedupe-refactor-preview.png`, `qa-artifacts/dedupe-refactor-preview-after-play.png`, `qa-artifacts/dedupe-refactor-preview.webm`.
## Notes
- Browser proof and CI diagnostic artifacts are intentionally local-only and not committed.
- Studio's Vite config intentionally keeps the thumbnail clip helper inline because Vite/Vitest config startup runs through Node's loader before package source `.ts` imports are transformed.
- The committed PR diff changes `vfr-screen-recording/output/compiled.html` but no longer changes `vfr-screen-recording/output/output.mp4` relative to `main`.
- I attempted a local `linux/amd64` Docker validation to mirror CI, but the local Docker build was blocked by Debian package download failures. The arm64 Docker image also cannot launch the x64 Puppeteer headless shell under OrbStack. The vfr baseline decision is therefore based on the uploaded CI artifact comparison above.
- I kept this validated issue batch in one PR because the fixes overlap the same CLI/runtime capture surfaces.
## Problem
Closes#574.
On Windows with cached headless-shell Chrome, a composition that reuses the same video file in three timeline clips can fail before frame capture starts:
```html
<video id="video1" src="1.mp4" data-start="0" muted data-duration="4" data-track-index="0" data-media-start="0"></video>
<video id="video2" src="1.mp4" data-start="4" muted data-duration="4" data-track-index="0" data-media-start="4"></video>
<video id="video3" src="1.mp4" data-start="8" muted data-duration="4" data-track-index="0" data-media-start="8"></video>
```
The reported render reaches video frame extraction, then dies at frame-capture initialization with:
```text
[FrameCapture] video metadata not ready after 45000ms. Video elements must load metadata before capture starts.
```
The important detail is that by this stage HyperFrames has already extracted video pixels through FFmpeg. Native Chromium video metadata is only being waited on for DOM layout stability, not because Chromium is the source of rendered pixels.
## Root Cause
The render pipeline has two separate media responsibilities:
- FFmpeg extracts video frames and audio from declared media.
- Chromium owns DOM layout and capture, while injected FFmpeg frames supply the video pixels before each captured frame.
Before this PR, every capture session still waited for every DOM `<video>` to reach `readyState >= 1` unless the element was a native HDR exception. That made native browser media metadata a hard render prerequisite even when the browser would not decode or provide the final video pixels.
That is why the issue fails at `25% Starting frame capture`: FFmpeg extraction has already succeeded, but capture initialization blocks on repeated native `<video src="1.mp4">` metadata loading in cached Windows headless-shell Chrome.
There was a second constraint: the readiness wait also prevents first-frame layout bugs. If a skipped `<video>` has no native metadata, Chromium can use the default `300x150` intrinsic video size, which breaks layouts such as `width: 100%; height: auto` before the first injected frame. The fix therefore must not simply skip all video readiness waits; it must provide dimensions for any skipped videos.
## What This Fixes
- Treats videos with successfully extracted FFmpeg frames and usable dimensions as out-of-band rendered video sources.
- Skips native browser metadata readiness waits for those extracted videos because Chromium is not responsible for their pixels.
- Passes FFmpeg-probed dimensions into capture as `videoMetadataHints`.
- Applies those hints before the readiness wait in both screenshot and BeginFrame initialization paths.
- Sets missing `width` / `height` attributes and an explicit `aspect-ratio` only when the element does not already provide one, preserving author styles where present.
- Keeps native HDR video IDs in the skip list, preserving the existing HEVC/HDR behavior where Chrome may not decode the source but FFmpeg/native HDR compositing can still render it.
- Uses one `buildCaptureOptions()` helper so calibration, HDR DOM capture, streaming capture, parallel capture, and sequential capture receive the same skip IDs and metadata hints.
- Adds tests for the skip-list and metadata-hint contract.
- Adds a Windows CI regression that reproduces the issue shape after the canary render warms the cached-browser path.
## Reviewer Map
Primary files:
- `packages/producer/src/services/renderOrchestrator.ts`
- `collectVideoReadinessSkipIds()` includes native HDR IDs plus extracted videos that have finite positive FFmpeg dimensions.
- `collectVideoMetadataHints()` converts extracted FFmpeg metadata into capture hints.
- `buildCaptureOptions()` threads `skipReadinessVideoIds` and `videoMetadataHints` into every capture path.
- `packages/engine/src/services/frameCapture.ts`
- `applyVideoMetadataHints()` runs in the page before video readiness polling.
- Both screenshot and BeginFrame initialization call it before checking non-skipped videos for `readyState >= 1`.
- `packages/engine/src/types.ts`
- Adds `CaptureVideoMetadataHint` and documents that readiness skips should be paired with metadata hints when layout may depend on intrinsic dimensions.
- `packages/producer/src/services/renderOrchestrator.test.ts`
- Covers that extracted videos with dimensions are skipped, invalid dimensions are not, native HDR IDs are preserved, and hints are stable/sorted.
- `.github/workflows/windows-render.yml`
- Adds the issue #574 Windows regression with the exact three-clip markup and a generated deterministic `1.mp4`.
## Why This Is Safe
The skip is intentionally gated:
- A standard video is skipped only after `extractAllVideoFrames()` succeeded for that video and returned usable dimensions.
- Videos with invalid dimensions are not skipped, so the old browser readiness guard still applies.
- DOM videos are still present for layout and element bounds; only the native metadata wait is skipped for sources whose pixels come from FFmpeg injection.
- Metadata hints are applied conservatively: existing `width`, `height`, and explicit `aspect-ratio` are not overwritten.
- Non-extracted videos, images, fonts, page readiness, and `window.__hf` readiness keep the existing waits.
- The fix is not limited to the sequential path from the issue; it is threaded through calibration, HDR DOM capture, streaming encode, parallel capture, and sequential capture.
A first local revision skipped readiness too broadly and caused `overlay-montage-prod` first-frame layout shrinkage. The current version fixes that by pairing skips with FFmpeg metadata hints; `overlay-montage-prod` now passes and is listed in verification below.
## Verification
### Root-Cause Reproduction Before Fix
The reporter did not attach the actual `1.mp4`, so the regression uses the exact issue markup and a deterministic generated 12s H.264 file named `1.mp4`.
I reproduced the failure in GitHub Actions by running this branch's new Windows workflow against unpatched `main`:
```bash
gh workflow run windows-render.yml --repo heygen-com/hyperframes --ref fix/reused-video-metadata -f ref=main
```
That means the workflow contains the new issue #574 regression, but the code under test is `main` without this fix.
Baseline failure:
- Run: https://github.com/heygen-com/hyperframes/actions/runs/25174603730
- Failed job: https://github.com/heygen-com/hyperframes/actions/runs/25174603730/job/73803179086
- Checkout proof: `ref: main`, `origin/main`, commit `8662598a3ac64018a2999d189ffb369e6d46b53a`.
- Failure proof: `Browser: cache`, `staticDuration:12`, `videoCount:3`, then `25% Starting frame capture` -> `[FrameCapture] video metadata not ready after 15000ms`.
This is the same failure class as the issue, on Windows, in cached-browser mode, before the fix.
### Fixed Windows Regression
The same regression passes on this PR branch:
- Run: https://github.com/heygen-com/hyperframes/actions/runs/25175048215
- Passing job: https://github.com/heygen-com/hyperframes/actions/runs/25175048215/job/73804781017
- Checkout proof: PR merge contains `79d6b41c9f2ba137cbfb9301678e0815b16c4f5a` merged into `8662598a3ac64018a2999d189ffb369e6d46b53a`.
- Passing proof: `Browser: cache`, `staticDuration:12`, `videoCount:3`, `25% Starting frame capture`, captures `360/360` frames, renders `issue-574.mp4`, and `ffprobe` verifies `1920x1080 @ 30/1, 12s`.
### Local Checks
- `bun run build:hyperframes-runtime`
- `bunx vitest run packages/producer/src/services/renderOrchestrator.test.ts`
- `bun run --filter @hyperframes/producer typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bunx oxlint packages/engine/src/services/frameCapture.ts packages/engine/src/types.ts packages/engine/src/index.ts packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts`
- `bunx oxfmt --check .github/workflows/windows-render.yml packages/engine/src/services/frameCapture.ts packages/engine/src/types.ts packages/engine/src/index.ts packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts`
- `git diff --check`
- Lefthook pre-commit: lint, format, typecheck where applicable
- Lefthook commit-msg: commitlint
### Local Render Checks
- Created `/tmp/hf-issue-574-repro` with the issue shape: three clips using the same `1.mp4`, `data-media-start=0/4/8`, 12s total.
- `PRODUCER_PLAYER_READY_TIMEOUT_MS=5000 bun packages/cli/src/cli.ts render /tmp/hf-issue-574-repro --workers 1 --quality draft --fps 30 --output /tmp/hf-issue-574-h264-fixed-v2.mp4` -> completed.
- Created `/tmp/hf-issue-574-prores` with the same three-clip shape using one FFmpeg-readable ProRes `.mov`, which exercises the browser-metadata failure class because Chromium should not be needed to decode the source.
- `PRODUCER_PLAYER_READY_TIMEOUT_MS=3000 bun packages/cli/src/cli.ts render /tmp/hf-issue-574-prores --workers 1 --quality draft --fps 30 --output /tmp/hf-issue-574-prores-fixed-v2.mp4` -> completed.
- `bun run --filter @hyperframes/producer test --sequential --keep-temp overlay-montage-prod` -> passed; this guards against skipped metadata shrinking `height:auto` video layout before the first injected frame.
- `ffmpeg -v error -i /tmp/hf-issue-574-prores-fixed-v2.mp4 -f null -`
- `ffmpeg -v error -i /tmp/hf-issue-574-h264-fixed-v2.mp4 -f null -`
- `ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json /tmp/hf-issue-574-h264-fixed-v2.mp4` -> H.264, 320x180, 30fps, 12.0s.
### Current PR Checks
- Windows render verification: pass on https://github.com/heygen-com/hyperframes/actions/runs/25175048215.
- Windows tests: pass on https://github.com/heygen-com/hyperframes/actions/runs/25175048215.
- Main CI build/lint/typecheck/test/smoke jobs: pass on https://github.com/heygen-com/hyperframes/actions/runs/25175048175.
- Regression shards observed passing include HDR, render-compat, styles A-G, and `overlay-montage-prod`. At the time this body was updated, the `fast` regression shard was still in progress in run https://github.com/heygen-com/hyperframes/actions/runs/25174515546.
### Browser Verification
- Used `agent-browser` to open `file:///tmp/hf-issue-574-h264-fixed-v2.mp4` and verify the rendered output displays in Chromium.
- Screenshot: `.debug/issue-574/h264-output-page.png`
- Agent-browser recording: `.debug/issue-574/h264-output-playback.webm`
## Notes / Caveats
- The reporter's exact `1.mp4` was not attached to #574. The committed Windows regression uses a generated deterministic H.264 file with the same filename and exact markup from the issue.
- The exact H.264 issue shape did not reproduce the timeout on this macOS/system-Chrome machine before the fix; it rendered successfully locally. The GitHub Actions baseline above reproduces it on Windows/cache without the fix.
- The Windows fixture intentionally runs after the existing canary render so the browser path is `Browser: cache`, matching the reporter's environment.
- The generated fixture emits sparse-keyframe warnings. Those warnings are expected and are not the failure being fixed; the baseline failure occurs before any frame capture because native browser video metadata never becomes ready.
- Browser proof artifacts are local-only under `.debug/issue-574/` and intentionally not committed.
## Problem
HyperFrames already had `--gpu`, but that flag only controlled FFmpeg hardware encoding. The browser capture path still forced Chrome/WebGL through SwiftShader software GL via `--use-angle=swiftshader`, so WebGL-heavy local renders could leave the biggest bottleneck on the CPU path.
That made the existing flag naming easy to misread: `--gpu` sounded like it accelerated the whole render, but it did not change the browser frame-capture backend.
## What this fixes
- Enables host browser GPU acceleration automatically for local CLI renders.
- Adds `--no-browser-gpu` as the local opt-out for software Chrome/WebGL capture.
- Keeps `--browser-gpu` as an explicit local browser-GPU request.
- Adds `browserGpuMode: "software" | "hardware"` to engine config, with `PRODUCER_BROWSER_GPU_MODE` env support for lower-level producer users.
- Keeps Docker browser capture on the deterministic software path.
- Maps hardware browser GPU mode to platform-native Chrome backends:
- macOS: Metal-backed ANGLE
- Windows: D3D11-backed ANGLE
- Linux: EGL
- Blocks explicit `--browser-gpu --docker` with a clear error because Docker browser GPU passthrough is not cross-platform.
- Clarifies docs so `--gpu` means FFmpeg encoder GPU and browser GPU means Chrome/WebGL capture GPU.
- Keeps encoder backend selection auto-detected from FFmpeg capabilities:
- NVIDIA: NVENC
- macOS: VideoToolbox
- Linux: VAAPI
- Intel: QSV
## Why two flags
There are two separate GPU surfaces in the render pipeline:
1. Browser GPU controls Chrome frame capture.
- Affects WebGL, canvas, CSS rendering, compositing, and screenshot capture inside the browser.
- This is enabled automatically for local CLI renders.
- Use `--no-browser-gpu` when you want the software browser baseline.
2. `--gpu` controls FFmpeg video encoding.
- Affects the final encode step after frames have already been captured.
- The concrete encoder is auto-detected from the host FFmpeg build and hardware.
- It can be faster for some machines/codecs, but it is not equivalent to browser rendering acceleration.
The controls stay independent because users may want:
- `hyperframes render` for the fast local default with browser GPU capture.
- `hyperframes render --no-browser-gpu` for the software-browser local baseline.
- `hyperframes render --gpu` for browser GPU capture plus hardware FFmpeg encoding.
- `hyperframes render --no-browser-gpu --gpu` for software browser capture plus hardware FFmpeg encoding.
- `hyperframes render --docker` for deterministic browser capture.
## Why `--gpu` does not imply browser GPU
Keeping `--gpu` scoped to FFmpeg encoding avoids a semantic break and keeps the risk profile explicit:
- `--gpu` already means encoder acceleration. Expanding it to also change Chrome capture would silently alter behavior for users who only wanted hardware encoding.
- Browser GPU and encoder GPU have different portability. Encoder GPU can work in Docker when the host exposes the right devices; browser GPU passthrough is not cross-platform, so this PR intentionally blocks explicit `--browser-gpu --docker`.
- The Apple presentation benchmark shows why the controls should stay separate: browser GPU capture was the useful improvement, while macOS VideoToolbox via `--gpu` was slower and produced larger output for this `standard` H.264 run.
If HyperFrames later wants a single umbrella acceleration control, it should be explicit, for example `--acceleration browser|encoder|all` or `--gpu=browser|encoder|all`, rather than changing the meaning of the existing boolean `--gpu`.
## Root cause
`buildChromeArgs()` always injected `--use-gl=angle --use-angle=swiftshader`. `disableGpu` only appended `--disable-gpu`; it did not provide a hardware-GPU mode. That made the public `--gpu` flag look broader than it was, because render capture stayed software-backed even when encoder GPU was requested.
## Verification
### Local checks
- `bun install`
- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/engine test src/config.test.ts src/services/browserManager.test.ts`
- `bun run --filter @hyperframes/cli test src/utils/dockerRunArgs.test.ts src/commands/render.test.ts`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `cd packages/producer && bunx vitest run src/services/renderOrchestrator.test.ts`
- `bunx oxlint packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts packages/cli/src/utils/dockerRunArgs.ts packages/cli/src/utils/dockerRunArgs.test.ts packages/engine/src/config.ts packages/engine/src/config.test.ts packages/engine/src/services/browserManager.ts packages/engine/src/services/browserManager.test.ts packages/producer/src/services/renderOrchestrator.test.ts`
- `bunx oxfmt --check ...` on changed source/docs files
- `git diff --check`
- `bun packages/cli/src/cli.ts render --help | rg -n "browser-gpu|no-browser-gpu|GPU"`
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --output /tmp/hf-auto-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict`
- Render plan prints `GPU: browser GPU (auto)`.
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --no-browser-gpu --output /tmp/hf-software-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict`
- Render plan does not print browser GPU.
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --docker --browser-gpu --output /tmp/should-not-render.mp4`
- Exits 1 with `Browser GPU is local-only`.
- `buildDockerRunArgs()` regression coverage asserts Docker container args include `--no-browser-gpu`, preventing nested container renders from re-enabling browser GPU through the local CLI default.
- `resolveBrowserGpuForCli()` regression coverage asserts `PRODUCER_BROWSER_GPU_MODE=software` opts out when no CLI browser-GPU flag is supplied, while explicit `--browser-gpu` / `--no-browser-gpu` still win.
- `ffmpeg -v error -i /tmp/hf-auto-browser-gpu-smoke.mp4 -f null -`
- `ffmpeg -v error -i /tmp/hf-software-browser-gpu-smoke.mp4 -f null -`
- `ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json /tmp/hf-browser-gpu-smoke.mp4` -> H.264, 1920x1080, 24fps, 5.0s
### Apple presentation benchmark
Rendered `/Users/miguel07code/Downloads/apple-presentation.zip` as supplied after extracting to `/tmp/hf-apple-profile/apple-presentation`.
Fixed settings:
- 1920x1080
- 30fps
- `standard` quality
- 4240 frames
- 141.32s duration
- 8-worker cap; render auto-calibration used 6 capture workers
- macOS host detected FFmpeg GPU encoder: `videotoolbox`
| Mode | Equivalent flags after this PR | Wall time | vs software-browser baseline | Speed | Capture | Encode | Output |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Software browser + CPU encode | `--no-browser-gpu` | 120.77s | baseline | 1.17x | 97.87s | 10.04s | 8.38MB |
| Browser GPU + CPU encode | default local render | 70.10s | 42.0% faster | 2.02x | 50.72s | 9.91s | 8.39MB |
| Software browser + encoder GPU | `--no-browser-gpu --gpu` | 133.16s | 10.3% slower | 1.06x | 103.58s | 18.31s | 25.43MB |
| Browser GPU + encoder GPU | `--gpu` | 74.12s | 38.6% faster | 1.91x | 46.69s | 17.93s | 25.45MB |
Result: browser GPU capture is the meaningful improvement for this WebGL/browser-capture-heavy presentation. VideoToolbox encoding was slower and produced larger files for this current `standard` H.264 path, so `--gpu` should stay separate and opt-in.
Why `--gpu` plus browser GPU was slower than browser GPU alone: the combined run captured about 4.0s faster than browser GPU alone, but VideoToolbox encoding was about 8.0s slower than CPU x264 encoding, so the encode loss outweighed the capture gain.
### VideoToolbox flag check
I also isolated the encode stage against the already-captured Apple frames to check whether macOS GPU encoding only needed special flags.
`ffmpeg -h encoder=h264_videotoolbox` does not expose a CRF/CQ-style quality option like x264. It exposes bitrate-oriented and VideoToolbox-specific options such as `-b:v`, `-realtime`, `-profile`, `-coder`, `-prio_speed`, `-power_efficient`, and `-allow_sw`. That means our current `-q:v` mapping is not equivalent to x264 CRF and can produce very different bitrate/size behavior.
Measured full-frame encode variants on this host:
| VideoToolbox variant | Encode wall time | Output size | Bitrate |
| --- | ---: | ---: | ---: |
| Current `-q:v 64 -allow_sw 1` | 18.76s | 25.31MB | 1.43 Mbps |
| Current without `-allow_sw 1` | 18.21s | 25.31MB | 1.43 Mbps |
| `-b:v 500k -maxrate 750k -bufsize 1000k -profile high -coder cabac -realtime 1 -prio_speed 1 -power_efficient 0` | 20.58s | 7.42MB | 0.42 Mbps |
| Same with `-b:v 1500k` | 20.84s | 16.70MB | 0.95 Mbps |
| `-b:v 500k -profile baseline -coder cavlc -realtime 1 -prio_speed 1 -power_efficient 0` | 18.11s | 8.94MB | 0.51 Mbps |
Conclusion: VideoToolbox can be made size/bitrate-predictable with explicit `--video-bitrate`, but the tested speed-oriented flags did not make it faster than CPU x264 wall time for this render. That reinforces keeping `--gpu` encoder acceleration explicit and separate from browser GPU capture.
Artifacts from the local benchmark:
- `/tmp/hf-apple-profile/results/cpu.mp4`
- `/tmp/hf-apple-profile/results/browser-gpu.mp4`
- `/tmp/hf-apple-profile/results/encoder-gpu.mp4`
- `/tmp/hf-apple-profile/results/full-gpu.mp4`
- `/tmp/hf-apple-profile/results/summary.json`
All four benchmark MP4s completed `ffprobe` and full `ffmpeg -f null` decode checks.
### Pixel comparison
Compared decoded MP4 output between software-browser and browser-GPU renders:
- Apple presentation:
- 4240 frames compared
- 636 exact matching decoded frame hashes
- 3604 different decoded frame hashes
- Average PSNR: 57.79 dB
- `css-spinner-render-compat` clean fixture:
- 120 frames compared
- 0 exact matching decoded frame hashes
- Average PSNR: 61.57 dB
Interpretation: browser GPU output is not strict hash/pixel-identical to the software-browser path after lossy H.264 encode, but the measured deltas are visually tiny. Above 50 dB PSNR is typically visually indistinguishable for normal video review. Use `--no-browser-gpu` or Docker when strict cross-run/cross-machine reproducibility matters more than local speed.
### Browser verification
- Started HyperFrames Studio preview for `packages/producer/tests/css-spinner-render-compat/src`.
- Used `agent-browser` to open `http://localhost:5191#project/src` and verify the composition loaded in Studio.
- Screenshots:
- `/tmp/hf-gpu-browser-proof/preview-loaded.png`
- `/tmp/hf-gpu-browser-proof/preview-playing.png`
- `/tmp/hf-gpu-browser-proof/preview-frame-60.png`
- Agent-browser recordings:
- `/tmp/hf-gpu-browser-proof/preview-playback.webm`
- `/tmp/hf-gpu-browser-proof/preview-seek.webm`
## Notes
- Browser GPU is enabled automatically for local CLI renders and disabled in Docker.
- `--no-browser-gpu` is the opt-out for software Chrome/WebGL capture.
- `--gpu` remains encoder-only and opt-in.
- The Apple presentation zip has existing lint errors around unmanaged nested videos and imperative media `play()` calls. The benchmark still compares the same supplied source across modes, but it should not be treated as a clean deterministic-composition fixture.
## Summary
- **Root cause**: `shouldFallbackToScreenshotAfterCalibrationError` only matched `HeadlessExperimental.beginFrame` errors. When a composition with many heavy videos (e.g. 7 videos with sparse keyframes) caused Chrome to be unresponsive in BeginFrame mode during calibration, a `Runtime.callFunctionOn timed out` or `Runtime.evaluate timed out` error was treated as an opaque failure — not a BeginFrame-mode signal. The render kept BeginFrame mode, spawned 3 workers with `captureCostMultiplier=8`, and all 3 workers also timed out initialising their sessions (0 frames captured, render fails).
- **Fix**: Add `Runtime.callFunctionOn timed out` and `Runtime.evaluate timed out` to the screenshot-fallback pattern. Any CDP call timing out during the short-timeout calibration probe now routes the render into single-worker screenshot mode — the safe fallback already used for explicit BeginFrame timeouts.
- **Result**: Compositions that overwhelm BeginFrame mode (reported in #566: 7 videos, 8 audios, 330-second render) now fall back cleanly and complete instead of failing with 0 frames.
## Test plan
- [x] New unit test: `falls back to screenshot mode after Runtime.callFunctionOn timeout during calibration` — asserts both `Runtime.callFunctionOn timed out` and `Runtime.evaluate timed out` return `true`
- [x] All existing `capture calibration safeguards` unit tests still pass
- [x] Pre-commit hooks (lint, format, typecheck) pass
Fixes#566
When an <audio> element referenced a file with no audio stream (e.g. a
silent screen-recording used as an audio src, or a video-only clip),
extractAudioMetadata threw "[FFmpeg] No audio stream found". The error
propagated uncaught through Promise.all in compileHtmlFile and crashed
the entire render.
Apply the same graceful-skip pattern already used for missing files and
failed downloads: catch the probe error and return { duration: 0 } so
the element is excluded from the composition without aborting the render.
Confirmed via 7 production HyperframeRenderWorkflow failures all sharing
the same TemporalMagicEditActivity.RENDER_PREVIEW stack trace.
* fix(hdr): filter zero-opacity elements and support overflow:hidden clip rects in HDR compositor
Two bugs in the HDR render pipeline:
1. Child data-start elements inside a parent with opacity:0 were still
composited as independent layers, painting over content in later scenes.
Fix: filter elements with effective opacity 0 before groupIntoLayers().
2. CSS overflow:hidden on ancestor elements was ignored for HDR video layers,
causing videos inside clipped containers (e.g. split-screen halves) to
render full-frame. Fix: add clipRect to ElementStackingInfo, compute it
from ancestor overflow:hidden in queryElementStacking(), and crop the
source buffer to clip bounds before blitting in blitHdrVideoLayer().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(hdr): move opacity filter into blit loop to preserve hide-list correctness
The previous approach filtered zero-opacity elements before groupIntoLayers(),
which broke the DOM screenshot hide-list — invisible video elements' <img>
replacements weren't properly hidden from sibling layer screenshots, causing
the vignelli-stacking regression.
Fix: keep all elements in groupIntoLayers() for correct hide-list generation.
Skip zero-opacity HDR elements only during the actual blit step with an early
`continue` in the compositing loop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(hdr): route identity-matrix HDR elements through region blit for clip rect support
parseTransformMatrix returns a valid matrix even for untransformed HDR
elements (Chrome reports matrix(1,0,0,1,0,0)). This made the affine blit
path always run, bypassing the region blit path which is the only one that
applies clip rects from overflow:hidden ancestors.
Fix: detect identity matrices and route them through the region path so
the cropRgb48le clip logic is reachable for split-screen layouts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(hdr): handle translation-only matrices for clip rect support
The previous isIdentity check only caught matrix(1,0,0,1,0,0). Elements
with layout translation (e.g. right-half split at left:960px reporting
matrix(1,0,0,1,960,0)) still routed through the affine path where clip
rects are not applied.
Fix: check for translation-only matrices (scale=1, rotation=0, any tx/ty)
and route those through the region blit path. el.x/el.y from
getBoundingClientRect already include the translation, so the region path
handles positioning correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(render): auto-detect HDR from media probes, add --sdr flag
Replace the --hdr opt-in model with automatic detection. When no flags
are passed, the renderer probes all video/image sources and enables HDR
output if any HDR color space is detected. Existing --hdr flag becomes
a force override. New --sdr flag forces SDR output.
Behavior matrix:
(no flags) + HDR content → HDR output
(no flags) + SDR content → SDR output
--hdr → force HDR (defaults to HLG if no HDR sources)
--sdr → force SDR (skips probing)
--hdr --sdr → error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "feat(render): auto-detect HDR from media probes, add --sdr flag"
This reverts commit 69fb52196f.
* chore(hdr): simplify review fixes — remove redundant guard, add image clip warning
- Remove redundant viewportMatrix.length >= 6 check (parseTransformMatrix
always returns 6-element array or null)
- Add clip rect warning log to blitHdrImageLayer for parity with video path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(render): auto-detect HDR from media probes, add --sdr flag
Replace the --hdr opt-in model with automatic detection. When no flags
are passed, the renderer probes all video/image sources and enables HDR
output if any HDR color space is detected. Existing --hdr flag becomes
a force override. New --sdr flag forces SDR output.
Behavior matrix:
(no flags) + HDR content → HDR output
(no flags) + SDR content → SDR output
--hdr → force HDR (defaults to HLG if no HDR sources)
--sdr → force SDR (skips probing)
--hdr --sdr → error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: align HDR auto-detect docs and tests
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>