fix(cli): preserve video frames when scenes reuse element IDs (#6095)

* fix(cli): preserve video frames when scenes reuse element IDs

* chore(core): keep media identity comments within the ratchet

* test(core): update page-build goldens for distinct media ids across repeated scenes
This commit is contained in:
Miguel Ángel
2026-10-22 23:23:17 +00:00
committed by GitHub
parent 9da107aef5
commit da6b77bd84
17 changed files with 187 additions and 31 deletions
+3
View File
@@ -1173,6 +1173,9 @@ nothing reports `motion_selector_missing` rather than quietly passing.
Capture specific frames as PNGs, without waiting for a full render.
Each sub-composition may reuse a local video ID such as `clip`. Snapshots keep each
scene's footage separate without requiring you to rename those IDs across files.
```bash
npx hyperframes snapshot my-project --at 2.9,10.4,18.7
npx hyperframes snapshot my-project --frames 10
@@ -26,7 +26,8 @@ vi.mock("puppeteer-core", async (importOriginal) => ({
// captureCompositionFrame.js imports these two from ../browser/gpuPolicy.js,
// which re-exports them from this mocked module — unmocked here, they'd read
// undefined and throw as soon as openSettledCompositionPage calls them.
vi.mock("@hyperframes/engine", () => ({
vi.mock("@hyperframes/engine", async (importOriginal) => ({
...(await importOriginal<typeof import("@hyperframes/engine")>()),
buildChromeArgs: mocks.buildChromeArgs,
resolveBrowserGpuMode: mocks.resolveBrowserGpuMode,
compositionRequiresWebGpu: mocks.compositionRequiresWebGpu,
@@ -327,6 +327,8 @@ export async function openSettledCompositionPage(
options.onBrowserReady?.();
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
const { installMediaRenderIdBridge } = await import("@hyperframes/engine");
await installMediaRenderIdBridge(page);
await page.setViewport(viewport);
if (answer) await answerPageInProcess(page, url, answer);
await options.beforeNavigate?.(page);
@@ -1027,3 +1027,56 @@ describe("snapshot composite recapture", () => {
expect(finalCapture).toBeGreaterThan(recapture);
});
});
it("addresses colliding scene videos by the capture page's render identity", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-snapshot-media-id-"));
writeFileSync(
join(project, "index.html"),
'<html><body><div data-composition-id="main" data-width="320" data-height="180" data-duration="20"></div></body></html>',
);
const videos = ["clip", "clip__hf2"].map((renderId) => ({
id: "clip",
renderId,
dataset: { start: "0", duration: "10" },
duration: 20,
currentSrc: "http://127.0.0.1/source.mov",
src: "",
defaultPlaybackRate: 1,
loop: false,
getBoundingClientRect: () => ({ width: 320, height: 180 }),
}));
let ids: string[] = [];
let calls = 0;
const evaluate = vi.fn(async (fn: () => unknown) => {
calls++;
if (calls === 1) return { loaded: [], errored: [], unused: [] };
if (calls === 2) return 20;
if (calls === 3) return true;
ids = (fn() as Array<{ id: string }>).map((video) => video.id);
throw new Error("candidate witness captured");
});
snapshotState.openSettledPage.mockResolvedValueOnce({
browser: { close: vi.fn(async () => undefined) },
page: { evaluate },
renderReadyTimedOut: false,
});
snapshotState.seek.mockResolvedValue(undefined);
vi.stubGlobal("window", {
devicePixelRatio: 1,
__hfMediaId: (video: { renderId: string }) => video.renderId,
});
vi.stubGlobal("document", { querySelectorAll: () => videos });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await expect(
snapshotCommand.run?.({
args: { dir: project, at: "1", end: false, output: join(project, "out") },
} as never),
).rejects.toBeDefined();
expect(ids).toEqual(["clip", "clip__hf2"]);
} finally {
log.mockRestore();
vi.unstubAllGlobals();
rmSync(project, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -769,7 +769,7 @@ async function captureSnapshots(
const rawDuration = parseFloat(v.dataset.duration ?? "");
const srcDur = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
return {
id: v.id,
id: runtimeWindow.__hfMediaId?.(v) || v.id,
src: v.currentSrc || v.src,
authoredStart,
authoredRate: v.dataset.playbackRate,
@@ -85,7 +85,7 @@
<div data-hf-id="hf-egjt" id="main" data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="12">
<div data-composition-file="compositions/card.html" data-height="180" data-width="320" data-hf-scene-no-swap="its script uses gsap.timeline({ paused: true }).from(" data-hf-scene="card__hf1" data-hf-original-composition-id="card" data-hf-id="hf-4uyr" class="clip" data-composition-id="card__hf1" data-start="2" data-duration="2" data-track-index="1" data-variable-values="{&quot;accent&quot;:&quot;#000001&quot;,&quot;title&quot;:&quot;Card 1&quot;,&quot;size&quot;:41}"><div style="width:320px;height:180px" data-hf-inner-root="true" data-hf-authored-id="card-root" data-hf-id="hf-hmcz" data-width="320" data-height="180" class="frame">
<h1 data-hf-id="hf-bowk" class="title intro">Title</h1>
<img data-hf-id="hf-ts3h" src="assets/linked.png" alt="">
<img data-hf-render-id="hf-media-0" data-hf-id="hf-ts3h" src="assets/linked.png" alt="">
<svg data-hf-id="hf-wmyb" width="10" height="10"><clippath data-hf-id="hf-fbxz" id="clip"><rect data-hf-id="hf-zjxd" width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;clip-path&quot;,&quot;localName&quot;:&quot;clip-path&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;url(#clip)&quot;,&quot;after&quot;:&quot;url(#clip)&quot;}]" data-hf-id="hf-07nt" clip-path="url(#clip)" width="10" height="10" /></svg>
<div data-hf-id="hf-5unm" class="row" style="background: url(assets/linked.png) no-repeat"><span data-hf-id="hf-0lnd" class="cell">Cell</span><span data-hf-id="hf-p0yb" class="gap"></span></div>
<div data-composition-file="compositions/badge.html" data-height="40" data-width="80" data-hf-id="hf-y6wo" class="nested" data-composition-id="badge" data-start="0" data-duration="1"><div data-composition-id="badge" style="width:80px;height:40px" data-hf-inner-root="true" data-hf-id="hf-ufoj" data-width="80" data-height="40">
@@ -95,7 +95,7 @@
</div></div>
<div data-composition-file="compositions/card.html" data-height="180" data-width="320" data-hf-scene-no-swap="its script uses gsap.timeline({ paused: true }).from(" data-hf-scene="card__hf2" data-hf-original-composition-id="card" data-hf-id="hf-pvmt" class="clip" data-composition-id="card__hf2" data-start="4" data-duration="2" data-track-index="0" data-variable-values="{&quot;accent&quot;:&quot;#000002&quot;,&quot;title&quot;:&quot;Card 2&quot;,&quot;size&quot;:42}"><div style="width:320px;height:180px" data-hf-inner-root="true" data-hf-authored-id="card-root" data-hf-id="hf-hmcz" data-width="320" data-height="180" class="frame">
<h1 data-hf-id="hf-bowk" class="title intro">Title</h1>
<img data-hf-id="hf-ts3h" src="assets/linked.png" alt="">
<img data-hf-render-id="hf-media-1" data-hf-id="hf-ts3h" src="assets/linked.png" alt="">
<svg data-hf-id="hf-wmyb" width="10" height="10"><clippath data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;id&quot;,&quot;localName&quot;:&quot;id&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;clip&quot;,&quot;after&quot;:&quot;card__hf2--clip&quot;}]" data-hf-authored-id="clip" data-hf-id="hf-fbxz" id="card__hf2--clip"><rect data-hf-id="hf-zjxd" width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;clip-path&quot;,&quot;localName&quot;:&quot;clip-path&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;url(#clip)&quot;,&quot;after&quot;:&quot;url(#card__hf2--clip)&quot;}]" data-hf-id="hf-07nt" clip-path="url(#card__hf2--clip)" width="10" height="10" /></svg>
<div data-hf-id="hf-5unm" class="row" style="background: url(assets/linked.png) no-repeat"><span data-hf-id="hf-0lnd" class="cell">Cell</span><span data-hf-id="hf-p0yb" class="gap"></span></div>
<div data-composition-file="compositions/badge.html" data-height="40" data-width="80" data-hf-id="hf-y6wo" class="nested" data-composition-id="badge" data-start="0" data-duration="1"><div data-composition-id="badge" style="width:80px;height:40px" data-hf-inner-root="true" data-hf-id="hf-ufoj" data-width="80" data-height="40">
@@ -105,7 +105,7 @@
</div></div>
<div data-composition-file="compositions/card.html" data-height="180" data-width="320" data-hf-scene-no-swap="its script uses gsap.timeline({ paused: true }).from(" data-hf-scene="card__hf3" data-hf-original-composition-id="card" data-hf-id="hf-1j35" class="clip" data-composition-id="card__hf3" data-start="6" data-duration="2" data-track-index="1" data-variable-values="{&quot;accent&quot;:&quot;#000003&quot;,&quot;title&quot;:&quot;Card 3&quot;,&quot;size&quot;:43}"><div style="width:320px;height:180px" data-hf-inner-root="true" data-hf-authored-id="card-root" data-hf-id="hf-hmcz" data-width="320" data-height="180" class="frame">
<h1 data-hf-id="hf-bowk" class="title intro">Title</h1>
<img data-hf-id="hf-ts3h" src="assets/linked.png" alt="">
<img data-hf-render-id="hf-media-2" data-hf-id="hf-ts3h" src="assets/linked.png" alt="">
<svg data-hf-id="hf-wmyb" width="10" height="10"><clippath data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;id&quot;,&quot;localName&quot;:&quot;id&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;clip&quot;,&quot;after&quot;:&quot;card__hf3--clip&quot;}]" data-hf-authored-id="clip" data-hf-id="hf-fbxz" id="card__hf3--clip"><rect data-hf-id="hf-zjxd" width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;clip-path&quot;,&quot;localName&quot;:&quot;clip-path&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;url(#clip)&quot;,&quot;after&quot;:&quot;url(#card__hf3--clip)&quot;}]" data-hf-id="hf-07nt" clip-path="url(#card__hf3--clip)" width="10" height="10" /></svg>
<div data-hf-id="hf-5unm" class="row" style="background: url(assets/linked.png) no-repeat"><span data-hf-id="hf-0lnd" class="cell">Cell</span><span data-hf-id="hf-p0yb" class="gap"></span></div>
<div data-composition-file="compositions/badge.html" data-height="40" data-width="80" data-hf-id="hf-y6wo" class="nested" data-composition-id="badge" data-start="0" data-duration="1"><div data-composition-id="badge" style="width:80px;height:40px" data-hf-inner-root="true" data-hf-id="hf-ufoj" data-width="80" data-height="40">
@@ -111,7 +111,7 @@
<div id="main" data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="12">
<div data-composition-file="compositions/card.html" data-height="180" data-width="320" data-hf-original-composition-id="card" class="clip" data-composition-id="card__hf1" data-start="2" data-duration="2" data-track-index="1" data-variable-values="{&quot;accent&quot;:&quot;#000001&quot;,&quot;title&quot;:&quot;Card 1&quot;,&quot;size&quot;:41}"><div style="width:320px;height:180px" data-hf-inner-root="true" data-hf-authored-id="card-root" data-width="320" data-height="180" class="frame">
<h1 class="title intro">Title</h1>
<img src="data:image/png;base64,bGlua2VkLXBuZw==" alt="">
<img data-hf-render-id="hf-media-0" src="data:image/png;base64,bGlua2VkLXBuZw==" alt="">
<svg width="10" height="10"><clippath id="clip"><rect width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;clip-path&quot;,&quot;localName&quot;:&quot;clip-path&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;url(#clip)&quot;,&quot;after&quot;:&quot;url(#clip)&quot;}]" clip-path="url(#clip)" width="10" height="10" /></svg>
<div class="row" style="background: url(data:image/png;base64,bGlua2VkLXBuZw==) no-repeat"><span class="cell">Cell</span><span class="gap"></span></div>
<div data-composition-file="compositions/badge.html" data-height="40" data-width="80" class="nested" data-composition-id="badge" data-start="0" data-duration="1"><div data-composition-id="badge" style="width:80px;height:40px" data-hf-inner-root="true" data-width="80" data-height="40">
@@ -121,7 +121,7 @@
</div></div>
<div data-composition-file="compositions/card.html" data-height="180" data-width="320" data-hf-original-composition-id="card" class="clip" data-composition-id="card__hf2" data-start="4" data-duration="2" data-track-index="0" data-variable-values="{&quot;accent&quot;:&quot;#000002&quot;,&quot;title&quot;:&quot;Card 2&quot;,&quot;size&quot;:42}"><div style="width:320px;height:180px" data-hf-inner-root="true" data-hf-authored-id="card-root" data-width="320" data-height="180" class="frame">
<h1 class="title intro">Title</h1>
<img src="data:image/png;base64,bGlua2VkLXBuZw==" alt="">
<img data-hf-render-id="hf-media-1" src="data:image/png;base64,bGlua2VkLXBuZw==" alt="">
<svg width="10" height="10"><clippath data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;id&quot;,&quot;localName&quot;:&quot;id&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;clip&quot;,&quot;after&quot;:&quot;card__hf2--clip&quot;}]" data-hf-authored-id="clip" id="card__hf2--clip"><rect width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;clip-path&quot;,&quot;localName&quot;:&quot;clip-path&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;url(#clip)&quot;,&quot;after&quot;:&quot;url(#card__hf2--clip)&quot;}]" clip-path="url(#card__hf2--clip)" width="10" height="10" /></svg>
<div class="row" style="background: url(data:image/png;base64,bGlua2VkLXBuZw==) no-repeat"><span class="cell">Cell</span><span class="gap"></span></div>
<div data-composition-file="compositions/badge.html" data-height="40" data-width="80" class="nested" data-composition-id="badge" data-start="0" data-duration="1"><div data-composition-id="badge" style="width:80px;height:40px" data-hf-inner-root="true" data-width="80" data-height="40">
@@ -131,7 +131,7 @@
</div></div>
<div data-composition-file="compositions/card.html" data-height="180" data-width="320" data-hf-original-composition-id="card" class="clip" data-composition-id="card__hf3" data-start="6" data-duration="2" data-track-index="1" data-variable-values="{&quot;accent&quot;:&quot;#000003&quot;,&quot;title&quot;:&quot;Card 3&quot;,&quot;size&quot;:43}"><div style="width:320px;height:180px" data-hf-inner-root="true" data-hf-authored-id="card-root" data-width="320" data-height="180" class="frame">
<h1 class="title intro">Title</h1>
<img src="data:image/png;base64,bGlua2VkLXBuZw==" alt="">
<img data-hf-render-id="hf-media-2" src="data:image/png;base64,bGlua2VkLXBuZw==" alt="">
<svg width="10" height="10"><clippath data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;id&quot;,&quot;localName&quot;:&quot;id&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;clip&quot;,&quot;after&quot;:&quot;card__hf3--clip&quot;}]" data-hf-authored-id="clip" id="card__hf3--clip"><rect width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{&quot;name&quot;:&quot;clip-path&quot;,&quot;localName&quot;:&quot;clip-path&quot;,&quot;namespaceURI&quot;:null,&quot;before&quot;:&quot;url(#clip)&quot;,&quot;after&quot;:&quot;url(#card__hf3--clip)&quot;}]" clip-path="url(#card__hf3--clip)" width="10" height="10" /></svg>
<div class="row" style="background: url(data:image/png;base64,bGlua2VkLXBuZw==) no-repeat"><span class="cell">Cell</span><span class="gap"></span></div>
<div data-composition-file="compositions/badge.html" data-height="40" data-width="80" class="nested" data-composition-id="badge" data-start="0" data-duration="1"><div data-composition-id="badge" style="width:80px;height:40px" data-hf-inner-root="true" data-width="80" data-height="40">
@@ -0,0 +1,42 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { expect, it } from "vitest";
import { bundleToSingleHtml } from "./htmlBundler.js";
it.each([false, true])(
"gives colliding scene videos distinct render identities (pre-stamped: %s)",
async (preStamped) => {
const project = mkdtempSync(join(tmpdir(), "hf-scene-media-identity-"));
try {
mkdirSync(join(project, "scenes"));
writeFileSync(
join(project, "index.html"),
`<html><body>
<div data-composition-id="main" data-width="320" data-height="180" data-duration="4">
<div data-composition-id="first" data-composition-src="scenes/first.html" data-start="0" data-duration="2"></div>
<div data-composition-id="second" data-composition-src="scenes/second.html" data-start="2" data-duration="2"></div>
</div></body></html>`,
);
for (const scene of ["first", "second"]) {
writeFileSync(
join(project, "scenes", `${scene}.html`),
`<template id="${scene}-template">
<div data-composition-id="${scene}" data-width="320" data-height="180" data-duration="2">
<video id="clip" ${preStamped ? 'data-hf-render-id="clip"' : ""} src="../source.mov" data-start="0" data-duration="2"></video>
</div></template>`,
);
}
const { document } = parseHTML(await bundleToSingleHtml(project, { staticGuard: false }));
const videos = [...document.querySelectorAll("video")];
expect(videos.map((video) => video.id)).toEqual(["clip", "clip"]);
expect(videos.map((video) => video.getAttribute("data-hf-render-id"))).toEqual([
"clip",
"clip__hf2",
]);
} finally {
rmSync(project, { recursive: true, force: true });
}
},
);
@@ -70,6 +70,7 @@ import {
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
import { assignMediaRenderIds } from "./mediaRenderIds";
const DEFAULT_RUNTIME_SCRIPT_URL = "";
@@ -1486,6 +1487,7 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
}
}
assignMediaRenderIds(document);
return document.toString();
}
@@ -60,6 +60,16 @@ describe("assignMediaRenderIds", () => {
).toEqual(["clip", "clip__hf2"]);
});
it("repairs duplicate existing stamps while reserving later unique stamps", () => {
expect(
stamp(
'<video id="clip" data-hf-render-id="clip" src="a.mp4">' +
'<video id="clip" data-hf-render-id="clip" src="b.mp4">' +
'<video id="clip" data-hf-render-id="clip__hf2" src="c.mp4">',
),
).toEqual(["clip", "clip__hf3", "clip__hf2"]);
});
it("does not claim an id that a later element already holds as its render id", () => {
// Re-running over a partially stamped document must not hand `clip__hf2`
// to the first element and collide with the element already holding it.
@@ -126,6 +136,23 @@ describe("assignMediaRenderIds", () => {
});
describe("audio group render ids", () => {
it("repairs duplicate bus stamps and reserves existing bus identities before new media", () => {
const { document } = parseHTML(
'<video id="bed" src="a.mp4"></video>' +
'<hf-audio-group id="bed" data-hf-render-id="bed"></hf-audio-group>' +
'<hf-audio-group id="bed" data-hf-render-id="bed"></hf-audio-group>' +
'<hf-audio-group id="bed" data-hf-render-id="bed__hf2"></hf-audio-group>',
);
assignMediaRenderIds(document);
const identities = () =>
[...document.querySelectorAll("video, hf-audio-group")].map((element) =>
element.getAttribute(MEDIA_RENDER_ID_ATTR),
);
expect(identities()).toEqual(["bed__hf3", "bed", "bed__hf4", "bed__hf2"]);
assignMediaRenderIds(document);
expect(identities()).toEqual(["bed__hf3", "bed", "bed__hf4", "bed__hf2"]);
});
/**
* A sub-composition declaring a bus AND its members, used twice. The author id
* `bed` is unique per FILE and duplicated once inlined, and the two instances
+13 -16
View File
@@ -92,17 +92,20 @@ function uniqueRenderId(baseId: string, taken: Set<string>): string {
/**
* Stamp `data-hf-render-id` on every media element in a compiled document.
*
* Idempotent: an element that already carries the attribute keeps it, so
* re-compiling a document (the resolved-durations recompile path) does not
* renumber ids out from under an in-flight extraction.
* Preserve unique existing stamps on recompilation. Repair stamps that collide
* after independently compiled scenes are merged.
*/
export function assignMediaRenderIds(document: DocumentLike): void {
const taken = new Set<string>();
const pending: MediaElementLike[] = [];
for (const el of document.querySelectorAll(MEDIA_SELECTOR)) {
const elements = [
...document.querySelectorAll(MEDIA_SELECTOR),
...document.querySelectorAll(AUDIO_GROUP_SELECTOR),
];
for (const el of elements) {
const existing = el.getAttribute(MEDIA_RENDER_ID_ATTR);
if (existing) {
if (existing && !taken.has(existing)) {
taken.add(existing);
continue;
}
@@ -119,7 +122,7 @@ export function assignMediaRenderIds(document: DocumentLike): void {
el.setAttribute(MEDIA_RENDER_ID_ATTR, renderId);
}
assignAudioGroupRenderIds(document, taken);
assignAudioGroupRenderIds(document);
}
/**
@@ -138,8 +141,8 @@ export function assignMediaRenderIds(document: DocumentLike): void {
* the first bus with that id, which is the pre-existing behaviour and the only
* sensible reading when there is one bus and several scenes referencing it.
*/
function assignAudioGroupRenderIds(document: DocumentLike, taken: Set<string>): void {
const busesById = stampAudioGroupBuses(document, taken);
function assignAudioGroupRenderIds(document: DocumentLike): void {
const busesById = indexAudioGroupBuses(document);
if (busesById.size === 0) return;
for (const member of document.querySelectorAll(
@@ -153,21 +156,15 @@ function assignAudioGroupRenderIds(document: DocumentLike, taken: Set<string>):
}
}
/** Every id'd bus, stamped and indexed by author id — several per id when a
/** Index stamped buses by author id; several share an id when a
* sub-composition that declares one is used more than once. */
function stampAudioGroupBuses(
document: DocumentLike,
taken: Set<string>,
): Map<string, ScopedElementLike[]> {
function indexAudioGroupBuses(document: DocumentLike): Map<string, ScopedElementLike[]> {
const busesById = new Map<string, ScopedElementLike[]>();
for (const bus of document.querySelectorAll(
AUDIO_GROUP_SELECTOR,
) as Iterable<ScopedElementLike>) {
const id = bus.getAttribute("id");
if (!id) continue;
const renderId = bus.getAttribute(MEDIA_RENDER_ID_ATTR) ?? uniqueRenderId(id, taken);
taken.add(renderId);
bus.setAttribute(MEDIA_RENDER_ID_ATTR, renderId);
const existing = busesById.get(id);
if (existing) existing.push(bus);
else busesById.set(id, [bus]);
@@ -21,7 +21,7 @@ import { isImageElement } from "./domRealm";
/**
* The render id an element is addressed by: its stamped, document-unique id
* when the producer compiled this document, else its author id. The two are
* when the compiler stamped this document, else its author id. The two are
* the same string whenever no collision forced a suffix.
*/
export function readMediaRenderId(media: Element): string | null {
+1
View File
@@ -158,6 +158,7 @@ export {
} from "./services/chromeMemorySampler.js";
// ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
export {
beginFrameCapture,
pageScreenshotCapture,
@@ -4,14 +4,14 @@
* The pipeline addresses media by id, but element ids are only unique within
* one composition FILE and the render document inlines many, so
* `document.getElementById` silently resolves duplicates to whichever element
* comes first. The producer stamps a document-unique `data-hf-render-id`
* comes first. The compiler stamps a document-unique `data-hf-render-id`
* (core's mediaRenderIds.ts); these helpers are the single place that knows to
* prefer it, so every capture stage addresses the same element.
*
* Installed via `evaluateOnNewDocument` so it exists before any page script.
* Every call site keeps a `getElementById` fallback for documents that were
* never compiled by the producer (snapshot, check, and direct engine callers),
* where the authored id already is the identity.
* Every call site keeps a `getElementById` fallback for uncompiled documents
* passed directly to the engine. CLI captures and producer renders both carry
* document-unique IDs from the compiler.
*
* `__hfMediaId` mirrors core's `readMediaRenderId`, which is the definition of
* this rule; the copy exists only because code serialized into `page.evaluate`
@@ -3,6 +3,7 @@
import { describe, it, expect, vi } from "vitest";
import { parseHTML } from "linkedom";
import { type Page } from "puppeteer-core";
import { installMediaRenderIdBridge } from "./mediaRenderIdBridge.js";
import {
pageScreenshotCapture,
pageContentExceedsCaptureHeight,
@@ -558,6 +559,33 @@ describe("video-frame injection respects ancestor visibility", () => {
};
}
it("switches frame visibility by render identity when scenes share an authored video id", async () => {
const { setup, teardown } = withGlobals(setupHostHiddenScenario({}));
try {
const first = setup.video.cloneNode() as HTMLVideoElement;
first.setAttribute("data-hf-render-id", "pip");
setup.video.setAttribute("data-hf-render-id", "pip__hf2");
const firstFrame = setup.document.createElement("img");
const secondFrame = setup.document.createElement("img");
firstFrame.className = secondFrame.className = "__render_frame__";
setup.video.before(first, firstFrame);
setup.video.after(secondFrame);
const page = {
...passthroughPage(),
evaluateOnNewDocument: async (fn: (attr: string) => void, attr: string) => fn(attr),
} as unknown as Page;
await installMediaRenderIdBridge(page);
await syncVideoFrameVisibility(page, ["pip__hf2"]);
expect(firstFrame.style.visibility).toBe("hidden");
expect(secondFrame.style.visibility).toBe("visible");
await syncVideoFrameVisibility(page, ["pip"]);
expect(firstFrame.style.visibility).toBe("visible");
expect(secondFrame.style.visibility).toBe("hidden");
} finally {
teardown();
}
});
it("skips replacement-frame creation when the video's host has visibility:hidden", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
try {
@@ -893,7 +893,7 @@ export async function syncVideoFrameVisibility(
const img = video.nextElementSibling as HTMLElement | null;
const hasImg = img && img.classList.contains("__render_frame__");
const ancestorHidden = isVisualAncestorHidden(video);
const visible = active.has(video.id) && !ancestorHidden;
const visible = active.has(window.__hfMediaId?.(video) || video.id) && !ancestorHidden;
if (visible) {
// Active video: show injected <img>, hide native <video>.
// Do NOT clobber inline opacity here — GSAP-controlled opacity must
+1 -1
View File
@@ -9,7 +9,7 @@
},
"@hyperframes/core": {
"packed": 1636442,
"unpacked": 3942104
"unpacked": 3942463
},
"@hyperframes/engine": {
"packed": 428347,