mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(producer): extract HDR video frames a window at a time to bound scratch disk (#6304)
* fix(producer): extract HDR video frames a window at a time so scratch disk stays bounded * fix(producer): drop the added compositor comment for the Comments gate * fix(producer): keep the HDR scratch read plain and fail the render when a window cannot be extracted * fix(producer): settle a looped HDR source's frame count before capture starts * chore(producer): raise the packed size budget by the windowed HDR extraction's measured delta
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof createHdrFrameWindows>,
|
||||
index: number,
|
||||
): Promise<number | null> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<HdrFrameLease | null>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
firstFrame: number;
|
||||
path: string;
|
||||
frames: number;
|
||||
fd: number;
|
||||
pins: number;
|
||||
ready: Promise<void>;
|
||||
}
|
||||
|
||||
export function createHdrFrameWindows(opts: {
|
||||
dir: string;
|
||||
frameSize: number;
|
||||
totalFrames: number;
|
||||
windowFrames: number;
|
||||
maxResident: number;
|
||||
extract: (spec: HdrWindowSpec) => Promise<number>;
|
||||
}): 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<number, Window>();
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
|
||||
hdrPerf,
|
||||
);
|
||||
} else {
|
||||
blitHdrVideoLayer(
|
||||
await blitHdrVideoLayer(
|
||||
sceneBuf,
|
||||
el,
|
||||
time,
|
||||
|
||||
@@ -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<RunFfmpegResult> => {
|
||||
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> = {}): 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
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<HdrVideoExtractionResult> {
|
||||
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<string, HdrVideoFrameSource>();
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"unpacked": 541601
|
||||
},
|
||||
"@hyperframes/producer": {
|
||||
"packed": 768601,
|
||||
"packed": 770990,
|
||||
"unpacked": 2808835
|
||||
},
|
||||
"@hyperframes/sdk": {
|
||||
|
||||
Reference in New Issue
Block a user