Skip to content

Commit 1555ff6

Browse files
committed
fix(producer): validate distributed video metadata
1 parent 3c857d7 commit 1555ff6

19 files changed

Lines changed: 672 additions & 104 deletions

examples/aws-lambda/template.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ Resources:
263263
- PlanTooLargeError
264264
- PLAN_PROTOCOL_UNSUPPORTED
265265
- PlanProtocolUnsupportedError
266+
- VIDEO_SOURCE_UNRENDERABLE
267+
- INVALID_VIDEO_METADATA
266268
- PLAN_ARTIFACT_DIGEST_MISMATCH
267269
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
268270
MaxAttempts: 0
@@ -305,6 +307,8 @@ Resources:
305307
- PLAN_PROTOCOL_UNSUPPORTED
306308
- PlanProtocolUnsupportedError
307309
- PLAN_V2_INTEGRITY_UNRECOVERABLE
310+
- VIDEO_SOURCE_UNRENDERABLE
311+
- INVALID_VIDEO_METADATA
308312
- PlanV2IntegrityError
309313
- PLAN_ARTIFACT_DIGEST_MISMATCH
310314
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
@@ -401,6 +405,7 @@ Resources:
401405
- PlanTooLargeError
402406
- PLAN_PROTOCOL_UNSUPPORTED
403407
- PlanProtocolUnsupportedError
408+
- INVALID_VIDEO_METADATA
404409
- PLAN_ARTIFACT_DIGEST_MISMATCH
405410
MaxAttempts: 0
406411
- ErrorEquals: [States.ALL]
@@ -497,6 +502,7 @@ Resources:
497502
- PLAN_PROTOCOL_UNSUPPORTED
498503
- PlanProtocolUnsupportedError
499504
- PLAN_V2_INTEGRITY_UNRECOVERABLE
505+
- INVALID_VIDEO_METADATA
500506
- PlanV2IntegrityError
501507
- PLAN_ARTIFACT_DIGEST_MISMATCH
502508
- ChromeBinaryUnavailableError

packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
7676
"PLAN_PROTOCOL_UNSUPPORTED",
7777
"PlanProtocolUnsupportedError",
7878
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
79+
"VIDEO_SOURCE_UNRENDERABLE",
80+
"INVALID_VIDEO_METADATA",
7981
"PlanV2IntegrityError",
8082
"PLAN_ARTIFACT_DIGEST_MISMATCH",
8183
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
@@ -209,6 +211,25 @@ describe("HyperframesRenderStack — snapshot", () => {
209211
}
210212
});
211213

214+
it("routes video failures consistently across SAM/CDK and both plan protocols", () => {
215+
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
216+
const v1 = getV1TaskStates(definition);
217+
const v2 = getV2TaskStates(definition);
218+
for (const planState of [v1.Plan, v2.PlanV2]) {
219+
const errors = new Set<string>();
220+
collectNonRetryableErrors(planState, errors);
221+
expect(errors.has("VIDEO_SOURCE_UNRENDERABLE")).toBe(true);
222+
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
223+
expect(errors.has("VIDEO_EXTRACTION_FAILED")).toBe(false);
224+
}
225+
for (const chunkState of [v1.RenderChunk, v2.RenderChunkV2]) {
226+
const errors = new Set<string>();
227+
collectNonRetryableErrors(chunkState, errors);
228+
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
229+
}
230+
}
231+
});
232+
212233
it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
213234
const { definition } = SYNTHED;
214235
const v1 = JSON.stringify({
@@ -271,6 +292,21 @@ function getV2TaskStates(definition: {
271292
};
272293
}
273294

295+
function getV1TaskStates(definition: {
296+
States: Record<string, unknown>;
297+
}): Record<"Plan" | "RenderChunk" | "Assemble", unknown> {
298+
const renderChunks = requireRecord(definition.States.RenderChunks, "RenderChunks state");
299+
const processor = isRecord(renderChunks.Iterator)
300+
? renderChunks.Iterator
301+
: requireRecord(renderChunks.ItemProcessor, "RenderChunks processor");
302+
const innerStates = requireRecord(processor.States, "RenderChunks processor states");
303+
return {
304+
Plan: definition.States.Plan,
305+
RenderChunk: innerStates.RenderChunk,
306+
Assemble: definition.States.Assemble,
307+
};
308+
}
309+
274310
function readSamDefinition(): { States: Record<string, unknown> } {
275311
const source = readFileSync(
276312
new URL("../../../../examples/aws-lambda/template.yaml", import.meta.url),

packages/aws-lambda/src/cdk/HyperframesRenderStack.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ export class HyperframesRenderStack extends Construct {
204204
"PLAN_PROTOCOL_UNSUPPORTED",
205205
"PlanProtocolUnsupportedError",
206206
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
207+
"VIDEO_SOURCE_UNRENDERABLE",
208+
"INVALID_VIDEO_METADATA",
207209
"PlanV2IntegrityError",
208210
"PLAN_ARTIFACT_DIGEST_MISMATCH",
209211
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
@@ -219,6 +221,7 @@ export class HyperframesRenderStack extends Construct {
219221
"PLAN_PROTOCOL_UNSUPPORTED",
220222
"PlanProtocolUnsupportedError",
221223
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
224+
"INVALID_VIDEO_METADATA",
222225
"PlanV2IntegrityError",
223226
"PLAN_ARTIFACT_DIGEST_MISMATCH",
224227
"ChromeBinaryUnavailableError",

packages/aws-lambda/src/handler.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { tmpdir } from "node:os";
2222
import { dirname, join } from "node:path";
2323
import {
2424
CURRENT_PLAN_PROTOCOL,
25+
PlanVideosMetadataError,
2526
type AssembleResult,
2627
type ChunkResult,
2728
type PlanResult,
@@ -243,19 +244,25 @@ describe("handler dispatch", () => {
243244
).toBe(true);
244245
});
245246

246-
it("normalizes producer terminal codes to Step Functions error names", async () => {
247+
it("normalizes producer workflow codes to Step Functions error names", async () => {
247248
for (const code of [
248249
"PLAN_TOO_LARGE",
249250
"PLAN_PROTOCOL_UNSUPPORTED",
250251
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
252+
"VIDEO_SOURCE_UNRENDERABLE",
253+
"VIDEO_EXTRACTION_FAILED",
254+
"INVALID_VIDEO_METADATA",
251255
] as const) {
252256
const tmpRoot = makeTmpRoot();
253257
const s3 = new FakeS3Client();
254258
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
255-
const terminal = Object.assign(new Error(`terminal: ${code}`), {
256-
code,
257-
name: "ProducerError",
258-
});
259+
const terminal =
260+
code === "INVALID_VIDEO_METADATA"
261+
? new PlanVideosMetadataError("test invalid plan video metadata")
262+
: Object.assign(new Error(`terminal: ${code}`), {
263+
code,
264+
name: "ProducerError",
265+
});
259266

260267
await expect(
261268
handler(

packages/aws-lambda/src/handler.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L
138138

139139
/**
140140
* AWS Lambda reports `Error.name` to Step Functions, while producer errors
141-
* expose stable machine codes separately. Normalize the terminal codes
141+
* expose stable machine codes separately. Normalize workflow-facing codes
142142
* whose historical class names differ from their orchestration contracts.
143143
*/
144144
// The explicit error-name mapping is the public Step Functions failure contract.
@@ -149,7 +149,10 @@ function normalizeTerminalErrorName(error: unknown): void {
149149
if (
150150
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
151151
candidate.code === "PLAN_TOO_LARGE" ||
152-
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
152+
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
153+
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
154+
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
155+
candidate.code === "INVALID_VIDEO_METADATA"
153156
) {
154157
candidate.name = candidate.code;
155158
}

packages/gcp-cloud-run/src/server.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
2222
import {
2323
CURRENT_PLAN_PROTOCOL,
2424
PLAN_V2_INTEGRITY_UNRECOVERABLE,
25+
PlanVideosMetadataError,
2526
PlanV2IntegrityError,
2627
PlanProtocolUnsupportedError,
2728
type AssembleResult,
@@ -574,6 +575,66 @@ describe("createApp HTTP mapping", () => {
574575
}
575576
});
576577

578+
it.each([
579+
["VIDEO_SOURCE_UNRENDERABLE", 400],
580+
["VIDEO_EXTRACTION_FAILED", 500],
581+
] as const)("routes producer video code %s with HTTP %s", async (code, expectedStatus) => {
582+
const gcs = new FakeGcs();
583+
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
584+
const app = createApp(
585+
depsWith(gcs, {
586+
renderChunk: async () => {
587+
throw Object.assign(new Error(`test ${code}`), {
588+
name: "ProducerError",
589+
code,
590+
});
591+
},
592+
}),
593+
);
594+
const res = await app.request("/", {
595+
method: "POST",
596+
headers: { "content-type": "application/json" },
597+
body: JSON.stringify({
598+
Action: "renderChunk",
599+
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
600+
PlanHash: PLAN_HASH,
601+
ChunkIndex: 0,
602+
ChunkOutputGcsPrefix: "gs://b/renders/r1/",
603+
Format: "mp4",
604+
}),
605+
});
606+
607+
expect(res.status).toBe(expectedStatus);
608+
const body = (await res.json()) as { error: string };
609+
expect(body.error).toBe(code);
610+
});
611+
612+
it("routes the real plan metadata error as non-retryable", async () => {
613+
const gcs = new FakeGcs();
614+
await seedProjectTar(gcs, "gs://b/sites/invalid-video-metadata/project.tar.gz");
615+
const app = createApp(
616+
depsWith(gcs, {
617+
plan: async () => {
618+
throw new PlanVideosMetadataError("test invalid plan video metadata");
619+
},
620+
}),
621+
);
622+
const res = await app.request("/", {
623+
method: "POST",
624+
headers: { "content-type": "application/json" },
625+
body: JSON.stringify({
626+
Action: "plan",
627+
ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz",
628+
PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/",
629+
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
630+
}),
631+
});
632+
633+
expect(res.status).toBe(400);
634+
const body = (await res.json()) as { error: string };
635+
expect(body.error).toBe("INVALID_VIDEO_METADATA");
636+
});
637+
577638
it("returns 500 for a retryable/unknown error", async () => {
578639
const gcs = new FakeGcs(); // plan tar NOT seeded → download fails (retryable)
579640
const app = createApp(depsWith(gcs));

packages/gcp-cloud-run/src/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ function normalizeTerminalErrorName(error: unknown): void {
182182
if (
183183
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
184184
candidate.code === "PLAN_TOO_LARGE" ||
185-
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
185+
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
186+
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
187+
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
188+
candidate.code === "INVALID_VIDEO_METADATA"
186189
) {
187190
candidate.name = candidate.code;
188191
}
@@ -899,6 +902,8 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
899902
"PLAN_ARTIFACT_DIGEST_MISMATCH",
900903
"PLAN_PROTOCOL_UNSUPPORTED",
901904
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
905+
"VIDEO_SOURCE_UNRENDERABLE",
906+
"INVALID_VIDEO_METADATA",
902907
// Producer error class names (`.name`) + their string code aliases — the
903908
// class sets `.name` to the class name but wraps a `code`; cover both so a
904909
// raw-code throw is caught too. Mirrors the AWS state machine's

packages/producer/src/distributed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export {
9494
type EffectiveChunkResult,
9595
// Error codes + classes
9696
FFMPEG_VERSION_MISMATCH,
97+
INVALID_VIDEO_METADATA,
9798
PLAN_HASH_MISMATCH,
9899
RenderChunkValidationError,
99100
} from "./services/distributed/renderChunk.js";
@@ -142,7 +143,7 @@ export {
142143
// ── Format union ────────────────────────────────────────────────────────────
143144
// Canonical output-format type. The aws-lambda package re-exports it so
144145
// CLI / adopter SDKs can derive runtime allowlists from one source.
145-
export type { DistributedFormat } from "./services/distributed/shared.js";
146+
export { PlanVideosMetadataError, type DistributedFormat } from "./services/distributed/shared.js";
146147

147148
// ── Plan-time shared types from `freezePlan` ───────────────────────────────
148149
// Re-exported so adopters that deserialize a planDir's `meta/encoder.json`

packages/producer/src/server.errorCode.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ describe("extractSafeRenderErrorCode", () => {
1919
expect(extractSafeRenderErrorCode({ code: "VIDEO_SOURCE_UNRENDERABLE" })).toBe(
2020
"VIDEO_SOURCE_UNRENDERABLE",
2121
);
22+
expect(extractSafeRenderErrorCode({ code: "INVALID_VIDEO_METADATA" })).toBe(
23+
"INVALID_VIDEO_METADATA",
24+
);
2225
});
2326

2427
it("does not forward arbitrary codes or parse message text", () => {

packages/producer/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ interface PreparedRenderInput {
119119

120120
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
121121
const SAFE_RENDER_ERROR_CODES = new Set<string>([
122+
"INVALID_VIDEO_METADATA",
122123
"VIDEO_SOURCE_UNRENDERABLE",
123124
"VIDEO_EXTRACTION_FAILED",
124125
]);

0 commit comments

Comments
 (0)