diff --git a/examples/aws-lambda/template.yaml b/examples/aws-lambda/template.yaml
index 4aaad5b38..34b2a0143 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
+ - MARKUP_NOT_MEDIA
+ - MarkupNotMediaError
- 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
+ - MARKUP_NOT_MEDIA
+ - MarkupNotMediaError
- 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 1f528294d..defd4372b 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",
+ "MARKUP_NOT_MEDIA",
+ "MarkupNotMediaError",
"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 1c371de24..db74a8271 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",
+ "MARKUP_NOT_MEDIA",
+ "MarkupNotMediaError",
"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 e224fde6e..92fbc0c8b 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 === "MARKUP_NOT_MEDIA" ||
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 7a1630659..d4b9adb4a 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 {
+ MARKUP_NOT_MEDIA,
+ MarkupNotMediaError,
+ assertNotMarkupPayload,
+ fingerprintElementId,
+ isMarkupPayload,
+} from "./utils/markupPayload.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 bc49a149e..cb37baa10 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 markup 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 1b063f3fd..21b56fa71 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 { isMarkupPayload } from "../utils/markupPayload.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 an HTML/XML 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 isMarkupPayload(srcPath)) {
+ failures.push({
+ stage: "source",
+ reason: "invalid_media",
+ owner: "user",
+ retryable: false,
+ elementId: element.id,
+ detail: boundedDetail(
+ `Audio element ${element.id} source is a markup document (HTML/XML), 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/markupPayload.test.ts b/packages/engine/src/utils/markupPayload.test.ts
new file mode 100644
index 000000000..db90cf3e4
--- /dev/null
+++ b/packages/engine/src/utils/markupPayload.test.ts
@@ -0,0 +1,173 @@
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ assertNotMarkupPayload,
+ fingerprintElementId,
+ isMarkupPayload,
+ MarkupNotMediaError,
+} from "./markupPayload.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("isMarkupPayload", () => {
+ 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"],
+ ])("detects %s", async (_label, contents) => {
+ expect(await isMarkupPayload(writeFixture("payload", contents))).toBe(true);
+ });
+
+ it("detects markup 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 isMarkupPayload(filePath)).toBe(true);
+ });
+
+ it("detects markup behind a leading NUL run", async () => {
+ const filePath = writeFixture(
+ "nul.html",
+ Buffer.concat([Buffer.from([0x00, 0x00, 0x00]), Buffer.from("x")]),
+ );
+ expect(await isMarkupPayload(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 isMarkupPayload(filePath)).toBe(true);
+ });
+
+ it("detects markup preceded by more whitespace than a short read would cover", 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 isMarkupPayload(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 isMarkupPayload(writeFixture("clip.bin", Buffer.from(bytes)))).toBe(false);
+ });
+
+ it("does not flag a container that merely contains markup further in", async () => {
+ const filePath = writeFixture(
+ "not-html.bin",
+ Buffer.concat([Buffer.from([0x00, 0x00, 0x01, 0xba]), Buffer.from(" {
+ expect(await isMarkupPayload(writeFixture("empty.bin", ""))).toBe(false);
+ });
+
+ it("reports not-markup 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 isMarkupPayload(join(dir, "assets"))).toBe(false);
+ });
+
+ it("reports not-markup instead of throwing when the file is missing", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-gone-"));
+ expect(await isMarkupPayload(join(dir, "evicted.mp4"))).toBe(false);
+ });
+});
+
+describe("assertNotMarkupPayload", () => {
+ it("throws MarkupNotMediaError carrying routing metadata and a hashed element key", async () => {
+ const filePath = writeFixture("nested.html", "");
+
+ let caught: unknown;
+ try {
+ await assertNotMarkupPayload(filePath, "aroll-scene-3");
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught).toBeInstanceOf(MarkupNotMediaError);
+ const error = caught as MarkupNotMediaError;
+ expect(error.code).toBe("MARKUP_NOT_MEDIA");
+ 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 assertNotMarkupPayload(
+ filePath,
+ "https://cdn.example.com/tenants/acme-corp/projects/secret-q4/streamed-preview.html",
+ ).catch((caught: unknown) => caught as MarkupNotMediaError);
+
+ 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("403/404");
+ });
+
+ it("resolves for a real container", async () => {
+ const filePath = writeFixture("clip.mp4", Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74]));
+ await expect(assertNotMarkupPayload(filePath, "v1")).resolves.toBeUndefined();
+ });
+
+ it("caps the fingerprint list so the message stays bounded", async () => {
+ const error = new MarkupNotMediaError(
+ 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/markupPayload.ts b/packages/engine/src/utils/markupPayload.ts
new file mode 100644
index 000000000..5cbac187e
--- /dev/null
+++ b/packages/engine/src/utils/markupPayload.ts
@@ -0,0 +1,140 @@
+import { createHash } from "node:crypto";
+import { open as openFile } from "node:fs/promises";
+
+export const MARKUP_NOT_MEDIA = "MARKUP_NOT_MEDIA" as const;
+
+/** Cap the joined fingerprint list so the message stays bounded. */
+const MAX_LISTED_FINGERPRINTS = 8;
+
+/**
+ * Thrown when a file behind a `