fix(producer): document read-only plan hashing (#2788)

This commit is contained in:
James Russo
2026-07-25 23:20:36 -04:00
committed by GitHub
parent f9f00b0efc
commit c6fdd9c015
14 changed files with 1602 additions and 31 deletions
+27
View File
@@ -54,6 +54,26 @@ export {
PlanTooLargeError,
} from "./services/distributed/plan.js";
// ── Plan v2 content-addressed transport ────────────────────────────────────
export {
createPlanV2FromV1,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
planV2,
readPlanV2Manifest,
validatePlanV2MaterializedTarget,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
PLAN_V2_MATERIALIZATION_MARKER,
PlanV2IntegrityError,
type PlanV2Artifact,
type PlanV2Limitations,
type PlanV2Manifest,
type PlanV2MaterializationResult,
type PlanV2MaterializationTarget,
type PlanV2Result,
} from "./services/distributed/planV2.js";
export { assembleV2, renderChunkV2 } from "./services/distributed/planV2Execution.js";
// ── RenderChunk (Activity B) ────────────────────────────────────────────────
export {
applyRuntimeEnvSnapshot,
@@ -91,14 +111,21 @@ export {
getDistributedRenderCapabilities,
PLAN_ARTIFACT_LAYOUT,
PLAN_HASH_SCHEMA,
PLAN_PROTOCOL_V2,
PLAN_PROTOCOL_UNSUPPORTED,
PLAN_SCHEMA_VERSION,
PLAN_V2_ARTIFACT_LAYOUT,
PLAN_V2_HASH_SCHEMA,
PLAN_V2_SCHEMA_VERSION,
PlanProtocolUnsupportedError,
readPlanProtocol,
readPlanProtocolV1,
type DistributedRenderCapabilities,
type PlanProtocolConsumerCapabilities,
type PlanProtocolDescriptor,
type PlanProtocolV1Descriptor,
type PlanProtocolV2Descriptor,
type SupportedPlanProtocolDescriptor,
} from "./services/distributed/planProtocol.js";
// ── Format union ────────────────────────────────────────────────────────────
+24
View File
@@ -133,17 +133,33 @@ export {
// separate subpath import.
export {
assemble,
assembleV2,
CURRENT_PLAN_PROTOCOL,
DISTRIBUTED_RENDER_CAPABILITIES,
getDistributedRenderCapabilities,
PLAN_ARTIFACT_LAYOUT,
PLAN_HASH_SCHEMA,
PLAN_PROTOCOL_V2,
PLAN_PROTOCOL_UNSUPPORTED,
PLAN_SCHEMA_VERSION,
PLAN_V2_ARTIFACT_LAYOUT,
PLAN_V2_HASH_SCHEMA,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
PLAN_V2_MATERIALIZATION_MARKER,
PLAN_V2_SCHEMA_VERSION,
createPlanV2FromV1,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
plan,
planV2,
PlanV2IntegrityError,
PlanProtocolUnsupportedError,
readPlanProtocol,
readPlanProtocolV1,
readPlanV2Manifest,
renderChunk,
renderChunkV2,
validatePlanV2MaterializedTarget,
type AssembleResult,
type ChunkResult,
type DistributedRenderCapabilities,
@@ -151,5 +167,13 @@ export {
type PlanProtocolConsumerCapabilities,
type PlanProtocolDescriptor,
type PlanProtocolV1Descriptor,
type PlanProtocolV2Descriptor,
type PlanResult,
type PlanV2Artifact,
type PlanV2Limitations,
type PlanV2Manifest,
type PlanV2MaterializationResult,
type PlanV2MaterializationTarget,
type PlanV2Result,
type SupportedPlanProtocolDescriptor,
} from "./distributed.js";
@@ -40,7 +40,8 @@ import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { formatExportFrameName } from "../../utils/paths.js";
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocol } from "./planProtocol.js";
import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocolV1 } from "./planProtocol.js";
import { validatePlanV2MaterializedTarget } from "./planV2.js";
import type { DistributedFormat } from "./shared.js";
/**
@@ -121,7 +122,8 @@ export async function assemble(
throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
}
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJsonForAssemble;
readPlanProtocol(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.assembler);
readPlanProtocolV1(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.assembler);
validatePlanV2MaterializedTarget(planDir, { role: "assembler" });
if (!existsSync(chunksJsonPath)) {
throw new Error(`[assemble] planDir missing meta/chunks.json: ${chunksJsonPath}`);
}
@@ -75,6 +75,7 @@ import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
import {
buildSyntheticRenderJob,
type DistributedFormat,
PLAN_AUDIO_RELATIVE_PATH,
PLAN_VIDEOS_META_RELATIVE_PATH,
type PlanVideosJson,
readFfmpegVersion,
@@ -225,7 +226,8 @@ export interface DistributedRenderConfig {
* 10 GB `/tmp` budget alongside the chunk worker's frame buffer +
* ffmpeg working set). Adapters that deploy onto storage with
* tighter ceilings can pass a smaller cap; tests pass a tiny cap to
* exercise the throw path.
* exercise the throw path. This applies to the monolithic v1 transport;
* `planV2()` emits content-addressed role dependencies and bypasses it.
*/
planDirSizeLimitBytes?: number;
@@ -340,9 +342,8 @@ export const MIN_CHUNK_SIZE = 10;
/**
* Default hard ceiling on `<planDir>/` size in bytes. 2 GB fits inside
* AWS Lambda's 10 GB `/tmp` alongside the chunk worker's captured frames
* and ffmpeg's temporary files. Compositions that exceed this have to
* fall back to the in-process renderer until per-chunk video-frame
* slicing lands.
* and ffmpeg's temporary files. Compositions that exceed this can opt into
* `planV2()` or fall back to the in-process renderer.
*/
export const PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
@@ -364,8 +365,9 @@ export class PlanTooLargeError extends Error {
`[plan] planDir size ${formatBytes(sizeBytes)} exceeds the configured ceiling ` +
`${formatBytes(limitBytes)} (PLAN_TOO_LARGE). The default 2 GB cap fits inside AWS ` +
`Lambda's 10 GB /tmp budget alongside the chunk worker's frame buffer and ffmpeg's ` +
`working set. To unblock: shorten the composition, lower the framerate, or use the ` +
`in-process renderer (\`executeRenderJob\`) — it has no planDir size cap.`,
`working set. To unblock: use the content-addressed \`planV2()\` transport, shorten ` +
`the composition, lower the framerate, or use the in-process renderer ` +
`(\`executeRenderJob\`) — it has no planDir size cap.`,
);
this.name = "PlanTooLargeError";
this.sizeBytes = sizeBytes;
@@ -1006,7 +1008,7 @@ export async function plan(
"utf-8",
);
const planAudioPath = join(planDir, "audio.aac");
const planAudioPath = join(planDir, PLAN_AUDIO_RELATIVE_PATH);
if (audioResult.hasAudio && existsSync(audioResult.audioOutputPath)) {
renameSync(audioResult.audioOutputPath, planAudioPath);
}
@@ -1,3 +1,7 @@
// These protocol rejection cases intentionally repeat the arrange/assert shape
// so each malformed wire descriptor remains independently readable.
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -9,6 +13,7 @@ import {
getDistributedRenderCapabilities,
PLAN_ARTIFACT_LAYOUT,
PLAN_HASH_SCHEMA,
PLAN_PROTOCOL_V2,
PLAN_PROTOCOL_UNSUPPORTED,
PLAN_SCHEMA_VERSION,
PlanProtocolUnsupportedError,
@@ -126,6 +131,10 @@ describe("readPlanProtocol()", () => {
).toBe(CURRENT_PLAN_PROTOCOL);
});
it("accepts the explicit v2 descriptor", () => {
expect(readPlanProtocol({ protocol: PLAN_PROTOCOL_V2 })).toBe(PLAN_PROTOCOL_V2);
});
it("rejects malformed and partial descriptors", () => {
for (const protocol of [
null,
@@ -166,19 +175,19 @@ describe("readPlanProtocol()", () => {
});
describe("getDistributedRenderCapabilities()", () => {
it("reports explicit v1 support for every distributed role", () => {
it("reports explicit v1 and v2 support for every distributed role", () => {
expect(getDistributedRenderCapabilities()).toBe(DISTRIBUTED_RENDER_CAPABILITIES);
expect(DISTRIBUTED_RENDER_CAPABILITIES).toEqual({
roles: {
planner: {
produces: [CURRENT_PLAN_PROTOCOL],
produces: [CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2],
},
chunk: {
accepts: [CURRENT_PLAN_PROTOCOL],
accepts: [CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2],
acceptsLegacyV1WithoutDescriptor: true,
},
assembler: {
accepts: [CURRENT_PLAN_PROTOCOL],
accepts: [CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2],
acceptsLegacyV1WithoutDescriptor: true,
},
},
@@ -273,6 +282,21 @@ describe("distributed plan protocol readers", () => {
expect((caught as PlanProtocolUnsupportedError).code).toBe(PLAN_PROTOCOL_UNSUPPORTED);
});
it("legacy activities reject a recognized v2 root before v1 layout access", async () => {
const planDir = createReaderPlan({
includeProtocol: true,
protocol: PLAN_PROTOCOL_V2,
omitDownstreamArtifacts: true,
});
await expect(renderChunk(planDir, 0, join(planDir, "unused-output"))).rejects.toThrow(
"must be materialized before v1 layout access",
);
await expect(assemble(planDir, [], null, join(planDir, "unused-output"))).rejects.toThrow(
"must be materialized before v1 layout access",
);
});
it("assemble rejects a partial protocol before parsing chunks", async () => {
const planDir = createReaderPlan({
includeProtocol: true,
@@ -11,6 +11,9 @@
export const PLAN_SCHEMA_VERSION = 1 as const;
export const PLAN_ARTIFACT_LAYOUT = "plan-dir-v1" as const;
export const PLAN_HASH_SCHEMA = "hyperframes-plan-hash-v1" as const;
export const PLAN_V2_SCHEMA_VERSION = 2 as const;
export const PLAN_V2_ARTIFACT_LAYOUT = "content-addressed-plan-v2" as const;
export const PLAN_V2_HASH_SCHEMA = "hyperframes-plan-manifest-hash-v2" as const;
export const PLAN_PROTOCOL_UNSUPPORTED = "PLAN_PROTOCOL_UNSUPPORTED" as const;
export interface PlanProtocolDescriptor {
@@ -25,6 +28,14 @@ export interface PlanProtocolV1Descriptor extends PlanProtocolDescriptor {
readonly hashSchema: typeof PLAN_HASH_SCHEMA;
}
export interface PlanProtocolV2Descriptor extends PlanProtocolDescriptor {
readonly schemaVersion: typeof PLAN_V2_SCHEMA_VERSION;
readonly artifactLayout: typeof PLAN_V2_ARTIFACT_LAYOUT;
readonly hashSchema: typeof PLAN_V2_HASH_SCHEMA;
}
export type SupportedPlanProtocolDescriptor = PlanProtocolV1Descriptor | PlanProtocolV2Descriptor;
/** Descriptor written by the current producer and accepted by v1 workers. */
export const CURRENT_PLAN_PROTOCOL: Readonly<PlanProtocolV1Descriptor> = Object.freeze({
schemaVersion: PLAN_SCHEMA_VERSION,
@@ -32,6 +43,13 @@ export const CURRENT_PLAN_PROTOCOL: Readonly<PlanProtocolV1Descriptor> = Object.
hashSchema: PLAN_HASH_SCHEMA,
});
/** Explicit opt-in descriptor for the content-addressed v2 transport layout. */
export const PLAN_PROTOCOL_V2: Readonly<PlanProtocolV2Descriptor> = Object.freeze({
schemaVersion: PLAN_V2_SCHEMA_VERSION,
artifactLayout: PLAN_V2_ARTIFACT_LAYOUT,
hashSchema: PLAN_V2_HASH_SCHEMA,
});
export interface PlanProtocolConsumerCapabilities {
readonly accepts: readonly Readonly<PlanProtocolDescriptor>[];
readonly acceptsLegacyV1WithoutDescriptor: boolean;
@@ -52,14 +70,14 @@ export const DISTRIBUTED_RENDER_CAPABILITIES: Readonly<DistributedRenderCapabili
Object.freeze({
roles: Object.freeze({
planner: Object.freeze({
produces: Object.freeze([CURRENT_PLAN_PROTOCOL]),
produces: Object.freeze([CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2]),
}),
chunk: Object.freeze({
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL]),
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2]),
acceptsLegacyV1WithoutDescriptor: true,
}),
assembler: Object.freeze({
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL]),
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2]),
acceptsLegacyV1WithoutDescriptor: true,
}),
}),
@@ -87,7 +105,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function protocolMatches(
descriptor: Record<string, unknown>,
expected: PlanProtocolDescriptor,
expected: SupportedPlanProtocolDescriptor,
): boolean {
return (
descriptor.schemaVersion === expected.schemaVersion &&
@@ -117,7 +135,7 @@ export function readPlanProtocol(
planJson: unknown,
capabilities: Readonly<PlanProtocolConsumerCapabilities> = DISTRIBUTED_RENDER_CAPABILITIES.roles
.chunk,
): Readonly<PlanProtocolV1Descriptor> {
): Readonly<SupportedPlanProtocolDescriptor> {
if (!isRecord(planJson)) {
throw new PlanProtocolUnsupportedError("plan.json must contain a JSON object");
}
@@ -145,11 +163,32 @@ export function readPlanProtocol(
}
}
if (
!protocolMatches(descriptor, CURRENT_PLAN_PROTOCOL) ||
!capabilitiesAccept(capabilities, CURRENT_PLAN_PROTOCOL)
) {
const protocol = protocolMatches(descriptor, CURRENT_PLAN_PROTOCOL)
? CURRENT_PLAN_PROTOCOL
: protocolMatches(descriptor, PLAN_PROTOCOL_V2)
? PLAN_PROTOCOL_V2
: null;
if (protocol === null || !capabilitiesAccept(capabilities, protocol)) {
throw new PlanProtocolUnsupportedError("unsupported plan.json protocol descriptor");
}
return protocol;
}
/**
* Validate that a directory is directly consumable by the legacy execution
* functions. A v2 transport must be materialized first; rejecting it here
* prevents readers from probing paths that have different meanings in v2.
*/
export function readPlanProtocolV1(
planJson: unknown,
capabilities: Readonly<PlanProtocolConsumerCapabilities> = DISTRIBUTED_RENDER_CAPABILITIES.roles
.chunk,
): Readonly<PlanProtocolV1Descriptor> {
const protocol = readPlanProtocol(planJson, capabilities);
if (protocol !== CURRENT_PLAN_PROTOCOL) {
throw new PlanProtocolUnsupportedError(
"content-addressed v2 plan must be materialized before v1 layout access",
);
}
return CURRENT_PLAN_PROTOCOL;
}
@@ -25,6 +25,7 @@ import {
PlanTooLargeError,
plan,
} from "./plan.js";
import { planV2, readPlanV2Manifest } from "./planV2.js";
import { DISTRIBUTED_DURATION_OUT_OF_RANGE } from "../render/planValidation.js";
const FIXTURE_HTML = `<!doctype html>
@@ -144,6 +145,40 @@ describe("plan() PLAN_TOO_LARGE throw path", () => {
},
TIMEOUT_MS,
);
it(
"lets planV2 complete the same render that trips the v1 transport cap",
async () => {
const projectDir = mkdtempSync(join(runRoot, "project-v1-v2-pressure-"));
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
const config = {
fps: 30 as const,
width: 320,
height: 240,
format: "mp4" as const,
planDirSizeLimitBytes: 1024,
};
const v1PlanDir = mkdtempSync(join(runRoot, "plandir-v1-pressure-"));
let v1Error: unknown;
try {
await plan(projectDir, config, v1PlanDir);
} catch (error) {
v1Error = error;
}
expect(v1Error).toBeInstanceOf(PlanTooLargeError);
expect((v1Error as PlanTooLargeError).code).toBe(PLAN_TOO_LARGE);
const v2PlanDir = join(runRoot, "plandir-v2-pressure");
const v2 = await planV2(projectDir, config, v2PlanDir);
const manifest = readPlanV2Manifest(v2.planDir);
expect(v2.planProtocol.schemaVersion).toBe(2);
expect(v2.planHash).toBe(manifest.planHash);
expect(v2.sourcePlanV1Hash).toBe(manifest.sourcePlanV1Hash);
expect(manifest.artifacts.length).toBeGreaterThan(0);
},
TIMEOUT_MS,
);
});
describe("plan() duration guard", () => {
@@ -0,0 +1,410 @@
import { afterEach, describe, expect, it } from "bun:test";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { 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";
import { FFMPEG_VERSION_MISMATCH, renderChunk, RenderChunkValidationError } from "./renderChunk.js";
import {
createPlanV2FromV1,
listPlanV2ArtifactsForTarget,
materializePlanV2Target,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
PlanV2IntegrityError,
readPlanV2Manifest,
validatePlanV2MaterializedTarget,
} from "./planV2.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function tempPath(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(root);
return root;
}
function refreshV1PlanHash(planDir: string): void {
const planPath = join(planDir, "plan.json");
const planJson = JSON.parse(readFileSync(planPath, "utf-8")) as Record<string, unknown>;
planJson.planHash = recomputePlanHashFromPlanDir(planDir);
writeFileSync(planPath, JSON.stringify(planJson), "utf-8");
}
function createV1Plan(
root: string,
options?: {
audio?: boolean;
video?: boolean;
omitVideoMetadata?: boolean;
fpsDen?: number;
},
): string {
const planDir = join(root, "v1");
mkdirSync(join(planDir, "compiled"), { recursive: true });
mkdirSync(join(planDir, "meta"), { recursive: true });
writeFileSync(join(planDir, "compiled", "index.html"), "<html>v2 fixture</html>");
writeFileSync(join(planDir, "compiled", "asset.txt"), "shared asset");
writeFileSync(
join(planDir, "meta", "chunks.json"),
JSON.stringify([
{ index: 0, startFrame: 0, endFrame: 1 },
{ index: 1, startFrame: 1, endFrame: 2 },
]),
);
writeFileSync(join(planDir, "meta", "encoder.json"), "{}");
writeFileSync(join(planDir, "meta", "composition.json"), "{}");
if (options?.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");
writeFileSync(
join(planDir, "meta", "videos.json"),
JSON.stringify({
videos: [
{
id: "hero",
src: "hero.mp4",
start: 0,
end: 1,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
extracted: [
{
videoId: "hero",
srcPath: "/fixture/hero.mp4",
framePattern: "frame_%05d.jpg",
fps: 30,
totalFrames: 3,
metadata: {
durationSeconds: 1,
videoStreamDurationSeconds: 1,
width: 16,
height: 16,
fps: 30,
videoCodec: "h264",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: null,
},
},
],
}),
);
if (options.omitVideoMetadata === true) {
rmSync(join(planDir, "meta", "videos.json"));
}
}
if (options?.audio) writeFileSync(join(planDir, "audio.aac"), "assemble-only-audio");
writeFileSync(
join(planDir, "plan.json"),
JSON.stringify({
protocol: CURRENT_PLAN_PROTOCOL,
planHash: "1".repeat(64),
chunkCount: 2,
totalFrames: 2,
hasAudio: options?.audio === true,
ffmpegVersion: "ffmpeg fixture",
producerVersion: "0.0.0-test",
fontSnapshotSha: "font-snapshot-fixture",
dimensions: {
fpsNum: 30,
fpsDen: options?.fpsDen ?? 1,
width: 16,
height: 16,
format: "mp4",
},
}),
);
refreshV1PlanHash(planDir);
return planDir;
}
describe("Plan v2 manifest", () => {
it("is deterministic and keeps the v1 transport opt-in", () => {
const root = tempPath("hf-plan-v2-determinism-");
const v1 = createV1Plan(root, { audio: true });
const first = createPlanV2FromV1(v1, join(root, "v2-a"));
const second = createPlanV2FromV1(v1, join(root, "v2-b"));
expect(first.planHash).toBe(second.planHash);
expect(readFileSync(first.manifestPath, "utf-8")).toBe(
readFileSync(second.manifestPath, "utf-8"),
);
expect(first.planHash).not.toBe(first.sourcePlanV1Hash);
expect(first.planProtocol.schemaVersion).toBe(2);
expect(first.limitations.videoDependencyMode).toBe("exact-rendered-frames");
});
it("rejects a stale v1 source hash before content-addressing its bytes", () => {
const root = tempPath("hf-plan-v2-source-hash-");
const v1 = createV1Plan(root);
writeFileSync(join(v1, "compiled", "index.html"), "<html>tampered after freeze</html>");
const destination = join(root, "v2");
let caught: unknown;
try {
createPlanV2FromV1(v1, destination);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(PlanV2IntegrityError);
expect(caught).toHaveProperty("name", "PlanV2IntegrityError");
expect(caught).toHaveProperty("code", PLAN_V2_INTEGRITY_UNRECOVERABLE);
expect(caught).toHaveProperty(
"message",
expect.stringMatching(/v1 plan content fingerprint does not match/),
);
expect(existsSync(destination)).toBe(false);
});
it("rejects symlinks instead of silently omitting them from the manifest", () => {
if (process.platform === "win32") return;
const root = tempPath("hf-plan-v2-symlink-");
const v1 = createV1Plan(root);
symlinkSync(join(v1, "compiled", "asset.txt"), join(v1, "compiled", "linked-asset.txt"));
refreshV1PlanHash(v1);
expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow(
/symlinks and special files are not allowed/,
);
});
it("rejects zero-based extracted-frame filenames before dependency selection", () => {
const root = tempPath("hf-plan-v2-zero-based-frame-");
const v1 = createV1Plan(root, { video: true });
writeFileSync(join(v1, "video-frames", "hero", "frame_00000.jpg"), "zero based");
refreshV1PlanHash(v1);
expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow(
/must use a 1-based safe integer/,
);
});
it("selects audio only for the assembler", () => {
const root = tempPath("hf-plan-v2-targets-");
const result = createPlanV2FromV1(createV1Plan(root, { audio: true }), join(root, "v2"));
const manifest = readPlanV2Manifest(result.planDir);
const chunk = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 0 });
const assembler = listPlanV2ArtifactsForTarget(manifest, { role: "assembler" });
expect(chunk.some((artifact) => artifact.path === "audio.aac")).toBe(false);
expect(chunk.some((artifact) => artifact.path === "compiled/index.html")).toBe(true);
expect(assembler.some((artifact) => artifact.path === "audio.aac")).toBe(true);
expect(assembler.some((artifact) => artifact.path === "compiled/index.html")).toBe(false);
});
it("uses the runtime frame lookup to select exact video frames per chunk", () => {
const root = tempPath("hf-plan-v2-video-reachability-");
const result = createPlanV2FromV1(createV1Plan(root, { video: true }), join(root, "v2"));
const manifest = readPlanV2Manifest(result.planDir);
const chunk0 = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 0 });
const chunk1 = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 1 });
expect(manifest.limitations.videoDependencyMode).toBe("exact-rendered-frames");
expect(chunk0.some((artifact) => artifact.path.endsWith("frame_00001.jpg"))).toBe(true);
expect(chunk0.some((artifact) => artifact.path.endsWith("frame_00002.jpg"))).toBe(false);
expect(chunk1.some((artifact) => artifact.path.endsWith("frame_00002.jpg"))).toBe(true);
expect(manifest.artifacts.some((artifact) => artifact.path.endsWith("frame_00003.jpg"))).toBe(
false,
);
});
it("falls back to the full source frame pack when video metadata is absent", () => {
const root = tempPath("hf-plan-v2-video-fallback-");
const result = createPlanV2FromV1(
createV1Plan(root, { video: true, omitVideoMetadata: true }),
join(root, "v2"),
);
const manifest = readPlanV2Manifest(result.planDir);
const chunk0 = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 0 });
const chunk1 = listPlanV2ArtifactsForTarget(manifest, { role: "chunk", chunkIndex: 1 });
expect(result.limitations.videoDependencyMode).toBe("full-source-pack");
expect(manifest.limitations.videoDependencyMode).toBe("full-source-pack");
for (const frameName of ["frame_00001.jpg", "frame_00002.jpg", "frame_00003.jpg"]) {
expect(chunk0.some((artifact) => artifact.path.endsWith(frameName))).toBe(true);
expect(chunk1.some((artifact) => artifact.path.endsWith(frameName))).toBe(true);
}
});
it("rejects malformed v1 video and chunk metadata at the JSON boundary", () => {
const videosRoot = tempPath("hf-plan-v2-malformed-videos-");
const videosPlan = createV1Plan(videosRoot, { video: true });
const videosPath = join(videosPlan, "meta", "videos.json");
const videosJson = JSON.parse(readFileSync(videosPath, "utf-8")) as {
videos: Array<Record<string, unknown>>;
};
delete videosJson.videos[0]?.hasAudio;
writeFileSync(videosPath, JSON.stringify(videosJson), "utf-8");
refreshV1PlanHash(videosPlan);
expect(() => createPlanV2FromV1(videosPlan, join(videosRoot, "v2"))).toThrow(
/videos\[0\]\.hasAudio must be boolean/,
);
const chunksRoot = tempPath("hf-plan-v2-malformed-chunks-");
const chunksPlan = createV1Plan(chunksRoot, { video: true });
writeFileSync(
join(chunksPlan, "meta", "chunks.json"),
JSON.stringify([{ index: 0, startFrame: 1, endFrame: 1 }]),
"utf-8",
);
refreshV1PlanHash(chunksPlan);
expect(() => createPlanV2FromV1(chunksPlan, join(chunksRoot, "v2"))).toThrow(
/endFrame must be greater than startFrame/,
);
});
it("rejects a fractional v1 fps contract that v2 cannot represent", () => {
const root = tempPath("hf-plan-v2-fps-den-");
const v1 = createV1Plan(root, { fpsDen: 1001 });
expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow("dimensions.fpsDen must be 1");
});
it("materializes and revalidates strict chunk and assembler subsets", () => {
const root = tempPath("hf-plan-v2-materialize-");
const result = createPlanV2FromV1(createV1Plan(root, { audio: true }), join(root, "v2"));
const chunkDir = join(root, "chunk");
const assemblerDir = join(root, "assembler");
const chunk = materializePlanV2Target(
result.planDir,
{ role: "chunk", chunkIndex: 1 },
chunkDir,
);
const assembler = materializePlanV2Target(result.planDir, { role: "assembler" }, assemblerDir);
expect(existsSync(join(chunkDir, "audio.aac"))).toBe(false);
expect(
validatePlanV2MaterializedTarget(chunkDir, { role: "chunk", chunkIndex: 1 })?.planHash,
).toBe(result.planHash);
expect(assembler.audioPath).toBe(join(assemblerDir, "audio.aac"));
expect(validatePlanV2MaterializedTarget(assemblerDir, { role: "assembler" })?.planHash).toBe(
result.planHash,
);
expect(chunk.sourcePlanV1Hash).toBe(result.sourcePlanV1Hash);
});
it("uses v2 subset integrity instead of the whole-v1 plan hash", async () => {
const root = tempPath("hf-plan-v2-subset-hash-");
const result = createPlanV2FromV1(createV1Plan(root, { audio: true }), join(root, "v2"));
const chunkDir = join(root, "chunk");
materializePlanV2Target(result.planDir, { role: "chunk", chunkIndex: 0 }, chunkDir);
let caught: unknown;
try {
await renderChunk(chunkDir, 0, join(root, "unused-output.mp4"));
} catch (error) {
caught = error;
}
// Reaching the ffmpeg probe proves the missing assembler-only audio did
// not trigger the v1 aggregate hash gate. Full CI installs no ffmpeg for
// the unit lane, while developer/render environments reach the deliberate
// version mismatch; both are valid stops before Chrome.
if (caught instanceof RenderChunkValidationError) {
expect(caught.code).toBe(FFMPEG_VERSION_MISMATCH);
} else {
expect(caught).toBeInstanceOf(Error);
expect(caught).toHaveProperty("code", "ENOENT");
}
});
it("rejects missing and corrupted blobs before publishing a destination", () => {
const root = tempPath("hf-plan-v2-corrupt-");
const result = createPlanV2FromV1(createV1Plan(root), join(root, "v2"));
const manifest = readPlanV2Manifest(result.planDir);
const artifact = listPlanV2ArtifactsForTarget(manifest, {
role: "chunk",
chunkIndex: 0,
})[0]!;
const blob = join(
result.planDir,
"artifacts",
"sha256",
artifact.sha256.slice(0, 2),
artifact.sha256,
);
writeFileSync(blob, "corrupt");
const destination = join(root, "never-published");
expect(() =>
materializePlanV2Target(result.planDir, { role: "chunk", chunkIndex: 0 }, destination),
).toThrow(/artifact (size|hash) mismatch/);
expect(existsSync(destination)).toBe(false);
});
it("rejects manifest and post-materialization tampering", () => {
const root = tempPath("hf-plan-v2-tamper-");
const result = createPlanV2FromV1(createV1Plan(root), join(root, "v2"));
const chunkDir = join(root, "chunk");
materializePlanV2Target(result.planDir, { role: "chunk", chunkIndex: 0 }, chunkDir);
writeFileSync(join(chunkDir, "compiled", "index.html"), "tampered");
let materializedError: unknown;
try {
validatePlanV2MaterializedTarget(chunkDir, { role: "chunk", chunkIndex: 0 });
} catch (error) {
materializedError = error;
}
expect(materializedError).toBeInstanceOf(PlanV2IntegrityError);
expect(materializedError).toHaveProperty("code", PLAN_V2_INTEGRITY_UNRECOVERABLE);
expect(materializedError).toHaveProperty(
"message",
expect.stringMatching(/materialized artifact (hash mismatch|missing or truncated)/),
);
const manifestPath = result.manifestPath;
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
manifest.totalFrames = 999;
writeFileSync(manifestPath, canonicalJsonStringify(manifest));
expect(() => readPlanV2Manifest(result.planDir)).toThrow("manifest integrity hash mismatch");
});
it("rejects a missing content-addressed artifact", () => {
const root = tempPath("hf-plan-v2-missing-");
const result = createPlanV2FromV1(createV1Plan(root), join(root, "v2"));
const manifest = readPlanV2Manifest(result.planDir);
const artifact = manifest.artifacts[0]!;
const blob = join(
result.planDir,
"artifacts",
"sha256",
artifact.sha256.slice(0, 2),
artifact.sha256,
);
rmSync(blob);
expect(() =>
materializePlanV2Target(
result.planDir,
{ role: "chunk", chunkIndex: 0 },
join(root, "destination"),
),
).toThrow("missing content-addressed artifact");
});
});
describe("Plan v2 hash schema", () => {
it("does not reuse a raw artifact digest as its manifest hash", () => {
const root = tempPath("hf-plan-v2-hash-");
const result = createPlanV2FromV1(createV1Plan(root), join(root, "v2"));
const manifest = readPlanV2Manifest(result.planDir);
expect(manifest.artifacts.some((artifact) => artifact.sha256 === result.planHash)).toBe(false);
expect(sha256Hex(readFileSync(result.manifestPath))).not.toBe(result.planHash);
});
});
@@ -0,0 +1,850 @@
/**
* Content-addressed distributed plan transport.
*
* V2 deliberately separates transport from execution. The transport root is
* a small immutable `plan.json` manifest plus sha256-addressed blobs. Workers
* select and materialize only the dependencies for their role, then invoke
* the existing v1 execution functions on the verified local layout.
*
* Video-frame dependencies are derived by evaluating the engine's own
* FrameLookupTable at every captured global frame. If legacy video metadata
* is absent, v2 explicitly falls back to the full-source pack.
*/
import {
closeSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { createFrameLookupTable, type ExtractedFrames } from "@hyperframes/engine";
import { recomputePlanHashFromPlanDir, type ChunkSliceJson } from "../render/stages/freezePlan.js";
import { canonicalJsonStringify, sha256Hex } from "../render/stages/planHash.js";
import { type DistributedRenderConfig, plan } from "./plan.js";
import {
PLAN_PROTOCOL_V2,
readPlanProtocolV1,
type PlanProtocolV2Descriptor,
} from "./planProtocol.js";
import {
PLAN_AUDIO_RELATIVE_PATH,
PLAN_VIDEOS_META_RELATIVE_PATH,
type DistributedFormat,
type PlanVideosJson,
} from "./shared.js";
const PLAN_V2_HASH_PREFIX = "hyperframes-plan-manifest-hash-v2\x00";
export const PLAN_V2_MATERIALIZATION_MARKER = ".hyperframes-plan-v2.json";
export const PLAN_V2_INTEGRITY_UNRECOVERABLE = "PLAN_V2_INTEGRITY_UNRECOVERABLE" as const;
/**
* A deterministic plan-v2 validation or integrity failure. Distributed
* adapters classify both the class name and code as terminal so immutable
* corruption does not consume the retry budget.
*/
export class PlanV2IntegrityError extends Error {
// Public adapters inspect this typed code even though OSS producer does not.
// fallow-ignore-next-line unused-class-member
readonly code: typeof PLAN_V2_INTEGRITY_UNRECOVERABLE = PLAN_V2_INTEGRITY_UNRECOVERABLE;
constructor(message: string) {
super(`[planV2] ${message}`);
this.name = "PlanV2IntegrityError";
}
}
export type PlanV2MaterializationTarget =
| Readonly<{ role: "chunk"; chunkIndex: number }>
| Readonly<{ role: "assembler" }>;
export interface PlanV2Artifact {
/** POSIX path in the materialized v1 execution directory. */
readonly path: string;
readonly sha256: string;
readonly sizeBytes: number;
/** Chunk indexes that need this artifact, or `"all"` for shared dependencies. */
readonly chunks: "all" | readonly number[];
readonly assembler: boolean;
}
export interface PlanV2Manifest {
readonly protocol: Readonly<PlanProtocolV2Descriptor>;
/** V2 manifest digest; intentionally distinct from the v1 execution hash. */
readonly planHash: string;
/** Original execution-plan hash retained for output/replay correlation. */
readonly sourcePlanV1Hash: string;
readonly chunkCount: number;
readonly totalFrames: number;
readonly fps: 24 | 30 | 60;
readonly width: number;
readonly height: number;
readonly format: DistributedFormat;
readonly ffmpegVersion: string;
readonly producerVersion: string;
readonly limitations: Readonly<PlanV2Limitations>;
readonly artifacts: readonly Readonly<PlanV2Artifact>[];
}
export interface PlanV2Limitations {
readonly videoDependencyMode: "exact-rendered-frames" | "full-source-pack";
}
export interface PlanV2Result {
readonly planDir: string;
readonly manifestPath: string;
readonly planProtocol: Readonly<PlanProtocolV2Descriptor>;
readonly planHash: string;
readonly sourcePlanV1Hash: string;
readonly chunkCount: number;
readonly totalFrames: number;
readonly fps: 24 | 30 | 60;
readonly width: number;
readonly height: number;
readonly format: DistributedFormat;
readonly ffmpegVersion: string;
readonly producerVersion: string;
readonly limitations: Readonly<PlanV2Limitations>;
}
export interface PlanV2MaterializationResult {
readonly planDir: string;
readonly target: PlanV2MaterializationTarget;
readonly planHash: string;
readonly sourcePlanV1Hash: string;
readonly artifactCount: number;
readonly sizeBytes: number;
readonly audioPath: string | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isSha256(value: unknown): value is string {
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
}
function isNonNegativeIntegerArray(value: unknown): value is number[] {
return (
Array.isArray(value) &&
value.every((item) => typeof item === "number" && Number.isInteger(item) && item >= 0)
);
}
function readJsonFile(path: string, label: string): unknown {
const contents = readFileSync(path, "utf-8");
try {
const parsed: unknown = JSON.parse(contents);
return parsed;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new PlanV2IntegrityError(`${label} is not readable JSON: ${detail}`);
}
}
function assertSafeRelativePath(path: string): void {
const normalized = path.split(/[\\/]+/);
if (
path.length === 0 ||
isAbsolute(path) ||
normalized.some((part) => part === "" || part === "." || part === "..")
) {
throw new PlanV2IntegrityError(`unsafe artifact path: ${JSON.stringify(path)}`);
}
}
function listFiles(root: string): Array<{ path: string; absolutePath: string }> {
const files: Array<{ path: string; absolutePath: string }> = [];
const rootResolved = resolve(root);
function walk(dir: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const absolutePath = join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
} else if (entry.isFile()) {
files.push({
path: relative(rootResolved, absolutePath).split(sep).join("/"),
absolutePath,
});
} else {
throw new PlanV2IntegrityError(
`unsupported filesystem entry (symlinks and special files are not allowed): ${absolutePath}`,
);
}
}
}
walk(rootResolved);
return files.sort((a, b) => a.path.localeCompare(b.path));
}
function blobPath(planV2Dir: string, sha256: string): string {
return join(planV2Dir, "artifacts", "sha256", sha256.slice(0, 2), sha256);
}
/** Hash large plan artifacts with bounded memory (important above the v1 2 GiB cap). */
function sha256File(path: string): string {
const hash = createHash("sha256");
const buffer = Buffer.allocUnsafe(1024 * 1024);
// lgtm[js/insecure-temporary-file] — read-only open of a caller-owned regular file;
// this call never creates a file, temporary or otherwise.
const fd = openSync(path, "r");
try {
let bytesRead = 0;
do {
bytesRead = readSync(fd, buffer, 0, buffer.byteLength, null);
if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
} while (bytesRead > 0);
} finally {
closeSync(fd);
}
return hash.digest("hex");
}
// This is the fail-safe policy table for every v1 artifact class. Keeping the
// branches together makes new artifact classes visibly fall through to both roles.
// fallow-ignore-next-line complexity
function artifactTargets(
path: string,
videoDependencies: ReadonlyMap<string, readonly number[]> | null,
): Pick<PlanV2Artifact, "chunks" | "assembler"> {
if (path === PLAN_AUDIO_RELATIVE_PATH) return { chunks: [], assembler: true };
if (path === "plan.json" || path === "meta/chunks.json" || path === "meta/encoder.json") {
return { chunks: "all", assembler: true };
}
if (
path === "meta/composition.json" ||
path === "meta/videos.json" ||
path.startsWith("compiled/")
) {
return { chunks: "all", assembler: false };
}
if (path.startsWith("video-frames/")) {
return {
chunks: videoDependencies === null ? "all" : (videoDependencies.get(path) ?? []),
assembler: false,
};
}
// Unknown future v1 files go to both roles. Over-including is safe;
// silently omitting a new execution dependency is not.
return { chunks: "all", assembler: true };
}
function listVideoFramePaths(planV1Dir: string, videos: PlanVideosJson): ExtractedFrames[] {
return videos.extracted.map((video) => {
const outputDir = join(planV1Dir, "video-frames", video.videoId);
const frameNames = readdirSync(outputDir).sort();
const framePaths = new Map<number, string>();
for (const frameName of frameNames) {
// ffmpeg's image sequence starts at 1. Preserve sparse indexes so a
// materialized chunk can carry only the frames it actually requests.
const match = /(\d+)(?=\.[^.]+$)/.exec(frameName);
if (!match) {
throw new PlanV2IntegrityError(`cannot derive extracted frame index from ${frameName}`);
}
const oneBasedFrameNumber = Number(match[1]);
if (!Number.isSafeInteger(oneBasedFrameNumber) || oneBasedFrameNumber < 1) {
throw new PlanV2IntegrityError(
`extracted frame filename must use a 1-based safe integer: ${frameName}`,
);
}
const frameIndex = oneBasedFrameNumber - 1;
if (framePaths.has(frameIndex)) {
throw new PlanV2IntegrityError(
`duplicate extracted frame index ${frameIndex} in ${outputDir}`,
);
}
framePaths.set(frameIndex, join(outputDir, frameName));
}
return {
...video,
outputDir,
framePaths,
ownedByLookup: false,
};
});
}
function readFiniteNumber(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new PlanV2IntegrityError(`${field} must be a finite number`);
}
return value;
}
function readBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
throw new PlanV2IntegrityError(`${field} must be boolean`);
}
return value;
}
function readVideoMetadata(
value: unknown,
field: string,
): PlanVideosJson["extracted"][number]["metadata"] {
if (!isRecord(value)) {
throw new PlanV2IntegrityError(`${field} must be an object`);
}
let colorSpace: PlanVideosJson["extracted"][number]["metadata"]["colorSpace"] = null;
if (value.colorSpace !== null) {
if (!isRecord(value.colorSpace)) {
throw new PlanV2IntegrityError(`${field}.colorSpace must be an object or null`);
}
colorSpace = {
colorTransfer: readString(value.colorSpace.colorTransfer, `${field}.colorTransfer`),
colorPrimaries: readString(value.colorSpace.colorPrimaries, `${field}.colorPrimaries`),
colorSpace: readString(value.colorSpace.colorSpace, `${field}.colorSpace`),
};
}
return {
durationSeconds: readFiniteNumber(value.durationSeconds, `${field}.durationSeconds`),
videoStreamDurationSeconds: readFiniteNumber(
value.videoStreamDurationSeconds,
`${field}.videoStreamDurationSeconds`,
),
width: readPositiveInteger(value.width, `${field}.width`),
height: readPositiveInteger(value.height, `${field}.height`),
fps: readFiniteNumber(value.fps, `${field}.fps`),
videoCodec: readString(value.videoCodec, `${field}.videoCodec`),
hasAudio: readBoolean(value.hasAudio, `${field}.hasAudio`),
isVFR: readBoolean(value.isVFR, `${field}.isVFR`),
hasAlpha: readBoolean(value.hasAlpha, `${field}.hasAlpha`),
colorSpace,
};
}
function parsePlanVideosJson(value: unknown): PlanVideosJson {
if (!isRecord(value) || !Array.isArray(value.videos) || !Array.isArray(value.extracted)) {
throw new PlanV2IntegrityError("meta/videos.json must contain videos and extracted arrays");
}
const videos = value.videos.map((video, index) => {
const field = `meta/videos.json.videos[${index}]`;
if (!isRecord(video)) throw new PlanV2IntegrityError(`${field} must be an object`);
return {
id: readString(video.id, `${field}.id`),
src: readString(video.src, `${field}.src`),
start: readFiniteNumber(video.start, `${field}.start`),
end: readFiniteNumber(video.end, `${field}.end`),
mediaStart: readFiniteNumber(video.mediaStart, `${field}.mediaStart`),
loop: readBoolean(video.loop, `${field}.loop`),
hasAudio: readBoolean(video.hasAudio, `${field}.hasAudio`),
};
});
const extracted = value.extracted.map((entry, index) => {
const field = `meta/videos.json.extracted[${index}]`;
if (!isRecord(entry)) throw new PlanV2IntegrityError(`${field} must be an object`);
return {
videoId: readString(entry.videoId, `${field}.videoId`),
srcPath: readString(entry.srcPath, `${field}.srcPath`),
framePattern: readString(entry.framePattern, `${field}.framePattern`),
fps: readFiniteNumber(entry.fps, `${field}.fps`),
totalFrames: readNonNegativeInteger(entry.totalFrames, `${field}.totalFrames`),
metadata: readVideoMetadata(entry.metadata, `${field}.metadata`),
};
});
return { videos, extracted };
}
function parseChunkSlices(value: unknown): ChunkSliceJson[] {
if (!Array.isArray(value)) {
throw new PlanV2IntegrityError("meta/chunks.json must be an array");
}
const indexes = new Set<number>();
return value.map((chunk, position) => {
const field = `meta/chunks.json[${position}]`;
if (!isRecord(chunk)) throw new PlanV2IntegrityError(`${field} must be an object`);
const index = readNonNegativeInteger(chunk.index, `${field}.index`);
const startFrame = readNonNegativeInteger(chunk.startFrame, `${field}.startFrame`);
const endFrame = readPositiveInteger(chunk.endFrame, `${field}.endFrame`);
if (endFrame <= startFrame) {
throw new PlanV2IntegrityError(`${field}.endFrame must be greater than startFrame`);
}
if (indexes.has(index)) {
throw new PlanV2IntegrityError(`meta/chunks.json contains duplicate chunk index ${index}`);
}
indexes.add(index);
return { index, startFrame, endFrame };
});
}
function buildVideoChunkDependencies(
planV1Dir: string,
dimensions: Record<string, unknown>,
): {
mode: "exact-rendered-frames" | "full-source-pack";
dependencies: ReadonlyMap<string, readonly number[]> | null;
} {
const videoRoot = join(planV1Dir, "video-frames");
const hasExtractedFrames = existsSync(videoRoot) && listFiles(videoRoot).length > 0;
const videosPath = join(planV1Dir, PLAN_VIDEOS_META_RELATIVE_PATH);
if (!existsSync(videosPath)) {
return hasExtractedFrames
? { mode: "full-source-pack", dependencies: null }
: { mode: "exact-rendered-frames", dependencies: new Map() };
}
const chunksPath = join(planV1Dir, "meta", "chunks.json");
const videos = readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH);
const chunks = readJsonFile(chunksPath, "meta/chunks.json");
const parsedVideos = parsePlanVideosJson(videos);
const parsedChunks = parseChunkSlices(chunks);
const extracted = listVideoFramePaths(planV1Dir, parsedVideos);
const table = createFrameLookupTable(parsedVideos.videos, extracted);
const fpsNum = readPositiveInteger(dimensions.fpsNum, "dimensions.fpsNum");
const fpsDen = readPositiveInteger(dimensions.fpsDen, "dimensions.fpsDen");
const mutable = new Map<string, Set<number>>();
for (const chunk of parsedChunks) {
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(planV1Dir), payload.framePath).split(sep).join("/");
assertSafeRelativePath(path);
const indexes = mutable.get(path) ?? new Set<number>();
indexes.add(chunk.index);
mutable.set(path, indexes);
}
}
}
return {
mode: "exact-rendered-frames",
dependencies: new Map(
[...mutable].map(([path, indexes]) => [path, [...indexes].sort((a, b) => a - b)]),
),
};
}
function manifestPayload(
manifest: Omit<PlanV2Manifest, "planHash">,
): Omit<PlanV2Manifest, "planHash"> {
return manifest;
}
function computeManifestHash(manifest: Omit<PlanV2Manifest, "planHash">): string {
return sha256Hex(`${PLAN_V2_HASH_PREFIX}${canonicalJsonStringify(manifestPayload(manifest))}`);
}
function writeBlob(sourcePath: string, destinationPath: string): void {
if (existsSync(destinationPath)) return;
mkdirSync(dirname(destinationPath), { recursive: true });
const temporaryDir = mkdtempSync(join(dirname(destinationPath), ".plan-v2-blob-"));
const temporaryPath = join(temporaryDir, "blob");
try {
copyFileSync(sourcePath, temporaryPath);
renameSync(temporaryPath, destinationPath);
} finally {
rmSync(temporaryDir, { recursive: true, force: true });
}
}
/** Convert a frozen v1 execution directory into the immutable v2 transport. */
export function createPlanV2FromV1(planV1Dir: string, planV2Dir: string): PlanV2Result {
if (existsSync(planV2Dir)) {
throw new PlanV2IntegrityError(`output directory already exists: ${planV2Dir}`);
}
const v1PlanPath = join(planV1Dir, "plan.json");
if (!existsSync(v1PlanPath)) {
throw new PlanV2IntegrityError(`v1 plan is missing plan.json: ${v1PlanPath}`);
}
const v1PlanValue = readJsonFile(v1PlanPath, "v1 plan.json");
if (!isRecord(v1PlanValue)) {
throw new PlanV2IntegrityError("v1 plan.json must be an object");
}
const v1Plan = v1PlanValue;
readPlanProtocolV1(v1Plan);
const sourcePlanV1Hash = v1Plan.planHash;
if (!isSha256(sourcePlanV1Hash)) {
throw new PlanV2IntegrityError("v1 plan.json.planHash must be a sha256 digest");
}
const recomputedSourcePlanV1Hash = recomputePlanHashFromPlanDir(planV1Dir);
if (recomputedSourcePlanV1Hash !== sourcePlanV1Hash) {
throw new PlanV2IntegrityError(
`v1 plan content fingerprint does not match plan.json.planHash: ` +
`expected ${sourcePlanV1Hash}, recomputed ${recomputedSourcePlanV1Hash}`,
);
}
mkdirSync(dirname(planV2Dir), { recursive: true });
const tempDir = mkdtempSync(join(dirname(planV2Dir), ".plan-v2-build-"));
try {
const artifacts: PlanV2Artifact[] = [];
const dimensions = v1Plan.dimensions;
if (!isRecord(dimensions)) {
throw new PlanV2IntegrityError("v1 plan.json.dimensions must be an object");
}
const videoDependencyPlan = buildVideoChunkDependencies(planV1Dir, dimensions);
for (const file of listFiles(planV1Dir)) {
const targets = artifactTargets(file.path, videoDependencyPlan.dependencies);
if (
file.path.startsWith("video-frames/") &&
targets.chunks !== "all" &&
targets.chunks.length === 0 &&
!targets.assembler
) {
continue;
}
const sha256 = sha256File(file.absolutePath);
const sizeBytes = statSync(file.absolutePath).size;
writeBlob(file.absolutePath, blobPath(tempDir, sha256));
artifacts.push({
path: file.path,
sha256,
sizeBytes,
...targets,
});
}
const base: Omit<PlanV2Manifest, "planHash"> = {
protocol: PLAN_PROTOCOL_V2,
sourcePlanV1Hash,
chunkCount: readPositiveInteger(v1Plan.chunkCount, "chunkCount"),
totalFrames: readPositiveInteger(v1Plan.totalFrames, "totalFrames"),
fps: readV1PlanFps(dimensions),
width: readPositiveInteger(dimensions.width, "dimensions.width"),
height: readPositiveInteger(dimensions.height, "dimensions.height"),
format: readDistributedFormat(dimensions.format),
ffmpegVersion: readString(v1Plan.ffmpegVersion, "ffmpegVersion"),
producerVersion: readString(v1Plan.producerVersion, "producerVersion"),
limitations: { videoDependencyMode: videoDependencyPlan.mode },
artifacts,
};
const manifest: PlanV2Manifest = {
...base,
planHash: computeManifestHash(base),
};
writeFileSync(join(tempDir, "plan.json"), canonicalJsonStringify(manifest), "utf-8");
renameSync(tempDir, planV2Dir);
return resultFromManifest(planV2Dir, manifest);
} catch (error) {
rmSync(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Plan directly into v2. The large v1 directory exists only as local staging;
* its historical 2 GiB transport cap is disabled because no monolithic
* archive is emitted.
*/
export async function planV2(
projectDir: string,
config: DistributedRenderConfig,
planV2Dir: string,
): Promise<PlanV2Result> {
if (existsSync(planV2Dir)) {
throw new PlanV2IntegrityError(`output directory already exists: ${planV2Dir}`);
}
mkdirSync(dirname(planV2Dir), { recursive: true });
const stagingDir = mkdtempSync(join(dirname(planV2Dir), ".plan-v2-source-"));
try {
await plan(
projectDir,
{ ...config, planDirSizeLimitBytes: Number.MAX_SAFE_INTEGER },
stagingDir,
);
return createPlanV2FromV1(stagingDir, planV2Dir);
} finally {
rmSync(stagingDir, { recursive: true, force: true });
}
}
function resultFromManifest(planV2Dir: string, manifest: PlanV2Manifest): PlanV2Result {
return {
planDir: planV2Dir,
manifestPath: join(planV2Dir, "plan.json"),
planProtocol: PLAN_PROTOCOL_V2,
planHash: manifest.planHash,
sourcePlanV1Hash: manifest.sourcePlanV1Hash,
chunkCount: manifest.chunkCount,
totalFrames: manifest.totalFrames,
fps: manifest.fps,
width: manifest.width,
height: manifest.height,
format: manifest.format,
ffmpegVersion: manifest.ffmpegVersion,
producerVersion: manifest.producerVersion,
limitations: manifest.limitations,
};
}
function readString(value: unknown, field: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new PlanV2IntegrityError(`${field} must be a non-empty string`);
}
return value;
}
function readPositiveInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
throw new PlanV2IntegrityError(`${field} must be a positive integer`);
}
return value;
}
function readNonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
throw new PlanV2IntegrityError(`${field} must be a non-negative integer`);
}
return value;
}
function readSupportedFps(value: unknown): 24 | 30 | 60 {
if (value !== 24 && value !== 30 && value !== 60) {
throw new PlanV2IntegrityError("dimensions.fpsNum must be 24, 30, or 60");
}
return value;
}
function readV1PlanFps(dimensions: Record<string, unknown>): 24 | 30 | 60 {
const fpsDen = readPositiveInteger(dimensions.fpsDen, "dimensions.fpsDen");
if (fpsDen !== 1) {
throw new PlanV2IntegrityError("dimensions.fpsDen must be 1 for plan v2");
}
return readSupportedFps(dimensions.fpsNum);
}
function readDistributedFormat(value: unknown): DistributedFormat {
if (value !== "mp4" && value !== "mov" && value !== "webm" && value !== "png-sequence") {
throw new PlanV2IntegrityError("dimensions.format is unsupported");
}
return value;
}
function parseArtifact(value: unknown, index: number): PlanV2Artifact {
if (!isRecord(value)) throw new PlanV2IntegrityError(`artifacts[${index}] must be an object`);
const path = readString(value.path, `artifacts[${index}].path`);
assertSafeRelativePath(path);
if (!isSha256(value.sha256)) {
throw new PlanV2IntegrityError(`artifacts[${index}].sha256 must be a lowercase sha256`);
}
const sizeBytes = value.sizeBytes;
if (typeof sizeBytes !== "number" || !Number.isInteger(sizeBytes) || sizeBytes < 0) {
throw new PlanV2IntegrityError(`artifacts[${index}].sizeBytes must be a non-negative integer`);
}
let chunks: "all" | readonly number[];
if (value.chunks === "all") {
chunks = "all";
} else if (isNonNegativeIntegerArray(value.chunks)) {
chunks = value.chunks;
} else {
throw new PlanV2IntegrityError(`artifacts[${index}].chunks is invalid`);
}
if (typeof value.assembler !== "boolean") {
throw new PlanV2IntegrityError(`artifacts[${index}].assembler must be boolean`);
}
return {
path,
sha256: value.sha256,
sizeBytes,
chunks,
assembler: value.assembler,
};
}
// Manifest parsing deliberately validates every untrusted field in one boundary
// before any artifact path is used; splitting it would weaken that audit point.
// fallow-ignore-next-line complexity
function parsePlanV2Manifest(value: unknown): Readonly<PlanV2Manifest> {
if (!isRecord(value)) throw new PlanV2IntegrityError("manifest must be an object");
if (
!isRecord(value.protocol) ||
value.protocol.schemaVersion !== PLAN_PROTOCOL_V2.schemaVersion ||
value.protocol.artifactLayout !== PLAN_PROTOCOL_V2.artifactLayout ||
value.protocol.hashSchema !== PLAN_PROTOCOL_V2.hashSchema
) {
throw new PlanV2IntegrityError("manifest protocol is not the supported v2 descriptor");
}
if (!isSha256(value.planHash) || !isSha256(value.sourcePlanV1Hash)) {
throw new PlanV2IntegrityError("manifest hashes must be lowercase sha256 digests");
}
if (!Array.isArray(value.artifacts)) throw new PlanV2IntegrityError("artifacts must be an array");
const artifacts = value.artifacts.map(parseArtifact);
const paths = new Set<string>();
for (const artifact of artifacts) {
if (paths.has(artifact.path))
throw new PlanV2IntegrityError(`duplicate artifact path: ${artifact.path}`);
paths.add(artifact.path);
}
if (!paths.has("plan.json"))
throw new PlanV2IntegrityError("manifest must include the v1 plan.json artifact");
if (
!isRecord(value.limitations) ||
(value.limitations.videoDependencyMode !== "exact-rendered-frames" &&
value.limitations.videoDependencyMode !== "full-source-pack")
) {
throw new PlanV2IntegrityError("unsupported video dependency mode");
}
const parsed: PlanV2Manifest = {
protocol: PLAN_PROTOCOL_V2,
planHash: value.planHash,
sourcePlanV1Hash: value.sourcePlanV1Hash,
chunkCount: readPositiveInteger(value.chunkCount, "chunkCount"),
totalFrames: readPositiveInteger(value.totalFrames, "totalFrames"),
fps: readSupportedFps(value.fps),
width: readPositiveInteger(value.width, "width"),
height: readPositiveInteger(value.height, "height"),
format: readDistributedFormat(value.format),
ffmpegVersion: readString(value.ffmpegVersion, "ffmpegVersion"),
producerVersion: readString(value.producerVersion, "producerVersion"),
limitations: { videoDependencyMode: value.limitations.videoDependencyMode },
artifacts,
};
const { planHash: _planHash, ...payload } = parsed;
const expectedHash = computeManifestHash(payload);
if (expectedHash !== parsed.planHash) {
throw new PlanV2IntegrityError("manifest integrity hash mismatch");
}
for (const [index, artifact] of parsed.artifacts.entries()) {
if (
artifact.chunks !== "all" &&
artifact.chunks.some((chunkIndex) => chunkIndex >= parsed.chunkCount)
) {
throw new PlanV2IntegrityError(`artifacts[${index}].chunks contains an out-of-range index`);
}
}
return parsed;
}
/** Strictly parse and integrity-check a v2 manifest before any blob access. */
export function readPlanV2Manifest(planV2Dir: string): Readonly<PlanV2Manifest> {
const manifestPath = join(planV2Dir, "plan.json");
if (!existsSync(manifestPath)) {
throw new PlanV2IntegrityError(`missing v2 manifest: ${manifestPath}`);
}
const value = readJsonFile(manifestPath, "v2 manifest");
return parsePlanV2Manifest(value);
}
export function listPlanV2ArtifactsForTarget(
manifest: Readonly<PlanV2Manifest>,
target: PlanV2MaterializationTarget,
): readonly Readonly<PlanV2Artifact>[] {
if (target.role === "chunk") {
if (
!Number.isInteger(target.chunkIndex) ||
target.chunkIndex < 0 ||
target.chunkIndex >= manifest.chunkCount
) {
throw new PlanV2IntegrityError(`chunkIndex ${String(target.chunkIndex)} is out of range`);
}
return manifest.artifacts.filter(
(artifact) =>
artifact.chunks === "all" ||
artifact.chunks.some((chunkIndex) => chunkIndex === target.chunkIndex),
);
}
return manifest.artifacts.filter((artifact) => artifact.assembler);
}
function verifyBlob(planV2Dir: string, artifact: Readonly<PlanV2Artifact>): string {
const sourcePath = blobPath(planV2Dir, artifact.sha256);
if (!existsSync(sourcePath)) {
throw new PlanV2IntegrityError(`missing content-addressed artifact ${artifact.sha256}`);
}
const stats = statSync(sourcePath);
if (!stats.isFile() || stats.size !== artifact.sizeBytes) {
throw new PlanV2IntegrityError(`artifact size mismatch for ${artifact.path}`);
}
if (sha256File(sourcePath) !== artifact.sha256) {
throw new PlanV2IntegrityError(`artifact hash mismatch for ${artifact.path}`);
}
return sourcePath;
}
/**
* Verify every selected blob first, then atomically publish a v1-compatible
* execution directory. The marker lets execution functions revalidate the
* selected subset without requiring assembler-only audio in chunk workers.
*/
export function materializePlanV2Target(
planV2Dir: string,
target: PlanV2MaterializationTarget,
destinationDir: string,
): PlanV2MaterializationResult {
if (existsSync(destinationDir)) {
throw new PlanV2IntegrityError(`materialization destination already exists: ${destinationDir}`);
}
const manifest = readPlanV2Manifest(planV2Dir);
const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
const verified = artifacts.map((artifact) => ({
artifact,
sourcePath: verifyBlob(planV2Dir, artifact),
}));
mkdirSync(dirname(destinationDir), { recursive: true });
const tempDir = mkdtempSync(join(dirname(destinationDir), ".plan-v2-materialize-"));
try {
for (const { artifact, sourcePath } of verified) {
const destinationPath = join(tempDir, ...artifact.path.split("/"));
mkdirSync(dirname(destinationPath), { recursive: true });
copyFileSync(sourcePath, destinationPath);
}
writeFileSync(
join(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
canonicalJsonStringify({ manifest, target }),
"utf-8",
);
renameSync(tempDir, destinationDir);
} catch (error) {
rmSync(tempDir, { recursive: true, force: true });
throw error;
}
return {
planDir: destinationDir,
target,
planHash: manifest.planHash,
sourcePlanV1Hash: manifest.sourcePlanV1Hash,
artifactCount: artifacts.length,
sizeBytes: artifacts.reduce((sum, artifact) => sum + artifact.sizeBytes, 0),
audioPath:
target.role === "assembler" &&
artifacts.some((artifact) => artifact.path === PLAN_AUDIO_RELATIVE_PATH)
? join(destinationDir, PLAN_AUDIO_RELATIVE_PATH)
: null,
};
}
/** Revalidate a materialized subset immediately before execution. */
export function validatePlanV2MaterializedTarget(
planDir: string,
expectedTarget: PlanV2MaterializationTarget,
): Readonly<PlanV2Manifest> | null {
const markerPath = join(planDir, PLAN_V2_MATERIALIZATION_MARKER);
if (!existsSync(markerPath)) return null;
const marker = readJsonFile(markerPath, "materialization marker");
if (!isRecord(marker) || !isRecord(marker.manifest) || !isRecord(marker.target)) {
throw new PlanV2IntegrityError("malformed materialization marker");
}
const target = marker.target;
if (
target.role !== expectedTarget.role ||
(expectedTarget.role === "chunk" && target.chunkIndex !== expectedTarget.chunkIndex)
) {
throw new PlanV2IntegrityError(
"materialization target does not match requested execution role",
);
}
const manifest = parsePlanV2Manifest(marker.manifest);
const required = listPlanV2ArtifactsForTarget(manifest, expectedTarget);
for (const artifact of required) {
const path = join(planDir, ...artifact.path.split("/"));
if (!existsSync(path) || statSync(path).size !== artifact.sizeBytes) {
throw new PlanV2IntegrityError(
`materialized artifact missing or truncated: ${artifact.path}`,
);
}
if (sha256File(path) !== artifact.sha256) {
throw new PlanV2IntegrityError(`materialized artifact hash mismatch: ${artifact.path}`);
}
}
return manifest;
}
@@ -0,0 +1,55 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { assemble, type AssembleResult } from "./assemble.js";
import { materializePlanV2Target } from "./planV2.js";
import { renderChunk, type ChunkResult } from "./renderChunk.js";
/**
* Direct v2 chunk-role entry point. Storage adapters may instead download the
* selected blobs themselves and call `materializePlanV2Target` + `renderChunk`.
*/
export async function renderChunkV2(
planV2Dir: string,
chunkIndex: number,
outputChunkPath: string,
): Promise<ChunkResult> {
const workRoot = mkdtempSync(join(tmpdir(), "hf-plan-v2-chunk-"));
const materializedPlanDir = join(workRoot, "plan");
try {
materializePlanV2Target(planV2Dir, { role: "chunk", chunkIndex }, materializedPlanDir);
return await renderChunk(materializedPlanDir, chunkIndex, outputChunkPath);
} finally {
rmSync(workRoot, { recursive: true, force: true });
}
}
/**
* Direct v2 assembler-role entry point. Audio is resolved exclusively from
* the assembler dependency set and never materialized by chunk workers.
*/
export async function assembleV2(
planV2Dir: string,
chunkPaths: readonly string[],
outputPath: string,
options?: Parameters<typeof assemble>[4],
): Promise<AssembleResult> {
const workRoot = mkdtempSync(join(tmpdir(), "hf-plan-v2-assembler-"));
const materializedPlanDir = join(workRoot, "plan");
try {
const materialized = materializePlanV2Target(
planV2Dir,
{ role: "assembler" },
materializedPlanDir,
);
return await assemble(
materializedPlanDir,
chunkPaths,
materialized.audioPath,
outputPath,
options,
);
} finally {
rmSync(workRoot, { recursive: true, force: true });
}
}
@@ -52,10 +52,14 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
);
expect(distributedSubpath.FFMPEG_VERSION_MISMATCH).toBe("FFMPEG_VERSION_MISMATCH");
expect(distributedSubpath.PLAN_HASH_MISMATCH).toBe("PLAN_HASH_MISMATCH");
expect(distributedSubpath.PLAN_V2_INTEGRITY_UNRECOVERABLE).toBe(
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
);
expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
expect(typeof distributedSubpath.PlanValidationError).toBe("function");
expect(typeof distributedSubpath.PlanV2IntegrityError).toBe("function");
expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
});
@@ -69,6 +73,9 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
expect(distributedSubpath.PLAN_SCHEMA_VERSION).toBe(1);
expect(distributedSubpath.PLAN_ARTIFACT_LAYOUT).toBe("plan-dir-v1");
expect(distributedSubpath.PLAN_HASH_SCHEMA).toBe("hyperframes-plan-hash-v1");
expect(distributedSubpath.PLAN_V2_SCHEMA_VERSION).toBe(2);
expect(distributedSubpath.PLAN_V2_ARTIFACT_LAYOUT).toBe("content-addressed-plan-v2");
expect(distributedSubpath.PLAN_V2_HASH_SCHEMA).toBe("hyperframes-plan-manifest-hash-v2");
expect(distributedSubpath.PLAN_PROTOCOL_UNSUPPORTED).toBe("PLAN_PROTOCOL_UNSUPPORTED");
expect(distributedSubpath.CURRENT_PLAN_PROTOCOL).toEqual({
schemaVersion: 1,
@@ -77,19 +84,24 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
});
expect(distributedSubpath.DISTRIBUTED_RENDER_CAPABILITIES.roles).toEqual({
planner: {
produces: [distributedSubpath.CURRENT_PLAN_PROTOCOL],
produces: [distributedSubpath.CURRENT_PLAN_PROTOCOL, distributedSubpath.PLAN_PROTOCOL_V2],
},
chunk: {
accepts: [distributedSubpath.CURRENT_PLAN_PROTOCOL],
accepts: [distributedSubpath.CURRENT_PLAN_PROTOCOL, distributedSubpath.PLAN_PROTOCOL_V2],
acceptsLegacyV1WithoutDescriptor: true,
},
assembler: {
accepts: [distributedSubpath.CURRENT_PLAN_PROTOCOL],
accepts: [distributedSubpath.CURRENT_PLAN_PROTOCOL, distributedSubpath.PLAN_PROTOCOL_V2],
acceptsLegacyV1WithoutDescriptor: true,
},
});
expect(typeof distributedSubpath.getDistributedRenderCapabilities).toBe("function");
expect(typeof distributedSubpath.readPlanProtocol).toBe("function");
expect(typeof distributedSubpath.planV2).toBe("function");
expect(typeof distributedSubpath.renderChunkV2).toBe("function");
expect(typeof distributedSubpath.assembleV2).toBe("function");
expect(typeof distributedSubpath.readPlanV2Manifest).toBe("function");
expect(typeof distributedSubpath.materializePlanV2Target).toBe("function");
expect(typeof distributedSubpath.PlanProtocolUnsupportedError).toBe("function");
});
});
@@ -108,7 +120,9 @@ describe("@hyperframes/producer (main entry)", () => {
);
expect(typeof producerIndex.getDistributedRenderCapabilities).toBe("function");
expect(producerIndex.PLAN_PROTOCOL_UNSUPPORTED).toBe("PLAN_PROTOCOL_UNSUPPORTED");
expect(producerIndex.PLAN_V2_INTEGRITY_UNRECOVERABLE).toBe("PLAN_V2_INTEGRITY_UNRECOVERABLE");
expect(typeof producerIndex.readPlanProtocol).toBe("function");
expect(typeof producerIndex.PlanV2IntegrityError).toBe("function");
expect(typeof producerIndex.PlanProtocolUnsupportedError).toBe("function");
});
@@ -150,4 +150,70 @@ describe("rebuildExtractedFramesFromPlanDir", () => {
rmSync(planDir, { recursive: true, force: true });
}
});
it("keeps the historical sorted-position mapping for v1 numeric filenames", () => {
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-v1-numeric-"));
try {
makeFramesDir(planDir, "vid-v1-numeric", ["frame_00000.jpg", "frame_00001.jpg"]);
const [extracted] = rebuildExtractedFramesFromPlanDir(planDir, [
{
videoId: "vid-v1-numeric",
srcPath: "/v1-numeric.mp4",
framePattern: "frame_%05d.jpg",
fps: 30,
totalFrames: 2,
metadata: VIDEO_METADATA_STUB,
},
]);
expect(extracted!.framePaths.get(0)).toBe(
join(planDir, "video-frames", "vid-v1-numeric", "frame_00000.jpg"),
);
expect(extracted!.framePaths.get(1)).toBe(
join(planDir, "video-frames", "vid-v1-numeric", "frame_00001.jpg"),
);
expect(extracted!.framePaths.get(-1)).toBeUndefined();
} finally {
rmSync(planDir, { recursive: true, force: true });
}
});
it("preserves original indexes for a sparse v2 chunk materialization", () => {
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-sparse-"));
try {
makeFramesDir(planDir, "vid-sparse", [
"frame_00021.jpg",
"frame_00022.jpg",
"frame_00101.jpg",
]);
const [extracted] = rebuildExtractedFramesFromPlanDir(
planDir,
[
{
videoId: "vid-sparse",
srcPath: "/sparse.mp4",
framePattern: "frame_%05d.jpg",
fps: 30,
totalFrames: 200,
metadata: VIDEO_METADATA_STUB,
},
],
"sparse-v2",
);
if (!extracted) throw new Error("expected sparse v2 extracted-frame result");
expect(extracted.framePaths.get(20)).toBe(
join(planDir, "video-frames", "vid-sparse", "frame_00021.jpg"),
);
expect(extracted.framePaths.get(21)).toBe(
join(planDir, "video-frames", "vid-sparse", "frame_00022.jpg"),
);
expect(extracted.framePaths.get(100)).toBe(
join(planDir, "video-frames", "vid-sparse", "frame_00101.jpg"),
);
expect(extracted.framePaths.get(0)).toBeUndefined();
} finally {
rmSync(planDir, { recursive: true, force: true });
}
});
});
@@ -80,7 +80,8 @@ import {
type PlanVideosJson,
readFfmpegVersion,
} from "./shared.js";
import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocol } from "./planProtocol.js";
import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocolV1 } from "./planProtocol.js";
import { validatePlanV2MaterializedTarget } from "./planV2.js";
/**
* Non-retryable error codes raised when the planDir is structurally
@@ -184,6 +185,7 @@ export interface ChunkResult {
export function rebuildExtractedFramesFromPlanDir(
planDir: string,
videos: PlanVideosJson["extracted"],
indexMode: "dense-v1" | "sparse-v2" = "dense-v1",
): ExtractedFrames[] {
const result: ExtractedFrames[] = [];
for (const v of videos) {
@@ -206,7 +208,12 @@ export function rebuildExtractedFramesFromPlanDir(
for (let i = 0; i < frames.length; i++) {
const frameName = frames[i];
if (!frameName) continue;
framePaths.set(i, join(outputDir, frameName));
// V1 plans preserve the historical sorted-position behavior even for
// unusual zero-based filenames. V2 materialization is sparse, so only
// that mode derives the original index from ffmpeg's 1-based filename.
const numbered = indexMode === "sparse-v2" ? /(\d+)(?=\.[^.]+$)/.exec(frameName) : null;
const frameIndex = numbered ? Number(numbered[1]) - 1 : i;
framePaths.set(frameIndex, join(outputDir, frameName));
}
result.push({
videoId: v.videoId,
@@ -342,7 +349,12 @@ export async function renderChunk(
);
}
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJson;
readPlanProtocol(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.chunk);
readPlanProtocolV1(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.chunk);
const planHashStarted = Date.now();
const v2Manifest = validatePlanV2MaterializedTarget(planDir, {
role: "chunk",
chunkIndex,
});
for (const required of [encoderJsonPath, chunksJsonPath]) {
if (!existsSync(required)) {
throw new RenderChunkValidationError(
@@ -421,8 +433,8 @@ export async function renderChunk(
// the chunk renders. Distinct from the other validation paths above
// because `MISSING_PLAN_ARTIFACT` etc. are structural; this is purely
// content-fingerprint drift.
const planHashStarted = Date.now();
const recomputedPlanHash = recomputePlanHashFromPlanDir(planDir);
const recomputedPlanHash =
v2Manifest === null ? recomputePlanHashFromPlanDir(planDir) : plan.planHash;
const planHashMs = Date.now() - planHashStarted;
if (recomputedPlanHash !== plan.planHash) {
throw new RenderChunkValidationError(
@@ -486,7 +498,11 @@ export async function renderChunk(
? createVideoFrameInjector(
createFrameLookupTable(
planVideos.videos,
rebuildExtractedFramesFromPlanDir(planDir, planVideos.extracted),
rebuildExtractedFramesFromPlanDir(
planDir,
planVideos.extracted,
v2Manifest === null ? "dense-v1" : "sparse-v2",
),
),
)
: null;
@@ -31,6 +31,13 @@ export type DistributedFormat = "mp4" | "mov" | "png-sequence" | "webm";
*/
export const PLAN_VIDEOS_META_RELATIVE_PATH = "meta/videos.json";
/**
* Relative path of the normalized audio artifact written into a distributed
* plan. Keep writers and transport readers coupled through this contract
* rather than duplicating a filename literal.
*/
export const PLAN_AUDIO_RELATIVE_PATH = "audio.aac";
/**
* On-disk shape of `<planDir>/meta/videos.json`. The engine's
* `ExtractedFrames` shape carries an absolute `outputDir`, a `framePaths`