From 9a1afce43c7cbca5deb2875b10bb468dd73cc338 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 12:39:54 -0700 Subject: [PATCH 1/2] fix(producer): disk-headroom gate before hdr raw frame pre-extraction --- .../render/stages/captureHdrResources.test.ts | 28 +++++++ .../render/stages/captureHdrResources.ts | 79 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/producer/src/services/render/stages/captureHdrResources.test.ts diff --git a/packages/producer/src/services/render/stages/captureHdrResources.test.ts b/packages/producer/src/services/render/stages/captureHdrResources.test.ts new file mode 100644 index 0000000000..503b97f1ed --- /dev/null +++ b/packages/producer/src/services/render/stages/captureHdrResources.test.ts @@ -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, + ); + }); +}); diff --git a/packages/producer/src/services/render/stages/captureHdrResources.ts b/packages/producer/src/services/render/stages/captureHdrResources.ts index 76129dd5fb..5683e0b17b 100644 --- a/packages/producer/src/services/render/stages/captureHdrResources.ts +++ b/packages/producer/src/services/render/stages/captureHdrResources.ts @@ -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(); + 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; From c20e20235b01fccec65e12f2d86775930e7e5553 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 13:55:01 -0700 Subject: [PATCH 2/2] refactor(producer): stop coupling the hdr disk-headroom gate's control flow to error-string matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses non-blocking review feedback on #2256: the previous shape used err.message.includes(...) inside a catch to distinguish a real headroom violation from a statfsSync failure — a future message tweak would silently fail open (statfs-unsupported and headroom-violation would take the same code path). Now statfsSync failure returns early (skip the gate) and the headroom check/throw happens in plain control flow outside any try/catch. --- .../render/stages/captureHdrResources.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/producer/src/services/render/stages/captureHdrResources.ts b/packages/producer/src/services/render/stages/captureHdrResources.ts index 5683e0b17b..fd65930a75 100644 --- a/packages/producer/src/services/render/stages/captureHdrResources.ts +++ b/packages/producer/src/services/render/stages/captureHdrResources.ts @@ -204,27 +204,29 @@ function assertHdrExtractionDiskHeadroom( log: ProducerLogger, ): void { const estimatedBytes = estimateHdrExtractionBytes(plannedVideos, fps); + let freeBytes: number; 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; + 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 }, + ); } }