diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index a69be7e02..d45fc1d23 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -7,8 +7,10 @@ import { parseHTML } from "linkedom";
import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
import { defaultLogger } from "../logger.js";
import {
+ assertNotHtmlPayload,
collectExternalAssets,
compileForRender,
+ HtmlNotVideoError,
injectSdkPositionEditsRenderScript,
detectAncestorBackgroundImage,
detectRenderModeHints,
@@ -2267,3 +2269,195 @@ describe("sub-composition variable injection (render path, #2064)", () => {
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 ", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-doctype-"));
+ const filePath = join(dir, "nested.html");
+ writeFileSync(
+ filePath,
+ "\n
streamed-preview",
+ );
+
+ 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 `` or ``).
+ 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, "");
+
+ 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 (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, "hi");
+
+ await expect(assertNotHtmlPayload(filePath, "raw.html")).rejects.toThrow(HtmlNotVideoError);
+ });
+
+ it("throws when the file starts with {
+ const dir = mkdtempSync(join(tmpdir(), "hf-html-sniff-xml-"));
+ const filePath = join(dir, "asset.svg");
+ writeFileSync(
+ filePath,
+ '',
+ );
+
+ 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 + ")]),
+ );
+
+ 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 ` {
+ it("aborts with HtmlNotVideoError when a