feat(producer): sniff HTML payload before ffprobe in resolveMediaDuration

STUDIO-5433 defense: when the downloaded media file begins with
<!DOCTYPE, <html, or <?xml, throw a typed HtmlNotVideoError naming
the offending src instead of letting ffprobe emit an inscrutable
moov-atom-not-found on a plain HTML page.

Complements #3033 diagnosability layer. Root-cause EF fix ships
separately.

Signed-off-by: Via <vance@heygen.com>
This commit is contained in:
Vance Ingalls (via Via)
2026-08-04 22:42:36 +00:00
parent 552419c52d
commit 8a11e9776d
2 changed files with 282 additions and 0 deletions
@@ -7,8 +7,10 @@ import { parseHTML } from "linkedom";
import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope"; import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
import { defaultLogger } from "../logger.js"; import { defaultLogger } from "../logger.js";
import { import {
assertNotHtmlPayload,
collectExternalAssets, collectExternalAssets,
compileForRender, compileForRender,
HtmlNotVideoError,
injectSdkPositionEditsRenderScript, injectSdkPositionEditsRenderScript,
detectAncestorBackgroundImage, detectAncestorBackgroundImage,
detectRenderModeHints, detectRenderModeHints,
@@ -2267,3 +2269,195 @@ describe("sub-composition variable injection (render path, #2064)", () => {
expect(compiled.html).not.toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/); expect(compiled.html).not.toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
}); });
}); });
// ── HTML 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.
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-"));
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>",
);
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<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>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["root"] = { duration: () => 4 };
</script>
</body>
</html>`,
);
let caught: unknown;
try {
await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
} 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);
});
});
@@ -11,6 +11,7 @@
*/ */
import { createReadStream, existsSync, mkdirSync, readFileSync } from "fs"; import { createReadStream, existsSync, mkdirSync, readFileSync } from "fs";
import { open as openFile } from "node:fs/promises";
import { join, dirname, resolve, basename } from "path"; import { join, dirname, resolve, basename } from "path";
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import { import {
@@ -21,6 +22,7 @@ import {
shouldClampResolvedMediaDuration, shouldClampResolvedMediaDuration,
CSS_URL_RE, CSS_URL_RE,
isNonRelativeUrl, isNonRelativeUrl,
redactTelemetryString,
type ResolvedDuration, type ResolvedDuration,
type UnresolvedElement, type UnresolvedElement,
} from "@hyperframes/core"; } from "@hyperframes/core";
@@ -146,6 +148,83 @@ 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 * Recursively walk every `data-composition-src` reference reachable from
* `html` (including nested sub-compositions) and verify each resolves to a * `html` (including nested sub-compositions) and verify each resolves to a
@@ -433,6 +512,15 @@ async function resolveMediaDuration(
return { duration: 0, resolvedPath: filePath }; 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 () => { return withMediaProbeSlot(async () => {
let profile: MediaProbeProfile; let profile: MediaProbeProfile;
try { try {