From f39b02a8e618d441535af9c3d9e1dc89e91547c6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 16 Apr 2026 22:58:00 -0700 Subject: [PATCH] feat(engine): add HDR two-pass compositing with DOM alpha overlay Per-frame in-memory alpha compositing: Chrome screenshots DOM with transparent background (PNG alpha), FFmpeg extracts native HLG/PQ frames as 16-bit PNG. DOM pixels (sRGB RGBA8) composited over HDR (rgb48le) via 256-entry sRGB-to-HLG/PQ LUT. Pure Node.js PNG decoder, single-pass FFmpeg extraction, native HDR detection before SDR conversion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 + packages/engine/src/index.ts | 14 +- .../engine/src/services/screenshotService.ts | 72 ++++ .../engine/src/services/streamingEncoder.ts | 21 +- .../engine/src/services/videoFrameInjector.ts | 108 +++++ packages/engine/src/utils/alphaBlit.test.ts | 328 +++++++++++++++ packages/engine/src/utils/alphaBlit.ts | 394 ++++++++++++++++++ .../src/services/renderOrchestrator.ts | 194 +++++++-- 8 files changed, 1097 insertions(+), 40 deletions(-) create mode 100644 packages/engine/src/utils/alphaBlit.test.ts create mode 100644 packages/engine/src/utils/alphaBlit.ts diff --git a/.gitignore b/.gitignore index 95a85b55..0846bd73 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,14 @@ packages/producer/tests/*/failures/ # Rendered output (not test fixtures — those use git LFS) output/ +renders/ !packages/producer/tests/*/output/ +# Composition source media (large binaries) +compositions/**/*.mp4 +compositions/**/*.mov +compositions/**/*.MOV + # npm pack artifacts *.tgz diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b61b4bd6..cba8278a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -77,6 +77,8 @@ export { injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, + initTransparentBackground, + captureAlphaPng, type BeginFrameResult, } from "./services/screenshotService.js"; @@ -154,15 +156,25 @@ export { export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js"; +export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le } from "./utils/alphaBlit.js"; + export { initHdrReadback, uploadAndReadbackHdrFrame, - convertHdrFrameToRgb48le, float16ToPqRgb, buildHdrChromeArgs, launchHdrBrowser, } from "./services/hdrCapture.js"; +export { captureScreenshotWithAlpha } from "./services/screenshotService.js"; + +export { + hideVideoElements, + showVideoElements, + queryVideoElementBounds, + type VideoElementBounds, +} from "./services/videoFrameInjector.js"; + export { isHdrColorSpace, detectTransfer, diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index 5be0f324..6a27ed8c 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -133,6 +133,78 @@ export async function pageScreenshotCapture(page: Page, options: CaptureOptions) return Buffer.from(result.data, "base64"); } +/** + * Capture a screenshot with transparent background (PNG + alpha channel). + * + * Used in the two-pass HDR compositing pipeline — captures DOM content + * (text, graphics, SDR overlays) with transparency where the background shows, + * so it can be overlaid on top of native HDR video frames in FFmpeg. + * + * Sets and restores the background color override on every call. For sessions + * that capture many frames, prefer calling initTransparentBackground() once + * at session init, then captureAlphaPng() per frame to avoid the 2× CDP + * round-trip overhead. + */ +export async function captureScreenshotWithAlpha( + page: Page, + width: number, + height: number, +): Promise { + const client = await getCdpSession(page); + // Force transparent background so the screenshot has a real alpha channel + await client.send("Emulation.setDefaultBackgroundColorOverride", { + color: { r: 0, g: 0, b: 0, a: 0 }, + }); + const result = await client.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + optimizeForSpeed: false, // must be false to preserve alpha + clip: { x: 0, y: 0, width, height, scale: 1 }, + }); + // Restore opaque background for subsequent captures + await client.send("Emulation.setDefaultBackgroundColorOverride", {}); + return Buffer.from(result.data, "base64"); +} + +/** + * Set the page background to transparent once for a dedicated HDR DOM session. + * + * Call this once after session initialization. Then use captureAlphaPng() per + * frame instead of captureScreenshotWithAlpha() to skip the per-frame CDP + * background override round-trips. + * + * Only use on sessions that are exclusively dedicated to transparent capture + * (e.g., the HDR two-pass DOM layer session) — the background will stay + * transparent for the lifetime of the session. + */ +export async function initTransparentBackground(page: Page): Promise { + const client = await getCdpSession(page); + await client.send("Emulation.setDefaultBackgroundColorOverride", { + color: { r: 0, g: 0, b: 0, a: 0 }, + }); +} + +/** + * Capture a transparent-background PNG screenshot without setting the + * background color override. Requires initTransparentBackground() to have + * been called once on this session. + * + * Faster than captureScreenshotWithAlpha() for per-frame use in the HDR + * two-pass compositing loop. + */ +export async function captureAlphaPng(page: Page, width: number, height: number): Promise { + const client = await getCdpSession(page); + const result = await client.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + optimizeForSpeed: false, // must be false to preserve alpha + clip: { x: 0, y: 0, width, height, scale: 1 }, + }); + return Buffer.from(result.data, "base64"); +} + export async function injectVideoFramesBatch( page: Page, updates: Array<{ videoId: string; dataUri: string }>, diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 23d6c5ea..09f0d88d 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -121,7 +121,13 @@ function buildStreamingArgs( // Input args: pipe from stdin const args: string[] = []; if (options.rawInputFormat) { - // Raw pixel input (HDR PQ-encoded rgb48le) + // Raw pixel input (HLG/PQ-encoded rgb48le from FFmpeg extraction). + // Tag the input with the correct color space so FFmpeg uses the right + // YUV matrix when converting rgb48le → yuv420p10le for encoding. + // Without these tags FFmpeg assumes bt709 and applies the wrong matrix. + const hdrTransfer = options.hdr?.transfer; + const inputColorTrc = + hdrTransfer === "pq" ? "smpte2084" : hdrTransfer === "hlg" ? "arib-std-b67" : undefined; args.push( "-f", "rawvideo", @@ -131,9 +137,18 @@ function buildStreamingArgs( `${options.width}x${options.height}`, "-framerate", String(fps), - "-i", - "-", ); + if (inputColorTrc) { + args.push( + "-color_primaries", + "bt2020", + "-color_trc", + inputColorTrc, + "-colorspace", + "bt2020nc", + ); + } + args.push("-i", "-"); } else { const inputCodec = imageFormat === "png" ? "png" : "mjpeg"; args.push("-f", "image2pipe", "-vcodec", inputCodec, "-framerate", String(fps), "-i", "-"); diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts index cfba09e8..668cee44 100644 --- a/packages/engine/src/services/videoFrameInjector.ts +++ b/packages/engine/src/services/videoFrameInjector.ts @@ -117,3 +117,111 @@ export function createVideoFrameInjector( } }; } + +// ── HDR compositing utilities ───────────────────────────────────────────────── + +/** + * Bounds and transform of a video element, queried from Chrome each frame. + * Used by the two-pass HDR compositing pipeline to position native HDR frames. + */ +export interface VideoElementBounds { + videoId: string; + x: number; + y: number; + width: number; + height: number; + opacity: number; + /** CSS transform matrix as a DOMMatrix-compatible string, e.g. "matrix(1,0,0,1,0,0)" */ + transform: string; + zIndex: number; + visible: boolean; +} + +/** + * Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so + * Chrome screenshots only contain DOM content (text, overlays) with transparent + * holes where the HDR videos go. + */ +export async function hideVideoElements(page: Page, videoIds: string[]): Promise { + if (videoIds.length === 0) return; + await page.evaluate((ids: string[]) => { + for (const id of ids) { + const el = document.getElementById(id) as HTMLVideoElement | null; + if (el) { + el.style.setProperty("visibility", "hidden", "important"); + el.style.setProperty("opacity", "0", "important"); + // Also hide the injected render frame image if present + const img = document.getElementById(`__render_frame_${id}__`); + if (img) img.style.setProperty("visibility", "hidden", "important"); + } + } + }, videoIds); +} + +/** + * Restore visibility of video elements after a DOM screenshot. + */ +export async function showVideoElements(page: Page, videoIds: string[]): Promise { + if (videoIds.length === 0) return; + await page.evaluate((ids: string[]) => { + for (const id of ids) { + const el = document.getElementById(id) as HTMLVideoElement | null; + if (el) { + el.style.removeProperty("visibility"); + el.style.removeProperty("opacity"); + const img = document.getElementById(`__render_frame_${id}__`); + if (img) img.style.removeProperty("visibility"); + } + } + }, videoIds); +} + +/** + * Query the current bounds, transform, and visibility of video elements. + * Called after seeking (so GSAP has moved things) but before the screenshot. + */ +export async function queryVideoElementBounds( + page: Page, + videoIds: string[], +): Promise { + if (videoIds.length === 0) return []; + return page.evaluate((ids: string[]): VideoElementBounds[] => { + return ids.map((id) => { + const el = document.getElementById(id) as HTMLVideoElement | null; + if (!el) { + return { + videoId: id, + x: 0, + y: 0, + width: 0, + height: 0, + opacity: 0, + transform: "none", + zIndex: 0, + visible: false, + }; + } + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + const zIndex = parseInt(style.zIndex) || 0; + const opacity = parseFloat(style.opacity) || 1; + const transform = style.transform || "none"; + const visible = + style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0; + return { + videoId: id, + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + opacity, + transform, + zIndex, + visible, + }; + }); + }, videoIds); +} diff --git a/packages/engine/src/utils/alphaBlit.test.ts b/packages/engine/src/utils/alphaBlit.test.ts new file mode 100644 index 00000000..78b7bcb0 --- /dev/null +++ b/packages/engine/src/utils/alphaBlit.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from "vitest"; +import { deflateSync } from "zlib"; +import { decodePng, blitRgba8OverRgb48le } from "./alphaBlit.js"; + +// ── PNG construction helpers ───────────────────────────────────────────────── + +function uint32BE(n: number): Buffer { + const b = Buffer.allocUnsafe(4); + b.writeUInt32BE(n, 0); + return b; +} + +function crc32(data: Buffer): number { + let crc = 0xffffffff; + const table = crc32Table(); + for (let i = 0; i < data.length; i++) { + crc = table[((crc ^ data[i]!) & 0xff)!]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +let _crcTable: Uint32Array | undefined; +function crc32Table(): Uint32Array { + if (_crcTable) return _crcTable; + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[i] = c; + } + _crcTable = t; + return t; +} + +function makeChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii"); + const crcInput = Buffer.concat([typeBuffer, data]); + const crcBuf = uint32BE(crc32(crcInput)); + return Buffer.concat([uint32BE(data.length), typeBuffer, data, crcBuf]); +} + +const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +/** + * Build a minimal RGBA PNG for testing. + * pixels: flat RGBA array (row-major, 8-bit per channel) + */ +function makePng(width: number, height: number, pixels: number[]): Buffer { + // IHDR + const ihdr = Buffer.allocUnsafe(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter method + ihdr[12] = 0; // interlace none + + // Raw scanlines with filter byte 0 (None) + const scanlines: number[] = []; + for (let y = 0; y < height; y++) { + scanlines.push(0); // filter type None + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + scanlines.push(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!, pixels[i + 3]!); + } + } + + const idatData = deflateSync(Buffer.from(scanlines)); + + return Buffer.concat([ + PNG_SIG, + makeChunk("IHDR", ihdr), + makeChunk("IDAT", idatData), + makeChunk("IEND", Buffer.alloc(0)), + ]); +} + +// ── decodePng tests ────────────────────────────────────────────────────────── + +describe("decodePng", () => { + it("decodes a 1x1 RGBA PNG correctly", () => { + // RGBA: red pixel, full opacity + const png = makePng(1, 1, [255, 0, 0, 255]); + const { width, height, data } = decodePng(png); + expect(width).toBe(1); + expect(height).toBe(1); + expect(data[0]).toBe(255); // R + expect(data[1]).toBe(0); // G + expect(data[2]).toBe(0); // B + expect(data[3]).toBe(255); // A + }); + + it("decodes a 2x2 RGBA PNG with multiple pixels", () => { + // TL=red, TR=green, BL=blue, BR=white (all full opacity) + const pixels = [ + 255, + 0, + 0, + 255, // TL red + 0, + 255, + 0, + 255, // TR green + 0, + 0, + 255, + 255, // BL blue + 255, + 255, + 255, + 255, // BR white + ]; + const png = makePng(2, 2, pixels); + const { width, height, data } = decodePng(png); + expect(width).toBe(2); + expect(height).toBe(2); + + // Top-left: red + expect(data[0]).toBe(255); + expect(data[1]).toBe(0); + expect(data[2]).toBe(0); + expect(data[3]).toBe(255); + + // Bottom-right: white + expect(data[12]).toBe(255); + expect(data[13]).toBe(255); + expect(data[14]).toBe(255); + expect(data[15]).toBe(255); + }); + + it("decodes a transparent pixel correctly", () => { + const png = makePng(1, 1, [128, 64, 32, 0]); + const { data } = decodePng(png); + expect(data[3]).toBe(0); // alpha = 0 + }); + + it("decodes a semi-transparent pixel correctly", () => { + const png = makePng(1, 1, [100, 150, 200, 128]); + const { data } = decodePng(png); + expect(data[0]).toBe(100); + expect(data[1]).toBe(150); + expect(data[2]).toBe(200); + expect(data[3]).toBe(128); + }); + + it("throws on invalid PNG signature", () => { + const buf = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(() => decodePng(buf)).toThrow("not a PNG file"); + }); +}); + +// ── blitRgba8OverRgb48le tests ─────────────────────────────────────────────── + +/** Build an rgb48le buffer with a single solid color (16-bit per channel) */ +function makeHdrFrame( + width: number, + height: number, + r16: number, + g16: number, + b16: number, +): Buffer { + const buf = Buffer.allocUnsafe(width * height * 6); + for (let i = 0; i < width * height; i++) { + buf.writeUInt16LE(r16, i * 6); + buf.writeUInt16LE(g16, i * 6 + 2); + buf.writeUInt16LE(b16, i * 6 + 4); + } + return buf; +} + +/** Build a raw RGBA array (Uint8Array) with a single solid color */ +function makeDomRgba( + width: number, + height: number, + r: number, + g: number, + b: number, + a: number, +): Uint8Array { + const arr = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + arr[i * 4 + 0] = r; + arr[i * 4 + 1] = g; + arr[i * 4 + 2] = b; + arr[i * 4 + 3] = a; + } + return arr; +} + +describe("blitRgba8OverRgb48le", () => { + it("fully transparent DOM: HDR pixel passes through unchanged", () => { + const hdr = makeHdrFrame(1, 1, 32000, 40000, 50000); + const dom = makeDomRgba(1, 1, 255, 0, 0, 0); // red but alpha=0 + const out = blitRgba8OverRgb48le(dom, hdr, 1, 1); + + expect(out.readUInt16LE(0)).toBe(32000); + expect(out.readUInt16LE(2)).toBe(40000); + expect(out.readUInt16LE(4)).toBe(50000); + }); + + it("fully opaque DOM: sRGB→HLG converted values", () => { + const hdr = makeHdrFrame(1, 1, 10000, 20000, 30000); + const dom = makeDomRgba(1, 1, 255, 128, 0, 255); // R=255, G=128, B=0, full opaque + const out = blitRgba8OverRgb48le(dom, hdr, 1, 1); + + // sRGB 255 → HLG 65535 (white maps to white) + // sRGB 128 → HLG ~46484 (mid-gray maps higher due to HLG OETF) + // sRGB 0 → HLG 0 + expect(out.readUInt16LE(0)).toBe(65535); + expect(out.readUInt16LE(2)).toBeGreaterThan(40000); // HLG mid-gray > sRGB mid-gray + expect(out.readUInt16LE(2)).toBeLessThan(50000); + expect(out.readUInt16LE(4)).toBe(0); + }); + + it("sRGB→HLG: black stays black, white stays white", () => { + const hdr = makeHdrFrame(1, 1, 0, 0, 0); + const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255); + const outBlack = blitRgba8OverRgb48le(domBlack, hdr, 1, 1); + expect(outBlack.readUInt16LE(0)).toBe(0); + + const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255); + const outWhite = blitRgba8OverRgb48le(domWhite, hdr, 1, 1); + expect(outWhite.readUInt16LE(0)).toBe(65535); + }); + + it("50% alpha: HLG-converted DOM blended with HDR", () => { + // DOM: white (255, 255, 255) at alpha=128 (~50%) + // HDR: black (0, 0, 0) + const hdr = makeHdrFrame(1, 1, 0, 0, 0); + const dom = makeDomRgba(1, 1, 255, 255, 255, 128); + const out = blitRgba8OverRgb48le(dom, hdr, 1, 1); + + // sRGB 255 → HLG 65535, blended 50/50 with black + const alpha = 128 / 255; + const expectedR = Math.round(65535 * alpha); + expect(out.readUInt16LE(0)).toBeCloseTo(expectedR, -1); + }); + + it("50% alpha blends with non-zero HDR", () => { + // DOM: 8-bit red=200, HDR: 16-bit red=32000, alpha=128 + const hdr = makeHdrFrame(1, 1, 32000, 0, 0); + const dom = makeDomRgba(1, 1, 200, 0, 0, 128); + const out = blitRgba8OverRgb48le(dom, hdr, 1, 1); + + // sRGB 200 → HLG value, blended ~50/50 with HDR red=32000 + // Result should be higher than 32000 (pulled up by the HLG-converted DOM value) + expect(out.readUInt16LE(0)).toBeGreaterThan(32000); + }); + + it("handles a 2x2 frame correctly pixel-by-pixel", () => { + const hdr = makeHdrFrame(2, 2, 0, 0, 0); + // First pixel: fully opaque white. Others: fully transparent. + const dom = new Uint8Array(2 * 2 * 4); + dom[0] = 255; + dom[1] = 255; + dom[2] = 255; + dom[3] = 255; // pixel 0: opaque white + // pixels 1-3: alpha=0 (transparent) + + const out = blitRgba8OverRgb48le(dom, hdr, 2, 2); + + // Pixel 0: sRGB white → HLG white (65535) + expect(out.readUInt16LE(0)).toBe(65535); + expect(out.readUInt16LE(2)).toBe(65535); + expect(out.readUInt16LE(4)).toBe(65535); + + // Pixel 1: transparent DOM → HDR black (0, 0, 0) + expect(out.readUInt16LE(6)).toBe(0); + expect(out.readUInt16LE(8)).toBe(0); + expect(out.readUInt16LE(10)).toBe(0); + }); + + it("output buffer has correct size", () => { + const hdr = makeHdrFrame(4, 3, 0, 0, 0); + const dom = makeDomRgba(4, 3, 0, 0, 0, 0); + const out = blitRgba8OverRgb48le(dom, hdr, 4, 3); + expect(out.length).toBe(4 * 3 * 6); + }); +}); + +// ── Round-trip test: decodePng → blitRgba8OverRgb48le ──────────────────────── + +describe("decodePng + blitRgba8OverRgb48le integration", () => { + it("transparent PNG overlay leaves HDR frame untouched", () => { + const width = 2; + const height = 2; + + // Build a fully transparent PNG + const pixels = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // all alpha=0 + const png = makePng(width, height, pixels); + const { data: domRgba } = decodePng(png); + + // HDR frame with known values + const hdr = makeHdrFrame(width, height, 10000, 20000, 30000); + + const out = blitRgba8OverRgb48le(domRgba, hdr, width, height); + + // All pixels should be unchanged HDR + for (let i = 0; i < width * height; i++) { + expect(out.readUInt16LE(i * 6 + 0)).toBe(10000); + expect(out.readUInt16LE(i * 6 + 2)).toBe(20000); + expect(out.readUInt16LE(i * 6 + 4)).toBe(30000); + } + }); + + it("fully opaque PNG overlay covers all HDR pixels (sRGB→HLG)", () => { + const width = 2; + const height = 2; + + // Build a fully opaque blue PNG (sRGB blue = 0,0,255) + const pixels = Array(width * height) + .fill(null) + .flatMap(() => [0, 0, 255, 255]); + const png = makePng(width, height, pixels); + const { data: domRgba } = decodePng(png); + + const hdr = makeHdrFrame(width, height, 50000, 40000, 30000); + const out = blitRgba8OverRgb48le(domRgba, hdr, width, height); + + // sRGB blue (0,0,255) → HLG (0, 0, 65535) — black/white map identically + for (let i = 0; i < width * height; i++) { + expect(out.readUInt16LE(i * 6 + 0)).toBe(0); + expect(out.readUInt16LE(i * 6 + 2)).toBe(0); + expect(out.readUInt16LE(i * 6 + 4)).toBe(65535); + } + }); +}); diff --git a/packages/engine/src/utils/alphaBlit.ts b/packages/engine/src/utils/alphaBlit.ts new file mode 100644 index 00000000..ff4b749e --- /dev/null +++ b/packages/engine/src/utils/alphaBlit.ts @@ -0,0 +1,394 @@ +/** + * Alpha Blit — in-memory PNG decode + alpha compositing over rgb48le HDR frames. + * + * Replaces per-frame FFmpeg spawns for the two-pass HDR compositing path. + * Uses only Node.js built-ins (zlib) — no additional dependencies. + */ + +import { inflateSync } from "zlib"; + +// ── PNG decoder ─────────────────────────────────────────────────────────────── + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) return a; + if (pb <= pc) return b; + return c; +} + +/** + * Decode a PNG buffer to raw RGBA pixel data (8-bit per channel). + * + * Supports color type 6 (RGBA) and color type 2 (RGB) at 8-bit depth, + * non-interlaced. Chrome's Page.captureScreenshot always emits this format. + * + * Returns a Uint8Array of width*height*4 bytes in RGBA order. + */ +export function decodePng(buf: Buffer): { width: number; height: number; data: Uint8Array } { + // Verify PNG signature + if ( + buf[0] !== 137 || + buf[1] !== 80 || + buf[2] !== 78 || + buf[3] !== 71 || + buf[4] !== 13 || + buf[5] !== 10 || + buf[6] !== 26 || + buf[7] !== 10 + ) { + throw new Error("decodePng: not a PNG file"); + } + + let pos = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idatChunks: Buffer[] = []; + + while (pos + 12 <= buf.length) { + const chunkLen = buf.readUInt32BE(pos); + const chunkType = buf.toString("ascii", pos + 4, pos + 8); + const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen); + + if (chunkType === "IHDR") { + width = chunkData.readUInt32BE(0); + height = chunkData.readUInt32BE(4); + bitDepth = chunkData[8] ?? 0; + colorType = chunkData[9] ?? 0; + } else if (chunkType === "IDAT") { + idatChunks.push(Buffer.from(chunkData)); + } else if (chunkType === "IEND") { + break; + } + + pos += 12 + chunkLen; // length(4) + type(4) + data(chunkLen) + crc(4) + } + + if (bitDepth !== 8) { + throw new Error(`decodePng: unsupported bit depth ${bitDepth} (expected 8)`); + } + // colorType 6 = RGBA, colorType 2 = RGB + if (colorType !== 6 && colorType !== 2) { + throw new Error(`decodePng: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`); + } + + const bpp = colorType === 6 ? 4 : 3; // bytes per pixel in the PNG stream + const stride = width * bpp; + + const compressed = Buffer.concat(idatChunks); + const decompressed = inflateSync(compressed); + + // Reconstruct filtered rows → output RGBA + const output = new Uint8Array(width * height * 4); + const prevRow = new Uint8Array(stride); + const currRow = new Uint8Array(stride); + + let srcPos = 0; + + for (let y = 0; y < height; y++) { + const filterType = decompressed[srcPos++] ?? 0; + const rawRow = decompressed.subarray(srcPos, srcPos + stride); + srcPos += stride; + + // Apply PNG filter to reconstruct scanline + switch (filterType) { + case 0: // None + currRow.set(rawRow); + break; + case 1: // Sub — difference from left pixel + for (let x = 0; x < stride; x++) { + currRow[x] = ((rawRow[x] ?? 0) + (x >= bpp ? (currRow[x - bpp] ?? 0) : 0)) & 0xff; + } + break; + case 2: // Up — difference from above pixel + for (let x = 0; x < stride; x++) { + currRow[x] = ((rawRow[x] ?? 0) + (prevRow[x] ?? 0)) & 0xff; + } + break; + case 3: // Average — difference from floor((left + above) / 2) + for (let x = 0; x < stride; x++) { + const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0; + const up = prevRow[x] ?? 0; + currRow[x] = ((rawRow[x] ?? 0) + Math.floor((left + up) / 2)) & 0xff; + } + break; + case 4: // Paeth predictor + for (let x = 0; x < stride; x++) { + const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0; + const up = prevRow[x] ?? 0; + const upLeft = x >= bpp ? (prevRow[x - bpp] ?? 0) : 0; + currRow[x] = ((rawRow[x] ?? 0) + paeth(left, up, upLeft)) & 0xff; + } + break; + default: + throw new Error(`decodePng: unknown filter type ${filterType} at row ${y}`); + } + + // Write to output as RGBA (expand RGB→RGBA if colorType=2) + const dstBase = y * width * 4; + if (colorType === 6) { + output.set(currRow, dstBase); + } else { + // RGB → RGBA: set alpha to 255 + for (let x = 0; x < width; x++) { + output[dstBase + x * 4 + 0] = currRow[x * 3 + 0] ?? 0; + output[dstBase + x * 4 + 1] = currRow[x * 3 + 1] ?? 0; + output[dstBase + x * 4 + 2] = currRow[x * 3 + 2] ?? 0; + output[dstBase + x * 4 + 3] = 255; + } + } + + prevRow.set(currRow); + } + + return { width, height, data: output }; +} + +// ── 16-bit PNG decoder ──────────────────────────────────────────────────────── + +/** + * Decode a 16-bit RGB PNG (from FFmpeg) to an rgb48le Buffer. + * + * FFmpeg's `-pix_fmt rgb48le -c:v png` produces 16-bit RGB PNGs. + * PNG stores 16-bit values in big-endian; this function swaps to little-endian + * for the streaming encoder's rgb48le input format. + * + * Supports colorType 2 (RGB) at 16-bit depth, non-interlaced. + */ +export function decodePngToRgb48le(buf: Buffer): { width: number; height: number; data: Buffer } { + // Verify PNG signature + if ( + buf[0] !== 137 || + buf[1] !== 80 || + buf[2] !== 78 || + buf[3] !== 71 || + buf[4] !== 13 || + buf[5] !== 10 || + buf[6] !== 26 || + buf[7] !== 10 + ) { + throw new Error("decodePngToRgb48le: not a PNG file"); + } + + let pos = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idatChunks: Buffer[] = []; + + while (pos + 12 <= buf.length) { + const chunkLen = buf.readUInt32BE(pos); + const chunkType = buf.toString("ascii", pos + 4, pos + 8); + const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen); + + if (chunkType === "IHDR") { + width = chunkData.readUInt32BE(0); + height = chunkData.readUInt32BE(4); + bitDepth = chunkData[8] ?? 0; + colorType = chunkData[9] ?? 0; + } else if (chunkType === "IDAT") { + idatChunks.push(Buffer.from(chunkData)); + } else if (chunkType === "IEND") { + break; + } + + pos += 12 + chunkLen; + } + + if (bitDepth !== 16) { + throw new Error(`decodePngToRgb48le: unsupported bit depth ${bitDepth} (expected 16)`); + } + if (colorType !== 2 && colorType !== 6) { + throw new Error( + `decodePngToRgb48le: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`, + ); + } + + // 16-bit: 2 bytes per channel. RGB=6 bytes/pixel, RGBA=8 bytes/pixel + const bpp = colorType === 6 ? 8 : 6; + const stride = width * bpp; + + const compressed = Buffer.concat(idatChunks); + const decompressed = inflateSync(compressed); + + // Reconstruct filtered rows (filter operates on individual bytes) + const currRow = new Uint8Array(stride); + const prevRow = new Uint8Array(stride); + + // Output: rgb48le = 3 channels × 2 bytes (LE) = 6 bytes/pixel + const output = Buffer.allocUnsafe(width * height * 6); + + let srcPos = 0; + + for (let y = 0; y < height; y++) { + const filterType = decompressed[srcPos++] ?? 0; + const rawRow = decompressed.subarray(srcPos, srcPos + stride); + srcPos += stride; + + switch (filterType) { + case 0: + currRow.set(rawRow); + break; + case 1: + for (let x = 0; x < stride; x++) { + currRow[x] = ((rawRow[x] ?? 0) + (x >= bpp ? (currRow[x - bpp] ?? 0) : 0)) & 0xff; + } + break; + case 2: + for (let x = 0; x < stride; x++) { + currRow[x] = ((rawRow[x] ?? 0) + (prevRow[x] ?? 0)) & 0xff; + } + break; + case 3: + for (let x = 0; x < stride; x++) { + const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0; + const up = prevRow[x] ?? 0; + currRow[x] = ((rawRow[x] ?? 0) + Math.floor((left + up) / 2)) & 0xff; + } + break; + case 4: + for (let x = 0; x < stride; x++) { + const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0; + const up = prevRow[x] ?? 0; + const upLeft = x >= bpp ? (prevRow[x - bpp] ?? 0) : 0; + currRow[x] = ((rawRow[x] ?? 0) + paeth(left, up, upLeft)) & 0xff; + } + break; + default: + throw new Error(`decodePngToRgb48le: unknown filter type ${filterType} at row ${y}`); + } + + // Convert big-endian 16-bit RGB(A) → little-endian rgb48le (drop alpha if RGBA) + const dstBase = y * width * 6; + for (let x = 0; x < width; x++) { + const srcBase = x * bpp; + // PNG stores 16-bit as big-endian: [high, low]. Swap to little-endian: [low, high]. + output[dstBase + x * 6 + 0] = currRow[srcBase + 1] ?? 0; // R low + output[dstBase + x * 6 + 1] = currRow[srcBase + 0] ?? 0; // R high + output[dstBase + x * 6 + 2] = currRow[srcBase + 3] ?? 0; // G low + output[dstBase + x * 6 + 3] = currRow[srcBase + 2] ?? 0; // G high + output[dstBase + x * 6 + 4] = currRow[srcBase + 5] ?? 0; // B low + output[dstBase + x * 6 + 5] = currRow[srcBase + 4] ?? 0; // B high + } + + prevRow.set(currRow); + } + + return { width, height, data: output }; +} + +// ── sRGB → HLG color conversion ─────────────────────────────────────────────── + +/** + * 256-entry LUT: sRGB 8-bit value → HLG 16-bit signal value. + * + * Converts DOM overlay pixels (Chrome sRGB) to HLG signal space so they + * composite correctly into the HLG/BT.2020 output without color shift. + * + * Pipeline per channel: sRGB EOTF (decode gamma) → linear → HLG OETF → 16-bit. + * + * Note: this converts the transfer function (gamma) but not the color primaries + * (bt709 → bt2020). For neutral/near-neutral content (text, UI elements) the + * gamut difference is negligible. Saturated sRGB colors may shift slightly. + */ +function buildSrgbToHlgLut(): Uint16Array { + const lut = new Uint16Array(256); + + // HLG OETF constants (Rec. 2100) + const a = 0.17883277; + const b = 1 - 4 * a; + const c = 0.5 - a * Math.log(4 * a); + + for (let i = 0; i < 256; i++) { + // sRGB EOTF: signal → linear + const v = i / 255; + const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + + // HLG OETF: linear → HLG signal + const hlg = linear <= 1 / 12 ? Math.sqrt(3 * linear) : a * Math.log(12 * linear - b) + c; + + lut[i] = Math.min(65535, Math.round(hlg * 65535)); + } + + return lut; +} + +const SRGB_TO_HLG = buildSrgbToHlgLut(); + +// ── Alpha compositing ───────────────────────────────────────────────────────── + +/** + * Alpha-composite a DOM RGBA overlay (8-bit sRGB) onto an HDR frame + * (rgb48le, HLG-encoded) in memory. + * + * DOM pixels are converted from sRGB to HLG signal space before blending + * so the composited output is uniformly HLG-encoded. Without this conversion, + * sRGB content (text, SDR video rendered by Chrome) would have incorrect + * gamma and appear orange/washed in HDR playback. + * + * For each pixel: + * - If DOM alpha == 0 → copy HDR pixel unchanged + * - If DOM alpha == 255 → use DOM pixel (sRGB→HLG converted) + * - Otherwise → blend converted DOM with HDR in HLG signal domain + * + * @param domRgba Raw RGBA pixel data from decodePng() — width*height*4 bytes + * @param hdrRgb48 HDR frame in rgb48le format — width*height*6 bytes + * @returns New rgb48le buffer with DOM composited on top (HLG-encoded) + */ +export function blitRgba8OverRgb48le( + domRgba: Uint8Array, + hdrRgb48: Buffer, + width: number, + height: number, +): Buffer { + const pixelCount = width * height; + const out = Buffer.allocUnsafe(pixelCount * 6); + const lut = SRGB_TO_HLG; + + for (let i = 0; i < pixelCount; i++) { + const da = domRgba[i * 4 + 3] ?? 0; + + if (da === 0) { + // Fully transparent DOM pixel — copy HDR unchanged + out[i * 6 + 0] = hdrRgb48[i * 6 + 0] ?? 0; + out[i * 6 + 1] = hdrRgb48[i * 6 + 1] ?? 0; + out[i * 6 + 2] = hdrRgb48[i * 6 + 2] ?? 0; + out[i * 6 + 3] = hdrRgb48[i * 6 + 3] ?? 0; + out[i * 6 + 4] = hdrRgb48[i * 6 + 4] ?? 0; + out[i * 6 + 5] = hdrRgb48[i * 6 + 5] ?? 0; + } else if (da === 255) { + // Fully opaque DOM pixel — convert sRGB → HLG + const r16 = lut[domRgba[i * 4 + 0] ?? 0] ?? 0; + const g16 = lut[domRgba[i * 4 + 1] ?? 0] ?? 0; + const b16 = lut[domRgba[i * 4 + 2] ?? 0] ?? 0; + out.writeUInt16LE(r16, i * 6); + out.writeUInt16LE(g16, i * 6 + 2); + out.writeUInt16LE(b16, i * 6 + 4); + } else { + // Partial alpha — convert sRGB→HLG then blend in HLG signal domain + const alpha = da / 255; + const invAlpha = 1 - alpha; + + // Read HDR pixel (little-endian uint16, already HLG-encoded) + const hdrR = (hdrRgb48[i * 6 + 0] ?? 0) | ((hdrRgb48[i * 6 + 1] ?? 0) << 8); + const hdrG = (hdrRgb48[i * 6 + 2] ?? 0) | ((hdrRgb48[i * 6 + 3] ?? 0) << 8); + const hdrB = (hdrRgb48[i * 6 + 4] ?? 0) | ((hdrRgb48[i * 6 + 5] ?? 0) << 8); + + // Convert DOM sRGB → HLG signal + const domR = lut[domRgba[i * 4 + 0] ?? 0] ?? 0; + const domG = lut[domRgba[i * 4 + 1] ?? 0] ?? 0; + const domB = lut[domRgba[i * 4 + 2] ?? 0] ?? 0; + + out.writeUInt16LE(Math.round(domR * alpha + hdrR * invAlpha), i * 6); + out.writeUInt16LE(Math.round(domG * alpha + hdrG * invAlpha), i * 6 + 2); + out.writeUInt16LE(Math.round(domB * alpha + hdrB * invAlpha), i * 6 + 4); + } + } + + return out; +} diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 869f27f6..9e77f3f9 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -55,8 +55,16 @@ import { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, - convertHdrFrameToRgb48le, isHdrColorSpace, + extractVideoMetadata, + initTransparentBackground, + captureAlphaPng, + decodePng, + decodePngToRgb48le, + blitRgba8OverRgb48le, + hideVideoElements, + showVideoElements, + queryVideoElementBounds, } from "@hyperframes/engine"; import { join, dirname, resolve } from "path"; import { randomUUID } from "crypto"; @@ -699,6 +707,29 @@ export async function executeRenderJob( const compiledDir = join(workDir, "compiled"); let extractionResult: Awaited> | null = null; + // Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR). + // This is needed to identify which videos are natively HDR vs converted-SDR + // for the two-pass compositing path. + const nativeHdrVideoIds = new Set(); + if (composition.videos.length > 0) { + await Promise.all( + composition.videos.map(async (v) => { + let videoPath = v.src; + if (!videoPath.startsWith("/")) { + const fromCompiled = existsSync(join(compiledDir, videoPath)) + ? join(compiledDir, videoPath) + : join(projectDir, videoPath); + videoPath = fromCompiled; + } + if (!existsSync(videoPath)) return; + const meta = await extractVideoMetadata(videoPath); + if (isHdrColorSpace(meta.colorSpace)) { + nativeHdrVideoIds.add(v.id); + } + }), + ); + } + if (composition.videos.length > 0) { extractionResult = await extractAllVideoFrames( composition.videos, @@ -713,7 +744,6 @@ export async function executeRenderJob( if (extractionResult.extracted.length > 0) { frameLookup = createFrameLookupTable(composition.videos, extractionResult.extracted); } - perfStages.videoExtractMs = Date.now() - stage2Start; // Auto-detect audio from video files via ffprobe metadata @@ -831,17 +861,56 @@ export async function executeRenderJob( job.framesRendered = 0; - // ── HDR pass-through path ──────────────────────────────────────────── - // When HDR output is requested AND the composition has HDR video sources, - // extract raw HLG frames and pass them directly to FFmpeg — no conversion. - // The HLG pixel values ARE the correct encoding. The output is tagged as - // HLG/BT.2020 so HDR displays render it correctly. + // ── HDR two-pass compositing path ──────────────────────────────────── + // Pass 1: Capture DOM layer with alpha (Chrome, video elements hidden) + // Pass 2: Extract native HLG frames from video sources (FFmpeg) + // Composite: overlay DOM on top of HDR video in FFmpeg per-frame // - // No WebGPU or Chrome needed for this path — it's a pure FFmpeg pipeline. - // GSAP transforms are NOT applied (future work: WebGPU shader transforms). + // This preserves HDR luminance from video sources while correctly + // compositing DOM content (text, graphics, SDR overlays) on top. + // GSAP-animated video position/opacity is applied via queried bounds. if (hasHdrVideo) { - log.info("[Render] HDR pass-through: extracting native HLG frames from video sources"); + log.info("[Render] HDR two-pass: DOM layer + native HLG video compositing"); + + // Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR + // videos are hidden + extracted natively. SDR videos stay in the DOM screenshot + // (injected via the frame injector) and get sRGB→HLG conversion in the blit. + const hdrVideoIds = composition.videos + .filter((v) => nativeHdrVideoIds.has(v.id)) + .map((v) => v.id); + + // Resolve HDR video source paths + const hdrVideoSrcPaths = new Map(); + for (const v of composition.videos) { + if (!hdrVideoIds.includes(v.id)) continue; + let srcPath = v.src; + if (!srcPath.startsWith("/")) { + const fromCompiled = join(compiledDir, srcPath); + srcPath = existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath); + } + hdrVideoSrcPaths.set(v.id, srcPath); + } + + // Launch headless Chrome for DOM capture. + // Pass the video frame injector so SDR videos are rendered correctly in Chrome. + // HDR videos get injected too but are hidden via hideVideoElements before the + // DOM screenshot — only the native FFmpeg-extracted HLG frames are used for HDR. + const domSession = await createCaptureSession( + fileServer!.url, + framesDir, + captureOptions, + createVideoFrameInjector(frameLookup), + cfg, + ); + await initializeSession(domSession); + assertNotAborted(); + lastBrowserConsole = domSession.browserConsoleBuffer; + + // Set transparent background once for this dedicated DOM session. + // captureAlphaPng() per frame skips the per-frame CDP set/reset overhead. + await initTransparentBackground(domSession.page); + // Spawn HDR streaming encoder accepting raw rgb48le composited frames const hdrEncoder = await spawnStreamingEncoder( videoOnlyPath, { @@ -862,60 +931,113 @@ export async function executeRenderJob( const { execSync } = await import("child_process"); + // ── Pre-extract all HDR video frames in a single FFmpeg pass ────── + // Per-frame `-ss` fast seek causes duplicate frames at keyframe boundaries. + // A single extraction pass decodes sequentially — every frame is unique. + 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; + try { + execSync( + `ffmpeg -ss ${video.mediaStart} -i "${srcPath}" -t ${duration} -r ${job.config.fps} ` + + `-vf "scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}" ` + + `-pix_fmt rgb48le -c:v png "${join(frameDir, "frame_%04d.png")}"`, + { maxBuffer: 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] }, + ); + } catch { + // If extraction fails, frames won't exist — loop handles gracefully + } + hdrFrameDirs.set(videoId, frameDir); + } + 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; + for (let i = 0; i < job.totalFrames!; i++) { assertNotAborted(); const time = i / job.config.fps; - const activeFrames = frameLookup!.getActiveFramePayloads(time); + // Seek timeline + await domSession.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time); - if (activeFrames.size > 0) { - const [videoId] = activeFrames.keys(); - const video = composition.videos.find((v) => v.id === videoId); - if (video) { - const localTime = time - video.start + video.mediaStart; + // Inject SDR video frames into the DOM (the hook handles all videos, + // but hideVideoElements below will hide the HDR ones before screenshot) + if (beforeCaptureHook) { + await beforeCaptureHook(domSession.page, time); + } - let srcPath = video.src; - const compiledDir = join(workDir, "compiled"); - if (!srcPath.startsWith("/")) { - const fromCompiled = join(compiledDir, srcPath); - srcPath = existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath); - } + // Query video element positions BEFORE hiding (so GSAP has already moved them) + const bounds = await queryVideoElementBounds(domSession.page, hdrVideoIds); + const activeBounds = bounds.filter((b) => b.visible); + + // Pass 1: Hide HDR videos (and their injected frames), capture DOM with alpha. + // SDR video frames remain visible in the screenshot. + await hideVideoElements(domSession.page, hdrVideoIds); + const domPng = await captureAlphaPng(domSession.page, width, height); + await showVideoElements(domSession.page, hdrVideoIds); + + // Pass 2: Read pre-extracted HDR frame and composite with DOM layer + const activeVideoId = activeBounds[0]?.videoId ?? hdrVideoIds[0]; + const video = composition.videos.find((v) => v.id === activeVideoId); + const frameDir = activeVideoId ? hdrFrameDirs.get(activeVideoId) : undefined; + + let composited: Buffer; + if (video && frameDir) { + // Frame index within the video (1-based for FFmpeg image2 output) + const videoFrameIndex = Math.round((time - video.start) * job.config.fps) + 1; + const framePath = join( + frameDir, + `frame_${String(videoFrameIndex).padStart(4, "0")}.png`, + ); - let rawFrame: Buffer; + let hdrRgb: Buffer; + if (existsSync(framePath)) { try { - rawFrame = execSync( - `ffmpeg -ss ${localTime} -i "${srcPath}" -vframes 1 -f rawvideo -pix_fmt rgba64le -`, - { maxBuffer: width * height * 8 + 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] }, - ); + hdrRgb = decodePngToRgb48le(readFileSync(framePath)).data; } catch { - rawFrame = Buffer.alloc(width * height * 8); + hdrRgb = Buffer.alloc(width * height * 6); } - - // Pass through HLG pixels as-is (RGBA → RGB, no color conversion) - const rgb48Frame = convertHdrFrameToRgb48le(rawFrame, width, height); - hdrEncoder.writeFrame(rgb48Frame); } else { - hdrEncoder.writeFrame(Buffer.alloc(width * height * 6)); + hdrRgb = Buffer.alloc(width * height * 6); + } + + // In-memory alpha composite: DOM PNG over HDR rgb48le + try { + const { data: domRgba } = decodePng(domPng); + composited = blitRgba8OverRgb48le(domRgba, hdrRgb, width, height); + } catch { + composited = hdrRgb; } } else { - hdrEncoder.writeFrame(Buffer.alloc(width * height * 6)); + composited = Buffer.alloc(width * height * 6); } + hdrEncoder.writeFrame(composited); + job.framesRendered = i + 1; if ((i + 1) % 10 === 0 || i + 1 === job.totalFrames!) { const frameProgress = (i + 1) / job.totalFrames!; updateJobStatus( job, "rendering", - `HDR frame ${i + 1}/${job.totalFrames}`, + `HDR composite frame ${i + 1}/${job.totalFrames}`, Math.round(25 + frameProgress * 55), onProgress, ); } } } finally { - // No browser to close — pure FFmpeg path + lastBrowserConsole = domSession.browserConsoleBuffer; + await closeCaptureSession(domSession); } const hdrEncodeResult = await hdrEncoder.close();