diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 8546c740f66..0a976f2dc67 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 diff --git a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts index fbc7de531f7..0942a77430c 100644 --- a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts @@ -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()), buildChromeArgs: mocks.buildChromeArgs, resolveBrowserGpuMode: mocks.resolveBrowserGpuMode, compositionRequiresWebGpu: mocks.compositionRequiresWebGpu, diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 495fd9186f7..a9f117af2cb 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -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); diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index df0413aa021..2cb8a62fef6 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -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"), + '
', + ); + 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 }); + } +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index e382eeabed6..311c3cd184b 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -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, diff --git a/packages/core/src/compiler/__goldens__/pageBuild.preview.html b/packages/core/src/compiler/__goldens__/pageBuild.preview.html index 56f6617237c..3a346a3e85c 100644 --- a/packages/core/src/compiler/__goldens__/pageBuild.preview.html +++ b/packages/core/src/compiler/__goldens__/pageBuild.preview.html @@ -85,7 +85,7 @@

Title

- +
Cell
@@ -95,7 +95,7 @@

Title

Title

- +
Cell
@@ -105,7 +105,7 @@

Title

Title

- +
Cell
diff --git a/packages/core/src/compiler/__goldens__/pageBuild.render.html b/packages/core/src/compiler/__goldens__/pageBuild.render.html index 8f7f66000d2..942b9eb97c7 100644 --- a/packages/core/src/compiler/__goldens__/pageBuild.render.html +++ b/packages/core/src/compiler/__goldens__/pageBuild.render.html @@ -111,7 +111,7 @@

Title

- +
Cell
@@ -121,7 +121,7 @@

Title

Title

- +
Cell
@@ -131,7 +131,7 @@

Title

Title

- +
Cell
diff --git a/packages/core/src/compiler/htmlBundler.mediaIdentity.test.ts b/packages/core/src/compiler/htmlBundler.mediaIdentity.test.ts new file mode 100644 index 00000000000..1af675ea5ac --- /dev/null +++ b/packages/core/src/compiler/htmlBundler.mediaIdentity.test.ts @@ -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"), + ` +
+
+
+
`, + ); + for (const scene of ["first", "second"]) { + writeFileSync( + join(project, "scenes", `${scene}.html`), + ``, + ); + } + 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 }); + } + }, +); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index cc045da9e29..17b5530d9d7 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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(); } diff --git a/packages/core/src/compiler/mediaRenderIds.test.ts b/packages/core/src/compiler/mediaRenderIds.test.ts index f0411a1f399..6f1b4c7fcee 100644 --- a/packages/core/src/compiler/mediaRenderIds.test.ts +++ b/packages/core/src/compiler/mediaRenderIds.test.ts @@ -60,6 +60,16 @@ describe("assignMediaRenderIds", () => { ).toEqual(["clip", "clip__hf2"]); }); + it("repairs duplicate existing stamps while reserving later unique stamps", () => { + expect( + stamp( + '