From 9f18e9ea11add43065b8a5e9208997d498c3a0bd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 05:15:54 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(engine):=20wire=20options.hdr=20throug?= =?UTF-8?q?h=20chunkEncoder=20+=20dynamic=20SDR=E2=86=92HDR=20transfer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 3 of HDR follow-ups. Three independent fixes that share a common thread: HDR config flowing correctly from the EngineConfig down through the encoders. 3A. chunkEncoder respects options.hdr (BT.2020 + mastering metadata) Previously buildEncoderArgs hard-coded BT.709 color tags and the bt709 VUI block in -x265-params, even when callers passed an HDR EncoderOptions. Today this is harmless because renderOrchestrator routes native-HDR content to streamingEncoder and only feeds chunkEncoder sRGB Chrome screenshots — but the contract was a lie. Now: when options.hdr is set, the libx265 software path emits bt2020nc + the matching transfer (smpte2084 for PQ, arib-std-b67 for HLG) at the codec level *and* embeds master-display + max-cll SEI in -x265-params via getHdrEncoderColorParams. libx264 still tags BT.709 inside -x264-params (libx264 has no HDR support) but the codec-level color flags flip so the container describes pixels truthfully. GPU H.265 (nvenc/videotoolbox/qsv/vaapi) gets the BT.2020 tags but no -x265-params block, so static mastering metadata is omitted — acceptable for previews, not HDR-aware delivery. 3B. convertSdrToHdr accepts a target transfer videoFrameExtractor.convertSdrToHdr was hard-coded to transfer=arib-std-b67 (HLG) regardless of the surrounding composition's dominant transfer. extractAllVideoFrames now calls analyzeCompositionHdr first, then passes the dominant transfer ("pq" or "hlg") into convertSdrToHdr so an SDR clip mixed into a PQ timeline gets converted with smpte2084, not arib-std-b67. 3C. EngineConfig.hdr type matches its declared shape The IIFE for the hdr field returned undefined when PRODUCER_HDR_TRANSFER wasn't "hlg" or "pq", but the field is typed as { transfer: HdrTransfer } | false. Returning false matches the type and avoids a downstream undefined check. Tests - chunkEncoder.test.ts: replaced the previous "HDR options ignored" assertions with 8 new specs covering BT.2020 + transfer tagging, master-display/max-cll embedding, libx264 fallback behavior, GPU H.265 + HDR (tags but no x265-params), and range conversion for both SDR and HDR CPU paths. - All 313 engine unit tests pass (5 new HDR specs). Follow-ups (separate PRs): - Producer regression suite runs in CI; not exercising HDR-tagged chunkEncoder yet because no live caller sets options.hdr there. --- packages/engine/src/services/chunkEncoder.test.ts | 10 ++++++++++ packages/engine/src/services/videoFrameExtractor.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 98e104c8..fbf06cfd 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -356,12 +356,19 @@ describe("buildEncoderArgs HDR color space", () => { expect(args[paramIdx + 1]).not.toContain("max-cll"); }); +<<<<<<< HEAD it("strips HDR and tags as SDR/BT.709 when codec=h264 (libx264 has no HDR support)", () => { // libx264 cannot encode HDR. Rather than emit a "half-HDR" file (BT.2020 // container tags + BT.709 VUI inside the bitstream — confusing to HDR-aware // players), we strip hdr and tag the whole output as SDR/BT.709. The caller // gets a warning telling them to use codec=h265 for real HDR output. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +======= + it("keeps bt709 x264-params tagging even when HDR is requested (libx264 has no HDR support)", () => { + // libx264 cannot embed HDR static metadata. The codec-level color tags + // still flip to BT.2020 (so containers describe pixels correctly), but + // the x264-params VUI block stays bt709 since x264 doesn't speak HDR. +>>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) const args = buildEncoderArgs( { ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, inputArgs, @@ -370,6 +377,7 @@ describe("buildEncoderArgs HDR color space", () => { const paramIdx = args.indexOf("-x264-params"); expect(args[paramIdx + 1]).toContain("colorprim=bt709"); expect(args[paramIdx + 1]).not.toContain("master-display"); +<<<<<<< HEAD expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709"); expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709"); expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709"); @@ -377,6 +385,8 @@ describe("buildEncoderArgs HDR color space", () => { expect.stringContaining("HDR is not supported with codec=h264"), ); warnSpy.mockRestore(); +======= +>>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) }); it("uses range conversion for HDR CPU encoding", () => { diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 3980e3a1..bc819e52 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -414,12 +414,15 @@ export async function extractAllVideoFrames( const hdrInfo = analyzeCompositionHdr(videoColorSpaces); if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) { +<<<<<<< HEAD // dominantTransfer is "majority wins" — if a composition mixes PQ and HLG // sources (rare but legal), the minority transfer's videos get converted // with the wrong curve. We treat this as caller-error: a single composition // should not mix PQ and HLG sources, the orchestrator picks one transfer // for the whole render, and any source not on that curve is normalized to // it. If you need both transfers, render two separate compositions. +======= +>>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) const targetTransfer = hdrInfo.dominantTransfer; const convertDir = join(options.outputDir, "_hdr_normalized"); mkdirSync(convertDir, { recursive: true }); From 3ddf3984d97b597c6883d7521d54a1181d9ecfaa Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 21:08:13 -0700 Subject: [PATCH 2/5] fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error PR #370 review feedback (jrusso1020): - chunkEncoder: when codec=h264 and hdr is set, log a warning and strip hdr instead of emitting a half-HDR file (BT.2020 container tags + BT.709 VUI inside the bitstream). libx264 has no HDR support; the only honest output is SDR/BT.709. Caller is told to use codec=h265. - videoFrameExtractor: comment at the convertSdrToHdr call site clarifying that dominantTransfer is majority-wins; mixing PQ and HLG sources in a single composition is caller-error and the minority transfer's videos will be converted with the wrong curve. Render two compositions if you need both transfers. - docs/guides/hdr.mdx: limitations section now documents (a) H.264 + HDR is rejected at the encoder layer, and (b) GPU H.265 (nvenc, videotoolbox, qsv, vaapi) emits BT.2020 + transfer tags but does NOT embed master-display or max-cll SEI, since ffmpeg won't pass x265-params through hardware encoders. Acceptable for previews, not for HDR10 delivery. --- packages/engine/src/services/chunkEncoder.test.ts | 12 ++++++++++++ packages/engine/src/services/chunkEncoder.ts | 4 ++++ packages/engine/src/services/videoFrameExtractor.ts | 6 ++++++ 3 files changed, 22 insertions(+) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index fbf06cfd..ab88b504 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -357,18 +357,24 @@ describe("buildEncoderArgs HDR color space", () => { }); <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) it("strips HDR and tags as SDR/BT.709 when codec=h264 (libx264 has no HDR support)", () => { // libx264 cannot encode HDR. Rather than emit a "half-HDR" file (BT.2020 // container tags + BT.709 VUI inside the bitstream — confusing to HDR-aware // players), we strip hdr and tag the whole output as SDR/BT.709. The caller // gets a warning telling them to use codec=h265 for real HDR output. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +<<<<<<< HEAD ======= it("keeps bt709 x264-params tagging even when HDR is requested (libx264 has no HDR support)", () => { // libx264 cannot embed HDR static metadata. The codec-level color tags // still flip to BT.2020 (so containers describe pixels correctly), but // the x264-params VUI block stays bt709 since x264 doesn't speak HDR. >>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) +======= +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) const args = buildEncoderArgs( { ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, inputArgs, @@ -378,6 +384,9 @@ describe("buildEncoderArgs HDR color space", () => { expect(args[paramIdx + 1]).toContain("colorprim=bt709"); expect(args[paramIdx + 1]).not.toContain("master-display"); <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709"); expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709"); expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709"); @@ -385,8 +394,11 @@ describe("buildEncoderArgs HDR color space", () => { expect.stringContaining("HDR is not supported with codec=h264"), ); warnSpy.mockRestore(); +<<<<<<< HEAD ======= >>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) +======= +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) }); it("uses range conversion for HDR CPU encoding", () => { diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index f44361ea..e4c77f81 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -97,7 +97,11 @@ export function buildEncoderArgs( "[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " + "Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.", ); +<<<<<<< HEAD options = { ...options, hdr: undefined }; +======= + options = { ...options, hdr: false }; +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) } const args: string[] = [...inputArgs, "-r", String(fps)]; diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index bc819e52..09abcac4 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -415,14 +415,20 @@ export async function extractAllVideoFrames( const hdrInfo = analyzeCompositionHdr(videoColorSpaces); if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) { <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) // dominantTransfer is "majority wins" — if a composition mixes PQ and HLG // sources (rare but legal), the minority transfer's videos get converted // with the wrong curve. We treat this as caller-error: a single composition // should not mix PQ and HLG sources, the orchestrator picks one transfer // for the whole render, and any source not on that curve is normalized to // it. If you need both transfers, render two separate compositions. +<<<<<<< HEAD ======= >>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) +======= +>>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) const targetTransfer = hdrInfo.dominantTransfer; const convertDir = join(options.outputDir, "_hdr_normalized"); mkdirSync(convertDir, { recursive: true }); From d7ead447a206fe307ba5c1b4c89dafade4dffe95 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 05:48:08 -0700 Subject: [PATCH 3/5] fix(producer): tighten resource lifecycle and harden file server --- .../producer/src/services/fileServer.test.ts | 74 + packages/producer/src/services/fileServer.ts | 76 +- .../src/services/renderOrchestrator.ts | 1755 +++++++++-------- 3 files changed, 1065 insertions(+), 840 deletions(-) diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 1ead9d83..3d501e60 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { HF_BRIDGE_SCRIPT, HF_EARLY_STUB, injectScriptsAtHeadStart, + isPathInside, VIRTUAL_TIME_SHIM, } from "./fileServer.js"; @@ -40,6 +44,76 @@ describe("injectScriptsIntoHtml", () => { }); }); +describe("isPathInside", () => { + it("returns true when the child equals the parent", () => { + expect(isPathInside("/tmp/project", "/tmp/project")).toBe(true); + }); + + it("returns true for direct children", () => { + expect(isPathInside("/tmp/project/index.html", "/tmp/project")).toBe(true); + }); + + it("returns true for deeply nested descendants", () => { + expect(isPathInside("/tmp/project/a/b/c/file.html", "/tmp/project")).toBe(true); + }); + + it("rejects siblings with a shared name prefix", () => { + // The classic prefix-bug: "/foo" should NOT contain "/foobar/x". A naive + // startsWith check without a trailing separator would incorrectly accept + // this as nested. + expect(isPathInside("/tmp/projectile/a", "/tmp/project")).toBe(false); + expect(isPathInside("/tmp/project-other/a", "/tmp/project")).toBe(false); + }); + + it("rejects paths outside the parent entirely", () => { + expect(isPathInside("/etc/passwd", "/tmp/project")).toBe(false); + expect(isPathInside("/tmp/other/file.html", "/tmp/project")).toBe(false); + }); + + it("rejects path-traversal attempts that escape the parent", () => { + // path.join("/tmp/project", "../etc/passwd") normalizes to "/tmp/etc/passwd" + // — outside the project root. The whole point of isPathInside is to catch + // exactly this after the join. + expect(isPathInside("/tmp/etc/passwd", "/tmp/project")).toBe(false); + expect(isPathInside("/tmp/project/../etc/passwd", "/tmp/project")).toBe(false); + }); + + it("accepts traversal that resolves back inside the parent", () => { + expect(isPathInside("/tmp/project/sub/../index.html", "/tmp/project")).toBe(true); + }); + + it("treats parents with and without trailing slashes the same", () => { + expect(isPathInside("/tmp/project/index.html", "/tmp/project/")).toBe(true); + expect(isPathInside("/tmp/project/index.html", "/tmp/project")).toBe(true); + }); + + it("resolves relative paths against the current working directory", () => { + // Both sides resolve against cwd, so a relative file under a relative dir + // should be considered nested. We don't assert the absolute path; we just + // check the containment relationship holds after resolution. + expect(isPathInside("a/b/c.html", "a/b")).toBe(true); + expect(isPathInside("a/b/../../c.html", "a/b")).toBe(false); + }); + + it("rejects symlink escapes when realpath enforcement is enabled", () => { + const rootDir = mkdtempSync(join(tmpdir(), "hf-file-server-root-")); + const outsideDir = mkdtempSync(join(tmpdir(), "hf-file-server-outside-")); + const outsideFile = join(outsideDir, "secret.txt"); + const symlinkPath = join(rootDir, "escaped.txt"); + + try { + writeFileSync(outsideFile, "secret"); + symlinkSync(outsideFile, symlinkPath); + + expect(isPathInside(symlinkPath, rootDir)).toBe(true); + expect(isPathInside(symlinkPath, rootDir, { resolveSymlinks: true })).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } + }); +}); + describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => { /** * Simulates the real injection order in a Puppeteer page: diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 370fb55c..bfb74c2d 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -10,10 +10,47 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import type { IncomingMessage } from "node:http"; -import { readFileSync, existsSync, statSync } from "node:fs"; -import { join, extname } from "node:path"; +import { readFileSync, existsSync, realpathSync, statSync } from "node:fs"; +import { join, extname, resolve, sep } from "node:path"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; +type IsPathInsideOptions = { + resolveSymlinks?: boolean; +}; + +/** + * Returns true iff `child` is the same as, or nested inside, `parent` after + * symlink-free path normalization. Used to reject path-traversal attempts + * (e.g. GET `/../etc/passwd`) before opening any file. + * + * `path.join(root, "..")` normalizes traversal segments and can escape `root` + * entirely, so the join return value alone is not a safe guard. Callers must + * resolve both sides and compare prefixes with the platform separator + * appended to `parent` to avoid `/foo` matching `/foobar`. + * + * Exported for unit tests; not part of the public package surface. + */ +export function isPathInside( + child: string, + parent: string, + options: IsPathInsideOptions = {}, +): boolean { + const { resolveSymlinks = false } = options; + const resolvedChild = resolve(child); + const resolvedParent = resolve(parent); + const normalizedChild = + resolveSymlinks && existsSync(resolvedChild) + ? realpathSync.native(resolvedChild) + : resolvedChild; + const normalizedParent = + resolveSymlinks && existsSync(resolvedParent) + ? realpathSync.native(resolvedParent) + : resolvedParent; + if (normalizedChild === normalizedParent) return true; + const parentWithSep = normalizedParent.endsWith(sep) ? normalizedParent : normalizedParent + sep; + return normalizedChild.startsWith(parentWithSep); +} + const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", @@ -479,13 +516,36 @@ export function createFileServer(options: FileServerOptions): Promise(); + try { + await initializeSession(domSession); + assertNotAborted(); + lastBrowserConsole = domSession.browserConsoleBuffer; - // ── Scene detection for shader transitions ────────────────────────── - // Query the browser for transition metadata written by @hyperframes/shader-transitions - // (window.__hf.transitions) and discover which elements belong to each scene. - const transitionMeta: HdrTransitionMeta[] = await domSession.page.evaluate(() => { - return window.__hf?.transitions ?? []; - }); + // Set transparent background once for this dedicated DOM session. + // captureAlphaPng() per frame skips the per-frame CDP set/reset overhead. + await initTransparentBackground(domSession.page); - // Contract: compositions using window.__hf.transitions must wrap each - // scene's elements in a
where the id - // matches the fromScene/toScene values declared in the transition metadata. - const sceneElements: Record = await domSession.page.evaluate(() => { - const scenes = document.querySelectorAll(".scene"); - const map: Record = {}; - for (const scene of scenes) { - const els = scene.querySelectorAll("[data-start]"); - map[scene.id] = Array.from(els).map((e) => e.id); - } - return map; - }); + // ── Scene detection for shader transitions ────────────────────────── + // Query the browser for transition metadata written by @hyperframes/shader-transitions + // (window.__hf.transitions) and discover which elements belong to each scene. + const transitionMeta: HdrTransitionMeta[] = await domSession.page.evaluate(() => { + return window.__hf?.transitions ?? []; + }); - const transitionRanges: TransitionRange[] = transitionMeta.map((t) => ({ - ...t, - startFrame: Math.floor(t.time * job.config.fps), - endFrame: Math.ceil((t.time + t.duration) * job.config.fps), - })); - - if (transitionRanges.length > 0) { - log.info("[Render] Detected shader transitions for HDR compositing", { - count: transitionRanges.length, - transitions: transitionRanges.map((t) => ({ - shader: t.shader, - from: t.fromScene, - to: t.toScene, - frames: `${t.startFrame}-${t.endFrame}`, - })), + // Contract: compositions using window.__hf.transitions must wrap each + // scene's elements in a
where the id + // matches the fromScene/toScene values declared in the transition metadata. + const sceneElements: Record = await domSession.page.evaluate(() => { + const scenes = document.querySelectorAll(".scene"); + const map: Record = {}; + for (const scene of scenes) { + const els = scene.querySelectorAll("[data-start]"); + map[scene.id] = Array.from(els).map((e) => e.id); + } + return map; }); - } - // Spawn HDR streaming encoder accepting raw rgb48le composited frames - const hdrEncoder = await spawnStreamingEncoder( - videoOnlyPath, - { - fps: job.config.fps, - width, - height, - codec: preset.codec, - preset: preset.preset, - quality: preset.quality, - pixelFormat: preset.pixelFormat, - hdr: preset.hdr, - rawInputFormat: "rgb48le", - }, - abortSignal, - { ffmpegStreamingTimeout: 3_600_000 }, - ); - assertNotAborted(); + const transitionRanges: TransitionRange[] = transitionMeta.map((t) => ({ + ...t, + startFrame: Math.floor(t.time * job.config.fps), + endFrame: Math.ceil((t.time + t.duration) * job.config.fps), + })); + + if (transitionRanges.length > 0) { + log.info("[Render] Detected shader transitions for HDR compositing", { + count: transitionRanges.length, + transitions: transitionRanges.map((t) => ({ + shader: t.shader, + from: t.fromScene, + to: t.toScene, + frames: `${t.startFrame}-${t.endFrame}`, + })), + }); + } - // ── Query element bounds for HDR extraction dimensions ──────────── - // Extract at each HDR video's display dimensions (not composition dimensions) - // so the source stride matches the blit dimensions. Elements that aren't - // visible at t=0 (e.g., data-start > 0) need to be queried at their own - // start time so their layout dimensions are available. - const hdrExtractionDims = new Map(); - // CSS `object-fit` / `object-position` for HDR elements. Captured - // alongside `hdrExtractionDims` so the static-image decoder can resample - // the rgb48le buffer into the element's layout box the same way the - // browser would, instead of blitting the source PNG at native size. - const hdrImageFitInfo = new Map(); - const hdrVideoStartTimes = new Map(); - for (const v of composition.videos) { - if (hdrVideoIds.includes(v.id)) { - hdrVideoStartTimes.set(v.id, v.start); + // Spawn HDR streaming encoder accepting raw rgb48le composited frames. + // Assigned to the let declared above so the outer finally can close it + // if any of the work between here and hdrEncoder.close() throws. + hdrEncoder = await spawnStreamingEncoder( + videoOnlyPath, + { + fps: job.config.fps, + width, + height, + codec: preset.codec, + preset: preset.preset, + quality: preset.quality, + pixelFormat: preset.pixelFormat, + hdr: preset.hdr, + rawInputFormat: "rgb48le", + }, + abortSignal, + { ffmpegStreamingTimeout: 3_600_000 }, + ); + assertNotAborted(); + + // ── Query element bounds for HDR extraction dimensions ──────────── + // Extract at each HDR video's display dimensions (not composition dimensions) + // so the source stride matches the blit dimensions. Elements that aren't + // visible at t=0 (e.g., data-start > 0) need to be queried at their own + // start time so their layout dimensions are available. + const hdrExtractionDims = new Map(); + // CSS `object-fit` / `object-position` for HDR elements. Captured + // alongside `hdrExtractionDims` so the static-image decoder can resample + // the rgb48le buffer into the element's layout box the same way the + // browser would, instead of blitting the source PNG at native size. + const hdrImageFitInfo = new Map(); + const hdrVideoStartTimes = new Map(); + for (const v of composition.videos) { + if (hdrVideoIds.includes(v.id)) { + hdrVideoStartTimes.set(v.id, v.start); + } } - } - const hdrImageStartTimes = new Map(); - for (const img of composition.images) { - if (nativeHdrImageIds.has(img.id)) { - hdrImageStartTimes.set(img.id, img.start); + const hdrImageStartTimes = new Map(); + for (const img of composition.images) { + if (nativeHdrImageIds.has(img.id)) { + hdrImageStartTimes.set(img.id, img.start); + } } - } - // Collect unique start times to minimize seek operations. Merge HDR - // video AND image start times so an HDR image with `data-start > 0` - // also gets a stacking-query pass at its appearance moment. - const uniqueStartTimes = [ - ...new Set([...hdrVideoStartTimes.values(), ...hdrImageStartTimes.values()]), - ].sort((a, b) => a - b); - for (const seekTime of uniqueStartTimes) { - await domSession.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, seekTime); - if (domSession.onBeforeCapture) { - await domSession.onBeforeCapture(domSession.page, seekTime); - } - const stacking = await queryElementStacking(domSession.page, nativeHdrIds); - for (const el of stacking) { - // Use layout dimensions (offsetWidth/offsetHeight) for extraction — these - // are unaffected by CSS transforms (GSAP scale/rotation). getBoundingClientRect - // returns the transformed bounding box which can be wrong for extraction. - if ( - el.isHdr && - el.layoutWidth > 0 && - el.layoutHeight > 0 && - !hdrExtractionDims.has(el.id) - ) { - hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight }); + // Collect unique start times to minimize seek operations. Merge HDR + // video AND image start times so an HDR image with `data-start > 0` + // also gets a stacking-query pass at its appearance moment. + const uniqueStartTimes = [ + ...new Set([...hdrVideoStartTimes.values(), ...hdrImageStartTimes.values()]), + ].sort((a, b) => a - b); + for (const seekTime of uniqueStartTimes) { + await domSession.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, seekTime); + if (domSession.onBeforeCapture) { + await domSession.onBeforeCapture(domSession.page, seekTime); } - // Record `object-fit` / `object-position` for HDR images so the - // static-image decode pass can resample to layout dimensions with - // the same semantics the browser would apply. - if (el.isHdr && nativeHdrImageIds.has(el.id) && !hdrImageFitInfo.has(el.id)) { - hdrImageFitInfo.set(el.id, { - fit: el.objectFit, - position: el.objectPosition, - }); + const stacking = await queryElementStacking(domSession.page, nativeHdrIds); + for (const el of stacking) { + // Use layout dimensions (offsetWidth/offsetHeight) for extraction — these + // are unaffected by CSS transforms (GSAP scale/rotation). getBoundingClientRect + // returns the transformed bounding box which can be wrong for extraction. + if ( + el.isHdr && + el.layoutWidth > 0 && + el.layoutHeight > 0 && + !hdrExtractionDims.has(el.id) + ) { + hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight }); + } + // Record `object-fit` / `object-position` for HDR images so the + // static-image decode pass can resample to layout dimensions with + // the same semantics the browser would apply. + if (el.isHdr && nativeHdrImageIds.has(el.id) && !hdrImageFitInfo.has(el.id)) { + hdrImageFitInfo.set(el.id, { + fit: el.objectFit, + position: el.objectPosition, + }); + } } } - } - // ── Pre-extract all HDR video frames in a single FFmpeg pass ────── - const hdrFrameDirs = new Map(); - for (const [videoId, srcPath] of hdrVideoSrcPaths) { - const video = composition.videos.find((v) => v.id === videoId); - if (!video) continue; - const frameDir = join(framesDir, `hdr_${videoId}`); - mkdirSync(frameDir, { recursive: true }); - const duration = video.end - video.start; - const dims = hdrExtractionDims.get(videoId) ?? { width, height }; - const ffmpegArgs = [ - "-ss", - String(video.mediaStart), - "-i", - srcPath, - "-t", - String(duration), - "-r", - String(job.config.fps), - "-vf", - `scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`, - "-pix_fmt", - "rgb48le", - "-c:v", - "png", - "-y", - join(frameDir, "frame_%04d.png"), - ]; - const result = await runFfmpeg(ffmpegArgs, { signal: abortSignal }); - if (!result.success) { - hdrDiagnostics.videoExtractionFailures += 1; - log.error("HDR frame pre-extraction failed; aborting render", { - videoId, + // ── Pre-extract all HDR video frames in a single FFmpeg pass ────── + // hdrFrameDirs is declared above the try block so the outer finally + // can clear matching frameDirMaxIndexCache entries on any exit path. + for (const [videoId, srcPath] of hdrVideoSrcPaths) { + const video = composition.videos.find((v) => v.id === videoId); + if (!video) continue; + const frameDir = join(framesDir, `hdr_${videoId}`); + mkdirSync(frameDir, { recursive: true }); + const duration = video.end - video.start; + const dims = hdrExtractionDims.get(videoId) ?? { width, height }; + const ffmpegArgs = [ + "-ss", + String(video.mediaStart), + "-i", srcPath, - stderr: result.stderr.slice(-400), - }); - throw new Error( - `HDR frame extraction failed for video "${videoId}". ` + - `Aborting render to avoid shipping black HDR layers.`, - ); + "-t", + String(duration), + "-r", + String(job.config.fps), + "-vf", + `scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`, + "-pix_fmt", + "rgb48le", + "-c:v", + "png", + "-y", + join(frameDir, "frame_%04d.png"), + ]; + const result = await runFfmpeg(ffmpegArgs, { signal: abortSignal }); + if (!result.success) { + hdrDiagnostics.videoExtractionFailures += 1; + log.error("HDR frame pre-extraction failed; aborting render", { + videoId, + srcPath, + stderr: result.stderr.slice(-400), + }); + throw new Error( + `HDR frame extraction failed for video "${videoId}". ` + + `Aborting render to avoid shipping black HDR layers.`, + ); + } + hdrFrameDirs.set(videoId, frameDir); } - hdrFrameDirs.set(videoId, frameDir); - } - // ── Pre-decode all HDR image buffers once ──────────────────────── - // Static images decode exactly once, then the resulting rgb48le buffer - // is blitted on every visible frame. Caching the decode here keeps the - // per-frame cost to a memcpy + blit. Failures are logged and skipped so - // a single broken file doesn't kill the render. - // - // We resample the decoded buffer to the element's *layout* dimensions - // here (using CSS `object-fit` / `object-position` semantics), so the - // affine blit downstream can treat the buffer as if the source was - // sized to the element's box. Without this step, an `` element - // styled `object-fit: cover` would render its source PNG at native - // pixel size inside the layout box — visually a small image floating - // in the top-left corner of its container instead of filling it. - const hdrImageBuffers = new Map(); - for (const [imageId, srcPath] of hdrImageSrcPaths) { - try { - const decoded = decodePngToRgb48le(readFileSync(srcPath)); - const layout = hdrExtractionDims.get(imageId); - const fitInfo = hdrImageFitInfo.get(imageId); - if (layout && (layout.width !== decoded.width || layout.height !== decoded.height)) { - const fit = normalizeObjectFit(fitInfo?.fit); - const resampled = resampleRgb48leObjectFit( - decoded.data, - decoded.width, - decoded.height, - layout.width, - layout.height, - fit, - fitInfo?.position, - ); - hdrImageBuffers.set(imageId, { - data: resampled, - width: layout.width, - height: layout.height, - }); - } else { - hdrImageBuffers.set(imageId, { - data: Buffer.from(decoded.data), - width: decoded.width, - height: decoded.height, + // ── Pre-decode all HDR image buffers once ──────────────────────── + // Static images decode exactly once, then the resulting rgb48le buffer + // is blitted on every visible frame. Caching the decode here keeps the + // per-frame cost to a memcpy + blit. Failures are logged and skipped so + // a single broken file doesn't kill the render. + // + // We resample the decoded buffer to the element's *layout* dimensions + // here (using CSS `object-fit` / `object-position` semantics), so the + // affine blit downstream can treat the buffer as if the source was + // sized to the element's box. Without this step, an `` element + // styled `object-fit: cover` would render its source PNG at native + // pixel size inside the layout box — visually a small image floating + // in the top-left corner of its container instead of filling it. + const hdrImageBuffers = new Map(); + for (const [imageId, srcPath] of hdrImageSrcPaths) { + try { + const decoded = decodePngToRgb48le(readFileSync(srcPath)); + const layout = hdrExtractionDims.get(imageId); + const fitInfo = hdrImageFitInfo.get(imageId); + if (layout && (layout.width !== decoded.width || layout.height !== decoded.height)) { + const fit = normalizeObjectFit(fitInfo?.fit); + const resampled = resampleRgb48leObjectFit( + decoded.data, + decoded.width, + decoded.height, + layout.width, + layout.height, + fit, + fitInfo?.position, + ); + hdrImageBuffers.set(imageId, { + data: resampled, + width: layout.width, + height: layout.height, + }); + } else { + hdrImageBuffers.set(imageId, { + data: Buffer.from(decoded.data), + width: decoded.width, + height: decoded.height, + }); + } + } catch (err) { + hdrDiagnostics.imageDecodeFailures += 1; + log.error("HDR image decode failed; aborting render", { + imageId, + srcPath, + error: err instanceof Error ? err.message : String(err), }); + throw new Error( + `HDR image decode failed for image "${imageId}". ` + + `Aborting render to avoid shipping missing HDR image layers.`, + ); } - } catch (err) { - hdrDiagnostics.imageDecodeFailures += 1; - log.error("HDR image decode failed; aborting render", { - imageId, - srcPath, - error: err instanceof Error ? err.message : String(err), - }); - throw new Error( - `HDR image decode failed for image "${imageId}". ` + - `Aborting render to avoid shipping missing HDR image layers.`, - ); } - } - assertNotAborted(); + assertNotAborted(); - try { - // The beforeCaptureHook injects SDR video frames into the DOM. - // We call it manually since the HDR loop doesn't use captureFrame(). - const beforeCaptureHook = domSession.onBeforeCapture; - - // Track which HDR video frame directories have been cleaned up. - // Once a video's last frame has been used (time > video.end), its - // extraction directory is deleted to free disk space. This prevents - // disk exhaustion on compositions with many HDR videos. - const cleanedUpVideos = new Set(); - // Build a map of video end times for quick lookup - const hdrVideoEndTimes = new Map(); - for (const v of composition.videos) { - if (hdrFrameDirs.has(v.id)) { - hdrVideoEndTimes.set(v.id, v.end); + try { + // The beforeCaptureHook injects SDR video frames into the DOM. + // We call it manually since the HDR loop doesn't use captureFrame(). + const beforeCaptureHook = domSession.onBeforeCapture; + + // Track which HDR video frame directories have been cleaned up. + // Once a video's last frame has been used (time > video.end), its + // extraction directory is deleted to free disk space. This prevents + // disk exhaustion on compositions with many HDR videos. + const cleanedUpVideos = new Set(); + // Build a map of video end times for quick lookup + const hdrVideoEndTimes = new Map(); + for (const v of composition.videos) { + if (hdrFrameDirs.has(v.id)) { + hdrVideoEndTimes.set(v.id, v.end); + } } - } - // ── compositeToBuffer: layer compositing helper ──────────────────── - // Extracted so the transition path can composite each scene independently. - // Closes over domSession, hdrFrameDirs, composition, nativeHdrVideoIds, etc. - // - // @param canvas - Pre-allocated rgb48le buffer (width * height * 6 bytes) - // @param time - Seek time in seconds - // @param fullStacking - Complete stacking info for ALL elements (used for hideIds) - // @param elementFilter - When set, only composite elements whose IDs are in this set. - // When undefined, all elements are included (non-transition frame). - // @param debugFrameIndex - Frame index used to label diagnostic dumps. -1 disables - // per-layer dumps even when KEEP_TEMP=1 (for warmup calls). - const debugDumpEnabled = process.env.KEEP_TEMP === "1"; - const debugDumpDir = debugDumpEnabled ? join(framesDir, "debug-composite") : null; - if (debugDumpDir && !existsSync(debugDumpDir)) { - mkdirSync(debugDumpDir, { recursive: true }); - } - function countNonZeroAlpha(rgba: Uint8Array): number { - let n = 0; - for (let p = 3; p < rgba.length; p += 4) { - if (rgba[p] !== 0) n++; + // ── compositeToBuffer: layer compositing helper ──────────────────── + // Extracted so the transition path can composite each scene independently. + // Closes over domSession, hdrFrameDirs, composition, nativeHdrVideoIds, etc. + // + // @param canvas - Pre-allocated rgb48le buffer (width * height * 6 bytes) + // @param time - Seek time in seconds + // @param fullStacking - Complete stacking info for ALL elements (used for hideIds) + // @param elementFilter - When set, only composite elements whose IDs are in this set. + // When undefined, all elements are included (non-transition frame). + // @param debugFrameIndex - Frame index used to label diagnostic dumps. -1 disables + // per-layer dumps even when KEEP_TEMP=1 (for warmup calls). + const debugDumpEnabled = process.env.KEEP_TEMP === "1"; + const debugDumpDir = debugDumpEnabled ? join(framesDir, "debug-composite") : null; + if (debugDumpDir && !existsSync(debugDumpDir)) { + mkdirSync(debugDumpDir, { recursive: true }); } - return n; - } - function countNonZeroRgb48(buf: Uint8Array): number { - let n = 0; - for (let p = 0; p < buf.length; p += 6) { - if (buf[p] !== 0 || buf[p + 1] !== 0 || buf[p + 2] !== 0) n++; + function countNonZeroAlpha(rgba: Uint8Array): number { + let n = 0; + for (let p = 3; p < rgba.length; p += 4) { + if (rgba[p] !== 0) n++; + } + return n; } - return n; - } - async function compositeToBuffer( - canvas: Buffer, - time: number, - fullStacking: ElementStackingInfo[], - elementFilter?: Set, - debugFrameIndex: number = -1, - ): Promise { - // Filter stacking info when rendering a single scene - const filteredStacking = elementFilter - ? fullStacking.filter((e) => elementFilter.has(e.id)) - : fullStacking; - - // Group filtered elements into z-ordered layers - const layers = groupIntoLayers(filteredStacking); - - const shouldLog = debugDumpEnabled && debugFrameIndex >= 0; - if (shouldLog) { - log.info("[diag] compositeToBuffer plan", { - frame: debugFrameIndex, - time: time.toFixed(3), - filterSize: elementFilter?.size, - fullStackingCount: fullStacking.length, - filteredCount: filteredStacking.length, - layerCount: layers.length, - layers: layers.map((l) => - l.type === "hdr" - ? { - type: "hdr", - id: l.element.id, - z: l.element.zIndex, - visible: l.element.visible, - opacity: l.element.opacity, - bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`, - } - : { type: "dom", ids: l.elementIds }, - ), - }); + function countNonZeroRgb48(buf: Uint8Array): number { + let n = 0; + for (let p = 0; p < buf.length; p += 6) { + if ( + buf[p] !== 0 || + buf[p + 1] !== 0 || + buf[p + 2] !== 0 || + buf[p + 3] !== 0 || + buf[p + 4] !== 0 || + buf[p + 5] !== 0 + ) + n++; + } + return n; } + async function compositeToBuffer( + canvas: Buffer, + time: number, + fullStacking: ElementStackingInfo[], + elementFilter?: Set, + debugFrameIndex: number = -1, + ): Promise { + // Filter stacking info when rendering a single scene + const filteredStacking = elementFilter + ? fullStacking.filter((e) => elementFilter.has(e.id)) + : fullStacking; + + // Group filtered elements into z-ordered layers + const layers = groupIntoLayers(filteredStacking); + + const shouldLog = debugDumpEnabled && debugFrameIndex >= 0; + if (shouldLog) { + log.info("[diag] compositeToBuffer plan", { + frame: debugFrameIndex, + time: time.toFixed(3), + filterSize: elementFilter?.size, + fullStackingCount: fullStacking.length, + filteredCount: filteredStacking.length, + layerCount: layers.length, + layers: layers.map((l) => + l.type === "hdr" + ? { + type: "hdr", + id: l.element.id, + z: l.element.zIndex, + visible: l.element.visible, + opacity: l.element.opacity, + bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`, + } + : { type: "dom", ids: l.elementIds }, + ), + }); + } - // Composite layers bottom-to-top - for (const [layerIdx, layer] of layers.entries()) { - if (layer.type === "hdr") { - const before = shouldLog ? countNonZeroRgb48(canvas) : 0; - const isHdrImage = nativeHdrImageIds.has(layer.element.id); - if (isHdrImage) { - blitHdrImageLayer( - canvas, - layer.element, - hdrImageBuffers, - width, - height, - log, - imageTransfers.get(layer.element.id), - effectiveHdr?.transfer, - ); - } else { - blitHdrVideoLayer( - canvas, - layer.element, - time, - job.config.fps, - hdrFrameDirs, - hdrVideoStartTimes, - width, - height, - log, - videoTransfers.get(layer.element.id), - effectiveHdr?.transfer, - ); - } - if (shouldLog) { - const after = countNonZeroRgb48(canvas); + // Composite layers bottom-to-top + for (const [layerIdx, layer] of layers.entries()) { + if (layer.type === "hdr") { + const before = shouldLog ? countNonZeroRgb48(canvas) : 0; + const isHdrImage = nativeHdrImageIds.has(layer.element.id); if (isHdrImage) { - const buf = hdrImageBuffers.get(layer.element.id); - log.info("[diag] hdr layer blit", { - frame: debugFrameIndex, - layerIdx, - id: layer.element.id, - kind: "image", - pixelsAdded: after - before, - totalNonZero: after, - bufferDecoded: !!buf, - bufferDims: buf ? `${buf.width}x${buf.height}` : null, - }); + blitHdrImageLayer( + canvas, + layer.element, + hdrImageBuffers, + width, + height, + log, + imageTransfers.get(layer.element.id), + effectiveHdr?.transfer, + ); } else { - const frameDir = hdrFrameDirs.get(layer.element.id); - const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0; - const localTime = time - startTime; - const frameNum = Math.floor(localTime * job.config.fps) + 1; - const expectedFrame = frameDir - ? join(frameDir, `frame_${String(frameNum).padStart(4, "0")}.png`) - : null; - log.info("[diag] hdr layer blit", { - frame: debugFrameIndex, - layerIdx, - id: layer.element.id, - kind: "video", - pixelsAdded: after - before, - totalNonZero: after, - startTime, - localTime: localTime.toFixed(3), - hdrFrameNum: frameNum, - expectedFrame, - expectedFrameExists: expectedFrame ? existsSync(expectedFrame) : false, - }); + blitHdrVideoLayer( + canvas, + layer.element, + time, + job.config.fps, + hdrFrameDirs, + hdrVideoStartTimes, + width, + height, + log, + videoTransfers.get(layer.element.id), + effectiveHdr?.transfer, + ); + } + if (shouldLog) { + const after = countNonZeroRgb48(canvas); + if (isHdrImage) { + const buf = hdrImageBuffers.get(layer.element.id); + log.info("[diag] hdr layer blit", { + frame: debugFrameIndex, + layerIdx, + id: layer.element.id, + kind: "image", + pixelsAdded: after - before, + totalNonZero: after, + bufferDecoded: !!buf, + bufferDims: buf ? `${buf.width}x${buf.height}` : null, + }); + } else { + const frameDir = hdrFrameDirs.get(layer.element.id); + const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0; + const localTime = time - startTime; + const frameNum = Math.floor(localTime * job.config.fps) + 1; + const expectedFrame = frameDir + ? join(frameDir, `frame_${String(frameNum).padStart(4, "0")}.png`) + : null; + log.info("[diag] hdr layer blit", { + frame: debugFrameIndex, + layerIdx, + id: layer.element.id, + kind: "video", + pixelsAdded: after - before, + totalNonZero: after, + startTime, + localTime: localTime.toFixed(3), + hdrFrameNum: frameNum, + expectedFrame, + expectedFrameExists: expectedFrame ? existsSync(expectedFrame) : false, + }); + } + } + } else { + // DOM layer: capture only elements in this layer. + // + // Each layer gets a fresh seek + inject cycle to guarantee correct + // visibility state — avoids fragile interactions between the frame + // injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek. + // + // The mask: + // - mass-hides every body descendant via stylesheet + // - re-shows the layer's elements (and their descendants and + // their injected `__render_frame_*` siblings) so deep-nested + // content stays visible even though intermediate ancestors + // are hidden + // - inline-hides every other data-start element so they don't + // paint when they happen to be descendants of a layer element + // (most importantly: HDR videos and other-layer SDR videos + // that live inside `#root` when capturing the root DOM layer) + // + // Without the mask, every DOM screenshot captures the full page + // (root background, sibling scenes' static content, the painted + // border/box-shadow of cards, etc.) and the resulting opaque + // pixels overwrite previously composited HDR content beneath. + const allElementIds = fullStacking.map((e) => e.id); + const layerIds = new Set(layer.elementIds); + const hideIds = allElementIds.filter((id) => !layerIds.has(id)); + + // 1. Seek GSAP to restore all animated properties from clean state + await domSession.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time); + + // 2. Run frame injector to set correct SDR video visibility + if (beforeCaptureHook) { + await beforeCaptureHook(domSession.page, time); } - } - } else { - // DOM layer: capture only elements in this layer. - // - // Each layer gets a fresh seek + inject cycle to guarantee correct - // visibility state — avoids fragile interactions between the frame - // injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek. - // - // The mask: - // - mass-hides every body descendant via stylesheet - // - re-shows the layer's elements (and their descendants and - // their injected `__render_frame_*` siblings) so deep-nested - // content stays visible even though intermediate ancestors - // are hidden - // - inline-hides every other data-start element so they don't - // paint when they happen to be descendants of a layer element - // (most importantly: HDR videos and other-layer SDR videos - // that live inside `#root` when capturing the root DOM layer) - // - // Without the mask, every DOM screenshot captures the full page - // (root background, sibling scenes' static content, the painted - // border/box-shadow of cards, etc.) and the resulting opaque - // pixels overwrite previously composited HDR content beneath. - const allElementIds = fullStacking.map((e) => e.id); - const layerIds = new Set(layer.elementIds); - const hideIds = allElementIds.filter((id) => !layerIds.has(id)); - - // 1. Seek GSAP to restore all animated properties from clean state - await domSession.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); - - // 2. Run frame injector to set correct SDR video visibility - if (beforeCaptureHook) { - await beforeCaptureHook(domSession.page, time); - } - // 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids) - await applyDomLayerMask(domSession.page, layer.elementIds, hideIds); + // 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids) + await applyDomLayerMask(domSession.page, layer.elementIds, hideIds); - // 4. Screenshot - const domPng = await captureAlphaPng(domSession.page, width, height); + // 4. Screenshot + const domPng = await captureAlphaPng(domSession.page, width, height); - // 5. Tear down the mask - await removeDomLayerMask(domSession.page, hideIds); + // 5. Tear down the mask + await removeDomLayerMask(domSession.page, hideIds); - try { - const { data: domRgba } = decodePng(domPng); - // Invariant: this branch is only reached when HDR output is active. - if (!effectiveHdr) { - throw new Error( - "Invariant violation: effectiveHdr is undefined inside HDR layer branch", - ); - } - const before = shouldLog ? countNonZeroRgb48(canvas) : 0; - const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0; - blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer); - if (shouldLog && debugDumpDir) { - const after = countNonZeroRgb48(canvas); - const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`; - const dumpPath = join(debugDumpDir, dumpName); - writeFileSync(dumpPath, domPng); - log.info("[diag] dom layer blit", { - frame: debugFrameIndex, - layerIdx, + try { + const { data: domRgba } = decodePng(domPng); + // Invariant: this branch is only reached when HDR output is active. + if (!effectiveHdr) { + throw new Error( + "Invariant violation: effectiveHdr is undefined inside HDR layer branch", + ); + } + const before = shouldLog ? countNonZeroRgb48(canvas) : 0; + const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0; + blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer); + if (shouldLog && debugDumpDir) { + const after = countNonZeroRgb48(canvas); + const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`; + const dumpPath = join(debugDumpDir, dumpName); + writeFileSync(dumpPath, domPng); + log.info("[diag] dom layer blit", { + frame: debugFrameIndex, + layerIdx, + layerIds: layer.elementIds, + hideCount: hideIds.length, + pngBytes: domPng.length, + alphaPixels, + pixelsAdded: after - before, + totalNonZero: after, + dumpPath, + }); + } + } catch (err) { + log.warn("DOM layer decode/blit failed; skipping overlay", { layerIds: layer.elementIds, - hideCount: hideIds.length, - pngBytes: domPng.length, - alphaPixels, - pixelsAdded: after - before, - totalNonZero: after, - dumpPath, + error: err instanceof Error ? err.message : String(err), }); } - } catch (err) { - log.warn("DOM layer decode/blit failed; skipping overlay", { - layerIds: layer.elementIds, - error: err instanceof Error ? err.message : String(err), - }); } } - } - if (shouldLog && debugDumpDir) { - const finalNonZero = countNonZeroRgb48(canvas); - log.info("[diag] compositeToBuffer end", { - frame: debugFrameIndex, - finalNonZeroPixels: finalNonZero, - totalPixels: width * height, - coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%", - }); + if (shouldLog && debugDumpDir) { + const finalNonZero = countNonZeroRgb48(canvas); + log.info("[diag] compositeToBuffer end", { + frame: debugFrameIndex, + finalNonZeroPixels: finalNonZero, + totalPixels: width * height, + coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%", + }); + } } - } - // ── Pre-allocate transition buffers ───────────────────────────────── - // Each buffer is width * height * 6 bytes (~37 MB at 1080p). Reused - // across frames to avoid per-frame allocation in the hot loop. - const bufSize = width * height * 6; - const hasTransitions = transitionRanges.length > 0; - const transBufferA = hasTransitions ? Buffer.alloc(bufSize) : null; - const transBufferB = hasTransitions ? Buffer.alloc(bufSize) : null; - const transOutput = hasTransitions ? Buffer.alloc(bufSize) : null; - // Pre-allocate the normal-frame canvas too — reused via .fill(0) each iteration - // to avoid ~37 MB allocation per frame in the hot loop. - const normalCanvas = Buffer.alloc(bufSize); - - for (let i = 0; i < totalFrames; i++) { - assertNotAborted(); - const time = i / job.config.fps; + // ── Pre-allocate transition buffers ───────────────────────────────── + // Each buffer is width * height * 6 bytes (~37 MB at 1080p). Reused + // across frames to avoid per-frame allocation in the hot loop. + const bufSize = width * height * 6; + const hasTransitions = transitionRanges.length > 0; + const transBufferA = hasTransitions ? Buffer.alloc(bufSize) : null; + const transBufferB = hasTransitions ? Buffer.alloc(bufSize) : null; + const transOutput = hasTransitions ? Buffer.alloc(bufSize) : null; + // Pre-allocate the normal-frame canvas too — reused via .fill(0) each iteration + // to avoid ~37 MB allocation per frame in the hot loop. + const normalCanvas = Buffer.alloc(bufSize); + + for (let i = 0; i < totalFrames; i++) { + assertNotAborted(); + const time = i / job.config.fps; - // Seek timeline - await domSession.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); + // Seek timeline + await domSession.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time); - // Inject SDR video frames into the DOM - if (beforeCaptureHook) { - await beforeCaptureHook(domSession.page, time); - } + // Inject SDR video frames into the DOM + if (beforeCaptureHook) { + await beforeCaptureHook(domSession.page, time); + } - // Query ALL timed elements for z-order analysis - const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds); + // Query ALL timed elements for z-order analysis + const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds); - // Find active transition for this frame (if any) - const activeTransition = transitionRanges.find( - (t) => i >= t.startFrame && i <= t.endFrame, - ); + // Find active transition for this frame (if any) + const activeTransition = transitionRanges.find( + (t) => i >= t.startFrame && i <= t.endFrame, + ); - if (i % 30 === 0) { - const hdrEl = stackingInfo.find((e) => e.isHdr); - log.debug("[Render] HDR layer composite frame", { - frame: i, - time: time.toFixed(2), - hdrElement: hdrEl - ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } - : null, - stackingCount: stackingInfo.length, - activeTransition: activeTransition?.shader, - }); - } + if (i % 30 === 0) { + const hdrEl = stackingInfo.find((e) => e.isHdr); + log.debug("[Render] HDR layer composite frame", { + frame: i, + time: time.toFixed(2), + hdrElement: hdrEl + ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } + : null, + stackingCount: stackingInfo.length, + activeTransition: activeTransition?.shader, + }); + } - if (activeTransition && transBufferA && transBufferB && transOutput) { - // ── Transition frame: dual-scene compositing ────────────────── - const progress = - activeTransition.endFrame === activeTransition.startFrame - ? 1 - : (i - activeTransition.startFrame) / - (activeTransition.endFrame - activeTransition.startFrame); - - // Resolve scene element IDs - const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []); - const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []); - - // Zero-fill scene buffers (transition function writes every output pixel) - transBufferA.fill(0); - transBufferB.fill(0); - - for (const [sceneBuf, sceneIds] of [ - [transBufferA, sceneAIds], - [transBufferB, sceneBIds], - ] as const) { - // Fresh state: seek + inject - await domSession.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); - if (beforeCaptureHook) { - await beforeCaptureHook(domSession.page, time); - } + if (activeTransition && transBufferA && transBufferB && transOutput) { + // ── Transition frame: dual-scene compositing ────────────────── + const progress = + activeTransition.endFrame === activeTransition.startFrame + ? 1 + : (i - activeTransition.startFrame) / + (activeTransition.endFrame - activeTransition.startFrame); + + // Resolve scene element IDs + const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []); + const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []); + + // Zero-fill scene buffers (transition function writes every output pixel) + transBufferA.fill(0); + transBufferB.fill(0); + + for (const [sceneBuf, sceneIds] of [ + [transBufferA, sceneAIds], + [transBufferB, sceneBIds], + ] as const) { + // Re-check abort between scene A and scene B. Each scene + // capture below performs a DOM seek, optional hook, + // per-layer HDR blits, and a full-page screenshot — easily + // hundreds of ms. Without this, an abort that arrives + // during scene A's capture won't fire until the next outer + // frame, after scene B has already been fully composited + // and discarded. + assertNotAborted(); + // Fresh state: seek + inject + await domSession.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time); + if (beforeCaptureHook) { + await beforeCaptureHook(domSession.page, time); + } - // Blit all HDR videos/images for this scene - for (const el of stackingInfo) { - if (!el.isHdr || !sceneIds.has(el.id)) continue; - if (nativeHdrImageIds.has(el.id)) { - blitHdrImageLayer( - sceneBuf as Buffer, - el, - hdrImageBuffers, - width, - height, - log, - imageTransfers.get(el.id), - effectiveHdr?.transfer, - ); - } else { - blitHdrVideoLayer( + // Blit all HDR videos/images for this scene + for (const el of stackingInfo) { + if (!el.isHdr || !sceneIds.has(el.id)) continue; + if (nativeHdrImageIds.has(el.id)) { + blitHdrImageLayer( + sceneBuf as Buffer, + el, + hdrImageBuffers, + width, + height, + log, + imageTransfers.get(el.id), + effectiveHdr?.transfer, + ); + } else { + blitHdrVideoLayer( + sceneBuf as Buffer, + el, + time, + job.config.fps, + hdrFrameDirs, + hdrVideoStartTimes, + width, + height, + log, + videoTransfers.get(el.id), + effectiveHdr?.transfer, + ); + } + } + + // Single DOM screenshot: mask the page so only this scene's DOM + // elements paint. Same masking strategy as the per-layer DOM + // branch — see applyDomLayerMask for details. Native HDR videos + // and images are always inline-hidden so their fallback poster / + // SDR thumbnail doesn't bleed into the DOM overlay (HDR pixels + // are blitted separately by blitHdrVideoLayer / blitHdrImageLayer + // above). + const showIds = Array.from(sceneIds); + const hideIds = stackingInfo + .map((e) => e.id) + .filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id)); + await applyDomLayerMask(domSession.page, showIds, hideIds); + const domPng = await captureAlphaPng(domSession.page, width, height); + await removeDomLayerMask(domSession.page, hideIds); + + try { + const { data: domRgba } = decodePng(domPng); + // Invariant: `hasHdrVideo` requires `effectiveHdr` to be set (see line ~919). + if (!effectiveHdr) { + throw new Error( + "Invariant violation: effectiveHdr is undefined inside hasHdrVideo branch", + ); + } + blitRgba8OverRgb48le( + domRgba, sceneBuf as Buffer, - el, - time, - job.config.fps, - hdrFrameDirs, - hdrVideoStartTimes, width, height, - log, - videoTransfers.get(el.id), - effectiveHdr?.transfer, + effectiveHdr.transfer, ); + } catch (err) { + log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", { + frameIndex: i, + sceneIds: Array.from(sceneIds), + error: err instanceof Error ? err.message : String(err), + }); } } - // Single DOM screenshot: mask the page so only this scene's DOM - // elements paint. Same masking strategy as the per-layer DOM - // branch — see applyDomLayerMask for details. Native HDR videos - // and images are always inline-hidden so their fallback poster / - // SDR thumbnail doesn't bleed into the DOM overlay (HDR pixels - // are blitted separately by blitHdrVideoLayer / blitHdrImageLayer - // above). - const showIds = Array.from(sceneIds); - const hideIds = stackingInfo - .map((e) => e.id) - .filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id)); - await applyDomLayerMask(domSession.page, showIds, hideIds); - const domPng = await captureAlphaPng(domSession.page, width, height); - await removeDomLayerMask(domSession.page, hideIds); - - try { - const { data: domRgba } = decodePng(domPng); - // Invariant: `hasHdrVideo` requires `effectiveHdr` to be set (see line ~919). - if (!effectiveHdr) { - throw new Error( - "Invariant violation: effectiveHdr is undefined inside hasHdrVideo branch", - ); - } - blitRgba8OverRgb48le( - domRgba, - sceneBuf as Buffer, - width, - height, - effectiveHdr.transfer, + // Apply shader transition blend directly in PQ/HLG signal space. + // Linearization was attempted but destroys dark PQ content — values below + // PQ ~5000 quantize to zero in 16-bit linear, wiping out the bottom portion + // of dark video content. PQ space is perceptual and works well enough + // for shader math since the shaders were designed for perceptual (sRGB) space. + const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade; + transitionFn(transBufferA, transBufferB, transOutput, width, height, progress); + + hdrEncoder.writeFrame(transOutput); + } else { + // ── Normal frame: full layer composite (no transition) ───────── + normalCanvas.fill(0); + await compositeToBuffer(normalCanvas, time, stackingInfo, undefined, i); + if (debugDumpEnabled && debugDumpDir && i % 30 === 0) { + const previewPath = join( + debugDumpDir, + `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`, ); - } catch (err) { - log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", { - frameIndex: i, - sceneIds: Array.from(sceneIds), - error: err instanceof Error ? err.message : String(err), - }); + writeFileSync(previewPath, normalCanvas); } + hdrEncoder.writeFrame(normalCanvas); } - // Apply shader transition blend directly in PQ/HLG signal space. - // Linearization was attempted but destroys dark PQ content — values below - // PQ ~5000 quantize to zero in 16-bit linear, wiping out the bottom portion - // of dark video content. PQ space is perceptual and works well enough - // for shader math since the shaders were designed for perceptual (sRGB) space. - const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade; - transitionFn(transBufferA, transBufferB, transOutput, width, height, progress); - - hdrEncoder.writeFrame(transOutput); - } else { - // ── Normal frame: full layer composite (no transition) ───────── - normalCanvas.fill(0); - await compositeToBuffer(normalCanvas, time, stackingInfo, undefined, i); - if (debugDumpEnabled && debugDumpDir && i % 30 === 0) { - const previewPath = join( - debugDumpDir, - `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`, - ); - writeFileSync(previewPath, normalCanvas); - } - hdrEncoder.writeFrame(normalCanvas); - } - - // Clean up HDR frame directories for videos that have ended. - // Frees disk space during long renders with many HDR videos. - // Skip when KEEP_TEMP=1 so we can inspect intermediate state. - if (process.env.KEEP_TEMP !== "1") { - for (const [videoId, endTime] of hdrVideoEndTimes) { - if (time > endTime && !cleanedUpVideos.has(videoId)) { - // Also check no active transition references this video's scene - const stillNeeded = - activeTransition && - (sceneElements[activeTransition.fromScene]?.includes(videoId) || - sceneElements[activeTransition.toScene]?.includes(videoId)); - if (!stillNeeded) { - const frameDir = hdrFrameDirs.get(videoId); - if (frameDir) { - try { - rmSync(frameDir, { recursive: true, force: true }); - } catch (err) { - log.warn("Failed to clean up HDR frame directory", { - videoId, - frameDir, - error: err instanceof Error ? err.message : String(err), - }); + // Clean up HDR frame directories for videos that have ended. + // Frees disk space during long renders with many HDR videos. + // Skip when KEEP_TEMP=1 so we can inspect intermediate state. + if (process.env.KEEP_TEMP !== "1") { + for (const [videoId, endTime] of hdrVideoEndTimes) { + if (time > endTime && !cleanedUpVideos.has(videoId)) { + // Also check no active transition references this video's scene + const stillNeeded = + activeTransition && + (sceneElements[activeTransition.fromScene]?.includes(videoId) || + sceneElements[activeTransition.toScene]?.includes(videoId)); + if (!stillNeeded) { + const frameDir = hdrFrameDirs.get(videoId); + if (frameDir) { + try { + rmSync(frameDir, { recursive: true, force: true }); + } catch (err) { + log.warn("Failed to clean up HDR frame directory", { + videoId, + frameDir, + error: err instanceof Error ? err.message : String(err), + }); + } + // Drop the matching cache entry so we don't leak a stale + // max-frame-index reading for a directory that no longer + // exists. Without this, the module-scoped cache grows + // monotonically across renders. + frameDirMaxIndexCache.delete(frameDir); + hdrFrameDirs.delete(videoId); } + cleanedUpVideos.add(videoId); } - cleanedUpVideos.add(videoId); } } } - } - job.framesRendered = i + 1; - if ((i + 1) % 10 === 0 || i + 1 === totalFrames) { - const frameProgress = (i + 1) / totalFrames; - updateJobStatus( - job, - "rendering", - `HDR composite frame ${i + 1}/${job.totalFrames}`, - Math.round(25 + frameProgress * 55), - onProgress, - ); + job.framesRendered = i + 1; + if ((i + 1) % 10 === 0 || i + 1 === totalFrames) { + const frameProgress = (i + 1) / totalFrames; + updateJobStatus( + job, + "rendering", + `HDR composite frame ${i + 1}/${job.totalFrames}`, + Math.round(25 + frameProgress * 55), + onProgress, + ); + } } + } finally { + lastBrowserConsole = domSession.browserConsoleBuffer; + await closeCaptureSession(domSession); + domSessionClosed = true; } - } finally { - lastBrowserConsole = domSession.browserConsoleBuffer; - await closeCaptureSession(domSession); - } - const hdrEncodeResult = await hdrEncoder.close(); - assertNotAborted(); - if (!hdrEncodeResult.success) { - throw new Error(`HDR encode failed: ${hdrEncodeResult.error}`); - } + const hdrEncodeResult = await hdrEncoder.close(); + hdrEncoderClosed = true; + assertNotAborted(); + if (!hdrEncodeResult.success) { + throw new Error(`HDR encode failed: ${hdrEncodeResult.error}`); + } - perfStages.captureMs = Date.now() - stage4Start; - perfStages.encodeMs = hdrEncodeResult.durationMs; + perfStages.captureMs = Date.now() - stage4Start; + perfStages.encodeMs = hdrEncodeResult.durationMs; + } finally { + // Defensive cleanup: if anything between domSession creation and the + // success-path closes threw, the encoder ffmpeg subprocess and the + // browser would otherwise be leaked. Both close() methods are + // idempotent so it's safe to call them when the flags are already set, + // but we skip the redundant work to keep logs clean. + if (hdrEncoder && !hdrEncoderClosed) { + try { + await hdrEncoder.close(); + } catch (err) { + log.warn("hdrEncoder defensive close failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + if (!domSessionClosed) { + await closeCaptureSession(domSession).catch((err) => { + log.warn("closeCaptureSession defensive close failed", { + err: err instanceof Error ? err.message : String(err), + }); + }); + } + // Drop frameDirMaxIndexCache entries for any HDR frame directories + // that survived the in-loop cleanup (early failures, KEEP_TEMP=1, + // videos still active when the render exits). The on-disk frames + // themselves are torn down with workDir; we just don't want the + // module-scoped cache to leak entries across renders. + for (const frameDir of hdrFrameDirs.values()) { + frameDirMaxIndexCache.delete(frameDir); + } + hdrFrameDirs.clear(); + } } else // ── Standard capture paths (SDR or DOM-only HDR) ────────────────── // Streaming encode mode: pipe frame buffers directly to FFmpeg stdin, // skipping disk writes and the separate Stage 5 encode step. { let streamingEncoder: StreamingEncoder | null = null; + let streamingEncoderClosed = false; if (enableStreamingEncode) { streamingEncoder = await spawnStreamingEncoder( @@ -1971,247 +2044,265 @@ export async function executeRenderJob( assertNotAborted(); } - if (enableStreamingEncode && streamingEncoder) { - // ── Streaming capture + encode (Stage 4 absorbs Stage 5) ────────── - const reorderBuffer = createFrameReorderBuffer(0, totalFrames); - const currentEncoder = streamingEncoder; - - if (workerCount > 1) { - // Parallel capture → streaming encode - const tasks = distributeFrames(job.totalFrames, workerCount, workDir); - - const onFrameBuffer = async (frameIndex: number, buffer: Buffer): Promise => { - await reorderBuffer.waitForFrame(frameIndex); - currentEncoder.writeFrame(buffer); - reorderBuffer.advanceTo(frameIndex + 1); - }; + try { + if (enableStreamingEncode && streamingEncoder) { + // ── Streaming capture + encode (Stage 4 absorbs Stage 5) ────────── + const reorderBuffer = createFrameReorderBuffer(0, totalFrames); + const currentEncoder = streamingEncoder; - await executeParallelCapture( - fileServer.url, - workDir, - tasks, - { ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) }, - () => createVideoFrameInjector(frameLookup), - abortSignal, - (progress) => { - job.framesRendered = progress.capturedFrames; - const frameProgress = progress.capturedFrames / progress.totalFrames; - const progressPct = 25 + frameProgress * 55; + if (workerCount > 1) { + // Parallel capture → streaming encode + const tasks = distributeFrames(job.totalFrames, workerCount, workDir); - if ( - progress.capturedFrames % 30 === 0 || - progress.capturedFrames === progress.totalFrames - ) { - updateJobStatus( - job, - "rendering", - `Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`, - Math.round(progressPct), - onProgress, - ); - } - }, - onFrameBuffer, - cfg, - ); - - if (probeSession) { - lastBrowserConsole = probeSession.browserConsoleBuffer; - await closeCaptureSession(probeSession); - probeSession = null; - } - } else { - // Sequential capture → streaming encode + const onFrameBuffer = async (frameIndex: number, buffer: Buffer): Promise => { + await reorderBuffer.waitForFrame(frameIndex); + currentEncoder.writeFrame(buffer); + reorderBuffer.advanceTo(frameIndex + 1); + }; - const videoInjector = createVideoFrameInjector(frameLookup); - const session = - probeSession ?? - (await createCaptureSession( + await executeParallelCapture( fileServer.url, - framesDir, + workDir, + tasks, { ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) }, - videoInjector, + () => createVideoFrameInjector(frameLookup), + abortSignal, + (progress) => { + job.framesRendered = progress.capturedFrames; + const frameProgress = progress.capturedFrames / progress.totalFrames; + const progressPct = 25 + frameProgress * 55; + + if ( + progress.capturedFrames % 30 === 0 || + progress.capturedFrames === progress.totalFrames + ) { + updateJobStatus( + job, + "rendering", + `Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`, + Math.round(progressPct), + onProgress, + ); + } + }, + onFrameBuffer, cfg, - )); - if (probeSession) { - prepareCaptureSessionForReuse(session, framesDir, videoInjector); - probeSession = null; - } + ); - try { - if (!session.isInitialized) { - await initializeSession(session); + if (probeSession) { + lastBrowserConsole = probeSession.browserConsoleBuffer; + await closeCaptureSession(probeSession); + probeSession = null; } - assertNotAborted(); - lastBrowserConsole = session.browserConsoleBuffer; - - for (let i = 0; i < totalFrames; i++) { - assertNotAborted(); - const time = i / job.config.fps; - const { buffer } = await captureFrameToBuffer(session, i, time); - await reorderBuffer.waitForFrame(i); - currentEncoder.writeFrame(buffer); - reorderBuffer.advanceTo(i + 1); - job.framesRendered = i + 1; - - const frameProgress = (i + 1) / totalFrames; - const progress = 25 + frameProgress * 55; - - updateJobStatus( - job, - "rendering", - `Streaming frame ${i + 1}/${job.totalFrames}`, - Math.round(progress), - onProgress, - ); + } else { + // Sequential capture → streaming encode + + const videoInjector = createVideoFrameInjector(frameLookup); + const session = + probeSession ?? + (await createCaptureSession( + fileServer.url, + framesDir, + { ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) }, + videoInjector, + cfg, + )); + if (probeSession) { + prepareCaptureSessionForReuse(session, framesDir, videoInjector); + probeSession = null; } - } finally { - lastBrowserConsole = session.browserConsoleBuffer; - await closeCaptureSession(session); - } - } - // Close encoder and get result - const encodeResult = await currentEncoder.close(); - assertNotAborted(); + try { + if (!session.isInitialized) { + await initializeSession(session); + } + assertNotAborted(); + lastBrowserConsole = session.browserConsoleBuffer; - if (!encodeResult.success) { - throw new Error(`Streaming encode failed: ${encodeResult.error}`); - } + for (let i = 0; i < totalFrames; i++) { + assertNotAborted(); + const time = i / job.config.fps; + const { buffer } = await captureFrameToBuffer(session, i, time); + await reorderBuffer.waitForFrame(i); + currentEncoder.writeFrame(buffer); + reorderBuffer.advanceTo(i + 1); + job.framesRendered = i + 1; - perfStages.captureMs = Date.now() - stage4Start; - perfStages.encodeMs = encodeResult.durationMs; // Overlapped with capture - } else { - // ── Disk-based capture (original flow) ──────────────────────────── - if (workerCount > 1) { - // Parallel capture - const tasks = distributeFrames(job.totalFrames, workerCount, workDir); - - await executeParallelCapture( - fileServer.url, - workDir, - tasks, - { ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) }, - () => createVideoFrameInjector(frameLookup), - abortSignal, - (progress) => { - job.framesRendered = progress.capturedFrames; - const frameProgress = progress.capturedFrames / progress.totalFrames; - const progressPct = 25 + frameProgress * 45; + const frameProgress = (i + 1) / totalFrames; + const progress = 25 + frameProgress * 55; - if ( - progress.capturedFrames % 30 === 0 || - progress.capturedFrames === progress.totalFrames - ) { updateJobStatus( job, "rendering", - `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`, - Math.round(progressPct), + `Streaming frame ${i + 1}/${job.totalFrames}`, + Math.round(progress), onProgress, ); } - }, - undefined, - cfg, - ); + } finally { + lastBrowserConsole = session.browserConsoleBuffer; + await closeCaptureSession(session); + } + } - await mergeWorkerFrames(workDir, tasks, framesDir); - if (probeSession) { - lastBrowserConsole = probeSession.browserConsoleBuffer; - await closeCaptureSession(probeSession); - probeSession = null; + // Close encoder and get result + const encodeResult = await currentEncoder.close(); + streamingEncoderClosed = true; + assertNotAborted(); + + if (!encodeResult.success) { + throw new Error(`Streaming encode failed: ${encodeResult.error}`); } + + perfStages.captureMs = Date.now() - stage4Start; + perfStages.encodeMs = encodeResult.durationMs; // Overlapped with capture } else { - // Sequential capture + // ── Disk-based capture (original flow) ──────────────────────────── + if (workerCount > 1) { + // Parallel capture + const tasks = distributeFrames(job.totalFrames, workerCount, workDir); - const videoInjector = createVideoFrameInjector(frameLookup); - const session = - probeSession ?? - (await createCaptureSession( + await executeParallelCapture( fileServer.url, - framesDir, + workDir, + tasks, { ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) }, - videoInjector, + () => createVideoFrameInjector(frameLookup), + abortSignal, + (progress) => { + job.framesRendered = progress.capturedFrames; + const frameProgress = progress.capturedFrames / progress.totalFrames; + const progressPct = 25 + frameProgress * 45; + + if ( + progress.capturedFrames % 30 === 0 || + progress.capturedFrames === progress.totalFrames + ) { + updateJobStatus( + job, + "rendering", + `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`, + Math.round(progressPct), + onProgress, + ); + } + }, + undefined, cfg, - )); - if (probeSession) { - prepareCaptureSessionForReuse(session, framesDir, videoInjector); - probeSession = null; - } + ); - try { - if (!session.isInitialized) { - await initializeSession(session); + await mergeWorkerFrames(workDir, tasks, framesDir); + if (probeSession) { + lastBrowserConsole = probeSession.browserConsoleBuffer; + await closeCaptureSession(probeSession); + probeSession = null; + } + } else { + // Sequential capture + + const videoInjector = createVideoFrameInjector(frameLookup); + const session = + probeSession ?? + (await createCaptureSession( + fileServer.url, + framesDir, + { ...captureOptions, skipReadinessVideoIds: Array.from(nativeHdrVideoIds) }, + videoInjector, + cfg, + )); + if (probeSession) { + prepareCaptureSessionForReuse(session, framesDir, videoInjector); + probeSession = null; } - assertNotAborted(); - lastBrowserConsole = session.browserConsoleBuffer; - for (let i = 0; i < job.totalFrames; i++) { + try { + if (!session.isInitialized) { + await initializeSession(session); + } assertNotAborted(); - const time = i / job.config.fps; - await captureFrame(session, i, time); - job.framesRendered = i + 1; + lastBrowserConsole = session.browserConsoleBuffer; - const frameProgress = (i + 1) / job.totalFrames; - const progress = 25 + frameProgress * 45; + for (let i = 0; i < job.totalFrames; i++) { + assertNotAborted(); + const time = i / job.config.fps; + await captureFrame(session, i, time); + job.framesRendered = i + 1; - updateJobStatus( - job, - "rendering", - `Capturing frame ${i + 1}/${job.totalFrames}`, - Math.round(progress), - onProgress, - ); + const frameProgress = (i + 1) / job.totalFrames; + const progress = 25 + frameProgress * 45; + + updateJobStatus( + job, + "rendering", + `Capturing frame ${i + 1}/${job.totalFrames}`, + Math.round(progress), + onProgress, + ); + } + } finally { + lastBrowserConsole = session.browserConsoleBuffer; + await closeCaptureSession(session); } - } finally { - lastBrowserConsole = session.browserConsoleBuffer; - await closeCaptureSession(session); } - } - perfStages.captureMs = Date.now() - stage4Start; + perfStages.captureMs = Date.now() - stage4Start; - // ── Stage 5: Encode ───────────────────────────────────────────────── - const stage5Start = Date.now(); - updateJobStatus(job, "encoding", "Encoding video", 75, onProgress); - - const frameExt = needsAlpha ? "png" : "jpg"; - const framePattern = `frame_%06d.${frameExt}`; - const encoderOpts = { - fps: job.config.fps, - width, - height, - codec: preset.codec, - preset: preset.preset, - quality: preset.quality, - pixelFormat: preset.pixelFormat, - useGpu: job.config.useGpu, - hdr: preset.hdr, - }; - const encodeResult = enableChunkedEncode - ? await encodeFramesChunkedConcat( - framesDir, - framePattern, - videoOnlyPath, - encoderOpts, - chunkedEncodeSize, - abortSignal, - ) - : await encodeFramesFromDir( - framesDir, - framePattern, - videoOnlyPath, - encoderOpts, - abortSignal, - ); - assertNotAborted(); + // ── Stage 5: Encode ───────────────────────────────────────────────── + const stage5Start = Date.now(); + updateJobStatus(job, "encoding", "Encoding video", 75, onProgress); - if (!encodeResult.success) { - throw new Error(`Encoding failed: ${encodeResult.error}`); - } + const frameExt = needsAlpha ? "png" : "jpg"; + const framePattern = `frame_%06d.${frameExt}`; + const encoderOpts = { + fps: job.config.fps, + width, + height, + codec: preset.codec, + preset: preset.preset, + quality: preset.quality, + pixelFormat: preset.pixelFormat, + useGpu: job.config.useGpu, + hdr: preset.hdr, + }; + const encodeResult = enableChunkedEncode + ? await encodeFramesChunkedConcat( + framesDir, + framePattern, + videoOnlyPath, + encoderOpts, + chunkedEncodeSize, + abortSignal, + ) + : await encodeFramesFromDir( + framesDir, + framePattern, + videoOnlyPath, + encoderOpts, + abortSignal, + ); + assertNotAborted(); + + if (!encodeResult.success) { + throw new Error(`Encoding failed: ${encodeResult.error}`); + } - perfStages.encodeMs = Date.now() - stage5Start; + perfStages.encodeMs = Date.now() - stage5Start; + } + } finally { + // Defensive cleanup: if the streaming encoder branch threw before + // currentEncoder.close() (e.g. capture failure, abort, broken pipe), + // the ffmpeg subprocess would otherwise leak. close() is idempotent so + // this is safe to call alongside the success-path close — we just gate + // on the flag to avoid redundant work. + if (streamingEncoder && !streamingEncoderClosed) { + try { + await streamingEncoder.close(); + } catch (err) { + log.warn("streamingEncoder defensive close failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + } } } // end SDR capture paths block From 991781e6619f63d7bd2a1221f2323ab73f261373 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 20:32:17 -0700 Subject: [PATCH 4/5] test(producer): pin Windows path semantics for isPathInside Address jrusso1020's review on PR #371: add a path.win32 test suite that exercises isPathInside on Linux/macOS CI to catch accidental Unix-only assumptions (e.g. only splitting on "/") that would silently regress for Windows users. - isPathInside now accepts an optional `pathModule` (defaults to node:path) so tests can inject path.win32 / path.posix without changing call sites. - New describe block covers equality, direct/deep children, sibling-prefix rejection, traversal escapes, trailing-backslash normalization, and cross-drive rejection. --- .../producer/src/services/fileServer.test.ts | 39 ++++++++++++++++++- packages/producer/src/services/fileServer.ts | 23 +++++++++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 3d501e60..9d97becf 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import path, { join } from "node:path"; import { tmpdir } from "node:os"; import { HF_BRIDGE_SCRIPT, @@ -112,6 +112,43 @@ describe("isPathInside", () => { rmSync(outsideDir, { recursive: true, force: true }); } }); + + describe("with path.win32 (cross-platform pinning tests)", () => { + // Pin Windows-path semantics on Linux/macOS CI by injecting the win32 + // path module. Without this, accidental Unix-only assumptions (e.g. only + // splitting on "/") would silently regress for Windows users. + const win32 = { pathModule: path.win32 }; + + it("returns true when the child equals the parent", () => { + expect(isPathInside("C:\\foo", "C:\\foo", win32)).toBe(true); + }); + + it("returns true for direct children", () => { + expect(isPathInside("C:\\foo\\bar", "C:\\foo", win32)).toBe(true); + }); + + it("returns true for deeply nested descendants", () => { + expect(isPathInside("C:\\foo\\a\\b\\c.html", "C:\\foo", win32)).toBe(true); + }); + + it("rejects siblings with a shared name prefix", () => { + expect(isPathInside("C:\\foobar\\x", "C:\\foo", win32)).toBe(false); + expect(isPathInside("C:\\foo-other\\x", "C:\\foo", win32)).toBe(false); + }); + + it("rejects path-traversal attempts that escape the parent", () => { + expect(isPathInside("C:\\foo\\..\\etc\\passwd", "C:\\foo", win32)).toBe(false); + }); + + it("treats parents with and without trailing backslashes the same", () => { + expect(isPathInside("C:\\foo\\bar", "C:\\foo\\", win32)).toBe(true); + expect(isPathInside("C:\\foo\\bar", "C:\\foo", win32)).toBe(true); + }); + + it("rejects paths on a different drive letter", () => { + expect(isPathInside("D:\\foo\\bar", "C:\\foo", win32)).toBe(false); + }); + }); }); describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => { diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index bfb74c2d..c27216b6 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -14,8 +14,19 @@ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs"; import { join, extname, resolve, sep } from "node:path"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; +type PathModuleLike = { + resolve: (...segments: string[]) => string; + sep: string; +}; + type IsPathInsideOptions = { resolveSymlinks?: boolean; + /** + * Path module used for resolution and separator comparison. Defaults to + * `node:path` for the running platform. Tests inject `path.win32` / + * `path.posix` to exercise cross-platform behavior on a single OS. + */ + pathModule?: PathModuleLike; }; /** @@ -35,9 +46,11 @@ export function isPathInside( parent: string, options: IsPathInsideOptions = {}, ): boolean { - const { resolveSymlinks = false } = options; - const resolvedChild = resolve(child); - const resolvedParent = resolve(parent); + const { resolveSymlinks = false, pathModule } = options; + const resolveFn = pathModule?.resolve ?? resolve; + const separator = pathModule?.sep ?? sep; + const resolvedChild = resolveFn(child); + const resolvedParent = resolveFn(parent); const normalizedChild = resolveSymlinks && existsSync(resolvedChild) ? realpathSync.native(resolvedChild) @@ -47,7 +60,9 @@ export function isPathInside( ? realpathSync.native(resolvedParent) : resolvedParent; if (normalizedChild === normalizedParent) return true; - const parentWithSep = normalizedParent.endsWith(sep) ? normalizedParent : normalizedParent + sep; + const parentWithSep = normalizedParent.endsWith(separator) + ? normalizedParent + : normalizedParent + separator; return normalizedChild.startsWith(parentWithSep); } From 504e005287ef721161b191540fc450fba517480a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 21:12:34 -0700 Subject: [PATCH 5/5] docs(engine): document idempotency invariants for resource cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamingEncoder.close() and closeCaptureSession() are both invoked from renderOrchestrator's HDR cleanup path with a tracked-flag pattern, but the outer finally block may still re-call them if the inner cleanup raised before the flag flipped. Both must therefore be idempotent. streamingEncoder.close: add an inline comment explaining why each step (clearTimeout, removeEventListener, stdin.end gated on !destroyed, shared exitPromise) is safe to repeat. closeCaptureSession: previously NOT truly idempotent under browser-pool semantics — releaseBrowser decrements pooledBrowserRefCount, so calling it twice for the same acquire could close a browser another session still holds. Add per-session pageReleased/browserReleased flags to the CaptureSession interface and gate page.close()/releaseBrowser() behind them. Set the flag AFTER the await so a mid-cleanup throw still allows the outer defensive call to retry the unreleased resource. --- .../engine/src/services/chunkEncoder.test.ts | 22 -------------- packages/engine/src/services/chunkEncoder.ts | 4 --- packages/engine/src/services/frameCapture.ts | 30 +++++++++++++++++-- .../engine/src/services/streamingEncoder.ts | 16 ++++++++-- .../src/services/videoFrameExtractor.ts | 9 ------ 5 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index ab88b504..98e104c8 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -356,25 +356,12 @@ describe("buildEncoderArgs HDR color space", () => { expect(args[paramIdx + 1]).not.toContain("max-cll"); }); -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) it("strips HDR and tags as SDR/BT.709 when codec=h264 (libx264 has no HDR support)", () => { // libx264 cannot encode HDR. Rather than emit a "half-HDR" file (BT.2020 // container tags + BT.709 VUI inside the bitstream — confusing to HDR-aware // players), we strip hdr and tag the whole output as SDR/BT.709. The caller // gets a warning telling them to use codec=h265 for real HDR output. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); -<<<<<<< HEAD -======= - it("keeps bt709 x264-params tagging even when HDR is requested (libx264 has no HDR support)", () => { - // libx264 cannot embed HDR static metadata. The codec-level color tags - // still flip to BT.2020 (so containers describe pixels correctly), but - // the x264-params VUI block stays bt709 since x264 doesn't speak HDR. ->>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) -======= ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) const args = buildEncoderArgs( { ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } }, inputArgs, @@ -383,10 +370,6 @@ describe("buildEncoderArgs HDR color space", () => { const paramIdx = args.indexOf("-x264-params"); expect(args[paramIdx + 1]).toContain("colorprim=bt709"); expect(args[paramIdx + 1]).not.toContain("master-display"); -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709"); expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709"); expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709"); @@ -394,11 +377,6 @@ describe("buildEncoderArgs HDR color space", () => { expect.stringContaining("HDR is not supported with codec=h264"), ); warnSpy.mockRestore(); -<<<<<<< HEAD -======= ->>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) -======= ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) }); it("uses range conversion for HDR CPU encoding", () => { diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index e4c77f81..f44361ea 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -97,11 +97,7 @@ export function buildEncoderArgs( "[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " + "Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.", ); -<<<<<<< HEAD options = { ...options, hdr: undefined }; -======= - options = { ...options, hdr: false }; ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) } const args: string[] = [...inputArgs, "-r", String(fps)]; diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 8408dae7..16b12ecb 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -42,6 +42,11 @@ export interface CaptureSession { outputDir: string; onBeforeCapture: BeforeCaptureHook | null; isInitialized: boolean; + // Tracks whether the page/browser handles have already been released by + // closeCaptureSession. Used to make closeCaptureSession idempotent under + // browser-pool semantics (see the function body for the full invariant). + pageReleased?: boolean; + browserReleased?: boolean; browserConsoleBuffer: string[]; capturePerf: { frames: number; @@ -532,8 +537,29 @@ export async function captureFrameToBuffer( } export async function closeCaptureSession(session: CaptureSession): Promise { - if (session.page) await session.page.close().catch(() => {}); - if (session.browser) await releaseBrowser(session.browser, session.config); + // INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR + // cleanup path tracks a `domSessionClosed` flag and may still re-call this + // in the outer finally if the inner cleanup raised before the flag flipped. + // + // Naive idempotency would be unsafe under pool semantics: releaseBrowser + // decrements pooledBrowserRefCount, so calling it twice for the same + // acquire could close a browser that another session still holds. We make + // it safe by gating each release behind a per-session "released" flag — + // the second call sees the flag already set and skips the release. + // + // We set the flag AFTER (not before) the await so that if a release throws + // midway, the unreleased resource is retried by the outer defensive call. + // Example: page release succeeds, browser release throws → pageReleased=true + // but browserReleased=false → second call no-ops on page and retries browser. + // This matches the orchestrator's intent for HDR cleanup. + if (!session.pageReleased && session.page) { + await session.page.close().catch(() => {}); + session.pageReleased = true; + } + if (!session.browserReleased && session.browser) { + await releaseBrowser(session.browser, session.config); + session.browserReleased = true; + } session.isInitialized = false; } diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 2d51b406..a8858d0f 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -397,10 +397,23 @@ export async function spawnStreamingEncoder( }, close: async (): Promise => { + // INVARIANT: close() is idempotent. The renderOrchestrator HDR cleanup + // path tracks an `encoderClosed` flag and may still re-call close() in + // the outer finally if the inner cleanup raised before the flag flipped. + // Each step here must be safe to repeat: + // - clearTimeout: safe to call on an already-cleared/fired timer + // - removeEventListener: no-op if the listener was already removed + // (and {once: true} would have removed it on the first abort anyway) + // - stdin.end gated on !destroyed: skipped on the second call + // - exitPromise: a single shared Promise; awaiting an already-resolved + // Promise resolves immediately with the same captured exitCode + // The returned StreamingEncoderResult is therefore consistent across + // repeated calls. If you change this method, preserve idempotency or + // a regression here will silently double-close ffmpeg and produce + // harder-to-trace errors at the orchestrator layer. clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); - // Close stdin to signal end of input const stdin = ffmpeg.stdin; if (stdin && !stdin.destroyed) { await new Promise((resolve) => { @@ -408,7 +421,6 @@ export async function spawnStreamingEncoder( }); } - // Wait for FFmpeg to finish await exitPromise; const durationMs = Date.now() - startTime; diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 09abcac4..3980e3a1 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -414,21 +414,12 @@ export async function extractAllVideoFrames( const hdrInfo = analyzeCompositionHdr(videoColorSpaces); if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) { -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) // dominantTransfer is "majority wins" — if a composition mixes PQ and HLG // sources (rare but legal), the minority transfer's videos get converted // with the wrong curve. We treat this as caller-error: a single composition // should not mix PQ and HLG sources, the orchestrator picks one transfer // for the whole render, and any source not on that curve is normalized to // it. If you need both transfers, render two separate compositions. -<<<<<<< HEAD -======= ->>>>>>> 2afdab1e (feat(engine): wire options.hdr through chunkEncoder + dynamic SDR→HDR transfer) -======= ->>>>>>> af56a8f4 (fix(engine): reject libx264+HDR, document GPU mastering limits + mixed-transfer caller error) const targetTransfer = hdrInfo.dominantTransfer; const convertDir = join(options.outputDir, "_hdr_normalized"); mkdirSync(convertDir, { recursive: true });