Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/aws-lambda/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/aws-lambda/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/engine/src/services/audioMixer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), "<!DOCTYPE html><html><body>not audio</body></html>");

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-"));
Expand Down
21 changes: 21 additions & 0 deletions packages/engine/src/services/audioMixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
177 changes: 177 additions & 0 deletions packages/engine/src/utils/notMediaPayload.test.ts
Original file line number Diff line number Diff line change
@@ -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", "<!DOCTYPE html>\n<html><body></body></html>"],
["bare html tag, uppercase", "<HTML><body>hi</body></HTML>"],
[
"xml prolog",
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
],
// The prolog-less form is the common minified SVG shape, and the one a
// `<!doctype|<html|<?xml` prefix allowlist misses.
["prolog-less svg", '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'],
// 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", '<?xml version="1.0"?><Error><Code>AccessDenied</Code></Error>'],
["comment first", "<!-- generated -->\n<!DOCTYPE html>"],
// 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<!doctype html><html></html>"),
]),
);
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("<html>x</html>")]),
);
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("<html>", "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)}<!doctype html>`);
// 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("<html later on")]),
);
expect(await isNotMediaPayload(filePath)).toBe(false);
});

it("does not flag an empty file", async () => {
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", "<!DOCTYPE html><html></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",
'<!DOCTYPE html><html data-request-token="tok_9fA3xQ7pLz">',
);

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);
});
});
Loading