feat(render): --resume and --keep-segments for segmented capture

Segments and their manifest live under renders/.hf-segments/<planHash>. A
resumed run re-validates every recorded segment before skipping it; a run
without --resume deletes the directory first, because a previous run's
segments must never be spliced into this output. The directory survives a
failure on purpose — that is the resume point — and is removed after a
successful render unless --keep-segments.

The frame-count check derives from probed duration x fps: the engine has no
exact-count reader and -count_frames decodes the whole file, while the only
thing this must catch is a segment missing frames, which shortens duration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-09-18 20:53:04 -07:00
co-authored by Claude Opus 5
parent 8c14169efd
commit a560f498cd
7 changed files with 174 additions and 1 deletions
+22
View File
@@ -355,6 +355,22 @@ export default defineCommand({
"Increase for complex compositions on slow hardware. Default: 45000 (45 s). " +
"Env: PRODUCER_PLAYER_READY_TIMEOUT_MS.",
},
resume: {
type: "boolean",
description:
"Segmented capture only (HF_SEGMENTED_CAPTURE=true): reuse segments a " +
"previous run of the same composition and settings already finished, " +
"recorded in renders/.hf-segments/<hash>/segments.json. Each reused " +
"segment is re-validated before it is skipped.",
default: false,
},
"keep-segments": {
type: "boolean",
description:
"Segmented capture only: keep renders/.hf-segments/<hash> after a " +
"successful render instead of deleting it.",
default: false,
},
"low-memory-mode": {
type: "boolean",
description:
@@ -470,6 +486,10 @@ export interface RenderOptions {
videoFrameFormat?: VideoFrameFormat;
quiet: boolean;
debug?: boolean;
/** Segmented capture: reuse a prior run's validated segments. */
resumeSegments?: boolean;
/** Segmented capture: keep the segment directory after success. */
keepSegments?: boolean;
bestEffort?: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
@@ -1049,6 +1069,8 @@ async function executeLocalRender(
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
debug: options.debug,
resumeSegments: options.resumeSegments,
keepSegments: options.keepSegments,
strictness: options.bestEffort === false ? "strict" : "best-effort",
},
});
@@ -336,4 +336,13 @@ describe("createRenderPlan", () => {
expect(plan.hfEnvOverrides).not.toContain("HF_SHADER_WORKER_ENTRY");
});
});
it("defaults the segmented resume flags off and reads --resume / --keep-segments", () => {
const off = createRenderPlan({ dir: projectDir });
expect(off.resumeSegments).toBe(false);
expect(off.keepSegments).toBe(false);
const on = createRenderPlan({ dir: projectDir, resume: true, "keep-segments": true });
expect(on.resumeSegments).toBe(true);
expect(on.keepSegments).toBe(true);
});
});
+8
View File
@@ -101,6 +101,8 @@ export interface RenderCommandArgs {
"browser-timeout"?: string;
"protocol-timeout"?: string;
"player-ready-timeout"?: string;
resume?: boolean;
"keep-segments"?: boolean;
"low-memory-mode"?: boolean;
"experimental-fast-capture"?: boolean;
"frames-cache-dir"?: string;
@@ -142,6 +144,10 @@ export interface RenderPlan {
quiet: boolean;
lintVerbose: boolean;
debug: boolean;
/** Segmented capture: reuse a prior run's validated segments. */
resumeSegments: boolean;
/** Segmented capture: keep the segment directory after success. */
keepSegments: boolean;
bestEffort: boolean;
batchJson: boolean;
effectiveQuiet: boolean;
@@ -537,6 +543,8 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
quiet,
lintVerbose: args["lint-verbose"] ?? false,
debug: args.debug ?? false,
resumeSegments: args.resume ?? false,
keepSegments: args["keep-segments"] ?? false,
bestEffort: args["best-effort"] ?? true,
batchJson,
effectiveQuiet: quiet || (batchPath != null && batchJson),
+4
View File
@@ -41,6 +41,10 @@ export interface RenderRequestOptions {
workers?: number;
useGpu?: boolean;
debug?: boolean;
/** Segmented capture: reuse segments recorded in a matching manifest. */
resumeSegments?: boolean;
/** Segmented capture: keep the segment directory after a successful render. */
keepSegments?: boolean;
strictness?: RenderConfig["strictness"];
entryFile?: string;
crf?: number;
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
computeSegmentPlanHash,
probeSegmentFrameCount,
readSegmentManifest,
segmentDirFor,
validateCompletedSegments,
@@ -184,3 +185,37 @@ describe("validateCompletedSegments", () => {
expect((await validateCompletedSegments(manifest, "new", async () => 3)).size).toBe(0);
});
});
describe("probeSegmentFrameCount", () => {
it("derives the count from duration and fps", async () => {
const probe = async () => ({ videoStreamDurationSeconds: 50, fps: 30 });
expect(await probeSegmentFrameCount("/s.mp4", probe)).toBe(1500);
});
it("returns null for anything it cannot trust, rather than a wrong count", async () => {
// Each of these would otherwise produce a number that could match the
// slice length by accident and let a bad segment be reused.
expect(
await probeSegmentFrameCount("/s.mp4", async () => ({
videoStreamDurationSeconds: Number.NaN,
fps: 30,
})),
).toBeNull();
expect(
await probeSegmentFrameCount("/s.mp4", async () => ({
videoStreamDurationSeconds: 50,
fps: 0,
})),
).toBeNull();
expect(
await probeSegmentFrameCount("/s.mp4", async () => {
throw new Error("not a media file");
}),
).toBeNull();
});
it("detects a truncated segment through its shorter duration", async () => {
const probe = async () => ({ videoStreamDurationSeconds: 33.3, fps: 30 });
expect(await probeSegmentFrameCount("/s.mp4", probe)).not.toBe(1500);
});
});
@@ -122,6 +122,30 @@ export function writeSegmentManifest(segmentDir: string, manifest: SegmentManife
renameSync(tmp, join(segmentDir, MANIFEST_FILENAME));
}
/**
* Frame count of a finished segment, derived from its probed duration and
* frame rate. There is no exact-count reader in the engine's ffprobe utils
* and `-count_frames` decodes the whole file; duration is enough here because
* the only failure this has to catch is a segment that is missing frames,
* which shortens the duration. Rounding can only reject a good segment, and
* the cost of that is re-capturing it.
*/
export async function probeSegmentFrameCount(
path: string,
probe: (path: string) => Promise<{ videoStreamDurationSeconds: number; fps: number }>,
): Promise<number | null> {
try {
const meta = await probe(path);
if (!Number.isFinite(meta.videoStreamDurationSeconds) || !Number.isFinite(meta.fps))
return null;
if (meta.fps <= 0) return null;
return Math.round(meta.videoStreamDurationSeconds * meta.fps);
} catch {
// Unprobeable means unusable: the segment is re-captured.
return null;
}
}
/**
* Indices safe to skip on resume. A segment counts only if the hash matches,
* the file exists at the recorded size, and its frame count equals the slice
@@ -58,6 +58,7 @@ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { HTML_BODY_CSS_HEIGHT_FIRST_RE, HTML_BODY_CSS_WIDTH_FIRST_RE } from "@hyperframes/parsers";
import {
type EngineConfig,
extractMediaMetadata,
resolveConfig,
type ExtractionResult,
type ExtractionPhaseBreakdown,
@@ -143,6 +144,15 @@ import {
} from "./render/capturePlan.js";
import { runCaptureSegmentedStage } from "./render/stages/captureSegmentedStage.js";
import { resolveSegmentFrames } from "./render/segmentPlan.js";
import {
computeSegmentPlanHash,
probeSegmentFrameCount,
readSegmentManifest,
segmentDirFor,
validateCompletedSegments,
writeSegmentManifest,
type SegmentManifest,
} from "./render/segmentManifest.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { formatCaptureFrameName } from "../utils/paths.js";
import { findRenderHdrAutoPromotionTrigger, resolveEffectiveHdrMode } from "./render/hdrMode.js";
@@ -296,6 +306,10 @@ export interface RenderConfig {
*/
fps: Fps;
quality: "draft" | "standard" | "high";
/** Segmented capture: reuse segments recorded in a matching manifest (Phase 2b). */
resumeSegments?: boolean;
/** Segmented capture: keep renders/.hf-segments/<planHash> after a successful render. */
keepSegments?: boolean;
/**
* Output container format. Defaults to `"mp4"`; existing renders are
* unaffected unless this field is set explicitly.
@@ -4292,6 +4306,49 @@ async function executeRenderPipeline(input: {
const segmentedPlan = capturePlan;
const captureFrameStart = Date.now();
resetCaptureAttemptProgress(job);
const segmentFrames = resolveSegmentFrames(process.env);
const segmentPlanHash = computeSegmentPlanHash({
compositionHash: compositionHash ?? "",
cliVersion: process.env.npm_package_version ?? "dev",
totalFrames,
segmentFrames,
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
imageFormat: captureOptions.format || "jpeg",
});
const segmentDir = segmentDirFor(join(projectDir, "renders"), segmentPlanHash);
let completedSegments: ReadonlySet<number> = new Set<number>();
if (job.config.resumeSegments === true) {
const existing = readSegmentManifest(segmentDir);
if (existing) {
completedSegments = await validateCompletedSegments(existing, segmentPlanHash, (path) =>
probeSegmentFrameCount(path, extractMediaMetadata),
);
log.info(`[Render] resuming: ${completedSegments.size} segments complete`, {
segmentDir,
});
}
if (completedSegments.size === 0) {
rmSync(segmentDir, { recursive: true, force: true });
}
} else {
// Without --resume the directory is stale by definition: a previous
// run's segments must never be spliced into this output.
rmSync(segmentDir, { recursive: true, force: true });
}
const segmentManifest: SegmentManifest = readSegmentManifest(segmentDir) ?? {
version: 1,
planHash: segmentPlanHash,
totalFrames,
segmentFrames,
completed: [],
};
const segmentedRes = await observeRenderStage(
observability,
"capture_segmented",
@@ -4331,7 +4388,16 @@ async function executeRenderPipeline(input: {
assertNotAborted,
onProgress,
dedupPerfs,
segmentFrames: resolveSegmentFrames(process.env),
segmentFrames,
segmentDir,
completedSegments,
onSegmentComplete: (entry) => {
segmentManifest.completed = [
...segmentManifest.completed.filter((e) => e.index !== entry.index),
{ ...entry, completedAt: new Date().toISOString() },
];
writeSegmentManifest(segmentDir, segmentManifest);
},
updateCaptureObservability,
}),
);
@@ -4348,6 +4414,11 @@ async function executeRenderPipeline(input: {
log.info(
`[Render] Segmented capture complete: ${segmentedRes.segments} segment(s) concatenated.`,
);
// Only after a successful capture: a failure leaves the directory
// in place, because that is exactly what --resume reads next time.
if (job.config.keepSegments !== true) {
rmSync(segmentDir, { recursive: true, force: true });
}
} else {
// Only the first segment's encoder can fail to spawn this way, so
// nothing was captured: drop to the plain streaming plan and let