Skip to content

feat(hdr): z-ordered multi-layer compositing with PQ support#289

Merged
vanceingalls merged 2 commits intomainfrom
feat/hdr-phase-2
Apr 19, 2026
Merged

feat(hdr): z-ordered multi-layer compositing with PQ support#289
vanceingalls merged 2 commits intomainfrom
feat/hdr-phase-2

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 16, 2026

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:

  1. Query stacking orderqueryElementStacking() asks Chrome for every timed element's z-index, bounds, opacity, visibility, and whether it's an HDR video.
  2. Group into layersgroupIntoLayers() 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).
  3. Composite bottom-to-top — DOM layers get Chrome alpha screenshots (with non-layer elements hidden). HDR layers get native pixel blits from pre-extracted frames. Each layer composites onto the running canvas.

Example z-order:

z=0: SDR background     → DOM layer 0 (1 screenshot)
z=1: HDR video           → HDR layer 1 (native blit)  
z=2: text overlay        → DOM layer 2 (1 screenshot)
z=3: HDR circle video    → HDR layer 3 (native blit)
z=4: logo                → DOM layer 4 (1 screenshot)

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 accepts transfer: "hlg" | "pq" parameter
  • blitRgb48leRegion() — positioned rectangular copy with bounds clipping and optional opacity

Files changed

File What changed
packages/engine/src/utils/layerCompositor.ts NEWgroupIntoLayers(), CompositeLayer type
packages/engine/src/services/videoFrameInjector.ts queryElementStacking() with effective z-index walk, ElementStackingInfo type
packages/engine/src/utils/alphaBlit.ts PQ LUT, blitRgb48leRegion(), transfer parameter
packages/producer/src/services/renderOrchestrator.ts Layer-driven compositing loop replacing simple two-pass

How 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

@vanceingalls vanceingalls marked this pull request as ready for review April 16, 2026 00:54
@vanceingalls vanceingalls changed the title docs: Phase 2 z-ordered multi-layer compositing design spec feat(hdr): z-ordered multi-layer compositing with PQ support Apr 17, 2026
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from 0d98d3f to ad300ba Compare April 18, 2026 00:47
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from 5e4ed3d to cdbe189 Compare April 18, 2026 06:16
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-1 branch 2 times, most recently from 0815192 to 95852c3 Compare April 18, 2026 07:54
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pathpackages/producer/src/services/renderOrchestrator.ts:983-990. process.stderr.write([DBG] f${i} …) every 30th frame on every render. Not behind log.debug. (Removed in #290 — ensure the stack lands together or squash carries this out.)
  • Per-DOM-layer captureAlphaPng cycle is the primary perf regressionrenderOrchestrator.ts:1024-1029. Each DOM layer triggers its own hideVideoElements + 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

  • getEffectiveZIndex misses CSS stacking-context semanticsvideoFrameInjector.ts:259-268 respects position !== "static" + explicit z-index, but CSS creates stacking contexts without explicit z-index via opacity<1, transform, filter, will-change, isolation. An HDR video inside a transform: translate(...) wrapper with no z-index returns the parent's parent's z-index, potentially sorting incorrectly. GSAP routinely sets transform on wrappers — this will bite. Document the supported subset at minimum.
  • Tie-break non-determinism documented-by-absencelayerCompositor.ts:31. Sort-by-z with many 0 defaults falls through to V8 stable sort → querySelectorAll DOM order. Probably fine; add a comment.
  • Silent swallow of HDR decode failuresrenderOrchestrator.ts:1005-1008. try { decodePngToRgb48le(...) } catch {}. Log at debug min with frame + path.
  • effectiveHdr! non-null assertionrenderOrchestrator.ts:1037. Inside the HDR branch but under a swallowed try/catch: if effectiveHdr is undefined (HDR video present but probe failed) → throws → caught → silent black DOM layer. Restore explicit default or assert early.

Suggestions

  • Tests for groupIntoLayers coverage gaps: empty input (should return []), negative z-index (valid CSS back layers), stable tie-break at equal z-index (input-order preservation).
  • ElementStackingInfo invariants undocumented — mixes layout, stacking, visibility. visible:false + width>0 is 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 groupIntoLayers explaining why the merge doesn't lose information.

Strengths

  • layerCompositor.ts is small, pure, well-tested (8 table-driven cases) — excellent module boundary.
  • blitRgb48leRegion bounds clipping via Math.max/min, empty-region short-circuit, full-opacity fast path via Buffer.copy — correct.
  • Re-seeking GSAP after showVideoElements (1031-1033) is a thoughtful fix for the removeProperty clobber.
  • CompositeLayer discriminated union → clean pattern match in the compositor loop.

🤖 Automated review. See stack meta-comment on #268.

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 18, 2026

Thanks. Fixes landed.

Addressed:

  • Debug stderr in hot path — already removed in feat(hdr): GSAP transforms and border-radius masks on HDR video #290's diff; confirmed still absent.
  • Silent HDR-decode swallow — now log.warn with element id + time.
  • effectiveHdr! non-null assertion — removed in a prior revision; current code uses effectiveHdr?.transfer or narrows once at the top of the HDR branch with an early throw on the invariant.
  • Tie-break non-determinism — documented in the groupIntoLayers docstring (V8 stable sort → querySelectorAll DOM order, which matches what Chrome does for equal-z elements in a stacking context). Added a test that exercises it explicitly.
  • getEffectiveZIndex stacking-context limitations — now documented: does NOT detect implicit stacking contexts created by opacity<1, transform, filter, will-change, isolation, mix-blend-mode. Recommends explicit z-index on wrappers that should be compositing roots.
  • groupIntoLayers coverage gaps — added tests for empty input, negative z-index (valid CSS back layers), and stable tie-break at equal z-index.
  • ElementStackingInfo invariants JSDoc — expanded the interface with per-field invariants and a preamble describing assumptions the producer makes.
  • Merge rule correctness — groupIntoLayers docstring now explains why merging adjacent DOM elements doesn't lose information (DOM layers render via full-page screenshot with non-layer elements hidden, so within-layer z-order is Chrome's).

Not addressed:

  • Per-DOM-layer captureAlphaPng cycle as perf regression — architectural (each DOM layer does its own hide/capture/show cycle). Worth a dedicated perf PR with benchmarks before/after; keeping it scoped.

@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from b76fe30 to aa33ab9 Compare April 19, 2026 21:32
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-1 branch 2 times, most recently from 216bb02 to ba531fe Compare April 19, 2026 22:11
@vanceingalls vanceingalls changed the base branch from feat/hdr-phase-1 to graphite-base/289 April 19, 2026 23:26
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>
@graphite-app graphite-app Bot changed the base branch from graphite-base/289 to main April 19, 2026 23:27
- 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>
@vanceingalls vanceingalls merged commit 0cc79a3 into main Apr 19, 2026
21 checks passed
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants