Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
mkdtempSync,
openSync,
readFileSync,
statfsSync,
} from "node:fs";
import { join } from "node:path";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -164,6 +165,71 @@ 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);
let freeBytes: number;
try {
const stat = statfsSync(framesDir);
freeBytes = stat.bavail * stat.bsize;
} catch {
// statfs unsupported on this platform/filesystem — skip the gate.
return;
}
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 },
);
}
}

/**
* 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
Expand All @@ -183,6 +249,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;
Expand Down
Loading