feat(engine): add HDR video output pipeline#265
Merged
vanceingalls merged 10 commits intomainfrom Apr 19, 2026
Merged
Conversation
85de722 to
0b70603
Compare
bc461a1 to
4923ab0
Compare
Collaborator
Author
This was referenced Apr 16, 2026
1a45995 to
ffc3b12
Compare
4b58dc9 to
837402d
Compare
4923ab0 to
686e45d
Compare
98f9d04 to
86a5959
Compare
Collaborator
miguel-heygen
left a comment
There was a problem hiding this comment.
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 injection —
packages/producer/src/services/renderOrchestrator.ts:~1831.srcPathandlocalTimeare interpolated directly intoexecSync("ffmpeg -ss ${localTime} -i \"${srcPath}\" …"). A filename containing",$, or backtick breaks quoting and runs arbitrary commands. UseexecFileSync("ffmpeg", [...])/runFfmpegwith an argv array. #268 migrates torunFfmpeg— 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 minimumlog.warnwith frame index + path. - Synchronous per-frame
execSyncblocks 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:~756—await import("puppeteer" as string)+ silentcatch {}fallback topuppeteer-core. Theas stringcast defeats TS resolution; the catch hides real import failures (e.g. brokenpuppeteerinstall). DistinguishERR_MODULE_NOT_FOUNDfrom 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; returnnulland 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
analyzeCompositionHdrinline (renderOrchestrator.ts:~1650-1661) instead of calling the helper it imports. videoFrameExtractor.ts:1117—isHdrColorSpace"re-exported for backward compatibility" but there is no prior public export. Drop and import fromutils/hdr.jsat 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:~969—Buffer.from(buffer)copy should be conditional onrawInputFormat; SDR path pays a memcpy it doesn't need..gitignorechurn (removingdocs/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.yamlin git-status must not land.
Strengths
utils/hdr.tsis clean and well-tested (SDR, HLG, PQ, mixed).ffprobe.tscolor-space extension usesnull(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 hvc1for QuickTime compatibility correctly noted.
🤖 Automated review. See stack meta-comment on #268 for cross-cutting findings.
Collaborator
Author
|
Thanks for the thorough review. Fixes landed. Addressed:
No-op:
|
86a5959 to
3ea76ea
Compare
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.
…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
b591eb4 to
6e4b2f0
Compare
miguel-heygen
approved these changes
Apr 19, 2026
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.
6e4b2f0 to
fb11145
Compare
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.
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
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
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).libx265,yuv420p10le, BT.2020 color primaries,hvc1codec tag (required for Apple playback).detectTransfer()(PQ vs HLG),getHdrEncoderColorParams(),analyzeCompositionHdr(). 15 unit tests.Key design decisions
--hdrflaghvc1codec taghvc1(nothev1) for HEVC playback.Files changed
packages/engine/src/utils/hdr.tspackages/engine/src/services/hdrCapture.tspackages/engine/src/services/streamingEncoder.tspackages/engine/src/services/chunkEncoder.tspackages/producer/src/services/renderOrchestrator.tsHow 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