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
+35 -1
View File
@@ -1360,6 +1360,32 @@ function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string
* with all media metadata resolved.
*/
// fallow-ignore-next-line complexity
/**
* Every render stage identifies `<video>`/`<audio>` by their real `id`: frame
* extraction keys injected stills as `__render_frame_<id>__`, the runtime
* frame-swap matches on `el.id` (runtime/media.ts), and the audio mixer selects
* `audio[id][src]`. A timed media element with no `id` — e.g. one carrying only
* `data-hf-id`, which is what Studio stamps — has an empty `el.id`, so its
* injected frames never match: the video renders as a blank wash and any
* separate `<audio>` is silently dropped. Assign a stable positional `id` to
* every timed media element missing one, on the same HTML that is parsed for
* media and served to the renderer, so the whole pipeline shares one identity.
* `data-hf-id` is intentionally NOT reused as the id — it is a Studio edit
* handle, not the render identity.
*/
function assignMissingMediaIds(html: string): string {
const { document } = parseHTML(html);
const media = document.querySelectorAll("video[data-start], audio[data-start]");
let seq = 0;
let changed = false;
for (const el of Array.from(media)) {
if (el.getAttribute("id")) continue;
el.setAttribute("id", `hf-media-${seq++}`);
changed = true;
}
return changed ? document.toString() : html;
}
export async function compileForRender(
projectDir: string,
htmlPath: string,
@@ -1484,7 +1510,15 @@ export async function compileForRender(
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
// These can't be served by the file server, so we map them to paths the
// orchestrator will copy into the compiled output directory.
const { html, externalAssets } = collectExternalAssets(embeddedHtml, projectDir);
const { html: htmlBeforeMediaIds, externalAssets } = collectExternalAssets(
embeddedHtml,
projectDir,
);
// Give every timed <video>/<audio> a real `id` before any stage parses or
// serves this HTML — id-less media (e.g. carrying only `data-hf-id`) would
// otherwise render as a blank wash with dropped audio. See assignMissingMediaIds.
const html = assignMissingMediaIds(htmlBeforeMediaIds);
for (const [relPath, absPath] of remoteMediaAssets) {
externalAssets.set(relPath, absPath);