mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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 <!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 { open as openFile } from "node:fs/promises";
|
||||
import { join, dirname, resolve, basename } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
shouldClampResolvedMediaDuration,
|
||||
CSS_URL_RE,
|
||||
isNonRelativeUrl,
|
||||
redactTelemetryString,
|
||||
type ResolvedDuration,
|
||||
type UnresolvedElement,
|
||||
} 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
|
||||
* `html` (including nested sub-compositions) and verify each resolves to a
|
||||
@@ -433,6 +512,15 @@ 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 {
|
||||
|
||||
Reference in New Issue
Block a user