fix(producer): validate distributed video metadata (#2839)

## What

- enforce a finite, validated `meta/videos.json` contract shared by Plan v1 and Plan v2
- preserve authored finite ends and source-derived trim-aware ends; bound any still-open end at the validated composition end
- fail distributed planning when any declared video source did not extract instead of publishing a blank-capable plan
- make the v1 chunk reader reject malformed/null video timing before frame injection
- route deterministic video-source/metadata failures as non-retryable in AWS and GCP while retaining retries for transient extraction failures

## Why

An open-ended video whose remote source could not be resolved retained `Infinity` through planning. Plan v2 correctly rejected that value, while Plan v1 serialized it as `null`; the v1 frame lookup could then suppress injected frames and silently produce incorrect output.

The invariant belongs at the shared metadata boundary. Both protocols must receive identical finite timing, and unavailable sources must fail closed before plan publication.

## Test plan

- [x] producer distributed planning, metadata, v1 chunk boundary, Plan v2 conversion/materialization, and public exports
- [x] core runtime media semantics (authored slots, natural duration, looping, non-looping hold)
- [x] engine video extraction and frame lookup
- [x] AWS Lambda/CDK/SAM and GCP Cloud Run error normalization/retry classification
- [x] producer, core, engine, AWS, and GCP typechecks/builds
- [x] formatting, oxlint, tracked-artifact, fallow, and commit hooks
- [x] exact incident composition replayed through the AWS Lambda handler's Lambda-local path in a Lambda-like container; Plan v1 and Plan v2 both fail closed as `VIDEO_SOURCE_UNRENDERABLE` during planning, before plan publication
- [x] full PR CI, including all nine regression shards and Windows render/tests

No production flags or deployment/release workflows are changed.
This commit is contained in:
James Russo
2026-07-28 00:42:36 -07:00
committed by GitHub
parent 3c857d768b
commit 557d82b6a9
19 changed files with 672 additions and 104 deletions
+6
View File
@@ -263,6 +263,8 @@ Resources:
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0
@@ -305,6 +307,8 @@ Resources:
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
@@ -401,6 +405,7 @@ Resources:
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0
- ErrorEquals: [States.ALL]
@@ -497,6 +502,7 @@ Resources:
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- INVALID_VIDEO_METADATA
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- ChromeBinaryUnavailableError
@@ -76,6 +76,8 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
@@ -209,6 +211,25 @@ describe("HyperframesRenderStack — snapshot", () => {
}
});
it("routes video failures consistently across SAM/CDK and both plan protocols", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
const v1 = getV1TaskStates(definition);
const v2 = getV2TaskStates(definition);
for (const planState of [v1.Plan, v2.PlanV2]) {
const errors = new Set<string>();
collectNonRetryableErrors(planState, errors);
expect(errors.has("VIDEO_SOURCE_UNRENDERABLE")).toBe(true);
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
expect(errors.has("VIDEO_EXTRACTION_FAILED")).toBe(false);
}
for (const chunkState of [v1.RenderChunk, v2.RenderChunkV2]) {
const errors = new Set<string>();
collectNonRetryableErrors(chunkState, errors);
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
}
}
});
it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
const { definition } = SYNTHED;
const v1 = JSON.stringify({
@@ -271,6 +292,21 @@ function getV2TaskStates(definition: {
};
}
function getV1TaskStates(definition: {
States: Record<string, unknown>;
}): Record<"Plan" | "RenderChunk" | "Assemble", unknown> {
const renderChunks = requireRecord(definition.States.RenderChunks, "RenderChunks state");
const processor = isRecord(renderChunks.Iterator)
? renderChunks.Iterator
: requireRecord(renderChunks.ItemProcessor, "RenderChunks processor");
const innerStates = requireRecord(processor.States, "RenderChunks processor states");
return {
Plan: definition.States.Plan,
RenderChunk: innerStates.RenderChunk,
Assemble: definition.States.Assemble,
};
}
function readSamDefinition(): { States: Record<string, unknown> } {
const source = readFileSync(
new URL("../../../../examples/aws-lambda/template.yaml", import.meta.url),
@@ -204,6 +204,8 @@ export class HyperframesRenderStack extends Construct {
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
@@ -219,6 +221,7 @@ export class HyperframesRenderStack extends Construct {
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
+9 -2
View File
@@ -22,6 +22,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
CURRENT_PLAN_PROTOCOL,
PlanVideosMetadataError,
type AssembleResult,
type ChunkResult,
type PlanResult,
@@ -243,16 +244,22 @@ describe("handler dispatch", () => {
).toBe(true);
});
it("normalizes producer terminal codes to Step Functions error names", async () => {
it("normalizes producer workflow codes to Step Functions error names", async () => {
for (const code of [
"PLAN_TOO_LARGE",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
"INVALID_VIDEO_METADATA",
] as const) {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const terminal = Object.assign(new Error(`terminal: ${code}`), {
const terminal =
code === "INVALID_VIDEO_METADATA"
? new PlanVideosMetadataError("test invalid plan video metadata")
: Object.assign(new Error(`terminal: ${code}`), {
code,
name: "ProducerError",
});
+5 -2
View File
@@ -138,7 +138,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
/**
* AWS Lambda reports `Error.name` to Step Functions, while producer errors
* expose stable machine codes separately. Normalize the terminal codes
* expose stable machine codes separately. Normalize workflow-facing codes
* whose historical class names differ from their orchestration contracts.
*/
// The explicit error-name mapping is the public Step Functions failure contract.
@@ -149,7 +149,10 @@ function normalizeTerminalErrorName(error: unknown): void {
if (
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
) {
candidate.name = candidate.code;
}
+61
View File
@@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
import {
CURRENT_PLAN_PROTOCOL,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
PlanVideosMetadataError,
PlanV2IntegrityError,
PlanProtocolUnsupportedError,
type AssembleResult,
@@ -574,6 +575,66 @@ describe("createApp HTTP mapping", () => {
}
});
it.each([
["VIDEO_SOURCE_UNRENDERABLE", 400],
["VIDEO_EXTRACTION_FAILED", 500],
] as const)("routes producer video code %s with HTTP %s", async (code, expectedStatus) => {
const gcs = new FakeGcs();
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const app = createApp(
depsWith(gcs, {
renderChunk: async () => {
throw Object.assign(new Error(`test ${code}`), {
name: "ProducerError",
code,
});
},
}),
);
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
ChunkOutputGcsPrefix: "gs://b/renders/r1/",
Format: "mp4",
}),
});
expect(res.status).toBe(expectedStatus);
const body = (await res.json()) as { error: string };
expect(body.error).toBe(code);
});
it("routes the real plan metadata error as non-retryable", async () => {
const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/invalid-video-metadata/project.tar.gz");
const app = createApp(
depsWith(gcs, {
plan: async () => {
throw new PlanVideosMetadataError("test invalid plan video metadata");
},
}),
);
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "plan",
ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
}),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("INVALID_VIDEO_METADATA");
});
it("returns 500 for a retryable/unknown error", async () => {
const gcs = new FakeGcs(); // plan tar NOT seeded → download fails (retryable)
const app = createApp(depsWith(gcs));
+6 -1
View File
@@ -182,7 +182,10 @@ function normalizeTerminalErrorName(error: unknown): void {
if (
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
) {
candidate.name = candidate.code;
}
@@ -899,6 +902,8 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
// Producer error class names (`.name`) + their string code aliases — the
// class sets `.name` to the class name but wraps a `code`; cover both so a
// raw-code throw is caught too. Mirrors the AWS state machine's
+2 -1
View File
@@ -94,6 +94,7 @@ export {
type EffectiveChunkResult,
// Error codes + classes
FFMPEG_VERSION_MISMATCH,
INVALID_VIDEO_METADATA,
PLAN_HASH_MISMATCH,
RenderChunkValidationError,
} from "./services/distributed/renderChunk.js";
@@ -142,7 +143,7 @@ export {
// ── Format union ────────────────────────────────────────────────────────────
// Canonical output-format type. The aws-lambda package re-exports it so
// CLI / adopter SDKs can derive runtime allowlists from one source.
export type { DistributedFormat } from "./services/distributed/shared.js";
export { PlanVideosMetadataError, type DistributedFormat } from "./services/distributed/shared.js";
// ── Plan-time shared types from `freezePlan` ───────────────────────────────
// Re-exported so adopters that deserialize a planDir's `meta/encoder.json`
@@ -19,6 +19,9 @@ describe("extractSafeRenderErrorCode", () => {
expect(extractSafeRenderErrorCode({ code: "VIDEO_SOURCE_UNRENDERABLE" })).toBe(
"VIDEO_SOURCE_UNRENDERABLE",
);
expect(extractSafeRenderErrorCode({ code: "INVALID_VIDEO_METADATA" })).toBe(
"INVALID_VIDEO_METADATA",
);
});
it("does not forward arbitrary codes or parse message text", () => {
+1
View File
@@ -119,6 +119,7 @@ interface PreparedRenderInput {
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
const SAFE_RENDER_ERROR_CODES = new Set<string>([
"INVALID_VIDEO_METADATA",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
]);
@@ -396,6 +396,83 @@ describe("plan() — golden planDir + planHash determinism", () => {
// runtime resolution variance on the CI host.
const TIMEOUT_MS = 30_000;
it(
"fails closed when an open-ended distributed video source cannot be extracted",
async () => {
const brokenProjectDir = join(runRoot, "broken-video-project");
const brokenPlanDir = join(runRoot, "broken-video-plan");
mkdirSync(brokenProjectDir, { recursive: true });
mkdirSync(brokenPlanDir, { recursive: true });
writeFileSync(
join(brokenProjectDir, "index.html"),
`<!doctype html>
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
<video id="hero" src="missing.mp4" data-start="0"></video>
</div>`,
);
let caught: unknown;
try {
await plan(
brokenProjectDir,
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
brokenPlanDir,
);
} catch (err) {
caught = err;
}
expect(caught).toHaveProperty("name", "VideoExtractionStageError");
expect(caught).toHaveProperty("code", "VIDEO_SOURCE_UNRENDERABLE");
expect(caught).toHaveProperty("retryable", false);
expect(existsSync(join(brokenPlanDir, "meta", "videos.json"))).toBe(false);
},
TIMEOUT_MS,
);
it(
"maps an open-ended remote video HTTP 404 to a terminal source error",
async () => {
const brokenProjectDir = join(runRoot, "remote-404-video-project");
const brokenPlanDir = join(runRoot, "remote-404-video-plan");
mkdirSync(brokenProjectDir, { recursive: true });
mkdirSync(brokenPlanDir, { recursive: true });
writeFileSync(
join(brokenProjectDir, "index.html"),
`<!doctype html>
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
<video id="hero" src="https://cdn.example/missing.mp4" data-start="0"></video>
</div>`,
);
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls += 1;
return new Response(null, { status: 404, statusText: "Not Found" });
}) as typeof fetch;
let caught: unknown;
try {
await plan(
brokenProjectDir,
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
brokenPlanDir,
);
} catch (err) {
caught = err;
} finally {
globalThis.fetch = originalFetch;
}
expect(fetchCalls).toBeGreaterThan(0);
expect(caught).toHaveProperty("name", "VideoExtractionStageError");
expect(caught).toHaveProperty("code", "VIDEO_SOURCE_UNRENDERABLE");
expect(caught).toHaveProperty("retryable", false);
expect(existsSync(join(brokenPlanDir, "meta", "videos.json"))).toBe(false);
},
TIMEOUT_MS,
);
it(
"produces the documented planDir layout",
async () => {
@@ -44,7 +44,10 @@ import {
import { closeFileServerSafely } from "../fileServer.js";
import { runAudioStage } from "../render/stages/audioStage.js";
import { runCompileStage } from "../render/stages/compileStage.js";
import { runExtractVideosStage } from "../render/stages/extractVideosStage.js";
import {
assertVideoExtractionSucceeded,
runExtractVideosStage,
} from "../render/stages/extractVideosStage.js";
import { runProbeStage } from "../render/stages/probeStage.js";
import {
type ChunkSliceJson,
@@ -65,6 +68,7 @@ import {
import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
import {
buildSyntheticRenderJob,
buildPlanVideosJson,
type DistributedFormat,
PLAN_AUDIO_RELATIVE_PATH,
PLAN_VIDEOS_META_RELATIVE_PATH,
@@ -1007,6 +1011,14 @@ export async function plan(
materializeSymlinks: true,
});
if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
if (extractResult.extractionResult) {
// Distributed chunks cannot safely fall back to native remote decoding:
// the planner-local source may be unavailable on another worker, and a
// missing frame set otherwise renders as a silent blank video. Unlike the
// separately canaried in-process policy, distributed planning always
// requires every declared source to extract successfully.
assertVideoExtractionSucceeded(extractResult.extractionResult);
}
// Skip `extractResult.frameLookup.cleanup()`: it would rm-rf each
// video's outputDir, but in `plan()` those directories ARE the source
// material the renames below move into `planDir/video-frames/`.
@@ -1049,8 +1061,9 @@ export async function plan(
// page's native `<video>` element decodes the source mp4 ~1 frame
// off the pre-extracted images the in-process baseline was captured
// from.
const planVideosJson: PlanVideosJson = {
const planVideosJson: PlanVideosJson = buildPlanVideosJson({
videos: composition.videos,
compositionEnd: job.duration ?? Number.NaN,
extracted: (extractResult.extractionResult?.extracted ?? []).map((ext) => ({
videoId: ext.videoId,
srcPath: ext.srcPath,
@@ -1059,7 +1072,7 @@ export async function plan(
totalFrames: ext.totalFrames,
metadata: ext.metadata,
})),
};
});
mkdirSync(join(planDir, "meta"), { recursive: true });
writeFileSync(
join(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
@@ -27,6 +27,7 @@ import {
validatePlanV2MaterializedTarget,
} from "./planV2.js";
import { LocalPlanV2ArtifactPublisher, type PlanV2ArtifactPublisher } from "./planV2Publisher.js";
import { buildPlanVideosJson, type PlanVideosJson } from "./shared.js";
const tempDirs: string[] = [];
@@ -178,6 +179,30 @@ describe("Plan v2 manifest", () => {
expect(first.limitations.videoDependencyMode).toBe("exact-rendered-frames");
});
it("accepts and materializes the same bounded timing produced for v1", () => {
const root = tempPath("hf-plan-v2-open-ended-video-");
const v1 = createV1Plan(root, { video: true });
const videosPath = join(v1, "meta", "videos.json");
const fixture = JSON.parse(readFileSync(videosPath, "utf-8")) as PlanVideosJson;
const bounded = buildPlanVideosJson({
videos: [{ ...fixture.videos[0]!, end: Number.POSITIVE_INFINITY }],
extracted: fixture.extracted,
compositionEnd: 2,
});
writeFileSync(videosPath, JSON.stringify(bounded));
refreshV1PlanHash(v1);
const v2 = createPlanV2FromV1(v1, join(root, "v2"));
const materialized = join(root, "chunk");
materializePlanV2Target(v2.planDir, { role: "chunk", chunkIndex: 1 }, materialized);
const materializedVideos = JSON.parse(
readFileSync(join(materialized, "meta", "videos.json"), "utf-8"),
) as PlanVideosJson;
expect(bounded.videos[0]?.end).toBe(2);
expect(materializedVideos.videos).toEqual(bounded.videos);
});
it("rejects a stale v1 source hash before content-addressing its bytes", () => {
const root = tempPath("hf-plan-v2-source-hash-");
const v1 = createV1Plan(root);
@@ -50,6 +50,8 @@ import {
PLAN_AUDIO_RELATIVE_PATH,
PLAN_VIDEOS_META_RELATIVE_PATH,
type DistributedFormat,
parsePlanVideosJson as parseSharedPlanVideosJson,
PlanVideosMetadataError,
type PlanVideosJson,
} from "./shared.js";
@@ -325,91 +327,15 @@ function listVideoFramePaths(planV1Dir: string, videos: PlanVideosJson): Extract
});
}
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: readColorComponent(
value.colorSpace.colorTransfer,
`${field}.colorSpace.colorTransfer`,
),
colorPrimaries: readColorComponent(
value.colorSpace.colorPrimaries,
`${field}.colorSpace.colorPrimaries`,
),
colorSpace: readColorComponent(value.colorSpace.colorSpace, `${field}.colorSpace.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");
try {
return parseSharedPlanVideosJson(value);
} catch (err) {
if (err instanceof PlanVideosMetadataError) {
throw new PlanV2IntegrityError(err.message);
}
throw err;
}
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 materializeExtractedVideoDirectories(planDir: string): void {
@@ -726,13 +652,6 @@ function readString(value: unknown, field: string): string {
return value;
}
function readColorComponent(value: unknown, field: string): string {
if (typeof value !== "string") {
throw new PlanV2IntegrityError(`${field} must be a 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`);
@@ -51,6 +51,7 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
);
expect(distributedSubpath.FFMPEG_VERSION_MISMATCH).toBe("FFMPEG_VERSION_MISMATCH");
expect(distributedSubpath.INVALID_VIDEO_METADATA).toBe("INVALID_VIDEO_METADATA");
expect(distributedSubpath.PLAN_HASH_MISMATCH).toBe("PLAN_HASH_MISMATCH");
expect(distributedSubpath.PLAN_V2_INTEGRITY_UNRECOVERABLE).toBe(
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
@@ -80,6 +80,8 @@ import {
} from "../fileServer.js";
import {
buildSyntheticRenderJob,
INVALID_VIDEO_METADATA,
parsePlanVideosJson,
type DistributedFormat,
PLAN_VIDEOS_META_RELATIVE_PATH,
type PlanVideosJson,
@@ -102,6 +104,7 @@ export const PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH";
export const MISSING_PLAN_ARTIFACT = "MISSING_PLAN_ARTIFACT";
export const CHUNK_INDEX_OUT_OF_RANGE = "CHUNK_INDEX_OUT_OF_RANGE";
export const MISSING_RUNTIME_ENV_SNAPSHOT = "MISSING_RUNTIME_ENV_SNAPSHOT";
export { INVALID_VIDEO_METADATA };
const LEGACY_DISTRIBUTED_VP9_CPU_USED = 2;
export type RenderChunkValidationCode =
@@ -110,6 +113,7 @@ export type RenderChunkValidationCode =
| typeof MISSING_PLAN_ARTIFACT
| typeof CHUNK_INDEX_OUT_OF_RANGE
| typeof MISSING_RUNTIME_ENV_SNAPSHOT
| typeof INVALID_VIDEO_METADATA
| typeof BROWSER_GPU_NOT_SOFTWARE;
/**
@@ -127,6 +131,18 @@ export class RenderChunkValidationError extends Error {
}
}
/** Validate the shared video contract before any v1 chunk can inject frames. */
export function validatePlanVideosForChunk(value: unknown): PlanVideosJson {
try {
return parsePlanVideosJson(value);
} catch (err) {
throw new RenderChunkValidationError(
INVALID_VIDEO_METADATA,
`[renderChunk] invalid meta/videos.json: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Result of {@link renderChunk}. The `sha256` field is the byte hash of the
* primary output (the mp4/mov file, or, for png-sequence, the sorted-frame
@@ -511,10 +527,11 @@ export async function renderChunk(
let planVideos: PlanVideosJson | null = null;
if (existsSync(videosJsonPath)) {
try {
planVideos = JSON.parse(readFileSync(videosJsonPath, "utf-8")) as PlanVideosJson;
planVideos = validatePlanVideosForChunk(JSON.parse(readFileSync(videosJsonPath, "utf-8")));
} catch (err) {
if (err instanceof RenderChunkValidationError) throw err;
throw new RenderChunkValidationError(
MISSING_PLAN_ARTIFACT,
INVALID_VIDEO_METADATA,
`[renderChunk] failed to parse ${videosJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "bun:test";
import {
INVALID_VIDEO_METADATA,
RenderChunkValidationError,
validatePlanVideosForChunk,
} from "./renderChunk.js";
describe("v1 chunk video metadata boundary", () => {
it("rejects legacy null timing before frame injection", () => {
let caught: unknown;
try {
validatePlanVideosForChunk({
videos: [
{
id: "hero",
src: "hero.mp4",
start: 0,
end: null,
mediaStart: 0,
loop: false,
hasAudio: false,
},
],
extracted: [],
});
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(RenderChunkValidationError);
expect((caught as RenderChunkValidationError).code).toBe(INVALID_VIDEO_METADATA);
expect((caught as Error).message).toContain("end must be a finite number");
});
});
@@ -60,6 +60,215 @@ export interface PlanVideosJson {
}>;
}
export const INVALID_VIDEO_METADATA = "INVALID_VIDEO_METADATA" as const;
/**
* Typed failure for the cross-process `meta/videos.json` contract.
*
* Plan v1 and Plan v2 share this metadata. Keeping the validation error in
* this storage-neutral module lets the v1 chunk reader fail closed while the
* v2 converter can wrap it in its own integrity-error contract.
*/
export class PlanVideosMetadataError extends Error {
// Read by cloud adapters across the package boundary to classify retries.
// fallow-ignore-next-line unused-class-member
readonly code = INVALID_VIDEO_METADATA;
constructor(message: string) {
super(message);
this.name = "PlanVideosMetadataError";
}
}
function metadataError(field: string, expectation: string): never {
throw new PlanVideosMetadataError(`${field} ${expectation}`);
}
function readRecord(value: unknown, field: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
metadataError(field, "must be an object");
}
return value as Record<string, unknown>;
}
function readNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.length === 0) {
metadataError(field, "must be a non-empty string");
}
return value;
}
function readString(value: unknown, field: string): string {
if (typeof value !== "string") {
metadataError(field, "must be a string");
}
return value;
}
function readFiniteNumber(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
metadataError(field, "must be a finite number");
}
return value;
}
function readBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
metadataError(field, "must be boolean");
}
return value;
}
function readPositiveInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
metadataError(field, "must be a positive integer");
}
return value;
}
function readNonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
metadataError(field, "must be a non-negative integer");
}
return value;
}
function readVideoMetadata(
value: unknown,
field: string,
): PlanVideosJson["extracted"][number]["metadata"] {
const record = readRecord(value, field);
let colorSpace: PlanVideosJson["extracted"][number]["metadata"]["colorSpace"] = null;
if (record.colorSpace !== null) {
const color = readRecord(record.colorSpace, `${field}.colorSpace`);
colorSpace = {
colorTransfer: readString(color.colorTransfer, `${field}.colorSpace.colorTransfer`),
colorPrimaries: readString(color.colorPrimaries, `${field}.colorSpace.colorPrimaries`),
colorSpace: readString(color.colorSpace, `${field}.colorSpace.colorSpace`),
};
}
return {
durationSeconds: readFiniteNumber(record.durationSeconds, `${field}.durationSeconds`),
videoStreamDurationSeconds: readFiniteNumber(
record.videoStreamDurationSeconds,
`${field}.videoStreamDurationSeconds`,
),
width: readPositiveInteger(record.width, `${field}.width`),
height: readPositiveInteger(record.height, `${field}.height`),
fps: readFiniteNumber(record.fps, `${field}.fps`),
videoCodec: readNonEmptyString(record.videoCodec, `${field}.videoCodec`),
hasAudio: readBoolean(record.hasAudio, `${field}.hasAudio`),
isVFR: readBoolean(record.isVFR, `${field}.isVFR`),
hasAlpha: readBoolean(record.hasAlpha, `${field}.hasAlpha`),
colorSpace,
};
}
/**
* Parse the untrusted on-disk `meta/videos.json` shape used by both plan
* protocols. In addition to field types, require a one-to-one relationship
* between declared videos and extracted-frame metadata: a distributed render
* cannot safely fall back to native remote video decoding when extraction
* failed on the planner.
*/
export function parsePlanVideosJson(value: unknown): PlanVideosJson {
const record = readRecord(value, "meta/videos.json");
if (!Array.isArray(record.videos) || !Array.isArray(record.extracted)) {
metadataError("meta/videos.json", "must contain videos and extracted arrays");
}
const videos = record.videos.map((value, index) => {
const field = `meta/videos.json.videos[${index}]`;
const video = readRecord(value, field);
return {
id: readNonEmptyString(video.id, `${field}.id`),
src: readNonEmptyString(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 = record.extracted.map((value, index) => {
const field = `meta/videos.json.extracted[${index}]`;
const entry = readRecord(value, field);
return {
videoId: readNonEmptyString(entry.videoId, `${field}.videoId`),
srcPath: readNonEmptyString(entry.srcPath, `${field}.srcPath`),
framePattern: readNonEmptyString(entry.framePattern, `${field}.framePattern`),
fps: readFiniteNumber(entry.fps, `${field}.fps`),
totalFrames: readNonNegativeInteger(entry.totalFrames, `${field}.totalFrames`),
metadata: readVideoMetadata(entry.metadata, `${field}.metadata`),
};
});
const videoIds = new Set<string>();
for (const video of videos) {
if (videoIds.has(video.id)) {
metadataError("meta/videos.json.videos", `contains duplicate id ${JSON.stringify(video.id)}`);
}
videoIds.add(video.id);
}
const extractedIds = new Set<string>();
for (const entry of extracted) {
if (extractedIds.has(entry.videoId)) {
metadataError(
"meta/videos.json.extracted",
`contains duplicate videoId ${JSON.stringify(entry.videoId)}`,
);
}
extractedIds.add(entry.videoId);
if (!videoIds.has(entry.videoId)) {
metadataError(
"meta/videos.json.extracted",
`references undeclared video ${JSON.stringify(entry.videoId)}`,
);
}
}
for (const video of videos) {
if (!extractedIds.has(video.id)) {
metadataError(
"meta/videos.json.extracted",
`is missing declared video ${JSON.stringify(video.id)}`,
);
}
}
return { videos, extracted };
}
/**
* Build the shared v1/v2 video metadata contract.
*
* Successful extraction normally replaces an open-ended video's `Infinity`
* with its finite natural source end. If duration probing/extraction could not
* derive that natural end, the last safe timing boundary is the already
* validated composition end. Authored finite ends are copied unchanged.
*/
export function buildPlanVideosJson(input: {
videos: readonly VideoElement[];
extracted: PlanVideosJson["extracted"];
compositionEnd: number;
}): PlanVideosJson {
const videos = input.videos.map((video, index) => {
if (Number.isFinite(video.end)) return { ...video };
if (
!Number.isFinite(input.compositionEnd) ||
input.compositionEnd <= 0 ||
input.compositionEnd <= video.start
) {
metadataError(
`meta/videos.json.videos[${index}].end`,
"cannot be resolved without a finite composition end after its start",
);
}
return { ...video, end: input.compositionEnd };
});
return parsePlanVideosJson({ videos, extracted: input.extracted });
}
const execFile = promisify(execFileCallback);
/**
@@ -0,0 +1,147 @@
import { describe, expect, it } from "bun:test";
import {
createFrameLookupTable,
type ExtractedFrames,
type VideoElement,
} from "@hyperframes/engine";
import {
buildPlanVideosJson,
parsePlanVideosJson,
PlanVideosMetadataError,
type PlanVideosJson,
} from "./shared.js";
function video(overrides: Partial<VideoElement> = {}): VideoElement {
return {
id: "hero",
src: "hero.mp4",
start: 2,
end: Number.POSITIVE_INFINITY,
mediaStart: 1,
loop: false,
hasAudio: false,
...overrides,
};
}
function extractedMetadata(
overrides: Partial<PlanVideosJson["extracted"][number]> = {},
): PlanVideosJson["extracted"][number] {
return {
videoId: "hero",
srcPath: "/plan/video-frames/hero",
framePattern: "frame_%05d.jpg",
fps: 2,
totalFrames: 4,
metadata: {
durationSeconds: 3,
videoStreamDurationSeconds: 3,
width: 16,
height: 16,
fps: 2,
videoCodec: "h264",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: null,
},
...overrides,
};
}
function extractedFrames(metadata: PlanVideosJson["extracted"][number]): ExtractedFrames {
return {
...metadata,
outputDir: metadata.srcPath,
framePaths: new Map([
[0, "frame-0.jpg"],
[1, "frame-1.jpg"],
[2, "frame-2.jpg"],
[3, "frame-3.jpg"],
]),
};
}
describe("distributed video metadata", () => {
it.each([false, true])("bounds an open-ended %s clip at the finite composition end", (loop) => {
const result = buildPlanVideosJson({
videos: [video({ loop })],
extracted: [extractedMetadata()],
compositionEnd: 8,
});
expect(result.videos[0]?.end).toBe(8);
expect(result.videos[0]?.loop).toBe(loop);
expect(JSON.stringify(result)).toContain('"end":8');
expect(JSON.stringify(result)).not.toContain('"end":null');
});
it("preserves authored and source-derived finite ends exactly", () => {
const authored = buildPlanVideosJson({
videos: [video({ end: 7 })],
extracted: [extractedMetadata()],
compositionEnd: 8,
});
const sourceDerived = buildPlanVideosJson({
// A 3s source trimmed by mediaStart=1 has 2s remaining: [2, 4].
videos: [video({ end: 4 })],
extracted: [extractedMetadata()],
compositionEnd: 8,
});
expect(authored.videos[0]?.end).toBe(7);
expect(sourceDerived.videos[0]?.end).toBe(4);
expect(sourceDerived.videos[0]?.mediaStart).toBe(1);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 0, 2])(
"fails closed when no safe composition boundary can be derived (%s)",
(compositionEnd) => {
expect(() =>
buildPlanVideosJson({
videos: [video()],
extracted: [extractedMetadata()],
compositionEnd,
}),
).toThrow(PlanVideosMetadataError);
},
);
it("rejects serialized null timing and missing extracted frames", () => {
const valid = buildPlanVideosJson({
videos: [video()],
extracted: [extractedMetadata()],
compositionEnd: 8,
});
expect(() =>
parsePlanVideosJson({
...valid,
videos: [{ ...valid.videos[0], end: null }],
}),
).toThrow(/end must be a finite number/);
expect(() => parsePlanVideosJson({ videos: valid.videos, extracted: [] })).toThrow(
/is missing declared video/,
);
});
it.each([
{ loop: false, expectedFrame: 3 },
{ loop: true, expectedFrame: 2 },
])(
"keeps v1 frame injection active through the bounded open-ended clip ($loop)",
({ loop, expectedFrame }) => {
const metadata = extractedMetadata();
const result = buildPlanVideosJson({
videos: [video({ loop })],
extracted: [metadata],
compositionEnd: 8,
});
const lookup = createFrameLookupTable(result.videos, [extractedFrames(metadata)]);
expect(lookup.getActiveFramePayloads(7).get("hero")?.frameIndex).toBe(expectedFrame);
expect(lookup.getFrame("hero", 8)).not.toBeNull();
expect(lookup.getFrame("hero", 8.01)).toBeNull();
},
);
});