mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
co-authored by
Claude Opus 5
parent
8c14169efd
commit
a560f498cd
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user