diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6e6ae731ea..4b4ad5b481 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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//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/ 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; @@ -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", }, }); diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index ea7d620efa..632813a9f5 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -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); + }); }); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 23bd6d3bfb..4095ea99dd 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -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), diff --git a/packages/producer/src/renderRequest.ts b/packages/producer/src/renderRequest.ts index 49ba5e00cd..12cbc442d5 100644 --- a/packages/producer/src/renderRequest.ts +++ b/packages/producer/src/renderRequest.ts @@ -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; diff --git a/packages/producer/src/services/render/segmentManifest.test.ts b/packages/producer/src/services/render/segmentManifest.test.ts index 0588816f37..26a1d997ef 100644 --- a/packages/producer/src/services/render/segmentManifest.test.ts +++ b/packages/producer/src/services/render/segmentManifest.test.ts @@ -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); + }); +}); diff --git a/packages/producer/src/services/render/segmentManifest.ts b/packages/producer/src/services/render/segmentManifest.ts index 1fcc268e22..52d94d4af5 100644 --- a/packages/producer/src/services/render/segmentManifest.ts +++ b/packages/producer/src/services/render/segmentManifest.ts @@ -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 { + 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 diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 87dbc0eb74..e5a9c87738 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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/ 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 = new Set(); + 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