diff --git a/packages/producer/src/services/hdrCompositor.test.ts b/packages/producer/src/services/hdrCompositor.test.ts index 1680f9a90f..1c2f75bd3c 100644 --- a/packages/producer/src/services/hdrCompositor.test.ts +++ b/packages/producer/src/services/hdrCompositor.test.ts @@ -73,7 +73,7 @@ describe("blitHdrVideoLayer", () => { { lead: 7, mediaStart: 6.5, painted: true }, ])( "paints at 1 s of a clip from $mediaStart s whose stream starts $lead s in: $painted", - ({ lead, mediaStart, painted }) => { + async ({ lead, mediaStart, painted }) => { const dir = mkdtempSync(join(tmpdir(), "hf-hdr-lead-")); const rawPath = join(dir, "frames.rgb48le"); const frameSize = 2 * 2 * 6; @@ -90,8 +90,15 @@ describe("blitHdrVideoLayer", () => { }); const source = { dir, - rawPath, - fd, + windows: { + totalFrames: 2, + lease: async (index: number) => ({ + fd, + offset: index * frameSize, + release: () => {}, + }), + close: () => {}, + }, width: 2, height: 2, frameSize, @@ -100,7 +107,16 @@ describe("blitHdrVideoLayer", () => { mediaStart, videoStreamLeadSeconds: lead, }; - blitHdrVideoLayer(canvas, el, 1, 1, new Map([["v", source]]), new Map([["v", 0]]), 2, 2); + await blitHdrVideoLayer( + canvas, + el, + 1, + 1, + new Map([["v", source]]), + new Map([["v", 0]]), + 2, + 2, + ); expect(canvas.some((byte) => byte !== 0)).toBe(painted); } finally { closeSync(fd); @@ -108,4 +124,37 @@ describe("blitHdrVideoLayer", () => { } }, ); + + it("fails the render when a window cannot be extracted, instead of dropping the layer", async () => { + const frameSize = 2 * 2 * 6; + const source = { + dir: "unused", + windows: { + totalFrames: 2, + lease: async () => { + throw new Error("HDR frame extraction failed"); + }, + close: () => {}, + }, + width: 2, + height: 2, + frameSize, + frameCount: 2, + scratch: Buffer.alloc(frameSize), + }; + const el = makeEl("v", { width: 2, height: 2, layoutWidth: 2, layoutHeight: 2, isHdr: true }); + + await expect( + blitHdrVideoLayer( + Buffer.alloc(frameSize), + el, + 1, + 1, + new Map([["v", source]]), + new Map([["v", 0]]), + 2, + 2, + ), + ).rejects.toThrow("HDR frame extraction failed"); + }); }); diff --git a/packages/producer/src/services/hdrCompositor.ts b/packages/producer/src/services/hdrCompositor.ts index 13e474dce7..7688751c60 100644 --- a/packages/producer/src/services/hdrCompositor.ts +++ b/packages/producer/src/services/hdrCompositor.ts @@ -11,7 +11,8 @@ * `captureHdrResources.ts`. */ -import { readSync, closeSync } from "fs"; +import { readSync } from "fs"; +import type { HdrFrameLease, HdrFrameWindows } from "./hdrFrameWindows.js"; import { join } from "path"; import { type CaptureSession, @@ -133,14 +134,13 @@ function cropRgb48le( */ export interface HdrVideoFrameSource { dir: string; - rawPath: string; - fd: number; + windows: HdrFrameWindows; width: number; height: number; frameSize: number; - frameCount: number; + readonly frameCount: number; scratch: Buffer; - /** The raw file contains one playable source cycle and must wrap at EOF. */ + /** The playable range is one source cycle and must wrap at its end. */ loop?: boolean; mediaStart?: number; videoStreamLeadSeconds?: number; @@ -148,10 +148,10 @@ export interface HdrVideoFrameSource { export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void { try { - closeSync(source.fd); + source.windows.close(); } catch (err) { - log?.warn("Failed to close HDR raw frame file", { - rawPath: source.rawPath, + log?.warn("Failed to close HDR raw frame windows", { + dir: source.dir, error: err instanceof Error ? err.message : String(err), }); } @@ -169,8 +169,7 @@ export function resolveHdrVideoFrameIndex( return loop ? frameIndex % frameCount : Math.min(frameIndex, frameCount - 1); } -// fallow-ignore-next-line complexity -export function blitHdrVideoLayer( +export async function blitHdrVideoLayer( canvas: Buffer, el: ElementStackingInfo, time: number, @@ -183,7 +182,7 @@ export function blitHdrVideoLayer( sourceTransfer?: HdrTransfer, targetTransfer?: HdrTransfer, hdrPerf?: HdrPerfCollector, -): void { +): Promise { const frameSource = hdrVideoFrameSources.get(el.id); const startTime = hdrStartTimes.get(el.id); if (!frameSource || startTime === undefined || el.opacity <= 0) { @@ -203,12 +202,44 @@ export function blitHdrVideoLayer( if (effectiveIndex === null) return; const mediaTime = (frameSource.mediaStart ?? 0) + effectiveIndex / fps; if (isVideoHiddenBeforeStreamStart(frameSource.videoStreamLeadSeconds, mediaTime)) return; - const frameOffset = effectiveIndex * frameSource.frameSize; + if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1; + const lease = await frameSource.windows.lease(effectiveIndex); + if (!lease) return; + try { + blitScratchFrameWithoutYielding( + canvas, + el, + frameSource, + lease, + width, + height, + log, + sourceTransfer, + targetTransfer, + hdrPerf, + ); + } finally { + lease.release(); + } +} + +// fallow-ignore-next-line complexity +function blitScratchFrameWithoutYielding( + canvas: Buffer, + el: ElementStackingInfo, + frameSource: HdrVideoFrameSource, + lease: HdrFrameLease, + width: number, + height: number, + log: ProducerLogger | undefined, + sourceTransfer: HdrTransfer | undefined, + targetTransfer: HdrTransfer | undefined, + hdrPerf: HdrPerfCollector | undefined, +): void { try { - if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1; const bytesRead = timeHdrPhase(hdrPerf, "hdrVideoReadDecodeMs", () => - readSync(frameSource.fd, frameSource.scratch, 0, frameSource.frameSize, frameOffset), + readSync(lease.fd, frameSource.scratch, 0, frameSource.frameSize, lease.offset), ); if (bytesRead !== frameSource.frameSize) return; const hdrRgb = frameSource.scratch; @@ -583,7 +614,7 @@ export async function compositeHdrFrame( hdrPerf, ); } else { - blitHdrVideoLayer( + await blitHdrVideoLayer( canvas, layer.element, time, @@ -627,7 +658,7 @@ export async function compositeHdrFrame( startTime, localTime: localTime.toFixed(3), hdrFrameNum: frameNum, - rawPath: frameSource?.rawPath ?? null, + dir: frameSource?.dir ?? null, frameCount: frameSource?.frameCount ?? null, }); } diff --git a/packages/producer/src/services/hdrFrameWindows.test.ts b/packages/producer/src/services/hdrFrameWindows.test.ts new file mode 100644 index 0000000000..9255e16003 --- /dev/null +++ b/packages/producer/src/services/hdrFrameWindows.test.ts @@ -0,0 +1,166 @@ +import { mkdtempSync, readdirSync, readSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHdrFrameWindows, type HdrWindowSpec } from "./hdrFrameWindows.js"; + +const FRAME_SIZE = 4; +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function fixture(opts: { + totalFrames: number; + windowFrames: number; + maxResident: number; + /** Frames the source really has; fewer than totalFrames models a short source. */ + sourceFrames?: number; +}) { + const dir = mkdtempSync(join(tmpdir(), "hf-hdr-windows-")); + dirs.push(dir); + const extracted: number[] = []; + const windows = createHdrFrameWindows({ + dir, + frameSize: FRAME_SIZE, + totalFrames: opts.totalFrames, + windowFrames: opts.windowFrames, + maxResident: opts.maxResident, + extract: async (spec: HdrWindowSpec) => { + extracted.push(spec.firstFrame); + const available = Math.max(0, (opts.sourceFrames ?? opts.totalFrames) - spec.firstFrame); + const frames = Math.min(spec.frames, available); + const data = Buffer.alloc(frames * FRAME_SIZE); + for (let j = 0; j < frames; j++) data.fill(spec.firstFrame + j, j * FRAME_SIZE); + writeFileSync(spec.path, data); + return frames; + }, + }); + return { dir, windows, extracted }; +} + +async function frameByte( + windows: ReturnType, + index: number, +): Promise { + const lease = await windows.lease(index); + if (!lease) return null; + try { + const buf = Buffer.alloc(FRAME_SIZE); + readSync(lease.fd, buf, 0, FRAME_SIZE, lease.offset); + return buf[0] ?? null; + } finally { + lease.release(); + } +} + +const windowFiles = (dir: string) => readdirSync(dir).filter((f) => f.startsWith("window-")); + +describe("createHdrFrameWindows", () => { + it("extracts a window only when a frame inside it is read, and reads that frame's bytes", async () => { + const { windows, extracted } = fixture({ totalFrames: 12, windowFrames: 4, maxResident: 3 }); + expect(extracted).toEqual([]); + + expect(await frameByte(windows, 5)).toBe(5); + + expect(extracted).toEqual([4]); + windows.close(); + }); + + it("keeps no more than maxResident windows on disk while the capture walks the whole clip", async () => { + const { dir, windows } = fixture({ totalFrames: 40, windowFrames: 4, maxResident: 2 }); + let mostFiles = 0; + for (let i = 0; i < 40; i++) { + expect(await frameByte(windows, i)).toBe(i); + mostFiles = Math.max(mostFiles, windowFiles(dir).length); + } + expect(mostFiles).toBeLessThanOrEqual(2); + windows.close(); + }); + + it("never evicts a window that is leased", async () => { + const { windows } = fixture({ totalFrames: 16, windowFrames: 4, maxResident: 2 }); + const held = await windows.lease(0); + expect(held).not.toBeNull(); + for (const i of [4, 8, 12]) await frameByte(windows, i); + + const buf = Buffer.alloc(FRAME_SIZE); + expect(readSync(held!.fd, buf, 0, FRAME_SIZE, held!.offset)).toBe(FRAME_SIZE); + expect(buf[0]).toBe(0); + held!.release(); + windows.close(); + }); + + it("holds the last frame when the source ends before the planned length", async () => { + const { windows } = fixture({ + totalFrames: 10, + windowFrames: 4, + maxResident: 3, + sourceFrames: 6, + }); + + expect(await frameByte(windows, 9)).toBe(5); + expect(windows.totalFrames).toBe(6); + windows.close(); + }); + + it("holds the last frame when the source ends exactly at a window boundary", async () => { + const { windows } = fixture({ + totalFrames: 12, + windowFrames: 4, + maxResident: 3, + sourceFrames: 4, + }); + + expect(await frameByte(windows, 9)).toBe(3); + expect(windows.totalFrames).toBe(4); + windows.close(); + }); + + it("reports no frame for a source that produced none", async () => { + const { windows } = fixture({ + totalFrames: 4, + windowFrames: 4, + maxResident: 2, + sourceFrames: 0, + }); + + expect(await frameByte(windows, 0)).toBeNull(); + windows.close(); + }); + + it("retries a window whose extraction failed", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-hdr-windows-")); + dirs.push(dir); + let attempts = 0; + const windows = createHdrFrameWindows({ + dir, + frameSize: FRAME_SIZE, + totalFrames: 4, + windowFrames: 4, + maxResident: 2, + extract: async (spec) => { + attempts += 1; + if (attempts === 1) throw new Error("ffmpeg failed"); + writeFileSync(spec.path, Buffer.alloc(spec.frames * FRAME_SIZE, 7)); + return spec.frames; + }, + }); + + await expect(windows.lease(0)).rejects.toThrow("ffmpeg failed"); + expect(await frameByte(windows, 0)).toBe(7); + windows.close(); + }); + + it("removes its window files on close", async () => { + const { dir, windows } = fixture({ totalFrames: 8, windowFrames: 4, maxResident: 3 }); + await frameByte(windows, 0); + await frameByte(windows, 5); + expect(windowFiles(dir).length).toBeGreaterThan(0); + + windows.close(); + + expect(windowFiles(dir)).toEqual([]); + }); +}); diff --git a/packages/producer/src/services/hdrFrameWindows.ts b/packages/producer/src/services/hdrFrameWindows.ts new file mode 100644 index 0000000000..c62555cbb8 --- /dev/null +++ b/packages/producer/src/services/hdrFrameWindows.ts @@ -0,0 +1,124 @@ +/** + * Bounded raw-frame storage for one HDR video: fixed windows of frames, extracted on first lease, + * least-recently-used evicted past `maxResident`. A leased window is never evicted. + */ +import { closeSync, constants, openSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +export interface HdrFrameLease { + fd: number; + offset: number; + release(): void; +} + +export interface HdrWindowSpec { + firstFrame: number; + frames: number; + path: string; +} + +export interface HdrFrameWindows { + readonly totalFrames: number; + lease(frameIndex: number): Promise; + close(): void; +} + +interface Window { + firstFrame: number; + path: string; + frames: number; + fd: number; + pins: number; + ready: Promise; +} + +export function createHdrFrameWindows(opts: { + dir: string; + frameSize: number; + totalFrames: number; + windowFrames: number; + maxResident: number; + extract: (spec: HdrWindowSpec) => Promise; +}): HdrFrameWindows { + const { dir, frameSize, windowFrames, maxResident, extract } = opts; + let totalFrames = opts.totalFrames; + // Map order is recency order: a leased window moves to the end. + const windows = new Map(); + + const drop = (index: number, w: Window): void => { + windows.delete(index); + if (w.fd >= 0) closeSync(w.fd); + rmSync(w.path, { force: true }); + }; + + const open = (index: number): Window => { + const known = windows.get(index); + if (known) { + windows.delete(index); + windows.set(index, known); + return known; + } + for (const [i, w] of windows) { + if (windows.size < maxResident) break; + if (w.pins === 0) drop(i, w); + } + const firstFrame = index * windowFrames; + const w: Window = { + firstFrame, + path: join(dir, `window-${index}.rgb48le`), + frames: 0, + fd: -1, + pins: 0, + ready: Promise.resolve(), + }; + w.ready = extract({ + firstFrame, + frames: Math.min(windowFrames, totalFrames - firstFrame), + path: w.path, + }).then((produced) => { + if (produced < 1) return; + w.fd = openSync(w.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + w.frames = produced; + }); + windows.set(index, w); + return w; + }; + + return { + get totalFrames() { + return totalFrames; + }, + async lease(frameIndex) { + for (;;) { + if (totalFrames < 1) return null; + const index = Math.min(Math.max(0, frameIndex), totalFrames - 1); + const key = Math.floor(index / windowFrames); + const w = open(key); + w.pins += 1; + try { + await w.ready; + } catch (error) { + w.pins -= 1; + windows.delete(key); + throw error; + } + const local = index - w.firstFrame; + if (local < w.frames) { + return { + fd: w.fd, + offset: local * frameSize, + release: () => { + w.pins -= 1; + }, + }; + } + // The source ended inside or before this window: hold its last frame. + w.pins -= 1; + totalFrames = Math.min(totalFrames, w.firstFrame + w.frames); + } + }, + close() { + for (const [i, w] of [...windows]) drop(i, w); + }, + }; +} diff --git a/packages/producer/src/services/render/stages/captureHdrFrameShared.ts b/packages/producer/src/services/render/stages/captureHdrFrameShared.ts index 15811d6ebd..eeb2214b80 100644 --- a/packages/producer/src/services/render/stages/captureHdrFrameShared.ts +++ b/packages/producer/src/services/render/stages/captureHdrFrameShared.ts @@ -219,7 +219,7 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise hdrPerf, ); } else { - blitHdrVideoLayer( + await blitHdrVideoLayer( sceneBuf, el, time, diff --git a/packages/producer/src/services/render/stages/captureHdrResources.test.ts b/packages/producer/src/services/render/stages/captureHdrResources.test.ts index 9fd118dbee..d1eb1e728f 100644 --- a/packages/producer/src/services/render/stages/captureHdrResources.test.ts +++ b/packages/producer/src/services/render/stages/captureHdrResources.test.ts @@ -1,12 +1,4 @@ -import { - closeSync, - constants, - existsSync, - mkdtempSync, - openSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -23,7 +15,6 @@ import { createRenderJob } from "../../renderOrchestrator.js"; import { resolveHdrVideoFrameIndex } from "../../hdrCompositor.js"; import { cleanupHdrVideoFrameSource, - estimateHdrExtractionBytes, extractHdrVideoFrames, getHdrExtractionReservedBytes, planHdrResources, @@ -52,6 +43,25 @@ function ffmpegResult(success: boolean): RunFfmpegResult { const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0; +/** + * A mock FFmpeg: answers the first-frame probe and fills each extraction's output with its frames, + * stopping where the source's `realFrames` (at the fixture's 2 fps) run out. + */ +function windowWriter(calls: string[][], realFrames = Infinity) { + return async (args: string[]): Promise => { + if (args.at(-1) === "-") return { ...ffmpegResult(true), stderr: "pts_time:0.000000" }; + calls.push(args); + const out = args.at(-1); + if (!out) throw new Error("mock FFmpeg output path missing"); + const filter = args[args.indexOf("-vf") + 1] ?? ""; + const lead = Number(/trim=start_frame=(\d+)/.exec(filter)?.[1] ?? 0); + const first = Math.round(Number(args[args.indexOf("-ss") + 1]) * 2) + lead; + const wanted = Number(args[args.indexOf("-frames:v") + 1]); + writeFileSync(out, Buffer.alloc(Math.max(0, Math.min(wanted, realFrames - first)) * 6)); + return ffmpegResult(true); + }; +} + function hdrVideo(id: string, overrides: Partial = {}): VideoElement { return { id, @@ -125,7 +135,17 @@ function videoComposition(src: string): CompositionMetadata { height: 1080, audios: [], images: [], - videos: [{ id: "a-roll", src, start: 0, end: 5, mediaStart: 0, loop: false, hasAudio: false }], + videos: [ + { + id: "a-roll", + src, + start: 0, + end: 5, + mediaStart: 0, + loop: false, + hasAudio: false, + }, + ], }; } @@ -169,41 +189,11 @@ describe("planHdrResources non-ASCII src resolution (PRINFRA-349)", () => { }); }); -describe("estimateHdrExtractionBytes", () => { - it("sums 6 bytes per pixel per frame across videos", () => { - // 10s @ 30fps of 1920x1080 = 300 frames * 1920*1080*6 - expect( - estimateHdrExtractionBytes([{ durationSeconds: 10, width: 1920, height: 1080 }], 30), - ).toBe(300 * 1920 * 1080 * 6); - }); - - it("accumulates multiple videos and rounds frame counts up", () => { - const bytes = estimateHdrExtractionBytes( - [ - { durationSeconds: 1.5, width: 100, height: 100 }, - { durationSeconds: 0.05, width: 100, height: 100 }, - ], - 30, - ); - expect(bytes).toBe((45 + 2) * 100 * 100 * 6); - }); - - it("treats negative durations as empty", () => { - expect(estimateHdrExtractionBytes([{ durationSeconds: -3, width: 100, height: 100 }], 30)).toBe( - 0, - ); - }); -}); - describe("resolveHdrExtractionWindow", () => { it("bounds a two-second composition backed by an unbounded HDR source to 60 raw frames", () => { const window = resolveHdrExtractionWindow(hdrVideo("long-hdr"), 2, videoMetadata(120)); if (!window) throw new Error("expected a visible HDR extraction window"); - const { durationSeconds } = window; - expect(durationSeconds).toBe(2); - expect(estimateHdrExtractionBytes([{ durationSeconds, width: 3840, height: 2160 }], 30)).toBe( - 60 * 3840 * 2160 * 6, - ); + expect(window.durationSeconds).toBe(2); }); it("independently caps a stale finite media end at the composition duration", () => { @@ -248,7 +238,12 @@ describe("resolveHdrExtractionWindow", () => { }); it.each([ - { start: -5, compositionDuration: 2, expected: null, label: "already ended" }, + { + start: -5, + compositionDuration: 2, + expected: null, + label: "already ended", + }, { start: -2, compositionDuration: 15, @@ -260,7 +255,11 @@ describe("resolveHdrExtractionWindow", () => { ({ start, compositionDuration, expected }) => { expect( resolveHdrExtractionWindow( - hdrVideo("open-held", { start, end: Number.POSITIVE_INFINITY, loop: false }), + hdrVideo("open-held", { + start, + end: Number.POSITIVE_INFINITY, + loop: false, + }), compositionDuration, videoMetadata(3), ), @@ -269,7 +268,11 @@ describe("resolveHdrExtractionWindow", () => { ); it.each([ - { loop: true, preservation: { preserveTimelinePhase: true }, label: "loop" }, + { + loop: true, + preservation: { preserveTimelinePhase: true }, + label: "loop", + }, { loop: false, preservation: { preserveTimelineEnd: true, ensureFinalFrame: true }, @@ -308,7 +311,12 @@ describe("resolveHdrExtractionWindow", () => { it("rejects mediaStart at source EOF before HDR budgeting", () => { expect(() => resolveHdrExtractionWindow(hdrVideo("past-eof", { mediaStart: 3 }), 60, videoMetadata(3)), - ).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false })); + ).toThrowError( + expect.objectContaining({ + kind: "media_start_out_of_range", + retryable: false, + }), + ); }); it("skips HDR media with no interval inside the composition", () => { @@ -385,8 +393,18 @@ describe.skipIf(!HAS_FFMPEG)("raw HDR held tails on sparse-timestamp sources", ( }); it.each([ - { label: "sub-1fps CFR", src: cfrFixture, expectedVfr: false, streamStart: 0 }, - { label: "sparse VFR", src: vfrFixture, expectedVfr: true, streamStart: 0 }, + { + label: "sub-1fps CFR", + src: cfrFixture, + expectedVfr: false, + streamStart: 0, + }, + { + label: "sparse VFR", + src: vfrFixture, + expectedVfr: true, + streamStart: 0, + }, { label: "non-zero stream start", src: nonZeroStartFixture, @@ -518,20 +536,20 @@ describe("extractHdrVideoFrames", () => { try { const extracted = await extractHdrVideoFrames({ ...fixture, - runFfmpegImpl: async (args) => { - calls.push(args); - const rawPath = args.at(-1); - if (!rawPath) throw new Error("mock FFmpeg output path missing"); - // The visible [0, 2] interval is two seconds at 2fps = 4 rgb48le 1x1 frames. - writeFileSync(rawPath, Buffer.alloc(4 * 6)); - return ffmpegResult(true); - }, + // The visible [0, 2] interval is two seconds at 2fps = 4 rgb48le 1x1 frames. + runFfmpegImpl: windowWriter(calls), }); try { expect(calls).toHaveLength(1); const args = calls[0] ?? []; - expect(args.slice(args.indexOf("-ss"), args.indexOf("-ss") + 2)).toEqual(["-ss", "60"]); - expect(args.slice(args.indexOf("-t"), args.indexOf("-t") + 2)).toEqual(["-t", "2"]); + expect(args.slice(args.indexOf("-ss"), args.indexOf("-ss") + 2)).toEqual([ + "-ss", + "60.000000", + ]); + expect(args.slice(args.indexOf("-frames:v"), args.indexOf("-frames:v") + 2)).toEqual([ + "-frames:v", + "4", + ]); expect(fixture.prep.hdrVideoStartTimes.get("preroll")).toBe(0); expect(extracted.sources.get("preroll")?.frameCount).toBe(4); expect(extracted.estimatedBytes).toBe(24); @@ -549,32 +567,27 @@ describe("extractHdrVideoFrames", () => { { loop: true, expectedFrameIndex: 4, - expectedFrameCount: 6, expectedStart: -5, - expectedSeek: "0", - expectedDuration: "3", + expectedSeek: "0.000000", + expectedFrames: "7", label: "loop phase", }, { loop: false, expectedFrameIndex: 0, - expectedFrameCount: 1, expectedStart: 0, expectedSeek: "2", - expectedDuration: undefined, + expectedFrames: "1", label: "held final frame", }, ])("preserves $label after materially negative preroll", async (testCase) => { - const { - loop, - expectedFrameIndex, - expectedFrameCount, - expectedStart, - expectedSeek, - expectedDuration, - } = testCase; + const { loop, expectedFrameIndex, expectedStart, expectedSeek, expectedFrames } = testCase; const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-negative-source-")); - const video = hdrVideo(`negative-${String(loop)}`, { start: -5, end: 10, loop }); + const video = hdrVideo(`negative-${String(loop)}`, { + start: -5, + end: 10, + loop, + }); const fixture = hdrExtractionFixture([video], framesDir); const calls: string[][] = []; @@ -593,13 +606,7 @@ describe("extractHdrVideoFrames", () => { finalFrameOnly: true, } : window, - runFfmpegImpl: async (args) => { - calls.push(args); - const rawPath = args.at(-1); - if (!rawPath) throw new Error("mock FFmpeg output path missing"); - writeFileSync(rawPath, Buffer.alloc(expectedFrameCount * 6)); - return ffmpegResult(true); - }, + runFfmpegImpl: windowWriter(calls, 6), }); try { const args = calls[0] ?? []; @@ -607,18 +614,10 @@ describe("extractHdrVideoFrames", () => { "-ss", expectedSeek, ]); - if (expectedDuration === undefined) { - expect(args).not.toContain("-t"); - expect(args.slice(args.indexOf("-frames:v"), args.indexOf("-frames:v") + 2)).toEqual([ - "-frames:v", - "1", - ]); - } else { - expect(args.slice(args.indexOf("-t"), args.indexOf("-t") + 2)).toEqual([ - "-t", - expectedDuration, - ]); - } + expect(args.slice(args.indexOf("-frames:v"), args.indexOf("-frames:v") + 2)).toEqual([ + "-frames:v", + expectedFrames, + ]); expect(fixture.prep.hdrVideoStartTimes.get(video.id)).toBe(expectedStart); const source = extracted.sources.get(video.id); expect(source?.loop).toBe(loop); @@ -652,18 +651,18 @@ describe("extractHdrVideoFrames", () => { // A valid mux can have a short video stream and a much longer audio // stream/container. Scratch planning must follow video EOF. extractMediaMetadataImpl: async () => videoMetadata(60, 3), - runFfmpegImpl: async (args) => { - calls.push(args); - const rawPath = args.at(-1); - if (!rawPath) throw new Error("mock FFmpeg output path missing"); - writeFileSync(rawPath, Buffer.alloc(6 * 6)); - return ffmpegResult(true); - }, + runFfmpegImpl: windowWriter(calls, 6), }); try { const args = calls[0] ?? []; - expect(args.slice(args.indexOf("-ss"), args.indexOf("-ss") + 2)).toEqual(["-ss", "0"]); - expect(args.slice(args.indexOf("-t"), args.indexOf("-t") + 2)).toEqual(["-t", "3"]); + expect(args.slice(args.indexOf("-ss"), args.indexOf("-ss") + 2)).toEqual([ + "-ss", + "0.000000", + ]); + expect(args.slice(args.indexOf("-frames:v"), args.indexOf("-frames:v") + 2)).toEqual([ + "-frames:v", + loop ? "7" : "6", + ]); expect(extracted.estimatedBytes).toBe(36); expect(getHdrExtractionReservedBytes()).toBe(36); expect(fixture.prep.hdrVideoStartTimes.get(video.id)).toBe(0); @@ -693,6 +692,7 @@ describe("extractHdrVideoFrames", () => { extractHdrVideoFrames({ ...fixture, runFfmpegImpl: async (args) => { + if (args.at(-1) === "-") return ffmpegResult(true); call += 1; const rawPath = args.at(-1); if (!rawPath) throw new Error("mock FFmpeg output path missing"); @@ -715,50 +715,113 @@ describe("extractHdrVideoFrames", () => { }); describe("cleanupHdrVideoFrameSource", () => { - it("closes the raw descriptor and immediately removes its directory", () => { - const dir = mkdtempSync(join(tmpdir(), "hf-hdr-cleanup-")); - const rawPath = join(dir, "frames.rgb48le"); - writeFileSync(rawPath, Buffer.alloc(12)); - const fd = openSync(rawPath, constants.O_RDONLY); - - cleanupHdrVideoFrameSource({ - dir, - rawPath, - fd, - width: 1, - height: 2, - frameSize: 12, - frameCount: 1, - scratch: Buffer.alloc(12), - }); - - expect(existsSync(dir)).toBe(false); - expect(() => closeSync(fd)).toThrow(); + const sourceIn = (dir: string, close: () => void) => ({ + dir, + windows: { totalFrames: 1, lease: async () => null, close }, + width: 1, + height: 2, + frameSize: 12, + frameCount: 1, + scratch: Buffer.alloc(12), }); - it("closes the descriptor but retains raw files with KEEP_TEMP=1", () => { + it("closes the windows and immediately removes their directory", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-hdr-cleanup-")); + writeFileSync(join(dir, "window-0.rgb48le"), Buffer.alloc(12)); + const close = vi.fn(); + + cleanupHdrVideoFrameSource(sourceIn(dir, close)); + + expect(existsSync(dir)).toBe(false); + expect(close).toHaveBeenCalledOnce(); + }); + + it("closes the windows but retains raw files with KEEP_TEMP=1", () => { vi.stubEnv("KEEP_TEMP", "1"); const dir = mkdtempSync(join(tmpdir(), "hf-hdr-keep-temp-")); - const rawPath = join(dir, "frames.rgb48le"); + const rawPath = join(dir, "window-0.rgb48le"); writeFileSync(rawPath, Buffer.alloc(12)); - const fd = openSync(rawPath, constants.O_RDONLY); + const close = vi.fn(); try { - cleanupHdrVideoFrameSource({ - dir, - rawPath, - fd, - width: 1, - height: 2, - frameSize: 12, - frameCount: 1, - scratch: Buffer.alloc(12), - }); + cleanupHdrVideoFrameSource(sourceIn(dir, close)); expect(existsSync(rawPath)).toBe(true); - expect(() => closeSync(fd)).toThrow(); + expect(close).toHaveBeenCalledOnce(); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); + +describe("extractHdrVideoFrames scratch bound", () => { + it("holds a long clip in a few windows instead of the whole clip", async () => { + const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-bounded-")); + const video = hdrVideo("long", { start: 0, end: 20, mediaStart: 0 }); + const fixture = hdrExtractionFixture([video], framesDir); + fixture.composition.duration = 20; + const calls: string[][] = []; + + try { + const extracted = await extractHdrVideoFrames({ + ...fixture, + maxResidentWindows: 2, + windowBytes: 8 * 6, + extractMediaMetadataImpl: async () => videoMetadata(20), + runFfmpegImpl: windowWriter(calls), + }); + try { + const source = extracted.sources.get("long"); + // 20 s at 2 fps is 40 one-pixel frames, held 8 per window. + expect(source?.frameCount).toBe(40); + expect(extracted.estimatedBytes).toBe(2 * 8 * 6); + let mostFiles = 0; + for (let i = 0; i < 40; i++) { + const lease = await source?.windows.lease(i); + lease?.release(); + mostFiles = Math.max(mostFiles, readdirSync(source?.dir ?? "").length); + } + expect(mostFiles).toBeLessThanOrEqual(2); + expect(calls.length).toBe(5); + // The second window starts at frame 8 (4 s): seek a whole second before it, trim the lead-in. + const second = calls[1] ?? []; + expect(second[second.indexOf("-ss") + 1]).toBe("3.000000"); + expect(second[second.indexOf("-vf") + 1]).toContain("fps=2:start_time=0.000000"); + expect(second[second.indexOf("-vf") + 1]).toContain("trim=start_frame=2"); + } finally { + for (const source of extracted.sources.values()) cleanupHdrVideoFrameSource(source); + extracted.releaseReservation(); + } + } finally { + rmSync(framesDir, { recursive: true, force: true }); + } + }); +}); + +describe("extractHdrVideoFrames loop frame count", () => { + it("knows a looped source's real frame count before capture starts", async () => { + const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-loop-count-")); + const video = hdrVideo("loop", { start: 0, end: 20, mediaStart: 0, loop: true }); + const fixture = hdrExtractionFixture([video], framesDir); + fixture.composition.duration = 20; + const realFrames = 37; + + try { + const extracted = await extractHdrVideoFrames({ + ...fixture, + windowBytes: 8 * 6, + extractMediaMetadataImpl: async () => videoMetadata(20), + runFfmpegImpl: windowWriter([], realFrames), + }); + try { + // 40 planned frames plus one so the real end is observable; the source really has 37. + expect(extracted.sources.get("loop")?.frameCount).toBe(realFrames); + } finally { + for (const source of extracted.sources.values()) cleanupHdrVideoFrameSource(source); + extracted.releaseReservation(); + } + } finally { + rmSync(framesDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/producer/src/services/render/stages/captureHdrResources.ts b/packages/producer/src/services/render/stages/captureHdrResources.ts index c53cf21f0f..b4abc313e3 100644 --- a/packages/producer/src/services/render/stages/captureHdrResources.ts +++ b/packages/producer/src/services/render/stages/captureHdrResources.ts @@ -16,17 +16,7 @@ * exist, leaving the caller with empty maps that the hot loop tolerates. */ -import { - closeSync, - constants, - fstatSync, - mkdirSync, - mkdtempSync, - openSync, - readFileSync, - rmSync, - statfsSync, -} from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, statfsSync } from "node:fs"; import { join } from "node:path"; import { type CaptureSession, @@ -52,10 +42,9 @@ import { } from "../../hdrCompositor.js"; import type { HdrDiagnostics, RenderJob } from "../../renderOrchestrator.js"; import type { CompositionMetadata } from "../shared.js"; +import { createHdrFrameWindows } from "../../hdrFrameWindows.js"; import { encoderFailureError } from "../encoderInterruption.js"; -const NO_FOLLOW_FLAG = constants.O_NOFOLLOW ?? 0; - function tempDirSafePrefix(id: string): string { const safe = id.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80); return safe || "video"; @@ -148,10 +137,16 @@ export async function probeHdrExtractionDims(args: { el.layoutHeight > 0 && !prep.hdrExtractionDims.has(el.id) ) { - prep.hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight }); + prep.hdrExtractionDims.set(el.id, { + width: el.layoutWidth, + height: el.layoutHeight, + }); } if (el.isHdr && nativeHdrImageIds.has(el.id) && !prep.hdrImageFitInfo.has(el.id)) { - prep.hdrImageFitInfo.set(el.id, { fit: el.objectFit, position: el.objectPosition }); + prep.hdrImageFitInfo.set(el.id, { + fit: el.objectFit, + position: el.objectPosition, + }); } } } @@ -170,9 +165,15 @@ export async function probeHdrExtractionDims(args: { const retryStacking = await queryElementStacking(domSession.page, nativeHdrIds); for (const el of retryStacking) { if (el.id === imageId && el.isHdr && el.layoutWidth > 0 && el.layoutHeight > 0) { - prep.hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight }); + prep.hdrExtractionDims.set(el.id, { + width: el.layoutWidth, + height: el.layoutHeight, + }); if (!prep.hdrImageFitInfo.has(el.id)) { - prep.hdrImageFitInfo.set(el.id, { fit: el.objectFit, position: el.objectPosition }); + prep.hdrImageFitInfo.set(el.id, { + fit: el.objectFit, + position: el.objectPosition, + }); } break; } @@ -180,26 +181,6 @@ export async function probeHdrExtractionDims(args: { } } -/** - * Total bytes the raw rgb48le pre-extraction below will write: 6 bytes/px × - * frame area × frame count, summed across HDR videos. Exposed for the - * disk-headroom gate — a plain iPhone HLG clip auto-detected as HDR filled a - * near-full disk with 16-bit raw frames before the user found --sdr (wild - * report, CLI 0.7.52), so the commitment must be checked and surfaced BEFORE - * FFmpeg starts writing. - */ -export function estimateHdrExtractionBytes( - videos: Array<{ durationSeconds: number; width: number; height: number }>, - fps: number, -): number { - let total = 0; - for (const v of videos) { - const frames = Math.ceil(Math.max(0, v.durationSeconds) * fps); - total += frames * v.width * v.height * 6; - } - return total; -} - const HDR_EXTRACTION_HEADROOM_FRACTION = 0.9; const HDR_EXTRACTION_CGROUP_BUDGET_FRACTION = 0.5; const HDR_EXTRACTION_WARN_BYTES = 10e9; @@ -299,18 +280,13 @@ export function resolveHdrExtractionWindow( return window; } -function cleanupHdrFrameDirectory( - frameDir: string, - rawPath: string | undefined, - log?: ProducerLogger, -): void { +function cleanupHdrFrameDirectory(frameDir: string, log?: ProducerLogger): void { if (process.env.KEEP_TEMP === "1") return; try { rmSync(frameDir, { recursive: true, force: true }); } catch (err) { log?.warn("Failed to clean up HDR raw frame directory", { frameDir, - rawPath, error: err instanceof Error ? err.message : String(err), }); } @@ -322,7 +298,7 @@ export function cleanupHdrVideoFrameSource( log?: ProducerLogger, ): void { closeHdrVideoFrameSource(source, log); - cleanupHdrFrameDirectory(source.dir, source.rawPath, log); + cleanupHdrFrameDirectory(source.dir, log); } /** @@ -336,11 +312,9 @@ export function cleanupHdrVideoFrameSource( */ function assertHdrExtractionDiskHeadroom( framesDir: string, - plannedVideos: Array<{ durationSeconds: number; width: number; height: number }>, - fps: number, + estimatedBytes: number, log: ProducerLogger, ): { estimatedBytes: number; budgetBytes: number | undefined } { - const estimatedBytes = estimateHdrExtractionBytes(plannedVideos, fps); const configuredBudget = resolveHdrExtractionBudgetBytes( process.env[HDR_EXTRACTION_MAX_BYTES_ENV], ); @@ -389,11 +363,36 @@ export interface HdrVideoExtractionResult { releaseReservation: () => void; } +const DEFAULT_HDR_WINDOW_BYTES = 512 * BYTES_PER_MIB; + +/** Frames per raw window: the byte budget over one frame, never below a few frames. */ +function hdrWindowFrames(frameSize: number, windowBytes: number): number { + return Math.max(8, Math.floor(windowBytes / frameSize)); +} + /** - * Extract each HDR video into a raw rgb48le frame file via a single FFmpeg - * pass per video, and open a file descriptor for each. The caller owns both - * source teardown and the aggregate scratch reservation, which intentionally - * remains held until the capture-stage finally block. + * Seconds from the extraction start to the first decoded frame, in output ticks. FFmpeg's `fps` + * grid is anchored to that frame, so every window has to be told the same offset. + */ +async function probeFirstTick( + srcPath: string, + mediaStart: number, + fps: number, + runFfmpegImpl: typeof runFfmpeg, + abortSignal: AbortSignal | undefined, +): Promise { + const args = ["-ss", String(mediaStart), "-i", srcPath, "-frames:v", "1"]; + const result = await runFfmpegImpl([...args, ...["-vf", "showinfo", "-f", "null", "-"]], { + signal: abortSignal, + }); + const match = /pts_time:(-?[\d.]+)/.exec(result.stderr); + return match ? Math.max(0, Math.floor(Number(match[1]) * fps + 0.5)) : 0; +} + +/** + * Plan each HDR video's frame windows; a source extracts a window with one FFmpeg pass when the + * capture first reaches it. The caller owns teardown and the scratch reservation (resident + * windows only), held until the capture-stage finally block. */ // fallow-ignore-next-line complexity export async function extractHdrVideoFrames(args: { @@ -406,19 +405,24 @@ export async function extractHdrVideoFrames(args: { height: number; abortSignal: AbortSignal | undefined; hdrDiagnostics: HdrDiagnostics; + maxResidentWindows?: number; + windowBytes?: number; runFfmpegImpl?: typeof runFfmpeg; extractMediaMetadataImpl?: typeof extractMediaMetadata; resolveFinalFrameExtractionWindowImpl?: typeof resolveFinalFrameExtractionWindow; }): Promise { const { job, log, framesDir, composition, prep, width, height, abortSignal, hdrDiagnostics } = args; + const maxResident = Math.max(2, args.maxResidentWindows ?? 2); + const windowBytes = args.windowBytes ?? DEFAULT_HDR_WINDOW_BYTES; const runFfmpegImpl = args.runFfmpegImpl ?? runFfmpeg; const extractMediaMetadataImpl = args.extractMediaMetadataImpl ?? extractMediaMetadata; const resolveFinalFrameExtractionWindowImpl = args.resolveFinalFrameExtractionWindowImpl ?? resolveFinalFrameExtractionWindow; + const fps = fpsToNumber(job.config.fps); const out = new Map(); mkdirSync(framesDir, { recursive: true }); - const plannedVideos: Array<{ durationSeconds: number; width: number; height: number }> = []; + let scratchBytes = 0; const extractionWindows = new Map< string, { window: HdrExtractionWindow; videoStreamLeadSeconds?: number } @@ -442,16 +446,14 @@ export async function extractHdrVideoFrames(args: { videoStreamLeadSeconds: metadata.videoStreamLeadSeconds, }); prep.hdrVideoStartTimes.set(videoId, window.compositionStart); - plannedVideos.push({ - durationSeconds: window.durationSeconds, - width: dims.width, - height: dims.height, - }); + const frameSize = dims.width * dims.height * 6; + const frames = window.finalFrameOnly ? 1 : Math.ceil(window.durationSeconds * fps); + scratchBytes += + Math.min(frames, maxResident * hdrWindowFrames(frameSize, windowBytes)) * frameSize; } const { estimatedBytes, budgetBytes } = assertHdrExtractionDiskHeadroom( framesDir, - plannedVideos, - fpsToNumber(job.config.fps), + scratchBytes, log, ); const releaseReservation = reserveHdrExtractionBytes(estimatedBytes, budgetBytes); @@ -466,79 +468,83 @@ export async function extractHdrVideoFrames(args: { const frameDir = mkdtempSync(join(framesDir, `hdr_${tempDirSafePrefix(videoId)}-`)); createdFrameDirs.add(frameDir); const dims = prep.hdrExtractionDims.get(videoId) ?? { width, height }; - const rawPath = join(frameDir, "frames.rgb48le"); + const frameSize = dims.width * dims.height * 6; const extractionMediaStart = window.extractionMediaStart ?? window.mediaStart; - const extractionStart = String(extractionMediaStart); - const ffmpegArgs: string[] = []; - if (window.finalFrameOnly) { - // Decode before seeking for the one-frame path. Input-side seeking can - // return zero frames for valid unindexed/negative-base transports. - ffmpegArgs.push("-i", srcPath, "-ss", extractionStart, "-frames:v", "1"); - } else { - ffmpegArgs.push( - "-ss", - extractionStart, - "-i", - srcPath, - "-t", - String(window.durationSeconds), + const scale = `scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`; + const firstTick = window.finalFrameOnly + ? 0 + : await probeFirstTick(srcPath, extractionMediaStart, fps, runFfmpegImpl, abortSignal); + const totalFrames = window.finalFrameOnly + ? 1 + : Math.max(1, Math.ceil(window.durationSeconds * fps - firstTick - 1e-9)) + + Number(video.loop); + const windowFrames = Math.min(totalFrames, hdrWindowFrames(frameSize, windowBytes)); + const windows = createHdrFrameWindows({ + dir: frameDir, + frameSize, + totalFrames, + windowFrames, + maxResident, + extract: async (spec) => { + const ffmpegArgs = window.finalFrameOnly + ? // Decode before seeking for the one-frame path. Input-side seeking can + // return zero frames for valid unindexed/negative-base transports. + ["-i", srcPath, "-ss", String(extractionMediaStart), "-frames:v", "1", "-vf", scale] + : hdrWindowFfmpegArgs({ + srcPath, + mediaStart: extractionMediaStart, + fps, + fpsArg: fpsToFfmpegArg(job.config.fps), + firstTick, + firstFrame: spec.firstFrame, + frames: spec.frames, + scale, + }); + const result = await runFfmpegImpl( + [...ffmpegArgs, "-pix_fmt", "rgb48le", "-f", "rawvideo", "-y", spec.path], + { signal: abortSignal }, + ); + if (!result.success) { + hdrDiagnostics.videoExtractionFailures += 1; + log.error("HDR frame extraction failed; aborting render", { + videoId, + srcPath, + firstFrame: spec.firstFrame, + stderr: result.stderr.slice(-400), + }); + throw encoderFailureError(`HDR frame extraction failed for video "${videoId}"`, { + error: `Aborting render to avoid shipping black HDR layers: ${result.stderr.slice(-400)}`, + failureReason: result.failureReason, + }); + } + return Math.min(Math.floor(statSync(spec.path).size / frameSize), spec.frames); + }, + }); + // The first window is extracted now so a bad source fails before capture starts. + const first = await windows.lease(0); + if (!first) { + hdrDiagnostics.videoExtractionFailures += 1; + throw new Error( + `HDR frame extraction produced no frames for video "${videoId}". ` + + `Aborting render to avoid shipping black HDR layers.`, ); } - if (!window.finalFrameOnly) { - ffmpegArgs.push("-r", fpsToFfmpegArg(job.config.fps)); - } - ffmpegArgs.push( - "-vf", - `scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`, - "-pix_fmt", - "rgb48le", - "-f", - "rawvideo", - "-y", - rawPath, - ); - const result = await runFfmpegImpl(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 encoderFailureError(`HDR frame extraction failed for video "${videoId}"`, { - error: `Aborting render to avoid shipping black HDR layers: ${result.stderr.slice(-400)}`, - failureReason: result.failureReason, - }); - } - const frameSize = dims.width * dims.height * 6; - const fd = openSync(rawPath, constants.O_RDONLY | NO_FOLLOW_FLAG); - let handedOff = false; - try { - const frameCount = Math.floor(fstatSync(fd).size / frameSize); - if (frameCount < 1) { - hdrDiagnostics.videoExtractionFailures += 1; - throw new Error( - `HDR frame extraction produced no frames for video "${videoId}". ` + - `Aborting render to avoid shipping black HDR layers.`, - ); - } - out.set(videoId, { - dir: frameDir, - rawPath, - fd, - width: dims.width, - height: dims.height, - frameSize, - frameCount, - scratch: Buffer.allocUnsafe(frameSize), - loop: video.loop, - mediaStart: extractionMediaStart, - videoStreamLeadSeconds: planned.videoStreamLeadSeconds, - }); - handedOff = true; - } finally { - if (!handedOff) closeSync(fd); - } + first.release(); + if (video.loop) (await windows.lease(totalFrames - 1))?.release(); + out.set(videoId, { + dir: frameDir, + windows, + width: dims.width, + height: dims.height, + frameSize, + get frameCount() { + return windows.totalFrames; + }, + scratch: Buffer.allocUnsafe(frameSize), + loop: video.loop, + mediaStart: extractionMediaStart, + videoStreamLeadSeconds: planned.videoStreamLeadSeconds, + }); } return { sources: out, estimatedBytes, releaseReservation }; } catch (error) { @@ -547,13 +553,38 @@ export async function extractHdrVideoFrames(args: { createdFrameDirs.delete(source.dir); } for (const frameDir of createdFrameDirs) { - cleanupHdrFrameDirectory(frameDir, undefined, log); + cleanupHdrFrameDirectory(frameDir, log); } releaseReservation(); throw error; } } +/** + * One window as one FFmpeg pass on the clip's own tick grid: seek to a whole second before the + * window (so timestamp rounding matches the clip's), pin `fps` to the clip's first tick, trim the lead-in. + */ +function hdrWindowFfmpegArgs(a: { + srcPath: string; + mediaStart: number; + fps: number; + fpsArg: string; + firstTick: number; + firstFrame: number; + frames: number; + scale: string; +}): string[] { + const unit = Math.ceil(a.fps); + const seekFrame = a.firstFrame < unit ? 0 : (Math.floor(a.firstFrame / unit) - 1) * unit; + const trim = a.firstFrame - seekFrame; + const filters = [`fps=${a.fpsArg}:start_time=${(a.firstTick / a.fps).toFixed(6)}`, a.scale]; + if (trim > 0) filters.push(`trim=start_frame=${trim}`); + return [ + ...["-ss", (a.mediaStart + seekFrame / a.fps).toFixed(6), "-i", a.srcPath], + ...["-vf", filters.join(","), "-frames:v", String(a.frames)], + ]; +} + /** * Decode each HDR image into an rgb48le buffer, resampling to the element's * layout box if known. Failures abort the render to avoid shipping missing @@ -584,7 +615,11 @@ export function decodeHdrImageBuffers(args: { fit, fitInfo?.position, ); - out.set(imageId, { data: resampled, width: layout.width, height: layout.height }); + out.set(imageId, { + data: resampled, + width: layout.width, + height: layout.height, + }); } else { out.set(imageId, { data: Buffer.from(decoded.data), diff --git a/packages/producer/src/services/render/stages/captureHdrStage.ts b/packages/producer/src/services/render/stages/captureHdrStage.ts index a98a843e30..e2dd713af2 100644 --- a/packages/producer/src/services/render/stages/captureHdrStage.ts +++ b/packages/producer/src/services/render/stages/captureHdrStage.ts @@ -298,6 +298,7 @@ export async function runCaptureHdrStage( height, abortSignal, hdrDiagnostics, + maxResidentWindows: Math.max(1, workerCount) + 1, }); releaseHdrExtractionReservation = extracted.releaseReservation; for (const [id, source] of extracted.sources) hdrVideoFrameSources.set(id, source); diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index cede2a2fe2..4cfa8bb3a3 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -36,7 +36,7 @@ "unpacked": 541601 }, "@hyperframes/producer": { - "packed": 768601, + "packed": 770990, "unpacked": 2808835 }, "@hyperframes/sdk": {