Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
23 changes: 23 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ describe("compileTimingAttrs", () => {
expect(unresolved).toHaveLength(0);
});

it("injects a real id when the element has only data-hf-id (not a phantom match)", () => {
// Regression: getAttr(tag, "id") matched the trailing id="…" inside
// data-hf-id="…" and returned a phantom, so compileTag skipped its
// hf-video-N injection — leaving no real el.id and a blank-wash render.
const html = '<video data-hf-id="hf-bgvideo01" src="a.mp4" data-start="0" data-duration="2">';
const { html: compiled } = compileTimingAttrs(html);

expect(compiled).toContain('id="hf-video-0"');
expect(compiled).toContain('data-hf-id="hf-bgvideo01"');
expect(compiled).toContain('data-end="2"');
});

it("injects a real id on an audio element that has only data-hf-id", () => {
// Audio side of the same bug: the mixer selects `audio[id][src]`, so a
// phantom-id match meant the element was dropped (silent). compileTag must
// inject a real hf-audio-N so the mixer can find it.
const html = '<audio data-hf-id="hf-bgaudio01" src="a.mp3" data-start="0" data-duration="2">';
const { html: compiled } = compileTimingAttrs(html);

expect(compiled).toContain('id="hf-audio-0"');
expect(compiled).toContain('data-hf-id="hf-bgaudio01"');
});

it("leaves data-end unchanged when already present", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-end="3">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ export function shouldClampMediaDuration(declaredDuration: number, maxDuration:
// ── Helpers ──────────────────────────────────────────────────────────────

function getAttr(tag: string, attr: string): string | null {
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
// `(?<![\w-])` anchors the attribute name to a fresh start. Without it,
// `getAttr(tag, "id")` matches the trailing `id="…"` inside `data-hf-id="…"`
// (and "src" inside `data-src`, etc.) and returns a phantom value. That bug
// made compileTag believe a Studio-stamped `data-hf-id`-only element already
// had an `id`, so it skipped its `hf-video-N` injection — leaving the element
// with no real `el.id`, which the render pipeline keys off of (blank wash).
const match = tag.match(new RegExp(`(?<![\\w-])${attr}=["']([^"']+)["']`));
return match ? (match[1] ?? null) : null;
}

Expand Down
36 changes: 1 addition & 35 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,32 +1360,6 @@ 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,
Expand Down Expand Up @@ -1510,15 +1484,7 @@ 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: 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);
const { html, externalAssets } = collectExternalAssets(embeddedHtml, projectDir);

for (const [relPath, absPath] of remoteMediaAssets) {
externalAssets.set(relPath, absPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
pipeline could not match its injected frames (keyed on the empty
el.id), so the footage rendered as a blank white/grey wash. The
baseline must show the testsrc2 footage, not a flat fill. -->
<video id="hf-media-0" data-hf-id="hf-bgvideo01" class="clip" src="clip.mp4" muted playsinline data-start="0" data-duration="2" data-track-index="0" data-end="2" data-has-audio="false"></video>
<video data-hf-id="hf-bgvideo01" class="clip" src="clip.mp4" muted playsinline data-start="0" data-duration="2" data-track-index="0" id="hf-video-0" data-end="2" data-has-audio="false"></video>

<div id="caption" class="label clip" data-start="0" data-duration="2" data-track-index="10">
FOOTAGE
Expand Down
Loading