fix(producer,lint): id-less media renders blank wash instead of footage (#1790)

* fix(producer,lint): id-less media renders blank wash instead of footage

A timed <video>/<audio> identified only by a Studio-stamped `data-hf-id`
(no real `id`) rendered as a flat white/grey wash with dropped audio, and
lint stayed silent so it surfaced only at render.

Root cause, two layers:

- lint `readAttr(tag, "id")` used a `\b` boundary, which treats the hyphen
  in `data-hf-id="…"` as a word break — so reading "id" matched the trailing
  `id="…"` inside `data-hf-id` and returned a phantom id. `media_missing_id`
  therefore never fired for media carrying only a data-hf-id. Switched to a
  `(?<![\w-])` lookbehind so a short name can't match the tail of a longer
  hyphenated attribute (also fixes "width" matching `data-width`, etc.).

- the render pipeline identifies media by the real `el.id`: frame extraction
  keys injected stills as `__render_frame_<id>__`, the runtime frame-swap
  matches on `el.id`, and the audio mixer selects `audio[id][src]`. An empty
  `el.id` meant injected frames/audio never matched. compileForRender now
  assigns a stable positional id to every id-less timed media element before
  any stage parses or serves the HTML.

Adds a producer regression fixture (video with data-hf-id, no id) and a lint
test covering the data-hf-id/id collision. Baseline mp4 generated separately.

* test(producer): baseline for video-hfid-no-id regression fixture

Golden compiled.html + output.mp4 (generated on linux/amd64 in the
Dockerfile.test image). Compare-mode passes: compilation, visual (0 failed
frames), and audio (correlation 1.000). A regression to the blank-wash
behaviour fails the visual check.
This commit is contained in:
Miguel Ángel
2026-06-29 18:28:24 -07:00
committed by GitHub
parent c9613cd826
commit 74f9c31b3f
8 changed files with 203 additions and 3 deletions
+19
View File
@@ -48,6 +48,25 @@ describe("media rules", () => {
expect(finding?.message).toContain("FROZEN");
});
it("flags media that has data-hf-id but no real id", async () => {
// Regression: readAttr(tag, "id") used a \b boundary that matched the
// trailing `id="…"` inside `data-hf-id="…"`, so media carrying only a
// Studio-stamped data-hf-id passed the check and then rendered as a blank
// wash (video) / silent (audio). data-hf-id is NOT a render id.
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-hf-id="hf-v1a2b3" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
<audio data-hf-id="hf-a4c5d6" data-start="0" data-duration="10" src="narration.wav"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "media_missing_id");
expect(findings).toHaveLength(2);
expect(findings.every((f) => f.severity === "error")).toBe(true);
});
it("does not flag media elements that have id", async () => {
const html = `
<html><body>
+10 -2
View File
@@ -112,7 +112,11 @@ export function findRootTag(source: string): OpenTag | null {
export function readAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
// `(?<![\w-])` not `\b`: a plain `\b` boundary treats the hyphen in a longer
// attribute as a word break, so reading "id" would wrongly match the trailing
// `id="…"` inside `data-hf-id="…"` (and "width" inside `data-width`, etc.).
// The lookbehind requires the match to start a fresh attribute name.
const match = tagSource.match(new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
return match?.[1] || null;
}
@@ -131,7 +135,11 @@ export function readAttr(tagSource: string, attr: string): string | null {
export function readJsonAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
// See readAttr: `(?<![\w-])` prevents a short name from matching the tail of a
// longer hyphenated attribute (e.g. "id" inside `data-hf-id`).
const match = tagSource.match(
new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"),
);
if (!match) return null;
return match[1] ?? match[2] ?? null;
}