mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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="{"accent":"#000001","title":"Card 1","size":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="[{"name":"clip-path","localName":"clip-path","namespaceURI":null,"before":"url(#clip)","after":"url(#clip)"}]" 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="{"accent":"#000002","title":"Card 2","size":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="[{"name":"id","localName":"id","namespaceURI":null,"before":"clip","after":"card__hf2--clip"}]" 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="[{"name":"clip-path","localName":"clip-path","namespaceURI":null,"before":"url(#clip)","after":"url(#card__hf2--clip)"}]" 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="{"accent":"#000003","title":"Card 3","size":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="[{"name":"id","localName":"id","namespaceURI":null,"before":"clip","after":"card__hf3--clip"}]" 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="[{"name":"clip-path","localName":"clip-path","namespaceURI":null,"before":"url(#clip)","after":"url(#card__hf3--clip)"}]" 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="{"accent":"#000001","title":"Card 1","size":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="[{"name":"clip-path","localName":"clip-path","namespaceURI":null,"before":"url(#clip)","after":"url(#clip)"}]" 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="{"accent":"#000002","title":"Card 2","size":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="[{"name":"id","localName":"id","namespaceURI":null,"before":"clip","after":"card__hf2--clip"}]" data-hf-authored-id="clip" id="card__hf2--clip"><rect width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{"name":"clip-path","localName":"clip-path","namespaceURI":null,"before":"url(#clip)","after":"url(#card__hf2--clip)"}]" 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="{"accent":"#000003","title":"Card 3","size":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="[{"name":"id","localName":"id","namespaceURI":null,"before":"clip","after":"card__hf3--clip"}]" data-hf-authored-id="clip" id="card__hf3--clip"><rect width="5" height="5" /></clippath><rect data-hf-svg-reference-aliases="[{"name":"clip-path","localName":"clip-path","namespaceURI":null,"before":"url(#clip)","after":"url(#card__hf3--clip)"}]" 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"@hyperframes/core": {
|
||||
"packed": 1636442,
|
||||
"unpacked": 3942104
|
||||
"unpacked": 3942463
|
||||
},
|
||||
"@hyperframes/engine": {
|
||||
"packed": 428347,
|
||||
|
||||
Reference in New Issue
Block a user