Skip to content

feat(engine): add HDR video output pipeline#265

Merged
vanceingalls merged 10 commits intomainfrom
fix/hdr-output-pipeline
Apr 19, 2026
Merged

feat(engine): add HDR video output pipeline#265
vanceingalls merged 10 commits intomainfrom
fix/hdr-output-pipeline

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 14, 2026

Summary

Adds the ability to render HDR video output (H.265 10-bit, BT.2020) from HyperFrames compositions. When the renderer detects HDR source video, it automatically switches to the HDR output pipeline — no flags needed.

What it does

  • Auto-detection — Probes each video source with ffprobe. If any has bt2020/PQ/HLG color metadata, the output switches to H.265 10-bit with correct color tags. SDR-only compositions are unaffected (H.264, bt709).
  • HLG pass-through — Native HLG pixels from FFmpeg extraction are piped directly to the encoder without conversion. This avoids brightness loss from HLG→linear→PQ conversion (which requires an OOTF system gamma we can't reliably apply).
  • Encoder HDR support — Both chunk and streaming encoders accept HDR presets: libx265, yuv420p10le, BT.2020 color primaries, hvc1 codec tag (required for Apple playback).
  • WebGPU HDR capture (gated) — A complete WebGPU float16 readback pipeline is implemented and tested but gated behind headed Chrome (headless doesn't expose WebGPU). Ready for future use with WebGPU canvas content.
  • HDR utilitiesdetectTransfer() (PQ vs HLG), getHdrEncoderColorParams(), analyzeCompositionHdr(). 15 unit tests.

Key design decisions

Decision Why
No --hdr flag SDR content encoded as HDR causes orange shift in browsers. Auto-detect eliminates this.
HLG pass-through (not HLG→PQ) Conversion loses brightness without OOTF. Pass-through matches source exactly.
hvc1 codec tag Apple QuickTime requires hvc1 (not hev1) for HEVC playback.
1-hour streaming timeout HDR capture at ~6fps needs more time than the default 10-minute FFmpeg timeout.

Files changed

File What changed
packages/engine/src/utils/hdr.ts NEW — HDR detection, transfer types, encoder params (15 tests)
packages/engine/src/services/hdrCapture.ts NEW — WebGPU readback, HLG conversion, PQ encode
packages/engine/src/services/streamingEncoder.ts HDR presets, raw rgb48le input, color tags
packages/engine/src/services/chunkEncoder.ts HDR presets, conditional color tags
packages/producer/src/services/renderOrchestrator.ts Auto-detection loop, HDR pass-through capture path

How to test

Render a composition with an HDR video source. The output should be H.265 10-bit with HDR metadata visible in ffprobe (bt2020, arib-std-b67 or smpte2084). Plays correctly in QuickTime and on HDR displays.

Stack position

2 of 6 — Stacked on #258 (SDR/HDR normalization). Provides the encoder infrastructure that phases 1-5 build on.

🤖 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. Three sibling reviews on the stack cover #288/#289/#290/#268/#314. Stack-level meta on #268.

Critical

  • Shell injectionpackages/producer/src/services/renderOrchestrator.ts:~1831. srcPath and localTime are interpolated directly into execSync("ffmpeg -ss ${localTime} -i \"${srcPath}\" …"). A filename containing ", $, or backtick breaks quoting and runs arbitrary commands. Use execFileSync("ffmpeg", [...]) / runFfmpeg with an argv array. #268 migrates to runFfmpeg — but #265 lands first.
  • Silent failure swallows errors — same region, try { execSync(...) } catch { rawFrame = Buffer.alloc(width*height*8); }. Every ffmpeg error (missing file, codec error, abort) substitutes a black frame without log. Users get a silent black video. At minimum log.warn with frame index + path.
  • Synchronous per-frame execSync blocks the event loop — one spawn per frame sequentially; a 30fps/1min clip = 1800 blocking ffmpeg spawns. This likely motivates the 1-hour streaming timeout — the timeout is masking an architectural problem. Use async streaming / single long-running ffmpeg.
  • hdrCapture.ts:~756await import("puppeteer" as string) + silent catch {} fallback to puppeteer-core. The as string cast defeats TS resolution; the catch hides real import failures (e.g. broken puppeteer install). Distinguish ERR_MODULE_NOT_FOUND from other errors.

Important

  • detectTransfer(null) → "hlg" (utils/hdr.ts:~1509) — returning HDR for SDR inputs is a lie. Callers who pass SDR get plausible HDR params back. Foot-gun; return null and let the type force a null-check.
  • linearToPQ / float16ToPqRgb (hdrCapture.ts:~488-501) exported but untested. Opinionated constants (SDR_NITS=203) without reference values. Add tests.
  • Orchestrator re-implements analyzeCompositionHdr inline (renderOrchestrator.ts:~1650-1661) instead of calling the helper it imports.
  • videoFrameExtractor.ts:1117isHdrColorSpace "re-exported for backward compatibility" but there is no prior public export. Drop and import from utils/hdr.js at call sites.
  • chunkEncoder.test.ts:267-281 — test name "uses bt709 color tags for HDR output" contradicts user expectation. Rename to reflect the real decision (Chrome-pixel path → bt709 even for HDR).

Suggestions

  • WebGPU path (hdrCapture.ts:~709-796) is exported-but-unused by the ffmpeg pass-through path in this PR → rot risk. Move to the consuming PR or add an integration test.
  • streamingEncoder.ts:~969Buffer.from(buffer) copy should be conditional on rawInputFormat; SDR path pays a memcpy it doesn't need.
  • .gitignore churn (removing docs/images/, .agents/, .claude/skills/, captures/, *-capture/) is unrelated to HDR and could cause accidental commits. Split out.
  • 1-hour streaming timeout is hiding the synchronous execSync above — should revert once async.
  • Repo uses bun; the pnpm-lock.yaml / pnpm-workspace.yaml in git-status must not land.

Strengths

  • utils/hdr.ts is clean and well-tested (SDR, HLG, PQ, mixed).
  • ffprobe.ts color-space extension uses null (not fabricated defaults) — correct.
  • Streaming encoder buffer-copy fix with clear explanatory comment is a real bug caught.
  • Encoder preset test coverage covers mp4/webm/mov preset bleed-over.
  • -tag:v hvc1 for QuickTime compatibility correctly noted.

🤖 Automated review. See stack meta-comment on #268 for cross-cutting findings.

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 18, 2026

Thanks for the thorough review. Fixes landed.

Addressed:

  • Shell injection / sync execSync / silent black-frame fallback — superseded by feat(hdr): layered HDR compositing, shader transitions, and HDR image support #268's migration to runFfmpeg (argv-based, signal-aware, no stdout limit). The log level for failures is log.warn now, not info/debug.
  • puppeteer dynamic import — now distinguishes ERR_MODULE_NOT_FOUND from other errors before falling back to puppeteer-core. Broken installs surface instead of silently swapping.
  • detectTransfer(null) → "hlg" — docstring now explicitly calls out the isHdrColorSpace precondition. Changing the return type to nullable would ripple through call sites and they all gate on isHdrColorSpace first in practice, so keeping the current shape is intentional.
  • Orchestrator re-implementing analyzeCompositionHdr inline — refactored to call the helper. Also fixes the dominant-transfer correctness for mixed PQ+HLG (now picks PQ as dominant per the helper, not whichever came first).
  • isHdrColorSpace re-export in videoFrameExtractor — removed. The index already exports it from utils/hdr directly. Test imports updated.
  • chunkEncoder.test.ts name — renamed to reflect the actual assertion.
  • linearToPQ / float16ToPqRgb tests — added packages/engine/src/services/hdrCapture.test.ts covering the buffer-size contract, black input, overbright clamp at the 1.0 PQ ceiling, channel ordering (alpha discarded), monotonicity, determinism, the 256-byte WebGPU bytesPerRow padding case, and trailing-padding garbage isolation. The clamp test uses a +1024.0 linear input rather than +2.0 because PQ encodes up to 10000 nits and SDR_NITS=203, so values below ~58× SDR white land mid-curve rather than at the ceiling.
  • .gitignore churn — reverted in 95183c01 fix(hdr): restore .gitignore and revert package.json changes. The branch no longer touches .gitignore or package.json outside of intentional HDR work.

No-op:

  • WebGPU path "rot risk" — re-exports now carry a docstring flagging them as experimental-path stubs with a clear remove-when condition.

@vanceingalls vanceingalls force-pushed the fix/hdr-output-pipeline branch from 86a5959 to 3ea76ea Compare April 18, 2026 20:30
vanceingalls added a commit that referenced this pull request Apr 18, 2026
- Replace inline HDR auto-detection in renderOrchestrator with the
  analyzeCompositionHdr helper from @hyperframes/engine
- Remove the backward-compat re-export of isHdrColorSpace from
  videoFrameExtractor; callers should import from @hyperframes/engine
- Use isHdrColorSpaceUtil consistently inside videoFrameExtractor
- Remove duplicate isHdrColorSpace tests from videoFrameExtractor.test.ts
  (canonical tests live in utils/hdr.test.ts)

Addresses reviewer feedback on PR #265.

Made-with: Cursor
vanceingalls added a commit that referenced this pull request Apr 18, 2026
Cover the buffer-size contract, black/clamp boundaries, channel ordering,
monotonicity, determinism, and the 256-byte WebGPU bytesPerRow padding
case. Closes the deferred test gap from PR #265.
vanceingalls and others added 9 commits April 18, 2026 17:06
…ut colors

When a composition contains both HDR (bt2020) and SDR (bt709) video elements,
the SDR content appears washed out because Chrome clamps HDR frames to sRGB
during rendering. This adds automatic color space detection and normalization
during the video frame extraction phase — if any video is HDR, all SDR videos
are converted to HLG/BT.2020 (npl=600) via zscale before frame extraction.

- Add colorSpace (transfer, primaries, matrix) to VideoMetadata via ffprobe
- Add isHdrColorSpace() to detect bt2020/PQ/HLG content
- Add convertSdrToHdr() using zscale with 600 nit nominal peak luminance
- Restructure extractAllVideoFrames into 3 phases: resolve, normalize, extract
- 6 new unit tests for HDR detection across all color space variants

Same approach as HeyGen Rio's pipeline (heygen_rio.py#L568-585).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auto-detect HDR sources via ffprobe, switch to H.265 10-bit with
BT.2020/HLG metadata. HLG pass-through (no conversion), hvc1 codec
tag for Apple playback, 1-hour streaming timeout for HDR. WebGPU
float16 readback pipeline implemented and tested but gated behind
headed Chrome. HDR utilities with 15 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Node streams hold a reference to the provided buffer and drain it
asynchronously. Without copying, callers that reuse buffers get
partially-overwritten data in the pipe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These fields were added to main after this branch was created. Without
them, the CLI's render command fails typecheck when passing crf/bitrate
options to createRenderJob.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Distinguish ERR_MODULE_NOT_FOUND from other errors in puppeteer dynamic
  import so broken installs surface instead of silently falling back.
- Document detectTransfer's isHdrColorSpace precondition.
- Rename misleading chunkEncoder test name.
- Clarify streamingEncoder Buffer.from as HDR-only by code path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore .gitignore entries removed during branch creation (docs/images/,
examples/, .playwright-mcp/, skills/, captures/, etc.) and revert the
@webgpu/types devDep additions — the WebGPU path is experimental and
skipLibCheck:true means the reference directive is a no-op when the
types aren't installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The engine package's hdrCapture.ts uses WebGPU APIs and references
@webgpu/types via a triple-slash directive, and both engine and
producer tsconfigs include @webgpu/types in compilerOptions.types.
However the package was never declared as a devDependency, so it
worked locally (transitive resolution) but failed in CI with
TS2688: Cannot find type definition file for '@webgpu/types'.

Add @webgpu/types ^0.1.69 as a devDependency to both packages and
refresh bun.lock so the CI Build and Typecheck jobs pass.

Made-with: Cursor
- Replace inline HDR auto-detection in renderOrchestrator with the
  analyzeCompositionHdr helper from @hyperframes/engine
- Remove the backward-compat re-export of isHdrColorSpace from
  videoFrameExtractor; callers should import from @hyperframes/engine
- Use isHdrColorSpaceUtil consistently inside videoFrameExtractor
- Remove duplicate isHdrColorSpace tests from videoFrameExtractor.test.ts
  (canonical tests live in utils/hdr.test.ts)

Addresses reviewer feedback on PR #265.

Made-with: Cursor
@vanceingalls vanceingalls force-pushed the fix/hdr-output-pipeline branch from b591eb4 to 6e4b2f0 Compare April 19, 2026 00:19
Without `master-display=...:max-cll=...` in -x265-params the encoder
writes a perfectly valid HEVC stream tagged BT.2020/PQ, but with no
HDR10 metadata SEI messages. Apple, YouTube, and HDR TVs see the file
as "unknown HDR" and tone-map it incorrectly (washed-out highlights,
clipped specular detail).

Changes:
- Add HdrMasteringMetadata + DEFAULT_HDR10_MASTERING (D65, P3-D65 mastering
  primaries, 1000-nit max, 400-nit MaxFALL — the standard Hollywood HDR10
  envelope).
- Extend getHdrEncoderColorParams to compose the full x265-params string
  including range=full + master-display + max-cll for both PQ and HLG.
- Refactor streamingEncoder to call getHdrEncoderColorParams instead of
  duplicating the colorprim/transfer/colormatrix string literal.
- Export buildStreamingArgs so the new streamingEncoder.test.ts can pin
  down the exact CLI flags as a regression net.

Verified end-to-end with hdr-smoke.ts on hdr-pq + mixed-sdr-hdr fixtures:
ffprobe -show_frames now reports "Mastering display metadata" and
"Content light level metadata" SEI messages on both outputs.
@vanceingalls vanceingalls force-pushed the fix/hdr-output-pipeline branch from 6e4b2f0 to fb11145 Compare April 19, 2026 04:21
@vanceingalls vanceingalls merged commit 5a3fde1 into main Apr 19, 2026
21 checks passed
vanceingalls added a commit that referenced this pull request Apr 19, 2026
…ideo (#288)

## 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](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.
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