mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(producer): classify JSON error bodies as non-media sources too
A source that answers with a JSON error body still reached ffprobe and
produced `moov atom not found`. Replicate returns
`{"detail": "requested file not found"}` for a dead asset, and a gateway
in front of it can relay that body with a success status.
The sniff now treats `<`, `{`, or `[` as the opening byte of a text
document. No supported container starts with any of them, so this is the
same trade as before: three bytes instead of an allowlist that grows one
entry per payload shape observed in production.
Renamed accordingly, since the class now covers JSON as well as markup:
MARKUP_NOT_MEDIA -> NOT_MEDIA_PAYLOAD, MarkupNotMediaError ->
NotMediaPayloadError, markupPayload.ts -> notMediaPayload.ts. Registry
entries in the Lambda name map, the CDK and SAM plan lists, the Cloud Run
set, and SAFE_RENDER_ERROR_CODES move with it.
Also documents the reachability boundary on the error class: only a 2xx
response gets here. `downloadToTemp` rejects 404/410 as `http_not_found`
before writing a byte, and every ffprobe input is local because
videoFrameExtractor downloads http srcs first. So the shapes this
classifies are soft-404 and interstitial HTML, S3/CloudFront error
documents, and JSON API error bodies -- each served with a success
status. A genuine 404 surfaces as a download failure, not as this error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f3689c1481
commit
349c066a83
@@ -265,8 +265,8 @@ Resources:
|
||||
- PlanProtocolUnsupportedError
|
||||
- VIDEO_SOURCE_UNRENDERABLE
|
||||
- INVALID_VIDEO_METADATA
|
||||
- MARKUP_NOT_MEDIA
|
||||
- MarkupNotMediaError
|
||||
- NOT_MEDIA_PAYLOAD
|
||||
- NotMediaPayloadError
|
||||
- PLAN_ARTIFACT_DIGEST_MISMATCH
|
||||
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
|
||||
MaxAttempts: 0
|
||||
@@ -311,8 +311,8 @@ Resources:
|
||||
- PLAN_V2_INTEGRITY_UNRECOVERABLE
|
||||
- VIDEO_SOURCE_UNRENDERABLE
|
||||
- INVALID_VIDEO_METADATA
|
||||
- MARKUP_NOT_MEDIA
|
||||
- MarkupNotMediaError
|
||||
- NOT_MEDIA_PAYLOAD
|
||||
- NotMediaPayloadError
|
||||
- PlanV2IntegrityError
|
||||
- PLAN_ARTIFACT_DIGEST_MISMATCH
|
||||
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
|
||||
|
||||
@@ -78,8 +78,8 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
|
||||
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"MARKUP_NOT_MEDIA",
|
||||
"MarkupNotMediaError",
|
||||
"NOT_MEDIA_PAYLOAD",
|
||||
"NotMediaPayloadError",
|
||||
"PlanV2IntegrityError",
|
||||
"PLAN_ARTIFACT_DIGEST_MISMATCH",
|
||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||
|
||||
@@ -206,8 +206,8 @@ export class HyperframesRenderStack extends Construct {
|
||||
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"MARKUP_NOT_MEDIA",
|
||||
"MarkupNotMediaError",
|
||||
"NOT_MEDIA_PAYLOAD",
|
||||
"NotMediaPayloadError",
|
||||
"PlanV2IntegrityError",
|
||||
"PLAN_ARTIFACT_DIGEST_MISMATCH",
|
||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||
|
||||
@@ -152,7 +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 === "NOT_MEDIA_PAYLOAD" ||
|
||||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
|
||||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
|
||||
candidate.code === "INVALID_VIDEO_METADATA"
|
||||
|
||||
@@ -274,12 +274,12 @@ export {
|
||||
} from "./utils/ffprobe.js";
|
||||
|
||||
export {
|
||||
MARKUP_NOT_MEDIA,
|
||||
MarkupNotMediaError,
|
||||
assertNotMarkupPayload,
|
||||
NOT_MEDIA_PAYLOAD,
|
||||
NotMediaPayloadError,
|
||||
assertMediaPayload,
|
||||
fingerprintElementId,
|
||||
isMarkupPayload,
|
||||
} from "./utils/markupPayload.js";
|
||||
isNotMediaPayload,
|
||||
} from "./utils/notMediaPayload.js";
|
||||
|
||||
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
export {
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("processCompositionAudio", () => {
|
||||
// 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 () => {
|
||||
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);
|
||||
|
||||
@@ -9,7 +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 { 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";
|
||||
@@ -734,13 +734,13 @@ export async function processCompositionAudio(
|
||||
return;
|
||||
}
|
||||
|
||||
// STUDIO-5433: an audio src that resolved to an HTML/XML document (an
|
||||
// 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 isMarkupPayload(srcPath)) {
|
||||
if (await isNotMediaPayload(srcPath)) {
|
||||
failures.push({
|
||||
stage: "source",
|
||||
reason: "invalid_media",
|
||||
@@ -748,7 +748,7 @@ export async function processCompositionAudio(
|
||||
retryable: false,
|
||||
elementId: element.id,
|
||||
detail: boundedDetail(
|
||||
`Audio element ${element.id} source is a markup document (HTML/XML), not media`,
|
||||
`Audio element ${element.id} source is a text document (HTML/XML/JSON), not media`,
|
||||
),
|
||||
});
|
||||
return;
|
||||
|
||||
+36
-32
@@ -3,11 +3,11 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertNotMarkupPayload,
|
||||
assertMediaPayload,
|
||||
fingerprintElementId,
|
||||
isMarkupPayload,
|
||||
MarkupNotMediaError,
|
||||
} from "./markupPayload.js";
|
||||
isNotMediaPayload,
|
||||
NotMediaPayloadError,
|
||||
} from "./notMediaPayload.js";
|
||||
|
||||
function writeFixture(name: string, contents: string | Buffer): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-"));
|
||||
@@ -16,7 +16,7 @@ function writeFixture(name: string, contents: string | Buffer): string {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("isMarkupPayload", () => {
|
||||
describe("isNotMediaPayload", () => {
|
||||
it.each([
|
||||
["doctype", "<!DOCTYPE html>\n<html><body></body></html>"],
|
||||
["bare html tag, uppercase", "<HTML><body>hi</body></HTML>"],
|
||||
@@ -31,11 +31,15 @@ describe("isMarkupPayload", () => {
|
||||
// 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 isMarkupPayload(writeFixture("payload", contents))).toBe(true);
|
||||
expect(await isNotMediaPayload(writeFixture("payload", contents))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects markup behind a UTF-8 BOM and leading whitespace", async () => {
|
||||
it("detects a document behind a UTF-8 BOM and leading whitespace", async () => {
|
||||
const filePath = writeFixture(
|
||||
"bom.html",
|
||||
Buffer.concat([
|
||||
@@ -43,15 +47,15 @@ describe("isMarkupPayload", () => {
|
||||
Buffer.from("\n \t<!doctype html><html></html>"),
|
||||
]),
|
||||
);
|
||||
expect(await isMarkupPayload(filePath)).toBe(true);
|
||||
expect(await isNotMediaPayload(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects markup behind a leading NUL run", async () => {
|
||||
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 isMarkupPayload(filePath)).toBe(true);
|
||||
expect(await isNotMediaPayload(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -63,15 +67,15 @@ describe("isMarkupPayload", () => {
|
||||
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 isMarkupPayload(filePath)).toBe(true);
|
||||
expect(await isNotMediaPayload(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects markup preceded by more whitespace than a short read would cover", async () => {
|
||||
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 isMarkupPayload(filePath)).toBe(false);
|
||||
expect(await isNotMediaPayload(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -85,50 +89,50 @@ describe("isMarkupPayload", () => {
|
||||
["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);
|
||||
expect(await isNotMediaPayload(writeFixture("clip.bin", Buffer.from(bytes)))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag a container that merely contains markup further in", async () => {
|
||||
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 isMarkupPayload(filePath)).toBe(false);
|
||||
expect(await isNotMediaPayload(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag an empty file", async () => {
|
||||
expect(await isMarkupPayload(writeFixture("empty.bin", ""))).toBe(false);
|
||||
expect(await isNotMediaPayload(writeFixture("empty.bin", ""))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports not-markup instead of throwing when the path is a directory", async () => {
|
||||
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 isMarkupPayload(join(dir, "assets"))).toBe(false);
|
||||
expect(await isNotMediaPayload(join(dir, "assets"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports not-markup instead of throwing when the file is missing", async () => {
|
||||
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 isMarkupPayload(join(dir, "evicted.mp4"))).toBe(false);
|
||||
expect(await isNotMediaPayload(join(dir, "evicted.mp4"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNotMarkupPayload", () => {
|
||||
it("throws MarkupNotMediaError carrying routing metadata and a hashed element key", async () => {
|
||||
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 assertNotMarkupPayload(filePath, "aroll-scene-3");
|
||||
await assertMediaPayload(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(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")]);
|
||||
@@ -145,26 +149,26 @@ describe("assertNotMarkupPayload", () => {
|
||||
'<!DOCTYPE html><html data-request-token="tok_9fA3xQ7pLz">',
|
||||
);
|
||||
|
||||
const error = await assertNotMarkupPayload(
|
||||
const error = await assertMediaPayload(
|
||||
filePath,
|
||||
"https://cdn.example.com/tenants/acme-corp/projects/secret-q4/streamed-preview.html",
|
||||
).catch((caught: unknown) => caught as MarkupNotMediaError);
|
||||
).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("403/404");
|
||||
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(assertNotMarkupPayload(filePath, "v1")).resolves.toBeUndefined();
|
||||
await expect(assertMediaPayload(filePath, "v1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps the fingerprint list so the message stays bounded", async () => {
|
||||
const error = new MarkupNotMediaError(
|
||||
const error = new NotMediaPayloadError(
|
||||
Array.from({ length: 12 }, (_unused, index) => fingerprintElementId(`el-${index}`)),
|
||||
);
|
||||
expect(error.message).toContain("+4");
|
||||
+45
-33
@@ -1,14 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { open as openFile } from "node:fs/promises";
|
||||
|
||||
export const MARKUP_NOT_MEDIA = "MARKUP_NOT_MEDIA" as const;
|
||||
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 `<video>`/`<audio>` src is a markup document
|
||||
* (HTML, XML, SVG) rather than a media container.
|
||||
* Thrown when a file behind a `<video>`/`<audio>` src is a text document
|
||||
* (HTML, XML, SVG, JSON) rather than a media container.
|
||||
*
|
||||
* Motivating incident: STUDIO-5433 — an authoring bug produced an a-roll
|
||||
* element whose src pointed at a `streamed-preview.html` URL that downloaded
|
||||
@@ -17,16 +17,24 @@ const MAX_LISTED_FINGERPRINTS = 8;
|
||||
* cause (the `mov,mp4,…` prefix is ffprobe's demuxer probe order, not the
|
||||
* file's true format), so the alert routed as an ffmpeg/codec bug.
|
||||
*
|
||||
* Deliberately says nothing about *why* the payload is markup: an unresolved
|
||||
* nested-composition URL and a CDN error page served with a 200 both land
|
||||
* here, and naming only the first sends on-call after the wrong cause.
|
||||
* Only a 2xx response reaches this classifier. `downloadToTemp` rejects a
|
||||
* 404/410 as `http_not_found` before writing a byte (urlDownloader.ts
|
||||
* `classifyHttpFailure`), and every ffprobe input is local — an http src is
|
||||
* downloaded first. So the shapes that land here are a soft-404 or
|
||||
* interstitial HTML body, an S3/CloudFront error document, or a JSON API error
|
||||
* body, each served with a success status.
|
||||
*
|
||||
* Deliberately says nothing about *why* the payload is a document: an
|
||||
* unresolved nested-composition URL and a CDN error page served with a 200
|
||||
* both land here, and naming only the first sends on-call after the wrong
|
||||
* cause.
|
||||
*
|
||||
* Message discipline mirrors `AssetMediaTypeMismatchError`: bounded text with
|
||||
* hashed element correlation keys, never the authored src or the payload's own
|
||||
* bytes — producer forwards `error.message` to API clients.
|
||||
*/
|
||||
export class MarkupNotMediaError extends Error {
|
||||
readonly code = MARKUP_NOT_MEDIA;
|
||||
export class NotMediaPayloadError extends Error {
|
||||
readonly code = NOT_MEDIA_PAYLOAD;
|
||||
readonly owner = "user" as const;
|
||||
readonly retryable = false as const;
|
||||
readonly elementFingerprints: readonly string[];
|
||||
@@ -35,12 +43,13 @@ export class MarkupNotMediaError extends Error {
|
||||
const listed = elementFingerprints.slice(0, MAX_LISTED_FINGERPRINTS).join(",");
|
||||
const elided = elementFingerprints.length - MAX_LISTED_FINGERPRINTS;
|
||||
super(
|
||||
`${elementFingerprints.length} media source(s) are markup documents (HTML/XML), not media ` +
|
||||
`containers [elements=${listed}${elided > 0 ? `,+${elided}` : ""}]. Either an unresolved ` +
|
||||
`${elementFingerprints.length} media source(s) are text documents ` +
|
||||
`(HTML/XML/JSON), not media containers ` +
|
||||
`[elements=${listed}${elided > 0 ? `,+${elided}` : ""}]. Either an unresolved ` +
|
||||
"nested-composition preview URL was authored as a media src, or the source answered with " +
|
||||
"an HTML/XML error page (expired signed URL, or a 403/404 body served as 200).",
|
||||
"an error page or API error body carrying a success status.",
|
||||
);
|
||||
this.name = "MarkupNotMediaError";
|
||||
this.name = "NotMediaPayloadError";
|
||||
this.elementFingerprints = elementFingerprints;
|
||||
}
|
||||
}
|
||||
@@ -51,8 +60,10 @@ export function fingerprintElementId(elementId: string): string {
|
||||
}
|
||||
|
||||
const SNIFF_BYTES = 512;
|
||||
const ASCII_LT = 0x3c;
|
||||
// NUL is skippable so UTF-16-encoded markup (`3C 00 68 00 …`) is caught, and so
|
||||
// `<` opens HTML/XML/SVG and an S3 `<Error>` body; `{` and `[` open a JSON API
|
||||
// error body, which some gateways return with a 200.
|
||||
const DOCUMENT_OPENING_BYTES = new Set([0x3c, 0x7b, 0x5b]);
|
||||
// NUL is skippable so UTF-16-encoded text (`3C 00 68 00 …`) is caught, and so
|
||||
// is a payload padded with NULs. No supported container is defeated by this:
|
||||
// mp4/mov open with a box size whose first non-NUL byte is the size itself, and
|
||||
// MPEG-PS `00 00 01 BA` stops at 0x01.
|
||||
@@ -91,50 +102,51 @@ async function readHead(filePath: string): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
function startsWithMarkupByte(head: Buffer): boolean {
|
||||
function startsWithDocumentByte(head: Buffer): boolean {
|
||||
for (let index = byteOrderMarkLength(head); index < head.length; index++) {
|
||||
const byte = head[index];
|
||||
if (byte === undefined) break;
|
||||
if (SKIPPABLE_LEADING_BYTES.has(byte)) continue;
|
||||
return byte === ASCII_LT;
|
||||
return DOCUMENT_OPENING_BYTES.has(byte);
|
||||
}
|
||||
// Empty or all-whitespace: not this classifier's call.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the file's first meaningful byte is `<` — i.e. the payload is a
|
||||
* markup document, not a media container. BOM- and whitespace-tolerant.
|
||||
* True when the file's first meaningful byte opens a text document (`<`, `{`,
|
||||
* `[`) rather than a media container. BOM- and whitespace-tolerant.
|
||||
*
|
||||
* One prefix byte replaces an allowlist of markup shapes: `<!doctype`,
|
||||
* `<html`, `<?xml`, a prolog-less `<svg`, and an S3 `<Error>` body all begin
|
||||
* with `<`, and no container this pipeline supports does — mp4/mov start with
|
||||
* a box size, Matroska/WebM `1A 45 DF A3`, Ogg `OggS`, RIFF `RIFF`, MPEG-TS
|
||||
* `0x47`, FLAC `fLaC`, ADTS `FF Fx`, MP3 `ID3`. An allowlist would need a new
|
||||
* entry every time a new payload shape shows up in production.
|
||||
* Three bytes replace an allowlist of document shapes: `<!doctype`, `<html`,
|
||||
* `<?xml`, a prolog-less `<svg`, an S3 `<Error>` body, and a JSON
|
||||
* `{"detail": "requested file not found"}` all start with one of them, and no
|
||||
* container this pipeline supports does — mp4/mov start with a box size,
|
||||
* Matroska/WebM `1A 45 DF A3`, Ogg `OggS`, RIFF `RIFF`, MPEG-TS `0x47`, FLAC
|
||||
* `fLaC`, ADTS `FF Fx`, MP3 `ID3`. An allowlist would need a new entry every
|
||||
* time a new payload shape shows up in production.
|
||||
*
|
||||
* Never throws: this is a classifier, not a gate. An unreadable file (EACCES,
|
||||
* EISDIR, a temp file evicted between `existsSync` and here) reports "not
|
||||
* markup" so the real probe still produces the real error, exactly as it did
|
||||
* EISDIR, a temp file evicted between `existsSync` and here) reports "not a
|
||||
* document" so the real probe still produces the real error, exactly as it did
|
||||
* before the sniff existed.
|
||||
*/
|
||||
export async function isMarkupPayload(filePath: string): Promise<boolean> {
|
||||
export async function isNotMediaPayload(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return startsWithMarkupByte(await readHead(filePath));
|
||||
return startsWithDocumentByte(await readHead(filePath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw {@link MarkupNotMediaError} if `filePath` is a markup payload.
|
||||
* Throw {@link NotMediaPayloadError} if `filePath` is a text document.
|
||||
*
|
||||
* Only for sources whose element type can never legitimately be markup —
|
||||
* Only for sources whose element type can never legitimately be one —
|
||||
* `<video>` and `<audio>`. An `<img>` src may be an SVG, which ffprobe reads
|
||||
* through its `svg_pipe` demuxer, so image sources must not be sniffed.
|
||||
*/
|
||||
export async function assertNotMarkupPayload(filePath: string, elementId: string): Promise<void> {
|
||||
if (await isMarkupPayload(filePath)) {
|
||||
throw new MarkupNotMediaError([fingerprintElementId(elementId)]);
|
||||
export async function assertMediaPayload(filePath: string, elementId: string): Promise<void> {
|
||||
if (await isNotMediaPayload(filePath)) {
|
||||
throw new NotMediaPayloadError([fingerprintElementId(elementId)]);
|
||||
}
|
||||
}
|
||||
@@ -906,13 +906,13 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
|
||||
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"MARKUP_NOT_MEDIA",
|
||||
"NOT_MEDIA_PAYLOAD",
|
||||
// Producer error class names (`.name`) + their string code aliases — the
|
||||
// class sets `.name` to the class name but wraps a `code`; cover both so a
|
||||
// raw-code throw is caught too. Mirrors the AWS state machine's
|
||||
// non-retryable list.
|
||||
"FormatNotSupportedInDistributedError",
|
||||
"MarkupNotMediaError",
|
||||
"NotMediaPayloadError",
|
||||
"PlanTooLargeError",
|
||||
"PlanProtocolUnsupportedError",
|
||||
"PlanV2IntegrityError",
|
||||
|
||||
+4
-4
@@ -1,17 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MarkupNotMediaError } from "@hyperframes/engine";
|
||||
import { NotMediaPayloadError } from "@hyperframes/engine";
|
||||
import { extractSafeRenderErrorMetadata } from "./server.js";
|
||||
|
||||
// Kept out of server.errorCode.test.ts so that suite keeps exactly one typed
|
||||
// error class in scope: two same-shaped classes there defeat the dead-code
|
||||
// analyzer's member resolution and it reports the sibling's fields as unused.
|
||||
describe("extractSafeRenderErrorMetadata — markup payloads", () => {
|
||||
describe("extractSafeRenderErrorMetadata — non-media payloads", () => {
|
||||
it("transports the code, owner, and retry policy", () => {
|
||||
// Without the SAFE_RENDER_ERROR_CODES entry the API emits
|
||||
// `errorCode: undefined` and the failure is indistinguishable from an
|
||||
// untyped crash — as unroutable as the `moov atom not found` it replaces.
|
||||
expect(extractSafeRenderErrorMetadata(new MarkupNotMediaError(["0123456789abcdef"]))).toEqual({
|
||||
errorCode: "MARKUP_NOT_MEDIA",
|
||||
expect(extractSafeRenderErrorMetadata(new NotMediaPayloadError(["0123456789abcdef"]))).toEqual({
|
||||
errorCode: "NOT_MEDIA_PAYLOAD",
|
||||
errorOwner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
@@ -121,7 +121,7 @@ interface PreparedRenderInput {
|
||||
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
|
||||
const SAFE_RENDER_ERROR_CODES = new Set<string>([
|
||||
"ASSET_MEDIA_TYPE_MISMATCH",
|
||||
"MARKUP_NOT_MEDIA",
|
||||
"NOT_MEDIA_PAYLOAD",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"VIDEO_EXTRACTION_FAILED",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MARKUP_NOT_MEDIA, MarkupNotMediaError } from "@hyperframes/engine";
|
||||
import { NOT_MEDIA_PAYLOAD, NotMediaPayloadError } from "@hyperframes/engine";
|
||||
import {
|
||||
ASSET_MEDIA_TYPE_MISMATCH,
|
||||
AssetMediaTypeMismatchError,
|
||||
@@ -193,11 +193,11 @@ describe("preflightCompositionAssetMediaTypes", () => {
|
||||
});
|
||||
|
||||
// STUDIO-5433. This preflight sees every local media src regardless of its
|
||||
// authored timing, so it is the only place a markup payload behind a
|
||||
// authored timing, so it is the only place a document payload behind a
|
||||
// `data-end` video or a `loop`ing audio is caught before frames are captured
|
||||
// — for those elements the compiler never resolves a duration, so its own
|
||||
// sniff never runs.
|
||||
describe("markup payloads", () => {
|
||||
describe("non-media payloads", () => {
|
||||
beforeAll(() => {
|
||||
writeFileSync(
|
||||
join(projectDir, "streamed-preview.html"),
|
||||
@@ -216,9 +216,9 @@ describe("preflightCompositionAssetMediaTypes", () => {
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MarkupNotMediaError);
|
||||
expect(caught).toBeInstanceOf(NotMediaPayloadError);
|
||||
expect(caught).toMatchObject({
|
||||
code: MARKUP_NOT_MEDIA,
|
||||
code: NOT_MEDIA_PAYLOAD,
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
@@ -227,11 +227,11 @@ describe("preflightCompositionAssetMediaTypes", () => {
|
||||
expect(message).not.toContain(fixtureDir);
|
||||
});
|
||||
|
||||
it("reports markup ahead of the type mismatch the same file also produces", async () => {
|
||||
it("reports the document verdict ahead of the type mismatch the same file also produces", async () => {
|
||||
// An HTML page under a <video> is both "not media" and "not video". The
|
||||
// markup verdict is the actionable one; the mismatch is a symptom of it.
|
||||
// document verdict is the actionable one; the mismatch is a symptom of it.
|
||||
await expect(run({ videoSrc: "streamed-preview.html" })).rejects.toBeInstanceOf(
|
||||
MarkupNotMediaError,
|
||||
NotMediaPayloadError,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("preflightCompositionAssetMediaTypes", () => {
|
||||
});
|
||||
|
||||
it("leaves an SVG image source alone", async () => {
|
||||
// ffprobe reads SVG through its svg_pipe demuxer, so markup is a
|
||||
// ffprobe reads SVG through its svg_pipe demuxer, so an XML body is a
|
||||
// legitimate <img> payload and must not be swept up by the sniff.
|
||||
await expect(run({ imageSrc: "brand-mark.svg" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
fingerprintElementId,
|
||||
isMarkupPayload,
|
||||
MarkupNotMediaError,
|
||||
isNotMediaPayload,
|
||||
NotMediaPayloadError,
|
||||
probeMediaProfile,
|
||||
resolveProjectRelativeSrc,
|
||||
type AudioElement,
|
||||
@@ -131,13 +131,13 @@ export async function preflightCompositionAssetMediaTypes(input: {
|
||||
}
|
||||
|
||||
const mismatches: AssetMediaTypeMismatch[] = [];
|
||||
const markupFingerprints: string[] = [];
|
||||
const notMediaFingerprints: string[] = [];
|
||||
const entries = [...byPath];
|
||||
await Promise.all(
|
||||
entries.map(([resolvedPath, pathReferences]) =>
|
||||
withMediaProbeSlot(async () => {
|
||||
// STUDIO-5433. This preflight is the only place every local media src is
|
||||
// seen regardless of its authored timing, so it is where a markup
|
||||
// seen regardless of its authored timing, so it is where a document
|
||||
// payload behind a `data-end` video gets caught — the compiler's own
|
||||
// sniff only runs for elements whose duration it has to resolve.
|
||||
//
|
||||
@@ -146,9 +146,11 @@ export async function preflightCompositionAssetMediaTypes(input: {
|
||||
// source is non-fatal by existing policy (audioStage ships the render
|
||||
// without the track and reports `audioError`), so it is classified
|
||||
// per-element in audioMixer instead of aborted here.
|
||||
const markupCandidates = pathReferences.filter((ref) => ref.expected === "video");
|
||||
if (markupCandidates.length > 0 && (await isMarkupPayload(resolvedPath))) {
|
||||
markupFingerprints.push(...markupCandidates.map((ref) => fingerprintElementId(ref.id)));
|
||||
const sniffableReferences = pathReferences.filter((ref) => ref.expected === "video");
|
||||
if (sniffableReferences.length > 0 && (await isNotMediaPayload(resolvedPath))) {
|
||||
notMediaFingerprints.push(
|
||||
...sniffableReferences.map((ref) => fingerprintElementId(ref.id)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,6 +175,6 @@ export async function preflightCompositionAssetMediaTypes(input: {
|
||||
|
||||
// Thrown ahead of the mismatch aggregate: "this file is an HTML page" is the
|
||||
// actionable diagnosis, while the type mismatch it also produces is a symptom.
|
||||
if (markupFingerprints.length > 0) throw new MarkupNotMediaError(markupFingerprints);
|
||||
if (notMediaFingerprints.length > 0) throw new NotMediaPayloadError(notMediaFingerprints);
|
||||
if (mismatches.length > 0) throw new AssetMediaTypeMismatchError(mismatches);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
|
||||
import { defaultLogger } from "../logger.js";
|
||||
import { MarkupNotMediaError } from "@hyperframes/engine";
|
||||
import { NotMediaPayloadError } from "@hyperframes/engine";
|
||||
import {
|
||||
collectExternalAssets,
|
||||
compileForRender,
|
||||
@@ -2278,12 +2278,12 @@ describe("sub-composition variable injection (render path, #2064)", () => {
|
||||
// from ffprobe — the `mov,mp4,…` prefix is ffprobe's demuxer probe order, NOT
|
||||
// the file's true format, so every alert routed as a codec/ffmpeg bug. The
|
||||
// byte-level sniff itself is unit-tested in
|
||||
// `engine/src/utils/markupPayload.test.ts`; what is pinned here is the
|
||||
// `engine/src/utils/notMediaPayload.test.ts`; what is pinned here is the
|
||||
// compiler's handling of the verdict, which differs by element type.
|
||||
|
||||
describe("compileForRender markup sniff (STUDIO-5433)", () => {
|
||||
describe("compileForRender non-media payload sniff (STUDIO-5433)", () => {
|
||||
function writeProject(mediaTag: string): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-e2e-"));
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-payload-sniff-e2e-"));
|
||||
mkdirSync(join(projectDir, "assets"));
|
||||
writeFileSync(
|
||||
join(projectDir, "assets", "nested.html"),
|
||||
@@ -2307,7 +2307,7 @@ describe("compileForRender markup sniff (STUDIO-5433)", () => {
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
it("aborts with MarkupNotMediaError before ffprobe when a <video> src is an HTML payload", async () => {
|
||||
it("aborts with NotMediaPayloadError before ffprobe when a <video> src is an HTML payload", async () => {
|
||||
// Mimics STUDIO-5433: an a-roll element whose src points at a legitimate
|
||||
// 6.5 KB `<!DOCTYPE html>` preview page instead of the rendered MP4.
|
||||
const projectDir = writeProject(
|
||||
@@ -2320,11 +2320,11 @@ describe("compileForRender markup sniff (STUDIO-5433)", () => {
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MarkupNotMediaError);
|
||||
const err = caught as MarkupNotMediaError;
|
||||
expect(caught).toBeInstanceOf(NotMediaPayloadError);
|
||||
const err = caught as NotMediaPayloadError;
|
||||
// Routing metadata, not just a readable string: these are what the server's
|
||||
// SAFE_RENDER_ERROR_CODES allowlist and the distributed retry sets key off.
|
||||
expect(err.code).toBe("MARKUP_NOT_MEDIA");
|
||||
expect(err.code).toBe("NOT_MEDIA_PAYLOAD");
|
||||
expect(err.owner).toBe("user");
|
||||
expect(err.retryable).toBe(false);
|
||||
// Correlation is the hashed element id — the authored src never reaches a
|
||||
@@ -2336,7 +2336,7 @@ describe("compileForRender markup sniff (STUDIO-5433)", () => {
|
||||
// error and this assertion would fail.
|
||||
});
|
||||
|
||||
it("drops an <audio> markup payload to duration 0 and warns instead of failing the render", async () => {
|
||||
it("drops an <audio> document payload to duration 0 and warns instead of failing the render", async () => {
|
||||
// The audio/video split is the compiler's contract: an unprobeable audio
|
||||
// src is excluded from the render, and only video surfaces its probe
|
||||
// failure. A hard abort here would take down renders that used to succeed
|
||||
@@ -2355,7 +2355,7 @@ describe("compileForRender markup sniff (STUDIO-5433)", () => {
|
||||
);
|
||||
|
||||
expect(compiled.html).not.toContain('id="a1" src="assets/nested.html" data-end');
|
||||
expect(warnings.join("\n")).toContain("HTML/XML document");
|
||||
expect(warnings.join("\n")).toContain("text document");
|
||||
expect(warnings.join("\n")).toContain("a1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,8 +51,8 @@ import {
|
||||
type AudioVolumeKeyframe,
|
||||
type MediaProbeProfile,
|
||||
analyzeKeyframeIntervals,
|
||||
assertNotMarkupPayload,
|
||||
MarkupNotMediaError,
|
||||
assertMediaPayload,
|
||||
NotMediaPayloadError,
|
||||
probeMediaProfile,
|
||||
} from "@hyperframes/engine";
|
||||
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
@@ -439,25 +439,25 @@ async function resolveMediaDuration(
|
||||
return withMediaProbeSlot(async () => {
|
||||
let profile: MediaProbeProfile;
|
||||
try {
|
||||
// Markup sniff (STUDIO-5433): if an authoring bug hands us an HTML/XML
|
||||
// Payload sniff (STUDIO-5433): if an authoring bug hands us a text
|
||||
// payload (e.g. an unresolved nested-composition preview URL), fail with
|
||||
// a typed MarkupNotMediaError instead of letting ffprobe emit an opaque
|
||||
// a typed NotMediaPayloadError instead of letting ffprobe emit an opaque
|
||||
// `[mov,mp4,...] moov atom not found` that routes as a codec bug.
|
||||
// Deliberately inside this try: the audio/video split below is the
|
||||
// contract, so a bad audio src must still degrade to duration 0 rather
|
||||
// than take down the whole render.
|
||||
await assertNotMarkupPayload(filePath, elementIdentity);
|
||||
await assertMediaPayload(filePath, elementIdentity);
|
||||
profile = await probeMediaProfile(filePath);
|
||||
} catch (error) {
|
||||
// Preserve the historical split: invalid video sources surface their
|
||||
// probe failure, while invalid/unreadable audio sources resolve to zero
|
||||
// duration and are excluded by the compiler.
|
||||
if (tagName !== "video") {
|
||||
if (error instanceof MarkupNotMediaError) {
|
||||
if (error instanceof NotMediaPayloadError) {
|
||||
// Dropping it silently is what let STUDIO-5433 resurface downstream
|
||||
// as `prepare/ffmpeg_failed` with owner "system".
|
||||
log?.warn(
|
||||
`[compile] Audio "${elementIdentity}" (${src}) is an HTML/XML document, not a media ` +
|
||||
`[compile] Audio "${elementIdentity}" (${src}) is a text document, not a media ` +
|
||||
"file — the element is dropped from the render. Point it at a rendered media file.",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user