mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
+120
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user