Skip to content

feat(engine): add HDR two-pass compositing — DOM layer + native HLG video#288

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

feat(engine): add HDR two-pass compositing — DOM layer + native HLG video#288
vanceingalls merged 7 commits intomainfrom
feat/hdr-phase-1

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 16, 2026

Summary

Compositions with HDR video AND DOM overlays (text, graphics, SDR video) couldn't render both correctly — either HDR data was lost (Chrome captures sRGB only) or DOM overlays were missing (FFmpeg pass-through skips Chrome). This PR adds in-memory alpha compositing that combines both.

What it does

Per-frame two-pass capture:

  1. DOM pass — Chrome screenshots the page with a transparent background (CDP alpha). HDR videos are hidden, leaving transparent holes where they go.
  2. HDR pass — Pre-extracted native HLG/PQ frames (16-bit PNG from FFmpeg) are read from disk.
  3. Composite — DOM pixels (sRGB RGBA8) are alpha-composited over HDR pixels (rgb48le) in Node.js memory, with sRGB→HLG/PQ conversion via a 256-entry lookup table.

Key components:

  • decodePng() / decodePngToRgb48le() — Pure Node.js PNG decoders (no native dependencies). Support all 5 PNG filter types.
  • blitRgba8OverRgb48le() — Alpha composite with per-pixel sRGB→HDR LUT conversion. Fast paths for alpha=0 (skip) and alpha=255 (overwrite).
  • initTransparentBackground() + captureAlphaPng() — Split CDP transparent background setup (once) from per-frame screenshot capture (eliminates 2 CDP round-trips per frame).
  • Single-pass FFmpeg extraction — All HDR frames extracted in one sequential FFmpeg run (avoids duplicate frames from per-frame -ss fast seek).

Key design decisions

Decision Why
In-memory compositing (not FFmpeg overlay) Eliminates ~2400 process spawns + temp files per render. Pure pixel math is 10x faster.
16-bit PNG intermediate Raw -f rawvideo loses color metadata, causing moiré artifacts. PNG is self-describing.
sRGB→HLG LUT (256 entries) DOM content is sRGB. Without conversion, it appears orange-shifted in HLG stream.
Native HDR detection before extraction extractAllVideoFrames converts SDR→HDR. Pre-extraction probe identifies original HDR sources so only truly-HDR videos get native extraction.

Files changed

File What changed
packages/engine/src/utils/alphaBlit.ts NEW — PNG decode, sRGB→HDR LUT, alpha compositing (14 tests)
packages/engine/src/services/screenshotService.ts Transparent background CDP, captureAlphaPng()
packages/engine/src/services/videoFrameInjector.ts hideVideoElements() / showVideoElements()
packages/engine/src/services/streamingEncoder.ts Input color space tags for rgb48le
packages/producer/src/services/renderOrchestrator.ts Two-pass HDR capture loop, native HDR detection

How to test

Render a composition with an HDR video background and text overlays. Both should be visible — HDR video at full quality, text crisp with correct colors (not orange-shifted).

Stack position

3 of 6 — Stacked on #265 (HDR output pipeline). This is the foundation for all layered compositing that follows.

🤖 Generated with Claude Code

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.

Critical

  • PNG decoder silently ignores interlace bitpackages/engine/src/utils/alphaBlit.ts:~74-82. IHDR byte 12 is never read or validated. An Adam7-interlaced PNG is silently mis-decoded (filter bytes at wrong positions → garbage) rather than thrown. Even if Chrome never emits interlaced, decodePngToRgb48le accepts ffmpeg PNGs too. Assert interlace === 0 or throw.
  • DOM-layer decode error swallowedrenderOrchestrator.ts:~1883. try { decodePng(...); blitRgba8OverRgb48le(...) } catch {}. Violates CLAUDE.md ("no try/catch fallbacks"). Every frame ships with no DOM layer → silent black-overlay video.
  • HDR frame-extraction failure swallowedrenderOrchestrator.ts:~1793. try { execSync(ffmpeg ...) } catch {} with comment "loop handles gracefully" — the loop substitutes Buffer.alloc(width*height*6) (pure black HDR) per frame with no warning.
  • HDR frame decode swallowedrenderOrchestrator.ts:~1865. Third instance of the same pattern.
  • Frame-index off-by-source-fpsrenderOrchestrator.ts:1850. videoFrameIndex = Math.round((time - video.start) * fps) + 1. ffmpeg -r ${fps} drops/duplicates to hit rate; source fps ≠ job fps can yield rounded indices beyond extracted count → file not found → silent black. No bounds check against dir size. (Partially mitigated in #290's freeze-on-last-frame fallback.)

Important

  • captureScreenshotWithAlpha restore not in finally (screenshotService.ts:87-107) — if Page.captureScreenshot throws, setDefaultBackgroundColorOverride({}) is never called; subsequent opaque captures keep a transparent background. Wrap restore in finally, or drop the helper (orchestrator uses initTransparentBackground + captureAlphaPng instead).
  • Multiple active HDR videos collapse to onerenderOrchestrator.ts:1843 picks activeBounds[0]. Overlapping HDR sources → second dropped silently. Log the limitation.
  • sRGB→HLG LUT convention undocumentedalphaBlit.ts:1276-1282 assumes "linear signal in [0,1] relative to SDR white" (no OOTF). Correct for overlay-over-HLG-video, but it's a non-obvious convention; the endpoint-only test does not capture it.
  • Fast-path desync risk — α=0 skip / α=255 overwrite (alphaBlit.ts:1310-1338) have no test comparing them against the slow path at boundaries. Add a parametric test at α=254.
  • Test coverage gapsmakePng test helper exercises only the None PNG filter (byte 0). Sub / Up / Average / Paeth: zero behavioral coverage. Chrome's libpng picks filters heuristically — this is exactly where regressions hide. Also: no multi-chunk IDAT test (Chrome splits at ~8KB); decodePngToRgb48le has no tests even though it's the HDR-frame hot path.
  • parseTransformMatrix returns NaN silently — non-numeric captures via match.slice(1,7).map(Number)NaN. det = NaN, checks pass, writeUInt16LE(NaN) throws deep in the blit. Validate with Number.isFinite → return null.

Suggestions

  • Comment "GSAP transforms are NOT applied (future work)" was swapped for "position/opacity applied via queried bounds", but transform/opacity aren't applied here — blitRgb48leAffine isn't called from the orchestrator in this diff. Tighten to "position queried; transform/opacity lands in later PR".
  • "optimizeForSpeed: false // must be false to preserve alpha" — cite CDP docs or drop the justification.
  • decodePngRaw silently accepts files missing IHDR → empty data. Throw on missing IHDR.
  • blitRgb48leRegion computes canvasHeight = canvas.length / (canvasWidth*6) — assumes tight packing. Fragile if a sliced Buffer is ever passed. Explicit canvasHeight param would future-proof.
  • page.evaluate round-trips per frame (bounds, hide, show) on top of captureAlphaPng. At 60fps×30s = 1800 frames — consolidate.

Strengths

  • decodePngRaw shares chunk-parse logic between 8-bit and 16-bit paths — real abstraction (matches CLAUDE.md).
  • 681-line test file is genuinely behavioral — pixel-value assertions, not type probes.
  • initTransparentBackground / captureAlphaPng split correctly eliminates 2× CDP round-trips per frame.
  • Replacing per-frame ffmpeg spawn with in-memory decode is a large perf win that justifies writing a PNG decoder.
  • parseTransformMatrix correctly rejects matrix3d.

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

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 18, 2026

Thanks for the review. Fixes landed.

Addressed:

  • PNG decoder interlace bitdecodePngRaw now asserts interlace === 0 (rejects Adam7-interlaced PNGs explicitly). Added test.
  • Missing IHDR chunkdecodePngRaw throws with a clear message instead of inflating empty data. Added test.
  • DOM-layer decode swallow / HDR extract swallow / HDR decode swallow — all three are now log.warn with frame time or element id, and the HDR-image extraction failure path now drops the image id from nativeHdrVideoIds instead of silently pointing at an empty frame dir.
  • captureScreenshotWithAlpha restore not in finally — wrapped Page.captureScreenshot in try/finally so the opaque background is restored even on throw.
  • Multiple active HDR videos collapse to one (activeBounds[0]) — superseded by the feat(hdr): layered HDR compositing, shader transitions, and HDR image support #268 refactor to blitHdrVideoLayer which composites each HDR layer independently via z-ordered layers.
  • parseTransformMatrix NaN silently — validates with Number.isFinite on all 6 values; returns null on any non-finite input so the identity fallback path takes over.
  • PNG filter test gap — added parametric round-trip tests for None/Sub/Up/Average/Paeth against a 3×2 unique-pixel fixture, plus explicit-rejection tests for Adam7 and missing IHDR.
  • Multi-chunk IDAT — added test that splits a compressed stream across two IDATs and verifies the decoder concatenates correctly.
  • decodePngToRgb48le tests — added: byte-order swap (BE wire → LE buffer), 16-bit precision preservation, and row-major multi-pixel layout.
  • sRGB→HLG LUT convention — LUT doc now spells out the scene-light-in-[0,1]-relative-to-SDR-white convention and why there's no OOTF (overlay composites on top of HLG, shares signal space).
  • Fast-path α=254 boundary — added test verifying α=254 and α=255 produce nearly identical output (guards the α=255 fast-path branch).
  • Frame-index off-by-source-fps — added an explicit bounds check against the actual frame-dir size. A module-level frameDirMaxIndexCache reads the max index once via readdirSync, then orchestrator clamps frameIndex > maxIndex to the last available frame (preserving the freeze-on-last-frame behavior without spuriously requesting unfilled indices).
  • blitRgb48leRegion explicit canvasHeight param — function signature now takes canvasHeight explicitly instead of inferring it from canvas.length / (canvasWidth * 6). All call sites updated.

Deferred:

  • page.evaluate consolidation — architectural; worth a dedicated perf pass.

@vanceingalls vanceingalls force-pushed the fix/hdr-output-pipeline branch from 86a5959 to 3ea76ea Compare April 18, 2026 20:30
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-1 branch 4 times, most recently from e16a65e to 29c016b Compare April 18, 2026 22:04
vanceingalls added a commit that referenced this pull request Apr 18, 2026
- blitRgb48leRegion: take canvasHeight as an explicit parameter so callers
  with non-square canvases or other byte-stride conventions cannot silently
  produce a wrong height. Updates all alphaBlit.test.ts call sites.
- renderOrchestrator: cache max frame index per pre-extracted HDR frame
  directory and bounds-check videoFrameIndex before existsSync. Avoids
  redundant filesystem syscalls when the requested time falls past the
  last extracted frame for a given clip.

Made-with: Cursor
@vanceingalls vanceingalls force-pushed the fix/hdr-output-pipeline branch from b591eb4 to 6e4b2f0 Compare April 19, 2026 00:19
@vanceingalls vanceingalls force-pushed the fix/hdr-output-pipeline branch from 6e4b2f0 to fb11145 Compare April 19, 2026 04:21
@vanceingalls vanceingalls changed the base branch from fix/hdr-output-pipeline to graphite-base/288 April 19, 2026 22:10
vanceingalls added a commit that referenced this pull request Apr 19, 2026
- blitRgb48leRegion: take canvasHeight as an explicit parameter so callers
  with non-square canvases or other byte-stride conventions cannot silently
  produce a wrong height. Updates all alphaBlit.test.ts call sites.
- renderOrchestrator: cache max frame index per pre-extracted HDR frame
  directory and bounds-check videoFrameIndex before existsSync. Avoids
  redundant filesystem syscalls when the requested time falls past the
  last extracted frame for a given clip.

Made-with: Cursor
@graphite-app graphite-app Bot changed the base branch from graphite-base/288 to main April 19, 2026 22:11
Copy link
Copy Markdown
Collaborator Author

Merge activity

  • Apr 19, 10:47 PM UTC: Graphite couldn't merge this PR because it had merge conflicts.

vanceingalls and others added 7 commits April 19, 2026 15:49
Per-frame in-memory alpha compositing: Chrome screenshots DOM with
transparent background (PNG alpha), FFmpeg extracts native HLG/PQ
frames as 16-bit PNG. DOM pixels (sRGB RGBA8) composited over HDR
(rgb48le) via 256-entry sRGB-to-HLG/PQ LUT. Pure Node.js PNG decoder,
single-pass FFmpeg extraction, native HDR detection before SDR
conversion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use Object.assign to merge runtime bridge (seek/duration) with
existing window.__hf properties instead of overwriting them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Assert PNG interlace === 0 (reject Adam7) and IHDR presence.
- Validate parseTransformMatrix against NaN/Infinity.
- Wrap captureScreenshotWithAlpha restore in finally.
- Document sRGB→HDR LUT convention (scene-light, no OOTF, 203-nit PQ).
- Note optimizeForSpeed empirical behavior for alpha preservation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- decodePng: filter coverage (None/Sub/Up/Average/Paeth), multi-IDAT
  payload, Adam7/missing-IHDR rejection
- decodePngToRgb48le: BE→LE swap, full 16-bit precision, row-major
  layout, 8-bit rejection
- blitRgba8OverRgb48le: α=254 must blend (no fast-path overwrite at
  the opaque boundary)
- producer: replace silent catches in renderOrchestrator with log.warn,
  drop unused convertHdrFrameToRgb48le import, tighten GSAP comment

Made-with: Cursor
- blitRgb48leRegion: take canvasHeight as an explicit parameter so callers
  with non-square canvases or other byte-stride conventions cannot silently
  produce a wrong height. Updates all alphaBlit.test.ts call sites.
- renderOrchestrator: cache max frame index per pre-extracted HDR frame
  directory and bounds-check videoFrameIndex before existsSync. Avoids
  redundant filesystem syscalls when the requested time falls past the
  last extracted frame for a given clip.

Made-with: Cursor
The render bridge previously overwrote `window.__hf` wholesale at end-of-body,
clobbering anything written during user `<script>` execution. Libraries such as
`@hyperframes/shader-transitions` populate `window.__hf.transitions` inside
their `init()` call, so the engine never saw a transition map and HDR
compositing fell back to plain DOM layers.

Two changes restore the contract:

1. Inject HF_EARLY_STUB at the very start of `<head>` so `window.__hf` exists
   before any other script runs. User libraries can now safely write to it
   during initialisation.
2. HF_BRIDGE_SCRIPT now patches the existing `__hf` object (defining `duration`
   as a getter and assigning `seek`) instead of replacing it, preserving any
   fields the page already populated.

Adds a regression test that simulates the real injection order — early stub,
authored page script writing `__hf.transitions`, bridge script — and asserts
both `transitions` and the bridge-installed `seek`/`duration` are present
afterwards.

Made-with: Cursor
@vanceingalls vanceingalls merged commit a21a62b into main Apr 19, 2026
21 checks passed
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.
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