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 `