fix(producer): disk-headroom gate before hdr raw frame pre-extraction

This commit is contained in:
Vance Ingalls
2026-07-11 14:14:09 -07:00
parent 3dc08c0868
commit 9a1afce43c
2 changed files with 106 additions and 1 deletions
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { estimateHdrExtractionBytes } from "./captureHdrResources.js";
describe("estimateHdrExtractionBytes", () => {
it("sums 6 bytes per pixel per frame across videos", () => {
// 10s @ 30fps of 1920x1080 = 300 frames * 1920*1080*6
expect(
estimateHdrExtractionBytes([{ durationSeconds: 10, width: 1920, height: 1080 }], 30),
).toBe(300 * 1920 * 1080 * 6);
});
it("accumulates multiple videos and rounds frame counts up", () => {
const bytes = estimateHdrExtractionBytes(
[
{ durationSeconds: 1.5, width: 100, height: 100 },
{ durationSeconds: 0.05, width: 100, height: 100 },
],
30,
);
expect(bytes).toBe((45 + 2) * 100 * 100 * 6);
});
it("treats negative durations as empty", () => {
expect(estimateHdrExtractionBytes([{ durationSeconds: -3, width: 100, height: 100 }], 30)).toBe(
0,
);
});
});
@@ -24,6 +24,7 @@ import {
mkdtempSync,
openSync,
readFileSync,
statfsSync,
} from "node:fs";
import { join } from "node:path";
import {
@@ -34,7 +35,7 @@ import {
resampleRgb48leObjectFit,
runFfmpeg,
} from "@hyperframes/engine";
import { fpsToFfmpegArg } from "@hyperframes/core";
import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js";
import type { HdrImageBuffer, HdrVideoFrameSource } from "../../hdrCompositor.js";
import type { HdrDiagnostics, RenderJob } from "../../renderOrchestrator.js";
@@ -164,6 +165,69 @@ export async function probeHdrExtractionDims(args: {
}
}
/**
* Total bytes the raw rgb48le pre-extraction below will write: 6 bytes/px ×
* frame area × frame count, summed across HDR videos. Exposed for the
* disk-headroom gate — a plain iPhone HLG clip auto-detected as HDR filled a
* near-full disk with 16-bit raw frames before the user found --sdr (wild
* report, CLI 0.7.52), so the commitment must be checked and surfaced BEFORE
* FFmpeg starts writing.
*/
export function estimateHdrExtractionBytes(
videos: Array<{ durationSeconds: number; width: number; height: number }>,
fps: number,
): number {
let total = 0;
for (const v of videos) {
const frames = Math.ceil(Math.max(0, v.durationSeconds) * fps);
total += frames * v.width * v.height * 6;
}
return total;
}
const HDR_EXTRACTION_HEADROOM_FRACTION = 0.9;
const HDR_EXTRACTION_WARN_BYTES = 10e9;
/**
* Disk-headroom gate for raw rgb48le pre-extraction: throws if the planned
* writes would consume more than 90% of free space at `framesDir`, warns
* above 10 GB either way. Fails fast with the --sdr escape hatch instead of
* running the disk to zero mid-extraction ("No space left on device", wild
* report: a plain iPhone HLG clip auto-detected as HDR). `statfsSync` errors
* (unsupported platform/filesystem) skip the gate silently — only the
* headroom violation itself is rethrown.
*/
function assertHdrExtractionDiskHeadroom(
framesDir: string,
plannedVideos: Array<{ durationSeconds: number; width: number; height: number }>,
fps: number,
log: ProducerLogger,
): void {
const estimatedBytes = estimateHdrExtractionBytes(plannedVideos, fps);
try {
const stat = statfsSync(framesDir);
const freeBytes = stat.bavail * stat.bsize;
const estimatedGb = (estimatedBytes / 1e9).toFixed(1);
if (estimatedBytes > freeBytes * HDR_EXTRACTION_HEADROOM_FRACTION) {
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}. ` +
`If the composition doesn't need HDR output, re-run with --sdr; ` +
`otherwise free up disk space and retry.`,
);
}
if (estimatedBytes > HDR_EXTRACTION_WARN_BYTES) {
log.warn(
`HDR pre-extraction will write ~${estimatedGb} GB of raw 16-bit frames ` +
`(pass --sdr to skip if HDR output isn't needed)`,
{ estimatedBytes, freeBytes },
);
}
} catch (err) {
if (err instanceof Error && err.message.includes("HDR pre-extraction needs")) throw err;
}
}
/**
* 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
@@ -183,6 +247,19 @@ export async function extractHdrVideoFrames(args: {
const { job, log, framesDir, composition, prep, width, height, abortSignal, hdrDiagnostics } =
args;
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 video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
const dims = prep.hdrExtractionDims.get(videoId) ?? { width, height };
plannedVideos.push({
durationSeconds: video.end - video.start,
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;