fix: bound HDR and video extraction resources (#2955)

* fix: bound HDR and video extraction resources

* fix: trim negative video extraction preroll

* fix: skip invisible video extraction windows

* fix: preserve negative-start loop and held tails

* fix: cap finite video slots to source duration

* fix: bound held-tail frame extraction

* fix: plan from playable video duration

* fix: preserve open-ended held video tails

* fix: resolve held tails from decoded frames

* fix: normalize final-frame probe timestamps

* fix: handle unseekable final-frame sources

* fix: dedupe final-frame probes per render

* refactor: clarify output dynamic range contract
This commit is contained in:
James Russo
2026-08-02 20:21:54 -07:00
committed by GitHub
parent 67ffafb11c
commit 2339757377
22 changed files with 2541 additions and 156 deletions
@@ -0,0 +1,89 @@
import { Hono } from "hono";
import { beforeEach, describe, expect, it, vi } from "vitest";
const capturedRenderConfigs = vi.hoisted(() => new Array<Record<string, unknown>>());
vi.mock("./services/renderOrchestrator.js", () => {
class RenderCancelledError extends Error {}
return {
RenderCancelledError,
createRenderJob: (config: Record<string, unknown>) => {
capturedRenderConfigs.push(config);
return {
config,
progress: 0,
currentStage: "queued",
framesRendered: 0,
totalFrames: 0,
warnings: [],
};
},
executeRenderJob: async (job: Record<string, unknown>) => {
job.outcome = "completed";
job.currentStage = "complete";
},
};
});
import { createRenderHandlers } from "./server.js";
function createInternalStreamingApp(): Hono {
const app = new Hono();
const handlers = createRenderHandlers({
getRequestId: () => "hdr-mode-test",
maxConcurrentRenders: 1,
});
app.post("/v1/render-stream", handlers.renderStream);
return app;
}
function requestRender(overrides: Record<string, unknown>) {
return createInternalStreamingApp().request("/v1/render-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ html: "<html><body></body></html>", ...overrides }),
});
}
describe("POST /v1/render-stream — outputDynamicRange", () => {
beforeEach(() => capturedRenderConfigs.splice(0));
it.each([
["auto", "auto"],
["hdr", "force-hdr"],
["sdr", "force-sdr"],
] as const)(
"maps %s through createRenderRequest to internal hdrMode %s",
async (outputDynamicRange, hdrMode) => {
const response = await requestRender({ outputDynamicRange });
expect(response.status).toBe(200);
expect(await response.text()).toContain('"type":"complete"');
expect(capturedRenderConfigs).toHaveLength(1);
expect(capturedRenderConfigs[0]?.hdrMode).toBe(hdrMode);
},
);
it("rejects an invalid mode before creating a render job", async () => {
const response = await requestRender({ outputDynamicRange: "force-sdr" });
expect(response.status).toBe(200);
expect(await response.text()).toContain(
'outputDynamicRange must be one of: \\"auto\\", \\"hdr\\", \\"sdr\\"',
);
expect(capturedRenderConfigs).toHaveLength(0);
});
it("accepts the matching legacy field during rolling deployment", async () => {
const response = await requestRender({
outputDynamicRange: "sdr",
hdrMode: "force-sdr",
});
expect(response.status).toBe(200);
expect(await response.text()).toContain('"type":"complete"');
expect(capturedRenderConfigs).toHaveLength(1);
expect(capturedRenderConfigs[0]?.hdrMode).toBe("force-sdr");
});
});
+50
View File
@@ -46,6 +46,33 @@ describe("parseRenderOptions — render strictness", () => {
});
});
describe("parseRenderOptions — outputDynamicRange", () => {
it.each(["auto", "hdr", "sdr"] as const)("forwards %s", (outputDynamicRange) => {
expect(parseRenderOptions({ outputDynamicRange }).outputDynamicRange).toBe(outputDynamicRange);
});
it("drops invalid values from the lenient parser", () => {
expect(
parseRenderOptions({ outputDynamicRange: "force-sdr" }).outputDynamicRange,
).toBeUndefined();
expect(parseRenderOptions({ outputDynamicRange: true }).outputDynamicRange).toBeUndefined();
});
it.each([
["auto", "auto"],
["force-hdr", "hdr"],
["force-sdr", "sdr"],
] as const)("maps legacy hdrMode %s to %s", (hdrMode, outputDynamicRange) => {
expect(parseRenderOptions({ hdrMode }).outputDynamicRange).toBe(outputDynamicRange);
});
it("prefers the canonical field when both equivalent fields are present", () => {
expect(
parseRenderOptions({ outputDynamicRange: "sdr", hdrMode: "force-sdr" }).outputDynamicRange,
).toBe("sdr");
});
});
describe("prepareRenderBody — validation", () => {
it.each(["", " "])(
"treats an empty projectDir as absent and uses inline HTML",
@@ -67,6 +94,29 @@ describe("prepareRenderBody — validation", () => {
expect((result as { error: string }).error).toContain("variables must be a JSON object");
});
it("rejects an explicitly-supplied invalid outputDynamicRange", async () => {
const result = await prepareRenderBody({
outputDynamicRange: "force-sdr",
html: "<html></html>",
});
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain(
'outputDynamicRange must be one of: "auto", "hdr", "sdr"',
);
});
it("rejects conflicting canonical and legacy policies", async () => {
const result = await prepareRenderBody({
outputDynamicRange: "sdr",
hdrMode: "force-hdr",
html: "<html></html>",
});
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain(
"outputDynamicRange and legacy hdrMode must describe the same output policy",
);
});
it("rejects an explicitly-supplied invalid outputResolution", async () => {
const result = await prepareRenderBody({ outputResolution: "8k", html: "<html></html>" });
expect(result).toHaveProperty("error");
+46
View File
@@ -84,6 +84,7 @@ interface RenderInput {
quality: "draft" | "standard" | "high";
format?: "mp4" | "webm" | "mov";
videoFrameFormat?: RenderConfig["videoFrameFormat"];
outputDynamicRange?: "auto" | "hdr" | "sdr";
workers?: number;
useGpu: boolean;
debug: boolean;
@@ -158,6 +159,28 @@ function parseServerFormat(value: unknown): RenderInput["format"] {
return value === "mp4" || value === "webm" || value === "mov" ? value : undefined;
}
function parseServerOutputDynamicRange(value: unknown): RenderInput["outputDynamicRange"] {
return value === "auto" || value === "hdr" || value === "sdr" ? value : undefined;
}
function parseLegacyServerHdrMode(value: unknown): RenderConfig["hdrMode"] {
return value === "auto" || value === "force-hdr" || value === "force-sdr" ? value : undefined;
}
function fromRenderHdrMode(hdrMode: RenderConfig["hdrMode"]): RenderInput["outputDynamicRange"] {
if (hdrMode === "force-hdr") return "hdr";
if (hdrMode === "force-sdr") return "sdr";
return hdrMode;
}
function toRenderHdrMode(
outputDynamicRange: RenderInput["outputDynamicRange"],
): RenderConfig["hdrMode"] {
if (outputDynamicRange === "hdr") return "force-hdr";
if (outputDynamicRange === "sdr") return "force-sdr";
return outputDynamicRange;
}
export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
// Accept either a JSON `number` (integer fps) or a JSON `string` (rational
// like "30000/1001"). Falls back to 30 fps on parse failure to preserve the
@@ -176,6 +199,9 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
const outputPath = parseOutputCandidate(body);
const entryFile = nonEmptyString(body.entryFile);
const format = parseServerFormat(body.format);
const outputDynamicRange =
parseServerOutputDynamicRange(body.outputDynamicRange) ??
fromRenderHdrMode(parseLegacyServerHdrMode(body.hdrMode));
const videoFrameFormat = isVideoFrameFormat(body.videoFrameFormat)
? body.videoFrameFormat
: undefined;
@@ -193,6 +219,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
strictness,
entryFile,
format,
outputDynamicRange,
variables,
outputResolution,
outputResolutionAspectAgnostic,
@@ -254,6 +281,7 @@ function buildRenderJobConfig(input: RenderInput, outputPath: string, log: Produ
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
videoFrameFormat: input.videoFrameFormat,
hdrMode: toRenderHdrMode(input.outputDynamicRange),
},
});
return renderConfigFromRequest(request, { logger: log });
@@ -286,6 +314,24 @@ function validateRenderOverrides(body: Record<string, unknown>): string | undefi
if (body.variables !== undefined && !isPlainObject(body.variables)) {
return 'variables must be a JSON object keyed by variable id (e.g. {"title":"Hello"})';
}
if (
body.outputDynamicRange !== undefined &&
parseServerOutputDynamicRange(body.outputDynamicRange) === undefined
) {
return 'outputDynamicRange must be one of: "auto", "hdr", "sdr"';
}
const legacyHdrMode = parseLegacyServerHdrMode(body.hdrMode);
if (body.hdrMode !== undefined && legacyHdrMode === undefined) {
return 'legacy hdrMode must be one of: "auto", "force-hdr", "force-sdr"';
}
const outputDynamicRange = parseServerOutputDynamicRange(body.outputDynamicRange);
if (
outputDynamicRange !== undefined &&
legacyHdrMode !== undefined &&
outputDynamicRange !== fromRenderHdrMode(legacyHdrMode)
) {
return "outputDynamicRange and legacy hdrMode must describe the same output policy";
}
return validateOutputResolutionOverride(body);
}
@@ -153,6 +153,13 @@ function readVideoMetadata(
record.videoStreamDurationSeconds,
`${field}.videoStreamDurationSeconds`,
),
// Plans written before stream-start metadata existed implicitly used the
// overwhelmingly common start-at-zero domain. Preserve that compatibility
// while carrying non-zero edit-list/transport timestamps in new plans.
videoStreamStartSeconds:
record.videoStreamStartSeconds === undefined
? 0
: readFiniteNumber(record.videoStreamStartSeconds, `${field}.videoStreamStartSeconds`),
width: readPositiveInteger(record.width, `${field}.width`),
height: readPositiveInteger(record.height, `${field}.height`),
fps: readFiniteNumber(record.fps, `${field}.fps`),
@@ -94,6 +94,26 @@ describe("distributed video metadata", () => {
expect(sourceDerived.videos[0]?.mediaStart).toBe(1);
});
it("round-trips a non-zero video stream start and defaults legacy plans to zero", () => {
const withStart = buildPlanVideosJson({
videos: [video()],
extracted: [
extractedMetadata({
metadata: {
...extractedMetadata().metadata,
videoStreamStartSeconds: 5,
},
}),
],
compositionEnd: 8,
});
expect(parsePlanVideosJson(withStart).extracted[0]?.metadata.videoStreamStartSeconds).toBe(5);
const legacy = structuredClone(withStart);
delete legacy.extracted[0]?.metadata.videoStreamStartSeconds;
expect(parsePlanVideosJson(legacy).extracted[0]?.metadata.videoStreamStartSeconds).toBe(0);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 0, 2])(
"fails closed when no safe composition boundary can be derived (%s)",
(compositionEnd) => {
@@ -139,6 +139,8 @@ export interface HdrVideoFrameSource {
frameSize: number;
frameCount: number;
scratch: Buffer;
/** The raw file contains one playable source cycle and must wrap at EOF. */
loop?: boolean;
}
export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void {
@@ -152,6 +154,18 @@ export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: Prod
}
}
export function resolveHdrVideoFrameIndex(
time: number,
startTime: number,
fps: number,
frameCount: number,
loop = false,
): number | null {
const frameIndex = Math.round((time - startTime) * fps);
if (frameIndex < 0 || frameCount < 1) return null;
return loop ? frameIndex % frameCount : Math.min(frameIndex, frameCount - 1);
}
// fallow-ignore-next-line complexity
export function blitHdrVideoLayer(
canvas: Buffer,
@@ -173,14 +187,18 @@ export function blitHdrVideoLayer(
return;
}
// Frame index within the video. Clamp to the extracted raw frame count so
// a composition that outlives the source clip freezes on the last frame,
// matching Chrome's <video> behavior.
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
if (videoFrameIndex < 1) return;
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
if (effectiveIndex < 1) return;
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
// Frame index within the extracted playable source range. Loops wrap one
// extracted cycle; non-loops clamp to its final frame, matching Chrome's
// held-tail behavior for authored slots that outlive the source.
const effectiveIndex = resolveHdrVideoFrameIndex(
time,
startTime,
fps,
frameSource.frameCount,
frameSource.loop,
);
if (effectiveIndex === null) return;
const frameOffset = effectiveIndex * frameSource.frameSize;
try {
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;
@@ -9,7 +9,6 @@
* centralized here.
*/
import { rmSync } from "node:fs";
import {
type BeforeCaptureHook,
type CaptureSession,
@@ -28,9 +27,9 @@ import {
type TransitionRange,
blitHdrImageLayer,
blitHdrVideoLayer,
closeHdrVideoFrameSource,
selectDomLayerShowIds,
} from "../../hdrCompositor.js";
import { cleanupHdrVideoFrameSource } from "./captureHdrResources.js";
import {
type HdrPerfCollector,
type HdrPerfTimingKey,
@@ -402,17 +401,7 @@ export function cleanupEndedHdrVideos(args: {
if (!stillNeeded) {
const frameSource = hdrVideoFrameSources.get(videoId);
if (frameSource) {
closeHdrVideoFrameSource(frameSource, log);
try {
rmSync(frameSource.dir, { recursive: true, force: true });
} catch (err) {
log.warn("Failed to clean up HDR raw frame directory", {
videoId,
frameDir: frameSource.dir,
rawPath: frameSource.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
cleanupHdrVideoFrameSource(frameSource, log);
hdrVideoFrameSources.delete(videoId);
}
cleanedUpVideos.add(videoId);
@@ -1,5 +1,119 @@
import { describe, expect, it } from "vitest";
import { estimateHdrExtractionBytes } from "./captureHdrResources.js";
import {
closeSync,
constants,
existsSync,
mkdtempSync,
openSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process";
import {
extractMediaMetadata,
resolveFinalFrameExtractionWindow,
runFfmpeg,
type RunFfmpegResult,
type VideoElement,
type VideoMetadata,
} from "@hyperframes/engine";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createRenderJob } from "../../renderOrchestrator.js";
import { resolveHdrVideoFrameIndex } from "../../hdrCompositor.js";
import {
cleanupHdrVideoFrameSource,
estimateHdrExtractionBytes,
extractHdrVideoFrames,
getHdrExtractionReservedBytes,
reserveHdrExtractionBytes,
resolveHdrExtractionActiveBudgetBytes,
resolveHdrExtractionBudgetBytes,
resolveHdrExtractionWindow,
} from "./captureHdrResources.js";
afterEach(() => {
vi.unstubAllEnvs();
expect(getHdrExtractionReservedBytes()).toBe(0);
});
function ffmpegResult(success: boolean): RunFfmpegResult {
return {
success,
exitCode: success ? 0 : 1,
stderr: success ? "" : "mock extraction failure",
durationMs: 1,
terminationReason: "exit",
};
}
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
function hdrVideo(id: string, overrides: Partial<VideoElement> = {}): VideoElement {
return {
id,
src: `${id}.mov`,
start: 0,
end: Number.POSITIVE_INFINITY,
mediaStart: 0,
loop: false,
hasAudio: false,
...overrides,
};
}
function videoMetadata(
durationSeconds: number,
videoStreamDurationSeconds = durationSeconds,
): VideoMetadata {
return {
durationSeconds,
videoStreamDurationSeconds,
width: 1,
height: 1,
fps: 2,
videoCodec: "hevc",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: null,
};
}
function hdrExtractionFixture(videos: VideoElement[], framesDir: string) {
return {
job: createRenderJob({ fps: { num: 2, den: 1 }, quality: "standard" }),
log: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
framesDir,
composition: {
duration: 2,
videos,
audios: [],
images: [],
width: 1,
height: 1,
},
prep: {
hdrVideoIds: videos.map((video) => video.id),
hdrVideoSrcPaths: new Map(videos.map((video) => [video.id, video.src])),
hdrVideoStartTimes: new Map(videos.map((video) => [video.id, video.start])),
hdrImageStartTimes: new Map(),
hdrExtractionDims: new Map(videos.map((video) => [video.id, { width: 1, height: 1 }])),
hdrImageFitInfo: new Map(),
},
width: 1,
height: 1,
abortSignal: undefined,
hdrDiagnostics: { videoExtractionFailures: 0, imageDecodeFailures: 0 },
extractMediaMetadataImpl: async () => videoMetadata(120),
resolveFinalFrameExtractionWindowImpl: async (_srcPath, _video, _metadata, window) => window,
};
}
describe("estimateHdrExtractionBytes", () => {
it("sums 6 bytes per pixel per frame across videos", () => {
@@ -26,3 +140,551 @@ describe("estimateHdrExtractionBytes", () => {
);
});
});
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,
);
});
it("independently caps a stale finite media end at the composition duration", () => {
expect(resolveHdrExtractionWindow(hdrVideo("hdr", { end: 60 }), 2, videoMetadata(60))).toEqual({
compositionStart: 0,
mediaStart: 0,
durationSeconds: 2,
});
});
it("trims materially negative preroll and advances the source offset", () => {
expect(
resolveHdrExtractionWindow(hdrVideo("hdr", { start: -60, end: 120 }), 2, videoMetadata(120)),
).toEqual({ compositionStart: 0, mediaStart: 60, durationSeconds: 2 });
});
it("preserves a short loop cycle when negative preroll crosses EOF", () => {
expect(
resolveHdrExtractionWindow(
hdrVideo("loop", { start: -5, end: 10, loop: true }),
2,
videoMetadata(3),
),
).toEqual({
compositionStart: -5,
mediaStart: 0,
durationSeconds: 3,
preserveTimelinePhase: true,
});
});
it("marks an entirely held interval for exact final-frame resolution", () => {
expect(
resolveHdrExtractionWindow(hdrVideo("held", { start: -5, end: 10 }), 2, videoMetadata(3)),
).toEqual({
compositionStart: -2.000001,
mediaStart: 2.999999,
durationSeconds: 0.000001,
preserveTimelineEnd: true,
ensureFinalFrame: true,
});
});
it.each([
{ start: -5, compositionDuration: 2, expected: null, label: "already ended" },
{
start: -2,
compositionDuration: 15,
expected: { compositionStart: 0, mediaStart: 2, durationSeconds: 1 },
label: "partially visible",
},
])(
"keeps an open-ended HDR clip source-bounded when $label",
({ start, compositionDuration, expected }) => {
expect(
resolveHdrExtractionWindow(
hdrVideo("open-held", { start, end: Number.POSITIVE_INFINITY, loop: false }),
compositionDuration,
videoMetadata(3),
),
).toEqual(expected);
},
);
it.each([
{ loop: true, preservation: { preserveTimelinePhase: true }, label: "loop" },
{
loop: false,
preservation: { preserveTimelineEnd: true, ensureFinalFrame: true },
label: "held tail",
},
])(
"caps a finite 60-second $label slot to one 3-second source range",
({ loop, preservation }) => {
expect(
resolveHdrExtractionWindow(hdrVideo("short", { end: 60, loop }), 60, videoMetadata(3)),
).toEqual({
compositionStart: 0,
mediaStart: 0,
durationSeconds: 3,
...preservation,
});
},
);
it("bounds an entirely held 120-second source to an exact one-frame plan", () => {
expect(
resolveHdrExtractionWindow(
hdrVideo("long-held", { start: -600, end: 10 }),
2,
videoMetadata(120),
),
).toEqual({
compositionStart: -480.000001,
mediaStart: 119.999999,
durationSeconds: 0.000001,
preserveTimelineEnd: true,
ensureFinalFrame: true,
});
});
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 }));
});
it("skips HDR media with no interval inside the composition", () => {
expect(
resolveHdrExtractionWindow(hdrVideo("hdr", { start: 3 }), 2, videoMetadata(60)),
).toBeNull();
});
});
describe.skipIf(!HAS_FFMPEG)("raw HDR held tails on sparse-timestamp sources", () => {
const fixtureDir = mkdtempSync(join(tmpdir(), "hf-hdr-sparse-held-tail-"));
const cfrFixture = join(fixtureDir, "sub-1fps-cfr.mp4");
const vfrFixture = join(fixtureDir, "sparse-vfr.mp4");
const nonZeroStartFixture = join(fixtureDir, "nonzero-start.mp4");
const negativeStartTransportFixture = join(fixtureDir, "negative-start.ts");
beforeAll(async () => {
const fixtures = [
{
path: cfrFixture,
input: "testsrc2=s=64x64:d=10:rate=1/5",
filters: [] as string[],
},
{
path: vfrFixture,
input: "testsrc2=s=64x64:d=10:rate=1/2",
filters: ["-vf", "select='eq(n,0)+eq(n,2)'", "-vsync", "vfr"],
},
{
path: nonZeroStartFixture,
input: "testsrc2=s=64x64:d=3:rate=1",
filters: ["-output_ts_offset", "5"],
},
{
path: negativeStartTransportFixture,
input: "testsrc2=s=64x64:d=3:rate=1",
filters: [
"-mpegts_copyts",
"1",
"-muxdelay",
"0",
"-avoid_negative_ts",
"disabled",
"-output_ts_offset",
"-2",
],
},
];
for (const fixture of fixtures) {
const result = await runFfmpeg([
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
fixture.input,
...fixture.filters,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-y",
fixture.path,
]);
if (!result.success) {
throw new Error(`sparse HDR fixture synthesis failed: ${result.stderr.slice(-400)}`);
}
}
}, 30_000);
afterAll(() => {
rmSync(fixtureDir, { recursive: true, force: true });
});
it.each([
{ 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,
expectedVfr: false,
streamStart: 5,
},
{
label: "unindexed negative-base MPEG-TS",
src: negativeStartTransportFixture,
expectedVfr: false,
streamStart: -2,
},
])(
"writes exactly one raw HDR frame for $label",
async ({ src, expectedVfr, streamStart }) => {
const metadata = await extractMediaMetadata(src);
expect(metadata.fps).toBeLessThanOrEqual(1);
expect(metadata.isVFR).toBe(expectedVfr);
expect(metadata.videoStreamStartSeconds).toBeCloseTo(streamStart, 6);
const framesDir = mkdtempSync(join(fixtureDir, "raw-out-"));
const video = hdrVideo(`real-${String(expectedVfr)}`, {
src,
start: -15,
end: 5,
});
const fixture = hdrExtractionFixture([video], framesDir);
fixture.prep.hdrExtractionDims.set(video.id, { width: 64, height: 64 });
const extracted = await extractHdrVideoFrames({
...fixture,
width: 64,
height: 64,
runFfmpegImpl: runFfmpeg,
extractMediaMetadataImpl: extractMediaMetadata,
resolveFinalFrameExtractionWindowImpl: resolveFinalFrameExtractionWindow,
});
try {
expect(extracted.sources.get(video.id)?.frameCount).toBe(1);
expect(extracted.estimatedBytes).toBe(64 * 64 * 6);
expect(fixture.prep.hdrVideoStartTimes.get(video.id)).toBe(0);
} finally {
for (const source of extracted.sources.values()) cleanupHdrVideoFrameSource(source);
extracted.releaseReservation();
rmSync(framesDir, { recursive: true, force: true });
}
},
30_000,
);
});
describe("resolveHdrExtractionBudgetBytes", () => {
it("uses half an actual cgroup limit when no env budget is configured", () => {
expect(resolveHdrExtractionBudgetBytes(undefined, 24 * 1024)).toBe(12 * 1024 ** 3);
expect(resolveHdrExtractionBudgetBytes(undefined, null)).toBeUndefined();
});
it("uses the stricter of the environment and cgroup budgets", () => {
expect(resolveHdrExtractionBudgetBytes(String(8 * 1024 ** 3), 24 * 1024)).toBe(8 * 1024 ** 3);
expect(resolveHdrExtractionBudgetBytes(String(20 * 1024 ** 3), 24 * 1024)).toBe(12 * 1024 ** 3);
expect(resolveHdrExtractionBudgetBytes("1234.9", null)).toBe(1234);
});
it("rejects invalid budgets", () => {
expect(() => resolveHdrExtractionBudgetBytes("0", null)).toThrow("must be a positive finite");
expect(() => resolveHdrExtractionBudgetBytes("Infinity", null)).toThrow(
"must be a positive finite",
);
});
});
describe("reserveHdrExtractionBytes", () => {
it("prevents concurrent aggregate overcommit and releases idempotently", () => {
const releaseFirst = reserveHdrExtractionBytes(60, 100);
try {
expect(getHdrExtractionReservedBytes()).toBe(60);
expect(() => reserveHdrExtractionBytes(50, 100)).toThrow("Concurrent HDR pre-extractions");
} finally {
releaseFirst();
releaseFirst();
}
const releaseAfter = reserveHdrExtractionBytes(100, 100);
expect(getHdrExtractionReservedBytes()).toBe(100);
releaseAfter();
});
it("prevents two concurrent jobs from overcommitting a disk-only budget", () => {
const diskOnlyBudget = resolveHdrExtractionActiveBudgetBytes(undefined, 100);
expect(diskOnlyBudget).toBe(90);
const releaseFirstJob = reserveHdrExtractionBytes(60, diskOnlyBudget);
try {
expect(() => reserveHdrExtractionBytes(40, diskOnlyBudget)).toThrow(
"Concurrent HDR pre-extractions",
);
} finally {
releaseFirstJob();
}
});
});
describe("extractHdrVideoFrames", () => {
it("pins FFmpeg seek/duration, raw frame count, and reservation lifetime", async () => {
const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-extract-"));
const video = hdrVideo("preroll", { start: -60, end: 120, mediaStart: 0 });
const fixture = hdrExtractionFixture([video], framesDir);
const calls: string[][] = [];
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);
},
});
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(fixture.prep.hdrVideoStartTimes.get("preroll")).toBe(0);
expect(extracted.sources.get("preroll")?.frameCount).toBe(4);
expect(extracted.estimatedBytes).toBe(24);
expect(getHdrExtractionReservedBytes()).toBe(24);
} finally {
for (const source of extracted.sources.values()) cleanupHdrVideoFrameSource(source);
extracted.releaseReservation();
}
} finally {
rmSync(framesDir, { recursive: true, force: true });
}
});
it.each([
{
loop: true,
expectedFrameIndex: 4,
expectedFrameCount: 6,
expectedStart: -5,
expectedSeek: "0",
expectedDuration: "3",
label: "loop phase",
},
{
loop: false,
expectedFrameIndex: 0,
expectedFrameCount: 1,
expectedStart: 0,
expectedSeek: "2",
expectedDuration: undefined,
label: "held final frame",
},
])("preserves $label after materially negative preroll", async (testCase) => {
const {
loop,
expectedFrameIndex,
expectedFrameCount,
expectedStart,
expectedSeek,
expectedDuration,
} = testCase;
const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-negative-source-"));
const video = hdrVideo(`negative-${String(loop)}`, { start: -5, end: 10, loop });
const fixture = hdrExtractionFixture([video], framesDir);
const calls: string[][] = [];
try {
const extracted = await extractHdrVideoFrames({
...fixture,
extractMediaMetadataImpl: async () => videoMetadata(3),
resolveFinalFrameExtractionWindowImpl: async (_srcPath, _video, _metadata, window) =>
window.ensureFinalFrame
? {
compositionStart: 0,
mediaStart: 2.999999,
extractionMediaStart: 2,
durationSeconds: 0.000001,
preserveTimelineEnd: true,
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);
},
});
try {
const args = calls[0] ?? [];
expect(args.slice(args.indexOf("-ss"), args.indexOf("-ss") + 2)).toEqual([
"-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(fixture.prep.hdrVideoStartTimes.get(video.id)).toBe(expectedStart);
const source = extracted.sources.get(video.id);
expect(source?.loop).toBe(loop);
expect(
resolveHdrVideoFrameIndex(0, expectedStart, 2, source?.frameCount ?? 0, source?.loop),
).toBe(expectedFrameIndex);
} finally {
for (const source of extracted.sources.values()) cleanupHdrVideoFrameSource(source);
extracted.releaseReservation();
}
} finally {
rmSync(framesDir, { recursive: true, force: true });
}
});
it.each([
{ loop: true, expectedFrameIndex: 4, label: "loop" },
{ loop: false, expectedFrameIndex: 5, label: "held tail" },
])(
"reserves one playable video-stream range for a finite 60-second $label slot with longer audio",
async ({ loop, expectedFrameIndex }) => {
const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-finite-short-source-"));
const video = hdrVideo(`finite-${String(loop)}`, { end: 60, loop });
const fixture = hdrExtractionFixture([video], framesDir);
fixture.composition.duration = 60;
const calls: string[][] = [];
try {
const extracted = await extractHdrVideoFrames({
...fixture,
// 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);
},
});
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(extracted.estimatedBytes).toBe(36);
expect(getHdrExtractionReservedBytes()).toBe(36);
expect(fixture.prep.hdrVideoStartTimes.get(video.id)).toBe(0);
const source = extracted.sources.get(video.id);
expect(source?.loop).toBe(loop);
expect(resolveHdrVideoFrameIndex(59, 0, 2, source?.frameCount ?? 0, source?.loop)).toBe(
expectedFrameIndex,
);
} finally {
for (const source of extracted.sources.values()) cleanupHdrVideoFrameSource(source);
extracted.releaseReservation();
}
} finally {
rmSync(framesDir, { recursive: true, force: true });
}
},
);
it("closes/removes completed and partial sources and releases reservation on failure", async () => {
const framesDir = mkdtempSync(join(tmpdir(), "hf-hdr-partial-"));
const fixture = hdrExtractionFixture([hdrVideo("first"), hdrVideo("second")], framesDir);
const createdRawPaths: string[] = [];
let call = 0;
try {
await expect(
extractHdrVideoFrames({
...fixture,
runFfmpegImpl: async (args) => {
call += 1;
const rawPath = args.at(-1);
if (!rawPath) throw new Error("mock FFmpeg output path missing");
createdRawPaths.push(rawPath);
if (call === 2) return ffmpegResult(false);
writeFileSync(rawPath, Buffer.alloc(4 * 6));
return ffmpegResult(true);
},
}),
).rejects.toThrow('HDR frame extraction failed for video "second"');
expect(fixture.hdrDiagnostics.videoExtractionFailures).toBe(1);
expect(createdRawPaths).toHaveLength(2);
for (const rawPath of createdRawPaths) expect(existsSync(dirname(rawPath))).toBe(false);
expect(getHdrExtractionReservedBytes()).toBe(0);
} finally {
rmSync(framesDir, { recursive: true, force: true });
}
});
});
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();
});
it("closes the descriptor 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");
writeFileSync(rawPath, Buffer.alloc(12));
const fd = openSync(rawPath, constants.O_RDONLY);
try {
cleanupHdrVideoFrameSource({
dir,
rawPath,
fd,
width: 1,
height: 2,
frameSize: 12,
frameCount: 1,
scratch: Buffer.alloc(12),
});
expect(existsSync(rawPath)).toBe(true);
expect(() => closeSync(fd)).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -24,20 +24,31 @@ import {
mkdtempSync,
openSync,
readFileSync,
rmSync,
statfsSync,
} from "node:fs";
import { join } from "node:path";
import {
type CaptureSession,
decodePngToRgb48le,
extractMediaMetadata,
getCgroupMemoryLimitMb,
normalizeObjectFit,
queryElementStacking,
resampleRgb48leObjectFit,
resolveFinalFrameExtractionWindow,
resolveVideoExtractionWindow,
runFfmpeg,
type TimelineExtractionWindow,
type VideoMetadata,
} from "@hyperframes/engine";
import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js";
import type { HdrImageBuffer, HdrVideoFrameSource } from "../../hdrCompositor.js";
import {
closeHdrVideoFrameSource,
type HdrImageBuffer,
type HdrVideoFrameSource,
} from "../../hdrCompositor.js";
import type { HdrDiagnostics, RenderJob } from "../../renderOrchestrator.js";
import type { CompositionMetadata } from "../shared.js";
@@ -107,6 +118,7 @@ export function planHdrResources(args: {
* probe for HDR images whose `data-start` instant reports zero dims (GSAP
* `from` tweens animate the element in slightly later).
*/
// fallow-ignore-next-line complexity code-duplication
export async function probeHdrExtractionDims(args: {
domSession: CaptureSession;
nativeHdrIds: Set<string>;
@@ -186,7 +198,129 @@ export function estimateHdrExtractionBytes(
}
const HDR_EXTRACTION_HEADROOM_FRACTION = 0.9;
const HDR_EXTRACTION_CGROUP_BUDGET_FRACTION = 0.5;
const HDR_EXTRACTION_WARN_BYTES = 10e9;
const HDR_EXTRACTION_MAX_BYTES_ENV = "HDR_EXTRACTION_MAX_BYTES";
const BYTES_PER_MIB = 1024 * 1024;
let aggregateHdrExtractionReservedBytes = 0;
export function resolveHdrExtractionBudgetBytes(
raw: string | undefined,
cgroupLimitMb: number | null = getCgroupMemoryLimitMb(),
): number | undefined {
let configuredBudget: number | undefined;
if (raw !== undefined && raw.trim() !== "") {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${HDR_EXTRACTION_MAX_BYTES_ENV} must be a positive finite byte count`);
}
configuredBudget = Math.floor(value);
}
const cgroupBudget =
cgroupLimitMb !== null && Number.isFinite(cgroupLimitMb) && cgroupLimitMb > 0
? Math.floor(cgroupLimitMb * BYTES_PER_MIB * HDR_EXTRACTION_CGROUP_BUDGET_FRACTION)
: undefined;
if (configuredBudget === undefined) return cgroupBudget;
if (cgroupBudget === undefined) return configuredBudget;
return Math.min(configuredBudget, cgroupBudget);
}
export function resolveHdrExtractionActiveBudgetBytes(
configuredBudgetBytes: number | undefined,
freeBytes: number,
): number {
const diskBudgetBytes = Math.floor(freeBytes * HDR_EXTRACTION_HEADROOM_FRACTION);
return configuredBudgetBytes === undefined
? diskBudgetBytes
: Math.min(configuredBudgetBytes, diskBudgetBytes);
}
export function reserveHdrExtractionBytes(
estimatedBytes: number,
budgetBytes: number | undefined,
): () => void {
if (!Number.isFinite(estimatedBytes) || estimatedBytes < 0) {
throw new Error(`HDR extraction reservation must be a finite non-negative byte count`);
}
const aggregateBytes = aggregateHdrExtractionReservedBytes + estimatedBytes;
if (budgetBytes !== undefined && aggregateBytes > budgetBytes) {
throw new Error(
`Concurrent HDR pre-extractions need ~${(aggregateBytes / 1e9).toFixed(1)} GB of raw ` +
`16-bit frame scratch, exceeding the active ${(budgetBytes / 1e9).toFixed(1)} GB budget.`,
);
}
aggregateHdrExtractionReservedBytes = aggregateBytes;
let released = false;
return () => {
if (released) return;
released = true;
aggregateHdrExtractionReservedBytes = Math.max(
0,
aggregateHdrExtractionReservedBytes - estimatedBytes,
);
};
}
export function getHdrExtractionReservedBytes(): number {
return aggregateHdrExtractionReservedBytes;
}
export type HdrExtractionWindow = TimelineExtractionWindow;
export function resolveHdrExtractionWindow(
video: {
id: string;
start: number;
end: number;
mediaStart: number;
loop: boolean;
},
compositionDuration: number,
metadata: VideoMetadata,
): HdrExtractionWindow | null {
if (!Number.isFinite(compositionDuration) || compositionDuration <= 0) {
throw new Error(
`Cannot extract HDR video "${video.id}" with invalid composition duration ${String(compositionDuration)}`,
);
}
const window = resolveVideoExtractionWindow(video, metadata, compositionDuration);
if (!Number.isFinite(window.durationSeconds)) {
throw new Error(
`HDR video "${video.id}" has no finite interval inside the ${compositionDuration}s composition`,
);
}
if (window.durationSeconds <= 0) return null;
if (!Number.isFinite(window.mediaStart) || window.mediaStart < 0) {
throw new Error(`HDR video "${video.id}" has invalid mediaStart ${String(window.mediaStart)}`);
}
return window;
}
function cleanupHdrFrameDirectory(
frameDir: string,
rawPath: string | undefined,
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),
});
}
}
/** Close the raw-frame descriptor and release its directory unless KEEP_TEMP=1. */
export function cleanupHdrVideoFrameSource(
source: HdrVideoFrameSource,
log?: ProducerLogger,
): void {
closeHdrVideoFrameSource(source, log);
cleanupHdrFrameDirectory(source.dir, source.rawPath, log);
}
/**
* Disk-headroom gate for raw rgb48le pre-extraction: throws if the planned
@@ -202,18 +336,30 @@ function assertHdrExtractionDiskHeadroom(
plannedVideos: Array<{ durationSeconds: number; width: number; height: number }>,
fps: number,
log: ProducerLogger,
): void {
): { estimatedBytes: number; budgetBytes: number | undefined } {
const estimatedBytes = estimateHdrExtractionBytes(plannedVideos, fps);
const configuredBudget = resolveHdrExtractionBudgetBytes(
process.env[HDR_EXTRACTION_MAX_BYTES_ENV],
);
const estimatedGb = (estimatedBytes / 1e9).toFixed(1);
if (configuredBudget !== undefined && estimatedBytes > configuredBudget) {
throw new Error(
`HDR pre-extraction needs ~${estimatedGb} GB of raw 16-bit frames, exceeding the ` +
`active scratch budget of ${(configuredBudget / 1e9).toFixed(1)} GB. ` +
`If the composition doesn't need HDR output, re-run with --sdr; otherwise reduce its ` +
`HDR duration/resolution or use a render container with a larger memory limit.`,
);
}
let freeBytes: number;
try {
const stat = statfsSync(framesDir);
freeBytes = stat.bavail * stat.bsize;
} catch {
// statfs unsupported on this platform/filesystem — skip the gate.
return;
return { estimatedBytes, budgetBytes: configuredBudget };
}
const estimatedGb = (estimatedBytes / 1e9).toFixed(1);
if (estimatedBytes > freeBytes * HDR_EXTRACTION_HEADROOM_FRACTION) {
const diskBudgetBytes = Math.floor(freeBytes * HDR_EXTRACTION_HEADROOM_FRACTION);
if (estimatedBytes > diskBudgetBytes) {
throw new Error(
`HDR pre-extraction needs ~${estimatedGb} GB of raw 16-bit frames but only ` +
`${(freeBytes / 1e9).toFixed(1)} GB is free at ${framesDir}. ` +
@@ -228,13 +374,25 @@ function assertHdrExtractionDiskHeadroom(
{ estimatedBytes, freeBytes },
);
}
return {
estimatedBytes,
budgetBytes: resolveHdrExtractionActiveBudgetBytes(configuredBudget, freeBytes),
};
}
export interface HdrVideoExtractionResult {
sources: Map<string, HdrVideoFrameSource>;
estimatedBytes: number;
releaseReservation: () => void;
}
/**
* Extract each HDR video into a raw rgb48le frame file via a single FFmpeg
* pass per video, and open a file descriptor for each. Returns a map keyed
* by video id. Caller owns lifecycle teardown (closing fds + rm-rf).
* 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.
*/
// fallow-ignore-next-line complexity
export async function extractHdrVideoFrames(args: {
job: RenderJob;
log: ProducerLogger;
@@ -245,90 +403,142 @@ export async function extractHdrVideoFrames(args: {
height: number;
abortSignal: AbortSignal | undefined;
hdrDiagnostics: HdrDiagnostics;
}): Promise<Map<string, HdrVideoFrameSource>> {
runFfmpegImpl?: typeof runFfmpeg;
extractMediaMetadataImpl?: typeof extractMediaMetadata;
resolveFinalFrameExtractionWindowImpl?: typeof resolveFinalFrameExtractionWindow;
}): Promise<HdrVideoExtractionResult> {
const { job, log, framesDir, composition, prep, width, height, abortSignal, hdrDiagnostics } =
args;
const runFfmpegImpl = args.runFfmpegImpl ?? runFfmpeg;
const extractMediaMetadataImpl = args.extractMediaMetadataImpl ?? extractMediaMetadata;
const resolveFinalFrameExtractionWindowImpl =
args.resolveFinalFrameExtractionWindowImpl ?? resolveFinalFrameExtractionWindow;
const out = new Map<string, HdrVideoFrameSource>();
mkdirSync(framesDir, { recursive: true });
const plannedVideos: Array<{ durationSeconds: number; width: number; height: number }> = [];
for (const [videoId] of prep.hdrVideoSrcPaths) {
const extractionWindows = new Map<string, HdrExtractionWindow>();
for (const [videoId, srcPath] of prep.hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
const dims = prep.hdrExtractionDims.get(videoId) ?? { width, height };
const metadata = await extractMediaMetadataImpl(srcPath);
const initialWindow = resolveHdrExtractionWindow(video, composition.duration, metadata);
if (!initialWindow) continue;
const window = await resolveFinalFrameExtractionWindowImpl(
srcPath,
video,
metadata,
initialWindow,
abortSignal,
);
extractionWindows.set(videoId, window);
prep.hdrVideoStartTimes.set(videoId, window.compositionStart);
plannedVideos.push({
durationSeconds: video.end - video.start,
durationSeconds: window.durationSeconds,
width: dims.width,
height: dims.height,
});
}
assertHdrExtractionDiskHeadroom(framesDir, plannedVideos, fpsToNumber(job.config.fps), log);
for (const [videoId, srcPath] of prep.hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
mkdirSync(framesDir, { recursive: true });
const frameDir = mkdtempSync(join(framesDir, `hdr_${tempDirSafePrefix(videoId)}-`));
const duration = video.end - video.start;
const dims = prep.hdrExtractionDims.get(videoId) ?? { width, height };
const rawPath = join(frameDir, "frames.rgb48le");
const ffmpegArgs = [
"-ss",
String(video.mediaStart),
"-i",
srcPath,
"-t",
String(duration),
"-r",
fpsToFfmpegArg(job.config.fps),
"-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 runFfmpeg(ffmpegArgs, { signal: abortSignal });
if (!result.success) {
hdrDiagnostics.videoExtractionFailures += 1;
log.error("HDR frame pre-extraction failed; aborting render", {
videoId,
srcPath,
stderr: result.stderr.slice(-400),
});
throw new Error(
`HDR frame extraction failed for video "${videoId}". ` +
`Aborting render to avoid shipping black HDR layers.`,
const { estimatedBytes, budgetBytes } = assertHdrExtractionDiskHeadroom(
framesDir,
plannedVideos,
fpsToNumber(job.config.fps),
log,
);
const releaseReservation = reserveHdrExtractionBytes(estimatedBytes, budgetBytes);
const createdFrameDirs = new Set<string>();
try {
for (const [videoId, srcPath] of prep.hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
const window = extractionWindows.get(videoId);
if (!video || !window) continue;
mkdirSync(framesDir, { recursive: true });
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 extractionStart = String(window.extractionMediaStart ?? window.mediaStart);
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),
);
}
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 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) {
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 new Error(
`HDR frame extraction produced no frames for video "${videoId}". ` +
`HDR frame extraction failed 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),
});
handedOff = true;
} finally {
if (!handedOff) closeSync(fd);
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,
});
handedOff = true;
} finally {
if (!handedOff) closeSync(fd);
}
}
return { sources: out, estimatedBytes, releaseReservation };
} catch (error) {
for (const source of out.values()) {
cleanupHdrVideoFrameSource(source, log);
createdFrameDirs.delete(source.dir);
}
for (const frameDir of createdFrameDirs) {
cleanupHdrFrameDirectory(frameDir, undefined, log);
}
releaseReservation();
throw error;
}
return out;
}
/**
@@ -60,7 +60,6 @@ import {
type HdrTransitionMeta,
type HdrVideoFrameSource,
type TransitionRange,
closeHdrVideoFrameSource,
resolveCompositeTransfer,
} from "../../hdrCompositor.js";
import { type HdrPerfCollector, createHdrPerfCollector } from "../hdrPerf.js";
@@ -68,6 +67,7 @@ import type { HdrDiagnostics, ProgressCallback, RenderJob } from "../../renderOr
import type { CompositionMetadata } from "../shared.js";
import {
decodeHdrImageBuffers,
cleanupHdrVideoFrameSource,
extractHdrVideoFrames,
planHdrResources,
probeHdrExtractionDims,
@@ -129,6 +129,7 @@ export interface CaptureHdrStageResult {
warnings: CaptureWarning[];
}
// fallow-ignore-next-line complexity
export async function runCaptureHdrStage(
input: CaptureHdrStageInput,
): Promise<CaptureHdrStageResult> {
@@ -215,6 +216,7 @@ export async function runCaptureHdrStage(
let hdrEncoder: StreamingEncoder | null = null;
let hdrEncoderClosed = false;
let domSessionClosed = false;
let releaseHdrExtractionReservation: (() => void) | null = null;
const hdrVideoFrameSources = new Map<string, HdrVideoFrameSource>();
try {
await initializeSession(domSession);
@@ -296,7 +298,8 @@ export async function runCaptureHdrStage(
abortSignal,
hdrDiagnostics,
});
for (const [id, source] of extracted) hdrVideoFrameSources.set(id, source);
releaseHdrExtractionReservation = extracted.releaseReservation;
for (const [id, source] of extracted.sources) hdrVideoFrameSources.set(id, source);
const hdrImageBuffers = decodeHdrImageBuffers({
log,
hdrImageSrcPaths,
@@ -454,9 +457,11 @@ export async function runCaptureHdrStage(
});
}
for (const frameSource of hdrVideoFrameSources.values()) {
closeHdrVideoFrameSource(frameSource, log);
cleanupHdrVideoFrameSource(frameSource, log);
}
hdrVideoFrameSources.clear();
releaseHdrExtractionReservation?.();
releaseHdrExtractionReservation = null;
}
return {
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, expect, it, mock } from "bun:test";
import { getCaptureStageBrowserConsole } from "../captureStageError.js";
import { createCapturePlan } from "../capturePlan.js";
@@ -131,8 +132,13 @@ mock.module("../../hdrCompositor.js", () => ({
}));
mock.module("./captureHdrResources.js", () => ({
cleanupHdrVideoFrameSource: () => {},
decodeHdrImageBuffers: () => new Map(),
extractHdrVideoFrames: async () => new Map(),
extractHdrVideoFrames: async () => ({
sources: new Map(),
estimatedBytes: 0,
releaseReservation: () => {},
}),
planHdrResources: () => ({
hdrVideoStartTimes: new Map(),
nativeHdrVideos: [],
@@ -0,0 +1,120 @@
import { resolveConfig, type ExtractionResult, type VideoElement } from "@hyperframes/engine";
import { describe, expect, it, vi } from "vitest";
const extractionCalls = vi.hoisted(
() => new Array<{ timelineEnd: number | undefined; durationSeconds: number }>(),
);
const fixtureState = vi.hoisted(() => ({ sourceDurationSeconds: 60 }));
vi.mock("@hyperframes/engine", async (importOriginal) => {
const real = await importOriginal<typeof import("@hyperframes/engine")>();
return {
...real,
extractAllVideoFrames: async (
videos: VideoElement[],
_baseDir: string,
options: { timelineEnd?: number },
): Promise<ExtractionResult> => {
const sourceDurationSeconds = fixtureState.sourceDurationSeconds;
const video = videos[0];
if (!video) throw new Error("timeline-bound fixture requires one video");
const requestedDuration = video.end - video.start;
const naturalDuration = sourceDurationSeconds - video.mediaStart;
const resolvedDuration =
Number.isFinite(requestedDuration) && requestedDuration > 0
? requestedDuration
: naturalDuration;
const durationSeconds =
options.timelineEnd === undefined
? resolvedDuration
: Math.min(resolvedDuration, Math.max(0, options.timelineEnd - video.start));
video.end = video.start + durationSeconds;
extractionCalls.push({ timelineEnd: options.timelineEnd, durationSeconds });
return {
success: true,
extracted: [],
errors: [],
totalFramesExtracted: 0,
durationMs: 0,
phaseBreakdown: {
resolveMs: 0,
cachePublishFailures: 0,
cacheGcEvictions: 0,
cacheGcBytesFreed: 0,
cacheAgedPartialsCleared: 0,
hdrProbeMs: 0,
hdrPreflightMs: 0,
hdrPreflightCount: 0,
vfrProbeMs: 0,
vfrPreflightMs: 0,
vfrPreflightCount: 0,
extractMs: 0,
cacheHits: 0,
cacheMisses: 0,
transientRetries: 0,
},
};
},
};
});
import { createRenderJob } from "../../renderOrchestrator.js";
import { runExtractVideosStage } from "./extractVideosStage.js";
async function runStage(compositionDuration: number, materializeSymlinks: boolean): Promise<void> {
const composition = {
duration: compositionDuration,
videos: [
{
id: "root-video",
src: "long.mp4",
start: 0,
end: Number.POSITIVE_INFINITY,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
audios: [],
images: [],
width: 1920,
height: 1080,
};
await runExtractVideosStage({
projectDir: "/tmp/hf-timeline-bound-project",
compiledDir: "/tmp/hf-timeline-bound-compiled",
job: createRenderJob({
fps: { num: 30, den: 1 },
quality: "standard",
hdrMode: "force-sdr",
}),
cfg: resolveConfig(),
composition,
abortSignal: undefined,
assertNotAborted: () => {},
materializeSymlinks,
});
}
describe.each([
["in-process", false],
["distributed plan", true],
] as const)("%s video extraction timeline bound", (_mode, materializeSymlinks) => {
it("caps an open 60-second source to a two-second composition", async () => {
extractionCalls.splice(0);
fixtureState.sourceDurationSeconds = 60;
await runStage(2, materializeSymlinks);
expect(extractionCalls).toEqual([{ timelineEnd: 2, durationSeconds: 2 }]);
});
it("keeps a two-second natural source inside a ten-second composition", async () => {
extractionCalls.splice(0);
fixtureState.sourceDurationSeconds = 2;
await runStage(10, materializeSymlinks);
expect(extractionCalls).toEqual([{ timelineEnd: 10, durationSeconds: 2 }]);
});
});
@@ -376,6 +376,7 @@ export async function runExtractVideosStage(
fps: fpsToNumber(job.config.fps),
outputDir: join(compiledDir, "__hyperframes_video_frames"),
format: job.config.videoFrameFormat ?? "auto",
timelineEnd: composition.duration,
maxTransientRetries: extractionPolicy.maxTransientRetries,
collectProbeFailures: extractionPolicy.failureMode === "enforce",
},
@@ -37,14 +37,24 @@ function makeVideo(overrides: Partial<VideoElement> & { id: string }): VideoElem
function makeExtracted(
videoId: string,
delivered: number,
options: { fps?: number; durationSeconds?: number; isVFR?: boolean } = {},
options: {
fps?: number;
durationSeconds?: number;
videoStreamDurationSeconds?: number;
isVFR?: boolean;
} = {},
): ExtractedFrames {
const { fps = 30, durationSeconds = Number.POSITIVE_INFINITY, isVFR = false } = options;
const {
fps = 30,
durationSeconds = Number.POSITIVE_INFINITY,
videoStreamDurationSeconds = durationSeconds,
isVFR = false,
} = options;
const framePaths = new Map<number, string>();
for (let i = 0; i < delivered; i += 1) framePaths.set(i, `/tmp/${videoId}/${i}.jpg`);
const metadata: VideoMetadata = {
durationSeconds,
videoStreamDurationSeconds: durationSeconds,
videoStreamDurationSeconds,
width: 1280,
height: 720,
fps,
@@ -219,6 +229,28 @@ describe("computeVideoFrameCoverage", () => {
expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 90, ratio: 1 });
});
it.each([
{ loop: false, label: "held tail" },
{ loop: true, label: "loop" },
])(
"credits the playable video stream instead of longer container audio for a $label",
({ loop }) => {
const videos = [makeVideo({ id: "long-audio-mux", start: 0, end: 60, loop })];
const extracted = [
makeExtracted("long-audio-mux", 90, {
durationSeconds: 60,
videoStreamDurationSeconds: 3,
}),
];
expect(computeVideoFrameCoverage(videos, extracted, 30)[0]).toMatchObject({
expectedFrames: 90,
capturedFrames: 90,
ratio: 1,
});
},
);
it("still fails when a looping clip's source extraction is truncated", () => {
// Fail-loud preserved for a genuinely-broken loop: only 60/90 source
// frames arrived, so the delivered set does NOT cover every repeat.
@@ -45,7 +45,11 @@
*/
import { parseHTML } from "linkedom";
import type { ExtractedFrames, VideoElement } from "@hyperframes/engine";
import {
resolvePlayableVideoDuration,
type ExtractedFrames,
type VideoElement,
} from "@hyperframes/engine";
export interface VideoFrameCoverageReport {
videoId: string;
@@ -155,7 +159,7 @@ function expectedFramesForVideo(
// full source *has* been delivered — the same 90 unique source frames
// cover the 300-frame slot — so coverage must measure source-source, not
// slot-source.
const sourceDuration = entry.metadata.durationSeconds - video.mediaStart;
const sourceDuration = resolvePlayableVideoDuration(entry.metadata) - video.mediaStart;
if (!Number.isFinite(sourceDuration) || sourceDuration <= 0) return slotFrames;
const sourceFrames = expectedFramesForClip(0, sourceDuration, fps, rounding);