feat(hdr): z-ordered multi-layer compositing with PQ support#289
Merged
vanceingalls merged 2 commits intomainfrom Apr 19, 2026
Merged
feat(hdr): z-ordered multi-layer compositing with PQ support#289vanceingalls merged 2 commits intomainfrom
vanceingalls merged 2 commits intomainfrom
Conversation
This was referenced Apr 16, 2026
Collaborator
Author
This was referenced Apr 16, 2026
45cb5a7 to
16ff590
Compare
e19081a to
1ea1b2b
Compare
16ff590 to
823ba6b
Compare
1ea1b2b to
528ea17
Compare
912fced to
f39b02a
Compare
2837b2c to
eb10879
Compare
f39b02a to
abd3c7e
Compare
eb10879 to
94d5cab
Compare
abd3c7e to
f8c5949
Compare
0d98d3f to
ad300ba
Compare
f8c5949 to
0045cd9
Compare
0045cd9 to
b2cc191
Compare
5e4ed3d to
cdbe189
Compare
0815192 to
95852c3
Compare
cdbe189 to
ae9bd45
Compare
Collaborator
miguel-heygen
left a comment
There was a problem hiding this comment.
Automated review (Claude Code)
Reviewed against CLAUDE.md. Stack-level meta on #268.
Note: despite the PR title ("PQ support"), the PQ LUT/OETF code lives in alphaBlit.ts outside this PR's diff, so I couldn't verify the 203-nit SDR-white choice or its rationalization from this diff alone.
Critical
- Debug stderr in hot path —
packages/producer/src/services/renderOrchestrator.ts:983-990.process.stderr.write([DBG] f${i} …)every 30th frame on every render. Not behindlog.debug. (Removed in #290 — ensure the stack lands together or squash carries this out.) - Per-DOM-layer
captureAlphaPngcycle is the primary perf regression —renderOrchestrator.ts:1024-1029. Each DOM layer triggers its ownhideVideoElements+captureAlphaPng+showVideoElements+seek()cycle. A DOM→HDR→DOM→HDR→DOM composition does ~3× Chrome work vs. a single screenshot. Consider caching layer groupings when DOM is static, or a single screenshot + CSS-visibility layering when no HDR is interleaved.
Important
getEffectiveZIndexmisses CSS stacking-context semantics —videoFrameInjector.ts:259-268respectsposition !== "static"+ explicitz-index, but CSS creates stacking contexts without explicit z-index viaopacity<1,transform,filter,will-change,isolation. An HDR video inside atransform: translate(...)wrapper with no z-index returns the parent's parent's z-index, potentially sorting incorrectly. GSAP routinely setstransformon wrappers — this will bite. Document the supported subset at minimum.- Tie-break non-determinism documented-by-absence —
layerCompositor.ts:31. Sort-by-z with many0defaults falls through to V8 stable sort →querySelectorAllDOM order. Probably fine; add a comment. - Silent swallow of HDR decode failures —
renderOrchestrator.ts:1005-1008.try { decodePngToRgb48le(...) } catch {}. Log at debug min with frame + path. effectiveHdr!non-null assertion —renderOrchestrator.ts:1037. Inside the HDR branch but under a swallowedtry/catch: ifeffectiveHdris undefined (HDR video present but probe failed) → throws → caught → silent black DOM layer. Restore explicit default or assert early.
Suggestions
- Tests for
groupIntoLayerscoverage gaps: empty input (should return[]), negative z-index (valid CSS back layers), stable tie-break at equal z-index (input-order preservation). ElementStackingInfoinvariants undocumented — mixes layout, stacking, visibility.visible:false+width>0is impossible by construction but the type allows it. Brief JSDoc per field.- Merge rule correctness comment — "adjacent DOM elements merge" is sound only because DOM layers are rendered via a full-page screenshot with non-layer elements hidden (so within-layer z-order is Chrome's). Worth a comment inside
groupIntoLayersexplaining why the merge doesn't lose information.
Strengths
layerCompositor.tsis small, pure, well-tested (8 table-driven cases) — excellent module boundary.blitRgb48leRegionbounds clipping viaMath.max/min, empty-region short-circuit, full-opacity fast path viaBuffer.copy— correct.- Re-seeking GSAP after
showVideoElements(1031-1033) is a thoughtful fix for theremovePropertyclobber. CompositeLayerdiscriminated union → clean pattern match in the compositor loop.
🤖 Automated review. See stack meta-comment on #268.
Collaborator
Author
|
Thanks. Fixes landed. Addressed:
Not addressed:
|
95852c3 to
4f2e453
Compare
1f03b5f to
e16a65e
Compare
ab87486 to
1ae0ee9
Compare
e16a65e to
29c016b
Compare
1ae0ee9 to
cae950e
Compare
5e72bda to
0fd7234
Compare
7ad09c5 to
fa0afc1
Compare
0fd7234 to
88387a8
Compare
miguel-heygen
approved these changes
Apr 19, 2026
88387a8 to
2c1d362
Compare
b76fe30 to
aa33ab9
Compare
216bb02 to
ba531fe
Compare
aa33ab9 to
bbaf4c3
Compare
ba531fe to
baa1627
Compare
bbaf4c3 to
d95a0ab
Compare
Per-frame z-order analysis groups elements into DOM and HDR layers, composited bottom-to-top. Adjacent DOM elements merge into single screenshots. PQ (HDR10/smpte2084) support via sRGB-to-PQ LUT with 203-nit SDR reference white. queryElementStacking walks DOM for effective z-index, groupIntoLayers splits on HDR/DOM boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
baa1627 to
a21a62b
Compare
d95a0ab to
9c4ef2f
Compare
- Document groupIntoLayers tie-break (V8 stable sort → DOM order). - Expand layerCompositor docstring: merge rationale, visibility inclusion. - Add tests: empty input, negative z-index, stable tie-break at equal z. - Document getEffectiveZIndex CSS stacking-context limitations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9c4ef2f to
7493f87
Compare
vanceingalls
added a commit
that referenced
this pull request
Apr 20, 2026
## Summary HDR video elements with GSAP animations (position, scale, rotation, opacity) and CSS border-radius rendered without any transforms applied — the video just sat at (0,0) full-size. This PR adds affine transform support and rounded-corner masking for natively-composited HDR video. ## What it does **Affine blit with bilinear interpolation:** - `blitRgb48leAffine()` — Takes a 4x4 DOMMatrix and maps each destination pixel back to source coordinates via the inverse transform. Bilinear interpolation between the 4 nearest source pixels produces smooth edges under rotation and non-integer scaling. Optional opacity and border-radius parameters. - `parseTransformMatrix()` — Parses CSS `matrix(a,b,c,d,e,f)` strings into `[a,b,c,d,e,f]` tuples. **Accumulated viewport matrix:** - `getViewportMatrix()` — Walks the `offsetParent` chain from element to viewport, accumulating position offsets and CSS transforms at each level. Correctly handles `transform-origin` using the CSS sandwich: `translate(origin) × M × translate(-origin)`. This is critical because GSAP animates transforms on wrapper divs, not directly on the video element. **Effective opacity:** - `getEffectiveOpacity()` — Multiplies opacity values walking up the ancestor chain. Uses `Number.isNaN()` (not `|| 1`) so opacity:0 isn't incorrectly treated as 1. **Border-radius masks:** - `roundedRectAlpha()` — Per-pixel anti-aliased rounded-rectangle mask with support for independent corner radii. - `getEffectiveBorderRadius()` — Walks ancestors for `overflow:hidden` + border-radius. Resolves percentage values (e.g., `50%` for circles) via `offsetWidth`/`offsetHeight`. **Layout dimensions for extraction:** - Uses `offsetWidth`/`offsetHeight` (unaffected by CSS transforms) instead of `getBoundingClientRect()` (which returns the transformed bounding box and wobbles under rotation). ## Files changed | File | What changed | |------|-------------| | `packages/engine/src/utils/alphaBlit.ts` | `blitRgb48leAffine()`, `parseTransformMatrix()`, `roundedRectAlpha()`, `cornerAlpha()` | | `packages/engine/src/services/videoFrameInjector.ts` | `getViewportMatrix()`, `getEffectiveOpacity()`, `getEffectiveBorderRadius()`, `layoutWidth`/`layoutHeight` on `ElementStackingInfo` | | `packages/producer/src/services/renderOrchestrator.ts` | Affine blit path, extraction at layout dimensions, border-radius parameter passing | ## How to test Render a composition with an HDR video that has GSAP scale + rotation animation and a `border-radius: 50%` wrapper (circle mask). The video should rotate smoothly with round edges — no wobble, no sharp corners. ## Stack position **5 of 6** — Stacked on #289 (z-ordered layers). Adds transform and masking support to the HDR blit that the layer compositor uses. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vanceingalls
added a commit
that referenced
this pull request
Apr 20, 2026
The --hdr flag was silently dropped when combined with --docker because renderDocker did not include it in the args passed to the in-container hyperframes render invocation. Forwarding it produces the same HDR10 MP4 (HEVC, yuv420p10le, BT.2020 PQ, mastering display + max-cll) as a local render — verified end-to-end with the new hdr-pq regression fixture. This PR also fills in the documentation gaps from the HDR feature stack (#288, #289, #290, #265, #268), which each added a slice of HDR support without pulling it together into a single guide. Code change: - packages/cli/src/commands/render.ts: refactor renderDocker to use a new pure helper buildDockerRunArgs (packages/cli/src/utils/dockerRunArgs.ts) that explicitly threads every render flag — including --hdr, --gpu, --quiet — into the docker run argv. Snapshot-tested in dockerRunArgs.test.ts so any future flag drop fails loudly. Docs: - New docs/guides/hdr.mdx — top-level HDR guide: quickstart, how detection works, source media requirements (HDR video + 16-bit HDR PNG), output format constraints, ffprobe verification, Docker rendering, limitations, common pitfalls - docs/guides/common-mistakes.mdx — "Expected HDR but got SDR" entry; --hdr is detection, not force; notes Docker now supports it - docs/guides/rendering.mdx — --hdr row in flags table; HDR card under Next Steps - docs/packages/cli.mdx — expanded --hdr description with link to the guide - docs/packages/producer.mdx — HDR Output section under RenderConfig - docs/packages/engine.mdx — HDR APIs section (color-space utilities + WebGPU readback runtime); now compiles because DEFAULT_HDR10_MASTERING and HdrMasteringMetadata are re-exported from packages/engine/src/index.ts - docs/docs.json — register the new HDR guide in nav Review feedback from PR #346: - packages/engine/src/index.ts — export DEFAULT_HDR10_MASTERING and HdrMasteringMetadata so the engine docs example compiles - docs/guides/hdr.mdx — drop fabricated "animated HDR images log a warning and use first frame" claim (not implemented); clarify that HDR <img> is 16-bit PNG only and other formats fall back to SDR; broaden the yt-dlp example to bestvideo[dynamic_range=HDR]+bestaudio so AV1-HDR streams are also picked up; qualify the Docker render benchmark claim - packages/producer/src/services/renderOrchestrator.ts — bump the HDR + non-mp4 fallback from log.info to log.warn and make the message actionable Regression test (hdr-pq): - packages/producer/tests/hdr-regression — delete the broken sub-tree. The old hdr-pq composition relied on a CDN shader-transitions library and window.__hf injection with scenes defaulting to opacity:0, so when the library failed to load most of the render was invisible. The fixture was also nested two directories deep, so discoverTestSuites (which only scans direct children of tests/) never picked it up. - packages/producer/tests/hdr-pq — new top-level fixture: 5s of an HDR10 HEVC Main10 BT.2020 PQ source clip with a static SDR DOM overlay. Source is a 5s excerpt (1:18–1:23) from https://youtu.be/56hEFqjKG0s, downloaded via yt-dlp and re-encoded to HEVC Main10 / 1920x1080 / 30fps with HDR10 mastering metadata. Self-contained: no external CDNs, no custom fonts, no animation libraries. NOTICE.md captures attribution. - packages/producer/src/regression-harness.ts — add optional hdr boolean to TestMetadata.renderConfig, validate it, and forward it to createRenderJob so HDR fixtures actually run through the HDR pipeline. - .github/workflows/regression.yml — exclude hdr from the fast shard and add a dedicated hdr shard so the new fixture runs on CI. - .gitattributes — LFS-track packages/producer/tests/*/src/*.mp4 so source clips don't bloat the repo. - packages/engine/src/services/frameCapture.ts — inject a no-op __name shim into every page via evaluateOnNewDocument. tsx transforms the engine source on the fly with esbuild's keepNames, which wraps every named function in __name(fn, "name"). When page.evaluate() serializes a callback those calls would crash with "__name is not defined" because the helper only exists in Node. The shim makes the engine work whether it's imported from compiled dist or from source via tsx.
3 tasks
vanceingalls
added a commit
that referenced
this pull request
Apr 22, 2026
…#365) ## Summary Replace the trivial `hdr-pq` and `hdr-image-only` tests with two consolidated, time-windowed regression suites that exercise the full HDR pipeline. These goldens are the safety net for every other PR in this stack. ## Why The pre-existing HDR tests covered only a single full-bleed video or image with a static text label — none of the features that the HDR pipeline has to handle differently from SDR (opacity animation, z-ordered multi-layer compositing, transforms, border-radius clipping, shader transitions, multiple HDR sources, object-fit modes, mixed HDR+SDR layering, HLG transfer). This PR builds the missing safety net first so every subsequent fix can be proven correct. ## What changed - New `packages/producer/tests/hdr-regression/` (PQ, BT.2020, ~20 s, 1080p, 8 windows A–H): - A: static baseline (HDR video + DOM overlay) - B: wrapper-opacity fade - C: direct-on-`<video>` opacity tween (documents the Chunk 1 bug) - D: z-order sandwich (DOM → HDR → DOM) - E: two HDR videos side-by-side (pins PR #289) - F: rotation + scale + border-radius (documents the Chunk 4 bug) - G: `object-fit: contain` - H: shader crossfade between HDR video and HDR image - New `packages/producer/tests/hdr-hlg-regression/` (HLG, ARIB STD-B67, ~5 s, 2 windows A–B) — exercises the separate HLG LUT/OETF code path that previously had **zero** coverage. - New `scripts/generate-hdr-photo-pq.py` synthesizes `hdr-photo-pq.png` with a cICP chunk for BT.2020/PQ/full. - Removed `tests/hdr-pq/` and `tests/hdr-image-only/`. - Updated `.github/workflows/regression.yml` HDR shard to run the new pair sequentially. - All compositions follow the documented timed-element pattern (`data-start`, `data-duration`, `class="clip"` directly on each timed leaf — no wrapper inheritance). ## Test plan - [x] Goldens generated with `bun run test:update --sequential`. - [x] `ffprobe` confirms HEVC/yuv420p10le/bt2020nc/smpte2084 (PQ) and arib-std-b67 (HLG). - [x] Suite green with `maxFrameFailures` budgets that absorb the documented Chunk 1 / Chunk 4 known-fails — tightened in follow-up PRs in this stack. ## Stack Foundational PR for the HDR follow-ups stack (Chunk 0 of `plans/hdr-followups.md`). Every subsequent PR builds on this safety net.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Phase 1 only handled HDR video on the bottom with DOM on top. Real compositions need arbitrary z-interleaving — HDR video between DOM layers, multiple HDR videos at different z-depths, SDR video as background below HDR. This PR adds per-frame z-order analysis and layer-by-layer compositing.
What it does
Per-frame algorithm:
queryElementStacking()asks Chrome for every timed element's z-index, bounds, opacity, visibility, and whether it's an HDR video.groupIntoLayers()walks the z-sorted list. Each time the content type switches (DOM ↔ HDR), a new layer starts. Adjacent DOM elements merge into a single layer (fewer Chrome screenshots).Example z-order:
This produces 3 screenshots + 2 native blits = 5 layer operations per frame.
Also adds PQ (HDR10) support:
buildSrgbToHdrLut("pq")— PQ OETF (SMPTE 2084) maps SDR white to ~203 nits (~58% PQ signal)blitRgba8OverRgb48le()now acceptstransfer: "hlg" | "pq"parameterblitRgb48leRegion()— positioned rectangular copy with bounds clipping and optional opacityFiles changed
packages/engine/src/utils/layerCompositor.tsgroupIntoLayers(),CompositeLayertypepackages/engine/src/services/videoFrameInjector.tsqueryElementStacking()with effective z-index walk,ElementStackingInfotypepackages/engine/src/utils/alphaBlit.tsblitRgb48leRegion(), transfer parameterpackages/producer/src/services/renderOrchestrator.tsHow to test
Render a composition with SDR video below HDR video below text overlays. All three should be visible at their correct z-depths.
Stack position
4 of 6 — Stacked on #288 (two-pass compositing). Generalizes the fixed two-layer model to arbitrary N-layer compositing.
🤖 Generated with Claude Code