perf(producer): extract distributed video frames in each chunk (#5290)

* perf(producer): extract distributed video frames in each chunk

Plan v2 no longer extracts long constant-frame-rate videos. It ships
each source once and every chunk extracts only the frames it shows, so
Plan time stops growing with video length (#5260).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(aws-lambda): plan no longer bounds video length

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(producer): pin deferred compiled sources to every chunk

Each chunk's page loads every <video> src during session init and a
missing file fails the chunk, so a deferred source under compiled/ must
keep reaching every chunk, as it does on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-08 21:50:01 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2df897aa43
commit 04f2bcd67a
12 changed files with 728 additions and 67 deletions
+10 -8
View File
@@ -223,14 +223,16 @@ The render bucket is created with CloudFormation `Retain` on delete — `hyperfr
- Each `--region` is an independent stack. Cross-region failover is not built
in.
- The distributed AWS path is SDR-only.
- Video length is bounded by the plan step. One Lambda invocation extracts
every frame of every video and uploads them before any chunk starts, and it
must finish inside Lambda's 15-minute limit. On a 10,240 MB function, a
26-minute 576×1024 24 fps H.264 clip plans in about 3.5 minutes. Larger
frames, high-bitrate footage and more videos take proportionally longer.
ffmpeg extracts long clips in 2-minute segments, so `FFMPEG_PROCESS_TIMEOUT_MS`
applies to each segment, not to the whole clip. Split edits that approach the
limit into separate renders.
- Each render chunk extracts only the video frames it shows, so the plan step
no longer grows with video length. On a 10,240 MB function, a 26-minute
576×1024 24 fps H.264 clip plans in about 20 seconds. Each chunk downloads
the whole source file of every video it shows. Variable-frame-rate and HDR
sources are the exception: the plan step still extracts every frame of those,
inside Lambda's 15-minute limit, in 2-minute segments, so
`FFMPEG_PROCESS_TIMEOUT_MS` applies to each segment, not to the whole clip.
- A chunk must also finish inside 15 minutes. For long renders, raise
`--max-parallel-chunks` (`maxParallelChunks` in the SDK) so each chunk covers
fewer frames.
## Related topics
+2
View File
@@ -221,6 +221,8 @@ export {
type VideoElement,
type ImageElement,
type ExtractedFrames,
type DeferredFrameRange,
type FrameRange,
type ExtractionOptions,
type ExtractionResult,
type ExtractionPhaseBreakdown,
@@ -27,6 +27,7 @@ export function extractedFrameIndex(file: string, format: ExtractedFrameFormat):
export function framePathsFromDirectory(
outputDir: string,
format: ExtractedFrameFormat,
allowGaps = false,
): Map<number, string> {
const suffix = `.${format}`;
const indexed = new Map<number, string>();
@@ -40,6 +41,7 @@ export function framePathsFromDirectory(
}
indexed.set(index, join(outputDir, file));
}
if (allowGaps) return new Map([...indexed].sort(([a], [b]) => a - b));
const ordered = new Map<number, string>();
for (let index = 0; index < indexed.size; index += 1) {
@@ -1926,6 +1926,112 @@ describe.skipIf(!HAS_FFMPEG)("frame sampling at the output frame rate", () => {
},
60_000,
);
// A distributed chunk extracts only the frames it renders; they must be the full extraction's.
describe("partial extraction", () => {
const gopSource = join(FIXTURE_DIR, "gop-30fps.mp4");
beforeAll(async () => {
// Real motion and long GOPs, so range seeks land mid-GOP.
const synth = await runFfmpeg([
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=s=64x36:r=30:d=20",
"-c:v",
"libx264",
"-g",
"250",
"-pix_fmt",
"yuv420p",
gopSource,
]);
if (!synth.success) throw new Error(`gop fixture synthesis failed: ${synth.stderr}`);
}, 30_000);
function frameBytes(dir: string): Map<string, Buffer> {
return new Map(readdirSync(dir).map((name) => [name, readFileSync(join(dir, name))]));
}
it.each([
{ fps: 24, startTime: 1.3, duration: 17 },
{ fps: 60, startTime: 0, duration: 20 },
])(
"frameRanges at $fps fps write the same files as a full extraction",
async (c) => {
const full = await extractVideoFramesRange(
gopSource,
`full-${c.fps}`,
c.startTime,
c.duration,
{ fps: c.fps, outputDir: FIXTURE_DIR, format: "jpg" },
);
const last = full.totalFrames;
const ranges = [
{ firstFrame: 0, frames: 5 },
{ firstFrame: 97, frames: 40 },
{ firstFrame: last - 3, frames: 3 },
];
const partial = await extractVideoFramesRange(
gopSource,
`partial-${c.fps}`,
c.startTime,
c.duration,
{ fps: c.fps, outputDir: FIXTURE_DIR, format: "jpg", frameRanges: ranges },
);
const fullFrames = frameBytes(full.outputDir);
const partialFrames = frameBytes(partial.outputDir);
expect(partialFrames.size).toBe(48);
for (const [name, bytes] of partialFrames) {
expect(fullFrames.get(name)?.equals(bytes), name).toBe(true);
}
},
60_000,
);
it.each([
{ fps: 24, mediaStart: 3.3 },
{ fps: 30, mediaStart: 0 },
{ fps: 60, mediaStart: 7.77 },
])(
"a deferred window to the source end at $fps fps reports the full frame count",
async (c) => {
const video = (): VideoElement => ({
id: `defer-${c.fps}`,
src: gopSource,
start: 0,
end: Infinity,
mediaStart: c.mediaStart,
loop: false,
hasAudio: false,
});
const full = await extractAllVideoFrames([video()], FIXTURE_DIR, {
fps: c.fps,
outputDir: join(FIXTURE_DIR, `defer-full-${c.fps}`),
});
const deferred = await extractAllVideoFrames([video()], FIXTURE_DIR, {
fps: c.fps,
outputDir: join(FIXTURE_DIR, `defer-${c.fps}`),
deferRangeExtraction: true,
});
expect(deferred.errors).toEqual([]);
const result = deferred.extracted[0]!;
expect(result.totalFrames).toBe(full.extracted[0]!.totalFrames);
expect(result.framePaths.size).toBe(0);
expect(readdirSync(result.outputDir)).toEqual([]);
expect(result.deferredRange).toEqual({
startTime: c.mediaStart,
durationSeconds: expect.any(Number),
format: "jpg",
});
},
60_000,
);
});
});
describe.skipIf(!HAS_FFMPEG)("held tails on sparse-timestamp sources", () => {
@@ -97,6 +97,7 @@ export interface ExtractedFrames {
totalFrames: number;
metadata: VideoMetadata;
framePaths: Map<number, string>;
deferredRange?: DeferredFrameRange;
/**
* True when the extractor owns `outputDir` and cleanup should rm it when
* the render ends. Cache hits set this to false so the shared entry isn't
@@ -194,6 +195,18 @@ export function extractionFrameCountForDuration(
return Math.max(1, Number.isSafeInteger(frames) ? frames : Number.MAX_SAFE_INTEGER);
}
export interface FrameRange {
firstFrame: number;
frames: number;
}
/** The window a deferred extraction samples; frame k is the source frame on screen at `startTime + k / fps`. */
export interface DeferredFrameRange {
startTime: number;
durationSeconds: number;
format: "jpg" | "png";
}
export interface ExtractionOptions {
/** Exact configured rate. Rational rates are passed to FFmpeg verbatim. */
fps: FpsInput;
@@ -222,6 +235,13 @@ export interface ExtractionOptions {
* candidate enforce lane may opt into typed aggregation.
*/
collectProbeFailures?: boolean;
/** Extract only these runs of output frames, named as a full extraction names them. CFR only. */
frameRanges?: readonly FrameRange[];
/**
* Leave CFR windows longer than a second unextracted, returning `deferredRange` and an exact
* `totalFrames` so another process can extract just the frames it needs with `frameRanges`.
*/
deferRangeExtraction?: boolean;
}
export const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
@@ -870,16 +890,28 @@ interface SegmentedExtraction {
filterAndEncodeArgs: string[];
videoPath: string;
startTime: number;
totalFrames: number;
fps: number;
segmentFrames: number;
segments: readonly FrameRange[];
runOptions: { signal?: AbortSignal; timeout: number };
}
/** Split runs so no single ffmpeg process extracts more than `segmentFrames` frames. */
function splitFrameRanges(ranges: readonly FrameRange[], segmentFrames: number): FrameRange[] {
const segments: FrameRange[] = [];
for (const range of ranges) {
for (let offset = 0; offset < range.frames; offset += segmentFrames) {
segments.push({
firstFrame: range.firstFrame + offset,
frames: Math.min(segmentFrames, range.frames - offset),
});
}
}
return segments;
}
async function runSegmentedExtraction(job: SegmentedExtraction): Promise<RunFfmpegResult> {
const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, totalFrames, fps, segmentFrames } =
job;
const segmentCount = Math.ceil(totalFrames / segmentFrames);
const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, segments } = job;
const segmentCount = segments.length;
// One failed segment fails the range, so stop the others instead of finishing them.
const failed = new AbortController();
const signal = job.runOptions.signal
@@ -890,8 +922,7 @@ async function runSegmentedExtraction(job: SegmentedExtraction): Promise<RunFfmp
const worker = async () => {
while (next < segmentCount && !signal.aborted) {
const index = next++;
const firstFrame = index * segmentFrames;
const frames = Math.min(segmentFrames, totalFrames - firstFrame);
const { firstFrame, frames } = segments[index]!;
const result = await runFfmpeg(
[
...decodeArgs,
@@ -991,6 +1022,9 @@ export async function extractVideoFramesRange(
const decodeWithVideoToolbox = isHdr && isMacOS && !toneMappedToSdr;
const sampleCfrAtOutputFps = !options.finalFrameOnly && !metadata.isVFR;
if (options.frameRanges && !sampleCfrAtOutputFps) {
throw new Error("frameRanges extraction needs a constant-frame-rate source");
}
const args: string[] = [];
if (decodeWithVideoToolbox) {
@@ -1092,15 +1126,17 @@ export async function extractVideoFramesRange(
const segmentFrames = Math.round(EXTRACTION_SEGMENT_SECONDS * fps);
const totalFrames = extractionFrameCountForDuration(duration, normalizedFps, false);
processResult =
sampleCfrAtOutputFps && totalFrames > segmentFrames
sampleCfrAtOutputFps && (options.frameRanges || totalFrames > segmentFrames)
? await runSegmentedExtraction({
decodeArgs,
filterAndEncodeArgs: [...filterArgs, ...encodeArgs],
videoPath,
startTime,
totalFrames,
fps,
segmentFrames,
segments: splitFrameRanges(
options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }],
segmentFrames,
),
runOptions,
})
: await runFfmpeg([...args, ...filterArgs, ...encodeArgs], runOptions);
@@ -1154,7 +1190,7 @@ export async function extractVideoFramesRange(
);
}
const framePaths = framePathsFromDirectory(videoOutputDir, format);
const framePaths = framePathsFromDirectory(videoOutputDir, format, !!options.frameRanges);
if (framePaths.size === 0 && duration > 0) {
throw new VideoSourceExtractionError(
"zero_output",
@@ -1168,7 +1204,7 @@ export async function extractVideoFramesRange(
// frames predicted by the probed duration, the duration is likely
// inflated (container duration includes a longer audio track). Correct
// it so downstream coverage accounting uses the actual video extent.
if (framePaths.size > 0 && duration > 0 && !options.finalFrameOnly) {
if (framePaths.size > 0 && duration > 0 && !options.finalFrameOnly && !options.frameRanges) {
const expectedAtFps = extractionFrameCountForDuration(duration, normalizedFps, metadata.isVFR);
if (expectedAtFps > 0 && framePaths.size <= expectedAtFps * 0.6) {
const correctedDuration = startTime + framePaths.size / fps;
@@ -2186,6 +2222,68 @@ export async function extractAllVideoFrames(
return rehydratePublishedCache(work, cacheTarget);
}
/**
* Every output slot before the source's end holds a frame, so decoding the last second gives
* the exact count. Anything unusual returns null and takes the normal path.
*/
async function deferExtraction(work: PreparedExtraction): Promise<ExtractedFrames | null> {
if (
work.finalFrameOnly ||
work.metadata.isVFR ||
work.sdrToHdrTransfer !== undefined ||
work.hdrToSdrTransformKey !== undefined
) {
return null;
}
const expectedFrames = extractionFrameCountForDuration(
work.videoDuration,
configuredFps,
false,
);
const tailFrames = Math.min(expectedFrames, Math.ceil(fps));
if (expectedFrames <= tailFrames) return null;
const probeDir = join(options.outputDir, `${work.video.id}.tail-probe`);
try {
const tail = await extractVideoFramesRange(
work.videoPath,
work.video.id,
work.extractionMediaStart,
work.videoDuration,
{
...scopedExtractionOptions(work),
frameRanges: [{ firstFrame: expectedFrames - tailFrames, frames: tailFrames }],
},
signal,
config,
probeDir,
);
const totalFrames = expectedFrames - tailFrames + tail.totalFrames;
// A full extraction this short rewrites the probed duration; let it.
if (totalFrames <= expectedFrames * 0.6) return null;
const outputDir = join(options.outputDir, work.video.id);
mkdirSync(outputDir, { recursive: true });
return {
videoId: work.video.id,
srcPath: work.videoPath,
outputDir,
framePattern: `${FRAME_FILENAME_PREFIX}%05d.${work.format}`,
fps,
totalFrames,
metadata: work.metadata,
framePaths: new Map(),
deferredRange: {
startTime: work.extractionMediaStart,
durationSeconds: work.videoDuration,
format: work.format,
},
};
} catch {
return null;
} finally {
rmSync(probeDir, { recursive: true, force: true });
}
}
async function executeDirectMiss(
miss: UniqueExtractionMiss,
maxTransientRetries = options.maxTransientRetries ?? 0,
@@ -2350,8 +2448,17 @@ export async function extractAllVideoFrames(
}
const uniqueOutcomes = new Map<string, ExtractionOutcome>();
if (options.deferRangeExtraction) {
const works = [...uniqueWorks.values()];
const deferred = await Promise.all(works.map(deferExtraction));
works.forEach((work, i) => {
const result = deferred[i];
if (result) uniqueOutcomes.set(work.dedupeKey, { result });
});
}
const cacheMisses: UniqueExtractionMiss[] = [];
for (const work of uniqueWorks.values()) {
if (uniqueOutcomes.has(work.dedupeKey)) continue;
const lookup = lookupCacheFor(work);
if ("work" in lookup) {
cacheMisses.push(lookup);
@@ -2419,7 +2526,7 @@ export async function extractAllVideoFrames(
errors.push(item.error);
} else if ("result" in item) {
extracted.push(item.result);
totalFramesExtracted += item.result.totalFrames;
if (!item.result.deferredRange) totalFramesExtracted += item.result.totalFrames;
}
}
@@ -26,8 +26,17 @@
* never have to handle them.
*/
import { cpSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import {
copyFileSync,
cpSync,
existsSync,
linkSync,
mkdirSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { extname, isAbsolute, join, relative, sep } from "node:path";
import { type CanvasResolution, fpsToNumber } from "@hyperframes/core";
import {
type EngineConfig,
@@ -276,6 +285,12 @@ export interface BuildLocalExecutionPlanOptions {
* transport cap because it publishes role-scoped content-addressed objects.
*/
readonly executionPlanSizeLimitBytes?: number;
/**
* Leave long constant-frame-rate video windows unextracted and ship their
* sources, so each chunk extracts only the frames it renders. Plan v2 only:
* chunks write those frames into their own work directory.
*/
readonly deferVideoExtraction?: boolean;
}
/**
@@ -825,6 +840,53 @@ export function resolveDistributedEngineConfig(config: DistributedRenderConfig):
};
}
function pathInside(root: string, path: string): string | null {
const rel = relative(root, path);
return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? null : rel;
}
/**
* Return a planDir-relative path chunks can extract a deferred video from.
* Sources the plan already ships under `compiled/` are reused; anything else
* (downloads, files outside the project) is linked into `video-sources/`.
* Call after the staged `compiled/` and `video-frames/` trees are promoted.
*/
function createVideoSourceShipper(input: {
planDir: string;
projectDir: string;
workCompiledDir: string;
workVideoFramesDir: string;
}): (srcPath: string) => string {
const shipped = new Map<string, string>();
return (srcPath) => {
const known = shipped.get(srcPath);
if (known) return known;
// Downloads were staged under the frame tree, which now lives at video-frames/.
const stagedRel = pathInside(input.workVideoFramesDir, srcPath);
const current = stagedRel === null ? srcPath : join(input.planDir, "video-frames", stagedRel);
let result: string | null = null;
for (const root of stagedRel === null ? [input.workCompiledDir, input.projectDir] : []) {
const rel = pathInside(root, srcPath);
if (rel !== null && existsSync(join(input.planDir, "compiled", rel))) {
result = ["compiled", ...rel.split(sep)].join("/");
break;
}
}
if (result === null) {
result = `video-sources/${shipped.size}${extname(current)}`;
const destination = join(input.planDir, ...result.split("/"));
mkdirSync(join(input.planDir, "video-sources"), { recursive: true });
try {
linkSync(current, destination);
} catch {
copyFileSync(current, destination);
}
}
shipped.set(srcPath, result);
return result;
};
}
/**
* Build the shared local execution representation used by both transport
* protocols. See the module docstring for the directory layout.
@@ -1043,6 +1105,7 @@ export async function buildLocalExecutionPlan(
abortSignal,
assertNotAborted,
materializeSymlinks: true,
deferRangeExtraction: options.deferVideoExtraction,
});
if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
if (extractResult.extractionResult) {
@@ -1082,6 +1145,7 @@ export async function buildLocalExecutionPlan(
const stagedVideoFrames = join(compiledDir, "__hyperframes_video_frames");
const videoFramesDst = join(planDir, "video-frames");
if (existsSync(videoFramesDst)) rmSync(videoFramesDst, { recursive: true, force: true });
rmSync(join(planDir, "video-sources"), { recursive: true, force: true });
if (existsSync(stagedVideoFrames)) {
renameSync(stagedVideoFrames, videoFramesDst);
} else {
@@ -1097,6 +1161,12 @@ export async function buildLocalExecutionPlan(
// page's native `<video>` element decodes the source mp4 ~1 frame
// off the pre-extracted images the in-process baseline was captured
// from.
const shipVideoSource = createVideoSourceShipper({
planDir,
projectDir,
workCompiledDir: compiledDir,
workVideoFramesDir: stagedVideoFrames,
});
const planVideosJson: PlanVideosJson = buildPlanVideosJson({
videos: composition.videos,
compositionEnd: job.duration ?? Number.NaN,
@@ -1107,6 +1177,9 @@ export async function buildLocalExecutionPlan(
fps: ext.fps,
totalFrames: ext.totalFrames,
metadata: ext.metadata,
...(ext.deferredRange
? { deferredRange: { ...ext.deferredRange, sourcePath: shipVideoSource(ext.srcPath) } }
: {}),
})),
});
mkdirSync(join(planDir, "meta"), { recursive: true });
@@ -11,7 +11,7 @@ import {
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
import { canonicalJsonStringify, sha256Hex } from "../render/stages/planHash.js";
import { CURRENT_PLAN_PROTOCOL } from "./planProtocol.js";
@@ -65,6 +65,8 @@ function createV1Plan(
framePattern?: string;
videoStart?: number;
videoEnd?: number;
/** Ship this planDir-relative source instead of extracted frames. */
deferredSource?: string;
},
): string {
const {
@@ -79,6 +81,7 @@ function createV1Plan(
framePattern = "frame_%05d.jpg",
videoStart = 0,
videoEnd = 1,
deferredSource,
} = options ?? {};
const planDir = join(root, "v1");
mkdirSync(join(planDir, "compiled"), { recursive: true });
@@ -97,9 +100,14 @@ function createV1Plan(
if (video) {
const framesDir = join(planDir, "video-frames", "hero");
mkdirSync(framesDir, { recursive: true });
writeFileSync(join(framesDir, "frame_00001.jpg"), "frame zero");
writeFileSync(join(framesDir, "frame_00002.jpg"), "frame one");
writeFileSync(join(framesDir, "frame_00003.jpg"), "never rendered");
if (deferredSource === undefined) {
writeFileSync(join(framesDir, "frame_00001.jpg"), "frame zero");
writeFileSync(join(framesDir, "frame_00002.jpg"), "frame one");
writeFileSync(join(framesDir, "frame_00003.jpg"), "never rendered");
} else if (!deferredSource.startsWith("..")) {
mkdirSync(dirname(join(planDir, deferredSource)), { recursive: true });
writeFileSync(join(planDir, deferredSource), "source video");
}
writeFileSync(
join(planDir, "meta", "videos.json"),
JSON.stringify({
@@ -133,6 +141,16 @@ function createV1Plan(
hasAlpha: false,
colorSpace: videoColorSpace,
},
...(deferredSource === undefined
? {}
: {
deferredRange: {
sourcePath: deferredSource,
startTime: 0,
durationSeconds: 0.1,
format: "jpg",
},
}),
},
],
}),
@@ -409,6 +427,49 @@ describe("Plan v2 manifest", () => {
);
});
it("ships a deferred video's source only to the chunks that show it", () => {
const root = tempPath("hf-plan-v2-deferred-source-");
const v1 = createV1Plan(root, {
video: true,
videoStart: 1 / 30,
deferredSource: "video-sources/0.mp4",
});
const manifest = readPlanV2Manifest(createPlanV2FromV1(v1, join(root, "v2")).planDir);
const chunk0 = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 0 });
const chunk1 = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 1 });
const assembler = listPlanV2ArtifactsForTarget(manifest, { role: "assembler" });
expect(manifest.limitations.videoDependencyMode).toBe("source-extract");
expect(chunk0.some((artifact) => artifact.path === "video-sources/0.mp4")).toBe(false);
expect(chunk1.some((artifact) => artifact.path === "video-sources/0.mp4")).toBe(true);
expect(assembler.some((artifact) => artifact.path === "video-sources/0.mp4")).toBe(false);
expect(manifest.artifacts.some((artifact) => artifact.path.startsWith("video-frames/"))).toBe(
false,
);
});
it("keeps a deferred source the page itself loads in every chunk", () => {
const root = tempPath("hf-plan-v2-deferred-compiled-source-");
const v1 = createV1Plan(root, {
video: true,
videoStart: 1 / 30,
deferredSource: "compiled/hero.mp4",
});
const manifest = readPlanV2Manifest(createPlanV2FromV1(v1, join(root, "v2")).planDir);
expect(manifest.limitations.videoDependencyMode).toBe("source-extract");
for (const chunkIndex of [0, 1]) {
const artifacts = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex });
expect(artifacts.some((artifact) => artifact.path === "compiled/hero.mp4")).toBe(true);
}
});
it("rejects a deferred video source outside the plan directory", () => {
const root = tempPath("hf-plan-v2-deferred-escape-");
const v1 = createV1Plan(root, { video: true, deferredSource: "../outside.mp4" });
expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow(PlanV2IntegrityError);
});
it("gives a chunk the frames of a video starting a hair after its frame, as export shows it", () => {
const root = tempPath("hf-plan-v2-snapped-start-");
const v1 = createV1Plan(root, { video: true, videoStart: 1 / 30 + 1e-6, videoEnd: 1 });
@@ -103,7 +103,7 @@ export interface PlanV2Manifest {
}
export interface PlanV2Limitations {
readonly videoDependencyMode: "exact-rendered-frames" | "full-source-pack";
readonly videoDependencyMode: "exact-rendered-frames" | "full-source-pack" | "source-extract";
}
export interface PlanV2Result {
@@ -288,6 +288,8 @@ function artifactTargets(
if (path === "plan.json" || path === "meta/chunks.json" || path === "meta/encoder.json") {
return { chunks: "all", assembler: true };
}
// Each chunk's page loads every <video> src at init (a missing one fails the
// chunk), so deferred sources under compiled/ still go to every chunk.
if (
path === "meta/composition.json" ||
path === "meta/videos.json" ||
@@ -295,7 +297,7 @@ function artifactTargets(
) {
return { chunks: "all", assembler: false };
}
if (path.startsWith("video-frames/")) {
if (path.startsWith("video-frames/") || path.startsWith("video-sources/")) {
return {
chunks: videoDependencies === null ? "all" : (videoDependencies.get(path) ?? []),
assembler: false,
@@ -309,6 +311,13 @@ function artifactTargets(
function listVideoFramePaths(executionPlanDir: string, videos: PlanVideosJson): ExtractedFrames[] {
return videos.extracted.map((video) => {
const outputDir = resolveExtractedVideoOutputDir(executionPlanDir, video.videoId);
if (video.deferredRange) {
const framePaths = new Map<number, string>();
for (let index = 0; index < video.totalFrames; index++) {
framePaths.set(index, join(outputDir, String(index)));
}
return { ...video, outputDir, framePaths, ownedByLookup: false };
}
const frameNames = readdirSync(outputDir).sort();
const framePaths = new Map<number, string>();
for (const frameName of frameNames) {
@@ -391,7 +400,7 @@ function buildVideoChunkDependencies(
executionPlanDir: string,
dimensions: Record<string, unknown>,
): {
mode: "exact-rendered-frames" | "full-source-pack";
mode: PlanV2Limitations["videoDependencyMode"];
dependencies: ReadonlyMap<string, readonly number[]> | null;
} {
const videoRoot = join(executionPlanDir, "video-frames");
@@ -415,13 +424,41 @@ function buildVideoChunkDependencies(
extracted,
resolveRenderFpsConfig({ num: fpsNum, den: fpsDen }).value,
);
const mutable = new Map<string, Set<number>>();
const deferredSources = new Map(
parsedVideos.extracted.flatMap((video) =>
video.deferredRange ? [[video.videoId, video.deferredRange.sourcePath] as const] : [],
),
);
const dependencies = collectChunkVideoDependencies({
executionPlanDir,
table,
chunks: parsedChunks,
fpsNum,
fpsDen,
deferredSources,
});
return {
mode: deferredSources.size > 0 ? "source-extract" : "exact-rendered-frames",
dependencies,
};
}
for (const chunk of parsedChunks) {
function collectChunkVideoDependencies(input: {
executionPlanDir: string;
table: ReturnType<typeof createFrameLookupTable>;
chunks: readonly ChunkSliceJson[];
fpsNum: number;
fpsDen: number;
deferredSources: ReadonlyMap<string, string>;
}): ReadonlyMap<string, readonly number[]> {
const mutable = new Map<string, Set<number>>();
for (const chunk of input.chunks) {
for (let frame = chunk.startFrame; frame < chunk.endFrame; frame++) {
const globalTime = (frame * fpsDen) / fpsNum;
for (const payload of table.getActiveFramePayloads(globalTime).values()) {
const path = relative(resolve(executionPlanDir), payload.framePath).split(sep).join("/");
const globalTime = (frame * input.fpsDen) / input.fpsNum;
for (const [videoId, payload] of input.table.getActiveFramePayloads(globalTime)) {
const path =
input.deferredSources.get(videoId) ??
relative(resolve(input.executionPlanDir), payload.framePath).split(sep).join("/");
assertSafeRelativePath(path);
const indexes = mutable.get(path) ?? new Set<number>();
indexes.add(chunk.index);
@@ -429,12 +466,7 @@ function buildVideoChunkDependencies(
}
}
}
return {
mode: "exact-rendered-frames",
dependencies: new Map(
[...mutable].map(([path, indexes]) => [path, [...indexes].sort((a, b) => a - b)]),
),
};
return new Map([...mutable].map(([path, indexes]) => [path, [...indexes].sort((a, b) => a - b)]));
}
function manifestPayload(
@@ -504,7 +536,7 @@ function buildPlanV2Publication(executionPlanDir: string): PlanV2Publication {
if (isExtractionCacheCompleteSentinelPath(file.path)) continue;
const targets = artifactTargets(file.path, videoDependencyPlan.dependencies);
if (
file.path.startsWith("video-frames/") &&
(file.path.startsWith("video-frames/") || file.path.startsWith("video-sources/")) &&
targets.chunks !== "all" &&
targets.chunks.length === 0 &&
!targets.assembler
@@ -648,6 +680,7 @@ export async function planV2WithPublisher(
try {
await buildLocalExecutionPlan(projectDir, config, stagingRoot, {
executionPlanSizeLimitBytes: Number.MAX_SAFE_INTEGER,
deferVideoExtraction: true,
});
return await publishPlanV2FromExecutionPlan(stagingRoot, publisher);
} catch (error) {
@@ -814,7 +847,8 @@ function parsePlanV2Manifest(value: unknown): Readonly<PlanV2Manifest> {
if (
!isRecord(value.limitations) ||
(value.limitations.videoDependencyMode !== "exact-rendered-frames" &&
value.limitations.videoDependencyMode !== "full-source-pack")
value.limitations.videoDependencyMode !== "full-source-pack" &&
value.limitations.videoDependencyMode !== "source-extract")
) {
throw new PlanV2IntegrityError("unsupported video dependency mode");
}
@@ -19,16 +19,18 @@
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CaptureOptions } from "@hyperframes/engine";
import { type CaptureOptions, extractVideoFramesRange } from "@hyperframes/engine";
import { HOST_CHROME_FAILURE_PATTERNS } from "./__test_utils__/hostChromeFailures.js";
import { RenderQualityError } from "../renderOrchestrator.js";
import { plan } from "./plan.js";
import {
CHUNK_INDEX_OUT_OF_RANGE,
distributedCaptureSessionDependencies,
extractDeferredVideoFramesForChunk,
MISSING_PLAN_ARTIFACT,
MISSING_RUNTIME_ENV_SNAPSHOT,
PLAN_HASH_MISMATCH,
@@ -579,3 +581,140 @@ describe("resolveLockedVp9CpuUsed", () => {
expect(resolveLockedVp9CpuUsed({ encoder: "libx264-software" })).toBeUndefined();
});
});
describe("extractDeferredVideoFramesForChunk", () => {
const root = mkdtempSync(join(tmpdir(), "hf-deferred-chunk-"));
const planDir = join(root, "plan");
const range = {
sourcePath: "video-sources/0.mp4",
startTime: 1.5,
durationSeconds: 2.5,
format: "jpg" as const,
};
const metadata = {
durationSeconds: 4,
videoStreamDurationSeconds: 4,
videoStreamStartSeconds: 0,
width: 64,
height: 36,
fps: 30,
videoCodec: "h264",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: null,
};
const planVideos = (loop: boolean) => ({
videos: [
{ id: "clip", src: "clip.mp4", start: 0, end: 4, mediaStart: 1.5, loop, hasAudio: false },
],
extracted: [
{
videoId: "clip",
srcPath: "/planner/clip.mp4",
framePattern: "frame_%05d.jpg",
fps: 30,
totalFrames: 75,
metadata,
deferredRange: range,
},
],
});
beforeAll(() => {
mkdirSync(join(planDir, "video-sources"), { recursive: true });
const synth = spawnSync("ffmpeg", [
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc2=s=64x36:r=30:d=4",
"-c:v",
"libx264",
"-g",
"60",
"-pix_fmt",
"yuv420p",
join(planDir, range.sourcePath),
]);
if (synth.status !== 0) throw new Error(synth.stderr.toString());
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
it("extracts exactly the frames the chunk shows, identical to the planner's", async () => {
const full = await extractVideoFramesRange(
join(planDir, range.sourcePath),
"clip",
range.startTime,
range.durationSeconds,
{ fps: 30, outputDir: join(root, "full"), format: "jpg" },
);
const [chunk] = await extractDeferredVideoFramesForChunk({
planDir,
planVideos: planVideos(false),
slice: { index: 1, startFrame: 30, endFrame: 45 },
fps: { num: 30, den: 1 },
outputRoot: join(root, "chunk-1"),
cfg: {},
});
expect([...chunk!.framePaths.keys()]).toEqual(Array.from({ length: 15 }, (_, i) => 30 + i));
for (const [index, path] of chunk!.framePaths) {
expect(readFileSync(path).equals(readFileSync(full.framePaths.get(index)!))).toBe(true);
}
});
it("extracts nothing for a chunk past the clip, and holds no frames", async () => {
const [chunk] = await extractDeferredVideoFramesForChunk({
planDir,
planVideos: { ...planVideos(false), videos: [{ ...planVideos(false).videos[0]!, end: 1 }] },
slice: { index: 2, startFrame: 60, endFrame: 90 },
fps: { num: 30, den: 1 },
outputRoot: join(root, "chunk-2"),
cfg: {},
});
expect(chunk!.framePaths.size).toBe(0);
});
it("wraps a looping clip back to its first frames", async () => {
const [chunk] = await extractDeferredVideoFramesForChunk({
planDir,
planVideos: planVideos(true),
slice: { index: 3, startFrame: 70, endFrame: 80 },
fps: { num: 30, den: 1 },
outputRoot: join(root, "chunk-3"),
cfg: {},
});
expect([...chunk!.framePaths.keys()].sort((a, b) => a - b)).toEqual([
0, 1, 2, 3, 4, 70, 71, 72, 73, 74,
]);
expect(readdirSync(join(root, "chunk-3", "clip"))).toHaveLength(10);
});
it("fails closed when the plan lacks the source", async () => {
const missing = { ...planVideos(false) };
missing.extracted = [
{ ...missing.extracted[0]!, deferredRange: { ...range, sourcePath: "video-sources/9.mp4" } },
];
let caught: unknown;
try {
await extractDeferredVideoFramesForChunk({
planDir,
planVideos: missing,
slice: { index: 0, startFrame: 0, endFrame: 5 },
fps: { num: 30, den: 1 },
outputRoot: join(root, "chunk-missing"),
cfg: {},
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RenderChunkValidationError);
expect((caught as RenderChunkValidationError).code).toBe(MISSING_PLAN_ARTIFACT);
});
});
@@ -55,6 +55,8 @@ import {
deriveBeginFrameProbeTimeTicks,
type EngineConfig,
type ExtractedFrames,
extractVideoFramesRange,
type FrameRange,
type FrameLookupTable,
getEncoderPreset,
initializeSession,
@@ -409,6 +411,92 @@ export function rebuildExtractedFramesFromPlanDir(
return result;
}
function contiguousRuns(sortedIndexes: readonly number[]): FrameRange[] {
const runs: FrameRange[] = [];
for (const index of sortedIndexes) {
const last = runs[runs.length - 1];
if (last && last.firstFrame + last.frames === index) last.frames++;
else runs.push({ firstFrame: index, frames: 1 });
}
return runs;
}
/**
* Extract the frames this chunk shows from videos the planner left
* unextracted. Frames land in `outputRoot/<videoId>/` under the names a full
* extraction gives them, so the lookup indexes them exactly as it would
* frames the planner wrote.
*/
export async function extractDeferredVideoFramesForChunk(input: {
planDir: string;
planVideos: PlanVideosJson;
slice: ChunkSliceJson;
fps: { num: number; den: number };
outputRoot: string;
cfg: Pick<EngineConfig, "ffmpegProcessTimeout">;
}): Promise<ExtractedFrames[]> {
const deferred = input.planVideos.extracted.filter((video) => video.deferredRange);
if (deferred.length === 0) return [];
const table = createFrameLookupTable(
input.planVideos.videos,
deferred.map((video) => ({
...video,
outputDir: input.outputRoot,
framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)])),
})),
resolveRenderFpsConfig(input.fps).value,
);
const shown = new Map<string, Set<number>>();
for (let frame = input.slice.startFrame; frame < input.slice.endFrame; frame++) {
const globalTime = (frame * input.fps.den) / input.fps.num;
for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
const indexes = shown.get(videoId) ?? new Set<number>();
indexes.add(payload.frameIndex);
shown.set(videoId, indexes);
}
}
return Promise.all(
deferred.map(async (video) => {
const outputDir = join(input.outputRoot, video.videoId);
const indexes = [...(shown.get(video.videoId) ?? [])].sort((a, b) => a - b);
if (indexes.length === 0) {
return { ...video, outputDir, framePaths: new Map<number, string>(), ownedByLookup: false };
}
const range = video.deferredRange!;
const sourcePath = join(input.planDir, ...range.sourcePath.split("/"));
if (!existsSync(sourcePath)) {
throw new RenderChunkValidationError(
MISSING_PLAN_ARTIFACT,
`[renderChunk] planDir is missing the source for video ${JSON.stringify(video.videoId)}: ${range.sourcePath}`,
);
}
const extracted = await extractVideoFramesRange(
sourcePath,
video.videoId,
range.startTime,
range.durationSeconds,
{
fps: input.fps,
outputDir: input.outputRoot,
format: range.format,
frameRanges: contiguousRuns(indexes),
},
undefined,
input.cfg,
);
const missing = indexes.find((index) => !extracted.framePaths.has(index));
if (missing !== undefined) {
throw new RenderChunkValidationError(
INVALID_VIDEO_METADATA,
`[renderChunk] video ${JSON.stringify(video.videoId)} has no frame ${missing}; ` +
`the plan expected ${video.totalFrames} frames from ${range.sourcePath}.`,
);
}
return { ...video, outputDir, framePaths: extracted.framePaths, ownedByLookup: false };
}),
);
}
/** Plan-time JSON manifest written by `freezePlan`. */
interface PlanJson {
protocol?: unknown;
@@ -667,28 +755,6 @@ export async function renderChunk(
forceScreenshotExplicitlyOptedOut: !encoder.forceScreenshot,
};
// Build the immutable frame lookup once. Each browser session/worker gets
// its own injector hook because the hook remembers the last injected frame
// per video; sharing that state across a fresh retry page can suppress the
// first injection and produce a blank/stale frame.
const videoFrameLookup =
planVideos && planVideos.extracted.length > 0
? createFrameLookupTable(
planVideos.videos,
rebuildExtractedFramesFromPlanDir(
planDir,
planVideos.extracted,
v2Manifest === null ? "dense-v1" : "sparse-v2",
),
resolveRenderFpsConfig(job.config.fps).value,
)
: null;
const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
const videoCaptureBeyondViewport = resolveVideoCaptureBeyondViewport(
planVideos?.videos.length ?? 0,
);
// ── Per-chunk work + frames directories ──
// Suffix workDir with pid + random bytes so concurrent invocations on
// the SAME `(planDir, chunkIndex)` (e.g. a scheduler that double-fires
@@ -700,6 +766,40 @@ export async function renderChunk(
mkdirSync(workDir, { recursive: true });
const framesDir = join(workDir, "captured-frames");
mkdirSync(framesDir, { recursive: true });
// Beside workDir, not in it: a screenshot retry wipes workDir.
const videoFramesDir = `${workDir}.video-frames`;
// Build the immutable frame lookup once. Each browser session/worker gets
// its own injector hook because the hook remembers the last injected frame
// per video; sharing that state across a fresh retry page can suppress the
// first injection and produce a blank/stale frame.
const videoFrameLookup =
planVideos && planVideos.extracted.length > 0
? createFrameLookupTable(
planVideos.videos,
[
...rebuildExtractedFramesFromPlanDir(
planDir,
planVideos.extracted.filter((video) => !video.deferredRange),
v2Manifest === null ? "dense-v1" : "sparse-v2",
),
...(await extractDeferredVideoFramesForChunk({
planDir,
planVideos,
slice,
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
outputRoot: videoFramesDir,
cfg,
})),
],
resolveRenderFpsConfig(job.config.fps).value,
)
: null;
const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
const videoCaptureBeyondViewport = resolveVideoCaptureBeyondViewport(
planVideos?.videos.length ?? 0,
);
// ── File server with the seeded-random shim ──
// `Math.random` / `crypto.getRandomValues` are seeded from virtual
@@ -1048,6 +1148,7 @@ export async function renderChunk(
// leaves the framesDir in place for inspection.
try {
rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
rmSync(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
} catch (err) {
log.warn("[renderChunk] failed to remove work dir", {
workDir,
@@ -97,9 +97,18 @@ export interface PlanVideosJson {
fps: number;
totalFrames: number;
metadata: VideoMetadata;
/** Present when chunks extract their own frames from `sourcePath` (relative to the planDir). */
deferredRange?: PlanDeferredFrameRange;
}>;
}
export interface PlanDeferredFrameRange {
sourcePath: string;
startTime: number;
durationSeconds: number;
format: "jpg" | "png";
}
export const INVALID_VIDEO_METADATA = "INVALID_VIDEO_METADATA" as const;
/**
@@ -173,6 +182,25 @@ function readNonNegativeInteger(value: unknown, field: string): number {
return value;
}
function readDeferredFrameRange(value: unknown, field: string): PlanDeferredFrameRange {
const range = readRecord(value, field);
const sourcePath = readNonEmptyString(range.sourcePath, `${field}.sourcePath`);
if (sourcePath.startsWith("/") || sourcePath.split(/[\\/]/).some((part) => part === "..")) {
metadataError(`${field}.sourcePath`, "must stay inside the plan directory");
}
const durationSeconds = readFiniteNumber(range.durationSeconds, `${field}.durationSeconds`);
if (durationSeconds <= 0) metadataError(`${field}.durationSeconds`, "must be positive");
if (range.format !== "jpg" && range.format !== "png") {
metadataError(`${field}.format`, 'must be "jpg" or "png"');
}
return {
sourcePath,
startTime: readFiniteNumber(range.startTime, `${field}.startTime`),
durationSeconds,
format: range.format,
};
}
function readRateSpec(value: unknown, field: string): RateSpec | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") return readFiniteNumber(value, field);
@@ -260,6 +288,9 @@ export function parsePlanVideosJson(value: unknown): PlanVideosJson {
fps: readFiniteNumber(entry.fps, `${field}.fps`),
totalFrames: readNonNegativeInteger(entry.totalFrames, `${field}.totalFrames`),
metadata: readVideoMetadata(entry.metadata, `${field}.metadata`),
...(entry.deferredRange === undefined
? {}
: { deferredRange: readDeferredFrameRange(entry.deferredRange, `${field}.deferredRange`) }),
};
});
@@ -83,6 +83,7 @@ export interface ExtractVideosStageInput {
* passes `true` so the planDir is self-contained.
*/
materializeSymlinks?: boolean;
deferRangeExtraction?: boolean;
}
export interface ExtractVideosStageResult {
@@ -382,6 +383,7 @@ export async function runExtractVideosStage(
abortSignal,
assertNotAborted,
materializeSymlinks,
deferRangeExtraction,
} = input;
const stage2Start = Date.now();
@@ -501,6 +503,7 @@ export async function runExtractVideosStage(
timelineEnd: composition.duration,
maxTransientRetries: extractionPolicy.maxTransientRetries,
collectProbeFailures: extractionPolicy.failureMode === "enforce",
deferRangeExtraction,
},
abortSignal,
cfg,