mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(producer): disk-headroom gate before hdr raw frame pre-extraction
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user