mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
fix(producer): document read-only plan hashing (#2788)
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user