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
+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();
},
);
});