Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <!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);
});
});
88 changes: 88 additions & 0 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

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 {
Expand All @@ -21,6 +22,7 @@
shouldClampResolvedMediaDuration,
CSS_URL_RE,
isNonRelativeUrl,
redactTelemetryString,
type ResolvedDuration,
type UnresolvedElement,
} from "@hyperframes/core";
Expand Down Expand Up @@ -146,6 +148,83 @@
}
}

/**
* 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
Expand Down Expand Up @@ -433,6 +512,15 @@
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 {
Expand Down
Loading