diff --git a/examples/aws-lambda/template.yaml b/examples/aws-lambda/template.yaml index 4aaad5b38b..7ae0b7f6f8 100644 --- a/examples/aws-lambda/template.yaml +++ b/examples/aws-lambda/template.yaml @@ -265,6 +265,8 @@ Resources: - PlanProtocolUnsupportedError - VIDEO_SOURCE_UNRENDERABLE - INVALID_VIDEO_METADATA + - NOT_MEDIA_PAYLOAD + - NotMediaPayloadError - PLAN_ARTIFACT_DIGEST_MISMATCH - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED MaxAttempts: 0 @@ -309,6 +311,8 @@ Resources: - PLAN_V2_INTEGRITY_UNRECOVERABLE - VIDEO_SOURCE_UNRENDERABLE - INVALID_VIDEO_METADATA + - NOT_MEDIA_PAYLOAD + - NotMediaPayloadError - PlanV2IntegrityError - PLAN_ARTIFACT_DIGEST_MISMATCH - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts index 1f528294dc..c7f27f503b 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts @@ -78,6 +78,8 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([ "PLAN_V2_INTEGRITY_UNRECOVERABLE", "VIDEO_SOURCE_UNRENDERABLE", "INVALID_VIDEO_METADATA", + "NOT_MEDIA_PAYLOAD", + "NotMediaPayloadError", "PlanV2IntegrityError", "PLAN_ARTIFACT_DIGEST_MISMATCH", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts index 1c371de246..baa7bd6b17 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts @@ -206,6 +206,8 @@ export class HyperframesRenderStack extends Construct { "PLAN_V2_INTEGRITY_UNRECOVERABLE", "VIDEO_SOURCE_UNRENDERABLE", "INVALID_VIDEO_METADATA", + "NOT_MEDIA_PAYLOAD", + "NotMediaPayloadError", "PlanV2IntegrityError", "PLAN_ARTIFACT_DIGEST_MISMATCH", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index e224fde6e0..bd10c12c20 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -152,6 +152,7 @@ function normalizeTerminalErrorName(error: unknown): void { candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" || candidate.code === "FONT_FETCH_FAILED" || candidate.code === "FONT_FETCH_UNAVAILABLE" || + candidate.code === "NOT_MEDIA_PAYLOAD" || candidate.code === "VIDEO_SOURCE_UNRENDERABLE" || candidate.code === "VIDEO_EXTRACTION_FAILED" || candidate.code === "INVALID_VIDEO_METADATA" diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7a16306591..5b5f71f7fb 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -273,6 +273,14 @@ export { type KeyframeAnalysis, } from "./utils/ffprobe.js"; +export { + NOT_MEDIA_PAYLOAD, + NotMediaPayloadError, + assertMediaPayload, + fingerprintElementId, + isNotMediaPayload, +} from "./utils/notMediaPayload.js"; + export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js"; export { runFfmpeg, diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index bc49a149ed..96f5a66069 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -115,6 +115,50 @@ describe("processCompositionAudio", () => { ]); }); + // STUDIO-5433: an audio src that resolved to an HTML/XML page (an unresolved + // nested-composition preview URL, or a 403/404 body served as a 200) skips the + // probe entirely when the element carries an authored duration, and used to + // surface as `prepare/ffmpeg_failed` with owner "system" — an authoring bug + // paged as a platform fault, after every frame had already been captured. + it("classifies a document audio source as a user-owned invalid media source", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "bgm.mp3"), "not audio"); + + const result = await processCompositionAudio( + [ + { + id: "bgm", + src: "bgm.mp3", + // Authored duration + loop is the shape that bypasses every probe. + start: 0, + end: 30, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 30, + ); + + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "source", + reason: "invalid_media", + owner: "user", + retryable: false, + elementId: "bgm", + }), + ]); + // Never reached ffmpeg: the whole point is failing before the work. + expect(runFfmpegMock).not.toHaveBeenCalled(); + }); + it("preserves muted tracks and uses unity master gain by default", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 1b063f3fd5..f27a9eabbb 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -9,6 +9,7 @@ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeF import { join, dirname } from "path"; import { parseHTML } from "linkedom"; import { extractAudioMetadata } from "../utils/ffprobe.js"; +import { isNotMediaPayload } from "../utils/notMediaPayload.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js"; @@ -733,6 +734,26 @@ export async function processCompositionAudio( return; } + // STUDIO-5433: an audio src that resolved to a text document (an + // unresolved nested-composition preview URL, or a 403/404 body served + // with a 200) never reaches the probe below when the element carries an + // authored duration or `loop`. It then fails inside ffmpeg as + // `prepare/ffmpeg_failed` with owner "system" — an authoring bug paged + // as a platform fault, after every frame has already been captured. + if (await isNotMediaPayload(srcPath)) { + failures.push({ + stage: "source", + reason: "invalid_media", + owner: "user", + retryable: false, + elementId: element.id, + detail: boundedDetail( + `Audio element ${element.id} source is a text document (HTML/XML/JSON), not media`, + ), + }); + return; + } + // Fallback: if no duration was specified, probe the actual file if (element.end - element.start <= 0) { let metadata; diff --git a/packages/engine/src/utils/notMediaPayload.test.ts b/packages/engine/src/utils/notMediaPayload.test.ts new file mode 100644 index 0000000000..bdfdb844a9 --- /dev/null +++ b/packages/engine/src/utils/notMediaPayload.test.ts @@ -0,0 +1,177 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + assertMediaPayload, + fingerprintElementId, + isNotMediaPayload, + NotMediaPayloadError, +} from "./notMediaPayload.js"; + +function writeFixture(name: string, contents: string | Buffer): string { + const dir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-")); + const filePath = join(dir, name); + writeFileSync(filePath, contents); + return filePath; +} + +describe("isNotMediaPayload", () => { + it.each([ + ["doctype", "\n"], + ["bare html tag, uppercase", "hi"], + [ + "xml prolog", + '', + ], + // The prolog-less form is the common minified SVG shape, and the one a + // `'], + // How a media URL most often downloads as XML in production: an expired + // signed URL or an ACL change, served as a 200 with an S3 error body. + ["s3 error document", 'AccessDenied'], + ["comment first", "\n"], + // Replicate answers a dead asset this way, and a gateway in front of it can + // relay the body with a 200 — the shape a `<` -only check misses. + ["json object error body", '{"detail": "requested file not found"}'], + ["json array body", '[{"error": "gone"}]'], + ])("detects %s", async (_label, contents) => { + expect(await isNotMediaPayload(writeFixture("payload", contents))).toBe(true); + }); + + it("detects a document behind a UTF-8 BOM and leading whitespace", async () => { + const filePath = writeFixture( + "bom.html", + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from("\n \t"), + ]), + ); + expect(await isNotMediaPayload(filePath)).toBe(true); + }); + + it("detects a document behind a leading NUL run", async () => { + const filePath = writeFixture( + "nul.html", + Buffer.concat([Buffer.from([0x00, 0x00, 0x00]), Buffer.from("x")]), + ); + expect(await isNotMediaPayload(filePath)).toBe(true); + }); + + it.each([ + ["little-endian", [0xff, 0xfe]], + ["big-endian", [0xfe, 0xff]], + ])("detects UTF-16 %s markup", async (_label, bom) => { + // UTF-16 interleaves NULs between ASCII bytes, so a utf8-decoded prefix + // comparison sees replacement characters and misses it entirely. + const body = Buffer.from("", "utf16le"); + const bytes = _label === "little-endian" ? body : body.swap16(); + const filePath = writeFixture("utf16.html", Buffer.concat([Buffer.from(bom), bytes])); + expect(await isNotMediaPayload(filePath)).toBe(true); + }); + + it("detects nothing when padding overruns the sniff window", async () => { + const filePath = writeFixture("padded.html", `${" ".repeat(600)}`); + // 600 bytes of padding overruns the 512-byte sniff window, so the verdict + // has to be "unknown" (false) rather than a misread — asserted here so the + // window size is a deliberate, visible bound rather than an accident. + expect(await isNotMediaPayload(filePath)).toBe(false); + }); + + it.each([ + ["mp4 / ftypmp42", [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32]], + ["matroska / webm EBML", [0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01]], + ["ogg", [0x4f, 0x67, 0x67, 0x53, 0x00, 0x02]], + ["riff / wav", [0x52, 0x49, 0x46, 0x46, 0x24, 0x08]], + ["mpeg-ts", [0x47, 0x40, 0x00, 0x10]], + ["flac", [0x66, 0x4c, 0x61, 0x43, 0x00]], + ["mp3 / ID3", [0x49, 0x44, 0x33, 0x03, 0x00]], + ["adts aac", [0xff, 0xf1, 0x50, 0x80]], + ["mpeg-ps", [0x00, 0x00, 0x01, 0xba]], + ])("does not flag a %s container", async (_label, bytes) => { + expect(await isNotMediaPayload(writeFixture("clip.bin", Buffer.from(bytes)))).toBe(false); + }); + + it("does not flag a container that merely contains a document byte further in", async () => { + const filePath = writeFixture( + "not-html.bin", + Buffer.concat([Buffer.from([0x00, 0x00, 0x01, 0xba]), Buffer.from(" { + expect(await isNotMediaPayload(writeFixture("empty.bin", ""))).toBe(false); + }); + + it("reports not-a-document instead of throwing when the path is a directory", async () => { + // `existsSync` passes for a directory, so callers reach the sniff with one. + // The read fails EISDIR; classifying rather than propagating keeps the real + // probe's own error as the one the caller sees. + const dir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-dir-")); + mkdirSync(join(dir, "assets")); + expect(await isNotMediaPayload(join(dir, "assets"))).toBe(false); + }); + + it("reports not-a-document instead of throwing when the file is missing", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-gone-")); + expect(await isNotMediaPayload(join(dir, "evicted.mp4"))).toBe(false); + }); +}); + +describe("assertMediaPayload", () => { + it("throws NotMediaPayloadError carrying routing metadata and a hashed element key", async () => { + const filePath = writeFixture("nested.html", ""); + + let caught: unknown; + try { + await assertMediaPayload(filePath, "aroll-scene-3"); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(NotMediaPayloadError); + const error = caught as NotMediaPayloadError; + expect(error.code).toBe("NOT_MEDIA_PAYLOAD"); + expect(error.owner).toBe("user"); + expect(error.retryable).toBe(false); + expect(error.elementFingerprints).toEqual([fingerprintElementId("aroll-scene-3")]); + expect(error.message).toContain(fingerprintElementId("aroll-scene-3")); + }); + + it("names both possible causes and leaks neither the src nor the payload bytes", async () => { + // `error.message` is forwarded to API clients over SSE/JSON, so a + // per-tenant CDN path or a token in the payload's first bytes must not + // reach it — and an on-call engineer must not be pointed at the authoring + // bug when a 403 error page is the actual cause. + const filePath = writeFixture( + "interstitial.html", + '', + ); + + const error = await assertMediaPayload( + filePath, + "https://cdn.example.com/tenants/acme-corp/projects/secret-q4/streamed-preview.html", + ).catch((caught: unknown) => caught as NotMediaPayloadError); + + expect(error.message).not.toContain("acme-corp"); + expect(error.message).not.toContain("secret-q4"); + expect(error.message).not.toContain("cdn.example.com"); + expect(error.message).not.toContain("tok_9fA3xQ7pLz"); + expect(error.message).toContain("unresolved"); + expect(error.message).toContain("success status"); + }); + + it("resolves for a real container", async () => { + const filePath = writeFixture("clip.mp4", Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74])); + await expect(assertMediaPayload(filePath, "v1")).resolves.toBeUndefined(); + }); + + it("caps the fingerprint list so the message stays bounded", async () => { + const error = new NotMediaPayloadError( + Array.from({ length: 12 }, (_unused, index) => fingerprintElementId(`el-${index}`)), + ); + expect(error.message).toContain("+4"); + expect(error.message.length).toBeLessThan(500); + }); +}); diff --git a/packages/engine/src/utils/notMediaPayload.ts b/packages/engine/src/utils/notMediaPayload.ts new file mode 100644 index 0000000000..446e37ad09 --- /dev/null +++ b/packages/engine/src/utils/notMediaPayload.ts @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; +import { open as openFile } from "node:fs/promises"; + +export const NOT_MEDIA_PAYLOAD = "NOT_MEDIA_PAYLOAD" as const; + +/** Cap the joined fingerprint list so the message stays bounded. */ +const MAX_LISTED_FINGERPRINTS = 8; + +/** + * Thrown when a file behind a `