mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(producer): scope and harden the markup-payload sniff
Review follow-up on the STUDIO-5433 defense.
Correctness
- The sniff ran above the documented video/audio failure split, so an
<audio> src that resolved to an HTML payload aborted the whole render
instead of degrading to duration 0. It now runs inside the same try, so
video surfaces the typed error while audio still drops out, with a
warning naming the element.
- Raw fs errors (EISDIR on a directory src, EACCES, the existsSync->open
ENOENT race, EMFILE) escaped and failed the compile with an unclassified
error carrying an unredacted temp path. The sniff is now a classifier that
never throws: an unreadable file reports "not markup" and the real probe
produces the real error.
- Elements whose duration the compiler never resolves (a data-end video, a
looping audio) skipped the sniff entirely, so the original ffprobe error
still escaped, and looping audio was reported as owner "system" after
every frame had been captured. Video is now caught in the asset preflight,
which sees every local src regardless of authored timing; audio is
classified per-element in audioMixer as source/invalid_media/owner "user",
keeping audio failures non-fatal as they already were.
- Detection is a byte-level check for a leading "<" (BOM-, whitespace- and
NUL-tolerant, looped read) instead of a <!doctype|<html|<?xml string
prefix, which missed a NUL-prefixed payload, >256B of leading whitespace,
UTF-16-encoded HTML, and a prolog-less <svg. No supported container starts
with "<", so the allowlist no longer grows per payload shape.
- finally { await fh.close() } could replace the in-flight typed error with
the close error.
Routing and privacy
- MARKUP_NOT_MEDIA is now in SAFE_RENDER_ERROR_CODES, the Lambda terminal
name map, the CDK and SAM non-retryable plan lists, and the Cloud Run
non-retryable set, and the class carries owner/retryable. Previously the
API emitted errorCode: undefined and a deterministic authoring bug burned
the full distributed retry budget.
- The message no longer carries 32 raw payload bytes or the src.
redactTelemetryString preserves host and path for HTTP srcs, so
per-tenant CDN paths reached a message the server forwards to clients.
Correlation is a sha256 element fingerprint, matching
AssetMediaTypeMismatchError.
- The message names both causes (unresolved nested-composition URL, or an
HTML/XML error page served as 200) rather than misdiagnosing an S3 403
body as an authoring bug.
Tests
- Byte-level detection is unit-tested in engine: markup shapes, BOMs,
UTF-16, nine container signatures, unreadable inputs.
- Replaced the tautological assertions. The old checks for "html" in and
"moov" absent from a fixed message template could not fail for any input.
- New coverage for audio degradation, the audioMixer classification, the
preflight video/image/audio split, and the API error metadata.
- The sibling htmlCompiler.mediaType failure was a vitest-under-bun runner
mismatch, not a missing ffmpeg binary. It passes, including the 4-wide
probe-semaphore invariant the sniff now runs inside.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a11e9776d
commit
f3689c1481
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"), "<!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-"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", "<!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>"],
|
||||
])("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<!doctype html><html></html>"),
|
||||
]),
|
||||
);
|
||||
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("<html>x</html>")]),
|
||||
);
|
||||
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("<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);
|
||||
});
|
||||
|
||||
it("detects markup preceded by more whitespace than a short read would cover", 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);
|
||||
});
|
||||
|
||||
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("<html later on")]),
|
||||
);
|
||||
expect(await isMarkupPayload(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag an empty file", async () => {
|
||||
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", "<!DOCTYPE html><html></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",
|
||||
'<!DOCTYPE html><html data-request-token="tok_9fA3xQ7pLz">',
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<video>`/`<audio>` src is a markup document
|
||||
* (HTML, XML, SVG) 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
|
||||
* to a legitimate 6.5 KB `<!DOCTYPE html>` page instead of the expected MP4.
|
||||
* ffprobe's `[mov,mp4,m4a,3gp,3g2,mj2 @ ...] moov atom not found` masked the
|
||||
* 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.
|
||||
*
|
||||
* 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;
|
||||
readonly owner = "user" as const;
|
||||
readonly retryable = false as const;
|
||||
readonly elementFingerprints: readonly string[];
|
||||
|
||||
constructor(elementFingerprints: readonly string[]) {
|
||||
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 ` +
|
||||
"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).",
|
||||
);
|
||||
this.name = "MarkupNotMediaError";
|
||||
this.elementFingerprints = elementFingerprints;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable, bounded correlation key. Never the raw element id or source. */
|
||||
export function fingerprintElementId(elementId: string): string {
|
||||
return createHash("sha256").update(elementId).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
const SNIFF_BYTES = 512;
|
||||
const ASCII_LT = 0x3c;
|
||||
// NUL is skippable so UTF-16-encoded markup (`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.
|
||||
const SKIPPABLE_LEADING_BYTES = new Set([0x00, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]);
|
||||
const BYTE_ORDER_MARKS = [
|
||||
Buffer.from([0xef, 0xbb, 0xbf]), // UTF-8
|
||||
Buffer.from([0xff, 0xfe]), // UTF-16 LE
|
||||
Buffer.from([0xfe, 0xff]), // UTF-16 BE
|
||||
];
|
||||
|
||||
function byteOrderMarkLength(head: Buffer): number {
|
||||
for (const mark of BYTE_ORDER_MARKS) {
|
||||
if (head.length >= mark.length && head.subarray(0, mark.length).equals(mark)) {
|
||||
return mark.length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Read up to {@link SNIFF_BYTES} from the front of the file. */
|
||||
async function readHead(filePath: string): Promise<Buffer> {
|
||||
const fh = await openFile(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(SNIFF_BYTES);
|
||||
let filled = 0;
|
||||
// Looped because a single read can come back short on NFS/FUSE and on
|
||||
// FIFOs, which would truncate the window mid-prefix.
|
||||
while (filled < buf.length) {
|
||||
const { bytesRead } = await fh.read(buf, filled, buf.length - filled, filled);
|
||||
if (bytesRead === 0) break;
|
||||
filled += bytesRead;
|
||||
}
|
||||
return buf.subarray(0, filled);
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function startsWithMarkupByte(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;
|
||||
}
|
||||
// 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* before the sniff existed.
|
||||
*/
|
||||
export async function isMarkupPayload(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return startsWithMarkupByte(await readHead(filePath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw {@link MarkupNotMediaError} if `filePath` is a markup payload.
|
||||
*
|
||||
* Only for sources whose element type can never legitimately be markup —
|
||||
* `<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)]);
|
||||
}
|
||||
}
|
||||
@@ -906,11 +906,13 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
|
||||
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"MARKUP_NOT_MEDIA",
|
||||
// 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",
|
||||
"PlanTooLargeError",
|
||||
"PlanProtocolUnsupportedError",
|
||||
"PlanV2IntegrityError",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MarkupNotMediaError } 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", () => {
|
||||
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",
|
||||
errorOwner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,6 +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",
|
||||
"INVALID_VIDEO_METADATA",
|
||||
"VIDEO_SOURCE_UNRENDERABLE",
|
||||
"VIDEO_EXTRACTION_FAILED",
|
||||
|
||||
@@ -2,6 +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 {
|
||||
ASSET_MEDIA_TYPE_MISMATCH,
|
||||
AssetMediaTypeMismatchError,
|
||||
@@ -191,6 +192,64 @@ describe("preflightCompositionAssetMediaTypes", () => {
|
||||
await expect(run({ imageSrc: "corrupt-media" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// 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
|
||||
// `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", () => {
|
||||
beforeAll(() => {
|
||||
writeFileSync(
|
||||
join(projectDir, "streamed-preview.html"),
|
||||
"<!DOCTYPE html><html><body>not media</body></html>",
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectDir, "brand-mark.svg"),
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an HTML payload under a video element", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await run({ videoSrc: "streamed-preview.html" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MarkupNotMediaError);
|
||||
expect(caught).toMatchObject({
|
||||
code: MARKUP_NOT_MEDIA,
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
});
|
||||
const message = (caught as Error).message;
|
||||
expect(message).not.toContain("streamed-preview.html");
|
||||
expect(message).not.toContain(fixtureDir);
|
||||
});
|
||||
|
||||
it("reports markup 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.
|
||||
await expect(run({ videoSrc: "streamed-preview.html" })).rejects.toBeInstanceOf(
|
||||
MarkupNotMediaError,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an audio source to the mixer's per-element classification", async () => {
|
||||
// A bad audio source is non-fatal by existing policy: the render ships
|
||||
// without the track and reports `audioError`. Aborting the whole compile
|
||||
// here would turn renders that used to succeed into hard failures, so
|
||||
// audioMixer classifies it as source/invalid_media/owner:user instead.
|
||||
await expect(run({ audioSrc: "streamed-preview.html" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves an SVG image source alone", async () => {
|
||||
// ffprobe reads SVG through its svg_pipe demuxer, so markup is a
|
||||
// legitimate <img> payload and must not be swept up by the sniff.
|
||||
await expect(run({ imageSrc: "brand-mark.svg" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-probes a path after its media contents are replaced", async () => {
|
||||
const mutablePath = join(projectDir, "mutable-media");
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
fingerprintElementId,
|
||||
isMarkupPayload,
|
||||
MarkupNotMediaError,
|
||||
probeMediaProfile,
|
||||
resolveProjectRelativeSrc,
|
||||
type AudioElement,
|
||||
@@ -39,10 +41,6 @@ export class AssetMediaTypeMismatchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintElementId(elementId: string): string {
|
||||
return createHash("sha256").update(elementId).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function detectedAssetMediaType(profile: MediaProbeProfile): DetectedAssetMediaType {
|
||||
if (profile.visualKind === "moving") return "video";
|
||||
if (profile.visualKind === "still") return "image";
|
||||
@@ -133,10 +131,27 @@ export async function preflightCompositionAssetMediaTypes(input: {
|
||||
}
|
||||
|
||||
const mismatches: AssetMediaTypeMismatch[] = [];
|
||||
const markupFingerprints: 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
|
||||
// payload behind a `data-end` video gets caught — the compiler's own
|
||||
// sniff only runs for elements whose duration it has to resolve.
|
||||
//
|
||||
// Video only, deliberately. An `<img>` may legitimately be an SVG,
|
||||
// which ffprobe reads through its `svg_pipe` demuxer. And a bad audio
|
||||
// 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)));
|
||||
return;
|
||||
}
|
||||
|
||||
let profile: MediaProbeProfile;
|
||||
try {
|
||||
profile = await probeMediaProfile(resolvedPath, { signal: input.signal });
|
||||
@@ -156,5 +171,8 @@ 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 (mismatches.length > 0) throw new AssetMediaTypeMismatchError(mismatches);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ 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 {
|
||||
assertNotHtmlPayload,
|
||||
collectExternalAssets,
|
||||
compileForRender,
|
||||
HtmlNotVideoError,
|
||||
injectSdkPositionEditsRenderScript,
|
||||
detectAncestorBackgroundImage,
|
||||
detectRenderModeHints,
|
||||
@@ -2270,163 +2269,25 @@ describe("sub-composition variable injection (render path, #2064)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── HTML payload sniff (STUDIO-5433) ───────────────────────────────────────
|
||||
// ── Markup payload sniff (STUDIO-5433) ─────────────────────────────────────
|
||||
//
|
||||
// Producer's `resolveMediaDuration` is a two-step pipeline (download → probe)
|
||||
// that runs on every media element without an authored duration. Prior to
|
||||
// this defense, an authoring bug that handed a `.html` payload through as a
|
||||
// video src produced an opaque `[mov,mp4,m4a,3gp,3g2,mj2 @ ...] moov atom
|
||||
// not found` from ffprobe — the `mov,mp4,…` prefix was ffprobe's default
|
||||
// demuxer probe order, NOT the file's true format, so every alert routed as
|
||||
// a codec/ffmpeg bug. The sniff below converts the class into a domain-typed
|
||||
// `HtmlNotVideoError` naming the src so it can be alerted and routed
|
||||
// correctly, independent of the (separate) authoring-side root cause fix.
|
||||
// that runs on every media element without an authored duration. Before this
|
||||
// defense, an authoring bug that handed a `.html` payload through as a video
|
||||
// src produced an opaque `[mov,mp4,m4a,3gp,3g2,mj2 @ ...] moov atom not found`
|
||||
// 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
|
||||
// compiler's handling of the verdict, which differs by element type.
|
||||
|
||||
describe("assertNotHtmlPayload", () => {
|
||||
it("throws HtmlNotVideoError when the file starts with <!DOCTYPE html>", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-doctype-"));
|
||||
const filePath = join(dir, "nested.html");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
"<!DOCTYPE html>\n<html><head><title>streamed-preview</title></head><body></body></html>",
|
||||
);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
// URL src (kept verbatim by redactTelemetryString apart from the query
|
||||
// string) — makes the src-attribution assertion below meaningful.
|
||||
await assertNotHtmlPayload(filePath, "https://cdn.example.com/streamed-preview.html");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(HtmlNotVideoError);
|
||||
const err = caught as HtmlNotVideoError;
|
||||
// The host of a plain URL survives redactTelemetryString; the trailing
|
||||
// `.html` basename gets replaced with `[file]` by the asset-basename rule.
|
||||
// The `[src=…]` framing is what matters for observability.
|
||||
expect(err.message).toContain("[src=https://cdn.example.com/");
|
||||
expect(err.message).toContain("cdn.example.com");
|
||||
// Sample of the file's first bytes appears in the message (case-insensitive
|
||||
// "html" comes from either `<!DOCTYPE html>` or `<html>`).
|
||||
expect(err.message.toLowerCase()).toContain("html");
|
||||
expect(err.code).toBe("HTML_NOT_VIDEO");
|
||||
});
|
||||
|
||||
it("redacts a relative-path src through redactTelemetryString before emitting", async () => {
|
||||
// A bare-relative path (`assets/nested.html`) is exactly the shape the
|
||||
// producer telemetry-redaction rules collapse to `[path]`. The error
|
||||
// message must go through redactTelemetryString so we neither leak the
|
||||
// path structure nor drop the `[src=…]` framing.
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-redact-"));
|
||||
const filePath = join(dir, "nested.html");
|
||||
writeFileSync(filePath, "<!DOCTYPE html><html></html>");
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await assertNotHtmlPayload(filePath, "assets/nested.html");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(HtmlNotVideoError);
|
||||
const err = caught as HtmlNotVideoError;
|
||||
expect(err.message).toContain("[src=[path]]");
|
||||
expect(err.message).not.toContain("assets/nested.html");
|
||||
});
|
||||
|
||||
it("throws when the file starts with <html> (no doctype, missing lang, upper/lower case)", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-htmltag-"));
|
||||
const filePath = join(dir, "raw.html");
|
||||
writeFileSync(filePath, "<HTML><body>hi</body></HTML>");
|
||||
|
||||
await expect(assertNotHtmlPayload(filePath, "raw.html")).rejects.toThrow(HtmlNotVideoError);
|
||||
});
|
||||
|
||||
it("throws when the file starts with <?xml (SVG / generic XML)", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-xml-"));
|
||||
const filePath = join(dir, "asset.svg");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
);
|
||||
|
||||
await expect(assertNotHtmlPayload(filePath, "asset.svg")).rejects.toThrow(HtmlNotVideoError);
|
||||
});
|
||||
|
||||
it("tolerates a UTF-8 BOM and leading whitespace before the prefix", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-bom-"));
|
||||
const filePath = join(dir, "bom.html");
|
||||
// 0xEF 0xBB 0xBF + newline + spaces + <!doctype ...
|
||||
const bomBuf = Buffer.from([0xef, 0xbb, 0xbf]);
|
||||
writeFileSync(
|
||||
filePath,
|
||||
Buffer.concat([bomBuf, Buffer.from("\n <!doctype html><html></html>")]),
|
||||
);
|
||||
|
||||
await expect(assertNotHtmlPayload(filePath, "bom.html")).rejects.toThrow(HtmlNotVideoError);
|
||||
});
|
||||
|
||||
it("does NOT throw for a real MP4 container (ftypmp42 header)", async () => {
|
||||
// A minimal MP4 file signature: `\x00\x00\x00\x18 ftypmp42 ...`.
|
||||
// We only care that the sniff prefix-match ignores it — downstream
|
||||
// ffprobe is what actually parses the container, and this test does
|
||||
// not exercise ffprobe.
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-mp4-"));
|
||||
const filePath = join(dir, "clip.mp4");
|
||||
const mp4Header = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32, 0x00, 0x00, 0x00,
|
||||
0x00, 0x6d, 0x70, 0x34, 0x32, 0x69, 0x73, 0x6f, 0x6d,
|
||||
]);
|
||||
writeFileSync(filePath, mp4Header);
|
||||
|
||||
// Should resolve without throwing.
|
||||
await assertNotHtmlPayload(filePath, "clip.mp4");
|
||||
});
|
||||
|
||||
it("does NOT throw for a WebM container (EBML header)", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-webm-"));
|
||||
const filePath = join(dir, "clip.webm");
|
||||
// Matroska/WebM starts with EBML header: 0x1A 0x45 0xDF 0xA3
|
||||
const webmHeader = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01]);
|
||||
writeFileSync(filePath, webmHeader);
|
||||
|
||||
await assertNotHtmlPayload(filePath, "clip.webm");
|
||||
});
|
||||
|
||||
it("does NOT throw for an empty file (0 bytes — separate code path handles it)", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-empty-"));
|
||||
const filePath = join(dir, "empty.bin");
|
||||
writeFileSync(filePath, "");
|
||||
|
||||
await assertNotHtmlPayload(filePath, "empty.bin");
|
||||
});
|
||||
|
||||
it("does NOT throw when the html prefix is deep inside the file, not at the start", async () => {
|
||||
// Regression guard: the check must be a prefix match, not a substring
|
||||
// scan. A legitimate media container that happens to contain the
|
||||
// substring `<html` further in must NOT be misclassified.
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-substr-"));
|
||||
const filePath = join(dir, "not-html.bin");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
Buffer.concat([Buffer.from([0x00, 0x00, 0x01, 0xba]), Buffer.from("<html later on")]),
|
||||
);
|
||||
|
||||
await assertNotHtmlPayload(filePath, "not-html.bin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compileForRender HTML sniff (STUDIO-5433)", () => {
|
||||
it("aborts with HtmlNotVideoError when a <video> src points at an HTML payload", async () => {
|
||||
// Mimics the STUDIO-5433 failure mode: an a-roll element with
|
||||
// `content.src` pointing at a `streamed-preview.html` (legitimate
|
||||
// 6.5 KB `<!DOCTYPE html>` page, NOT an MP4). The producer's ffprobe
|
||||
// step would previously emit an opaque `moov atom not found` — with
|
||||
// the sniff in place, we abort with a typed error naming the src.
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-html-sniff-e2e-"));
|
||||
describe("compileForRender markup sniff (STUDIO-5433)", () => {
|
||||
function writeProject(mediaTag: string): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-markup-sniff-e2e-"));
|
||||
mkdirSync(join(projectDir, "assets"));
|
||||
writeFileSync(
|
||||
join(projectDir, "assets", "nested.html"),
|
||||
"<!DOCTYPE html>\n<html><head><title>streamed-preview</title></head><body><p>Not a video.</p></body></html>",
|
||||
"<!DOCTYPE html>\n<html><head><title>streamed-preview</title></head><body></body></html>",
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
@@ -2434,7 +2295,7 @@ describe("compileForRender HTML sniff (STUDIO-5433)", () => {
|
||||
<html>
|
||||
<body>
|
||||
<div data-composition-id="root" data-width="640" data-height="360" data-start="0" data-duration="4">
|
||||
<video id="v1" src="assets/nested.html" data-start="0" muted></video>
|
||||
${mediaTag}
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
@@ -2443,6 +2304,15 @@ describe("compileForRender HTML sniff (STUDIO-5433)", () => {
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
it("aborts with MarkupNotMediaError 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(
|
||||
'<video id="v1" src="assets/nested.html" data-start="0" muted></video>',
|
||||
);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
@@ -2450,14 +2320,42 @@ describe("compileForRender HTML sniff (STUDIO-5433)", () => {
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(HtmlNotVideoError);
|
||||
const err = caught as HtmlNotVideoError;
|
||||
// Bare relative src `assets/nested.html` is redacted to `[path]` by
|
||||
// redactTelemetryString — but the `[src=…]` framing survives.
|
||||
expect(err.message).toContain("[src=[path]]");
|
||||
expect(err.message.toLowerCase()).toContain("html");
|
||||
// The sniff must run BEFORE ffprobe, so no ffprobe-specific noise
|
||||
// (`moov atom not found`) can appear.
|
||||
expect(err.message).not.toMatch(/moov/i);
|
||||
expect(caught).toBeInstanceOf(MarkupNotMediaError);
|
||||
const err = caught as MarkupNotMediaError;
|
||||
// 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.owner).toBe("user");
|
||||
expect(err.retryable).toBe(false);
|
||||
// Correlation is the hashed element id — the authored src never reaches a
|
||||
// message that producer forwards to API clients.
|
||||
expect(err.elementFingerprints).toHaveLength(1);
|
||||
expect(err.message).not.toContain("assets/nested.html");
|
||||
// Also the ordering pin: this fixture is an input ffprobe rejects, so if
|
||||
// the sniff ran after the probe the rejection would be ffprobe's untyped
|
||||
// error and this assertion would fail.
|
||||
});
|
||||
|
||||
it("drops an <audio> markup 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
|
||||
// without the offending audio.
|
||||
const projectDir = writeProject(
|
||||
'<audio id="a1" src="assets/nested.html" data-start="0"></audio>',
|
||||
);
|
||||
const warnings: string[] = [];
|
||||
const log = { ...defaultLogger, warn: (message: string) => warnings.push(message) };
|
||||
|
||||
const compiled = await compileForRender(
|
||||
projectDir,
|
||||
join(projectDir, "index.html"),
|
||||
projectDir,
|
||||
{ log },
|
||||
);
|
||||
|
||||
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("a1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
|
||||
import { createReadStream, existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { open as openFile } from "node:fs/promises";
|
||||
import { join, dirname, resolve, basename } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
shouldClampResolvedMediaDuration,
|
||||
CSS_URL_RE,
|
||||
isNonRelativeUrl,
|
||||
redactTelemetryString,
|
||||
type ResolvedDuration,
|
||||
type UnresolvedElement,
|
||||
} from "@hyperframes/core";
|
||||
@@ -53,6 +51,8 @@ import {
|
||||
type AudioVolumeKeyframe,
|
||||
type MediaProbeProfile,
|
||||
analyzeKeyframeIntervals,
|
||||
assertNotMarkupPayload,
|
||||
MarkupNotMediaError,
|
||||
probeMediaProfile,
|
||||
} from "@hyperframes/engine";
|
||||
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
@@ -148,83 +148,6 @@ class EmptyCompositionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a media file handed to `resolveMediaDuration` is actually an
|
||||
* HTML (or generic XML) payload rather than a video/audio container.
|
||||
*
|
||||
* Motivating incident: STUDIO-5433 — an authoring bug produced an a-roll
|
||||
* element with `content.src` pointing at a `streamed-preview.html` URL that
|
||||
* downloaded to a legitimate 6.5 KB `<!DOCTYPE html>` page instead of the
|
||||
* expected MP4. ffprobe's opaque `[mov,mp4,m4a,3gp,3g2,mj2 @ ...] moov atom
|
||||
* not found` masked the actual cause (the `mov,mp4,…` prefix is just
|
||||
* ffprobe's default demuxer probe order, not the file's true format), and
|
||||
* every "moov atom not found" alert routed as an ffmpeg/codec bug instead of
|
||||
* an authoring bug.
|
||||
*
|
||||
* Sniffing 256 bytes off the front of the downloaded file is nearly free
|
||||
* (single small read via `fs.promises.open`), and converting this class of
|
||||
* failure into a domain-typed error means:
|
||||
* 1. Operators see `html-not-video: src=X` in logs, not `moov atom not found`.
|
||||
* 2. Follow-on telemetry / alerts can partition on this shape.
|
||||
* 3. The authoring-side root-cause fix (re-point nested a-roll src to a
|
||||
* rendered MP4 before it reaches producer) can ship independently — this
|
||||
* defense catches the class regardless of when authoring lands.
|
||||
*
|
||||
* Exported so callers that want `instanceof` narrowing (tests, upstream
|
||||
* telemetry classifiers) can discriminate this from other probe failures.
|
||||
*/
|
||||
export class HtmlNotVideoError extends Error {
|
||||
readonly code = "HTML_NOT_VIDEO" as const;
|
||||
readonly src: string;
|
||||
readonly headSample: string;
|
||||
|
||||
constructor(redactedSrc: string, headSample: string) {
|
||||
super(
|
||||
`Refusing to probe HTML payload as video: [src=${redactedSrc}]. ` +
|
||||
`First 32 bytes: ${headSample}. ` +
|
||||
"This usually means an unresolved nested composition URL was handed to ffprobe.",
|
||||
);
|
||||
this.name = "HtmlNotVideoError";
|
||||
this.src = redactedSrc;
|
||||
this.headSample = headSample;
|
||||
}
|
||||
}
|
||||
|
||||
// Matches `<!doctype`, `<html`, or `<?xml` at the very start (case-insensitive,
|
||||
// BOM- and leading-whitespace-tolerant handled at the caller). `<?xml` catches
|
||||
// SVG and generic XML documents — same class of "not a video container".
|
||||
const HTML_PAYLOAD_PREFIX_RE = /^(?:<!doctype|<html|<\?xml)/i;
|
||||
// UTF-8 BOM as a JS string code point (0xFEFF).
|
||||
const UTF8_BOM_CHAR = 0xfeff;
|
||||
|
||||
/**
|
||||
* Sniff the first ~256 bytes of a downloaded media file. If it starts with an
|
||||
* HTML or generic XML prefix, throw `HtmlNotVideoError` naming the offending
|
||||
* `src` instead of letting ffprobe emit an opaque `moov atom not found`.
|
||||
*
|
||||
* Callers pass the caller-provided `src` (URL or path used in the composition
|
||||
* HTML), which is redacted via `redactTelemetryString` before it reaches the
|
||||
* error message — the `filePath` argument is the on-disk copy we actually
|
||||
* read from. Exported for direct unit-test coverage.
|
||||
*/
|
||||
export async function assertNotHtmlPayload(filePath: string, src: string): Promise<void> {
|
||||
const fh = await openFile(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(256);
|
||||
const { bytesRead } = await fh.read(buf, 0, 256, 0);
|
||||
if (bytesRead === 0) return;
|
||||
let head = buf.subarray(0, bytesRead).toString("utf8");
|
||||
if (head.charCodeAt(0) === UTF8_BOM_CHAR) head = head.slice(1);
|
||||
const trimmed = head.replace(/^\s+/, "");
|
||||
if (HTML_PAYLOAD_PREFIX_RE.test(trimmed)) {
|
||||
const sample = trimmed.slice(0, 32).replace(/\s+/g, " ");
|
||||
throw new HtmlNotVideoError(redactTelemetryString(src), sample);
|
||||
}
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk every `data-composition-src` reference reachable from
|
||||
* `html` (including nested sub-compositions) and verify each resolves to a
|
||||
@@ -492,6 +415,7 @@ async function resolveMediaDuration(
|
||||
downloadDir: string,
|
||||
tagName: string,
|
||||
elementIdentity: string,
|
||||
log?: ProducerLogger,
|
||||
): Promise<{ duration: number; resolvedPath: string }> {
|
||||
let filePath = src;
|
||||
|
||||
@@ -512,24 +436,33 @@ async function resolveMediaDuration(
|
||||
return { duration: 0, resolvedPath: filePath };
|
||||
}
|
||||
|
||||
// Defensive HTML sniff (STUDIO-5433). If an authoring bug hands us a
|
||||
// `.html` payload (e.g. an unresolved nested-composition preview URL),
|
||||
// refuse to probe and throw a typed HtmlNotVideoError. Without this,
|
||||
// ffprobe emits an opaque `[mov,mp4,...] moov atom not found` that masks
|
||||
// the actual cause and routes as a codec/ffmpeg bug. Runs before the
|
||||
// media-probe slot so we don't consume concurrency budget on payloads
|
||||
// that were never going to probe.
|
||||
await assertNotHtmlPayload(filePath, src);
|
||||
|
||||
return withMediaProbeSlot(async () => {
|
||||
let profile: MediaProbeProfile;
|
||||
try {
|
||||
// Markup sniff (STUDIO-5433): if an authoring bug hands us an HTML/XML
|
||||
// payload (e.g. an unresolved nested-composition preview URL), fail with
|
||||
// a typed MarkupNotMediaError 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);
|
||||
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") return { duration: 0, resolvedPath: filePath };
|
||||
if (tagName !== "video") {
|
||||
if (error instanceof MarkupNotMediaError) {
|
||||
// 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 ` +
|
||||
"file — the element is dropped from the render. Point it at a rendered media file.",
|
||||
);
|
||||
}
|
||||
return { duration: 0, resolvedPath: filePath };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertAssetMediaTypeProfile(tagName === "video" ? "video" : "audio", profile, elementIdentity);
|
||||
@@ -577,9 +510,15 @@ async function compileHtmlFile(
|
||||
// Phase 1: Resolve missing durations (parallel ffprobe)
|
||||
const resolvedResults = await Promise.all(
|
||||
mediaUnresolved.map((el) =>
|
||||
resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName, el.id).then(
|
||||
({ duration }) => ({ id: el.id, duration }),
|
||||
),
|
||||
resolveMediaDuration(
|
||||
el.src!,
|
||||
el.mediaStart,
|
||||
baseDir,
|
||||
downloadDir,
|
||||
el.tagName,
|
||||
el.id,
|
||||
log,
|
||||
).then(({ duration }) => ({ id: el.id, duration })),
|
||||
),
|
||||
);
|
||||
const resolutions: ResolvedDuration[] = resolvedResults.filter((r) => r.duration > 0);
|
||||
@@ -601,6 +540,7 @@ async function compileHtmlFile(
|
||||
downloadDir,
|
||||
el.tagName,
|
||||
el.id,
|
||||
log,
|
||||
);
|
||||
return { id: el.id, tagName: el.tagName, duration: el.duration, maxDuration, src: el.src! };
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user