diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts
index df0413aa02..f26176f050 100644
--- a/packages/cli/src/commands/snapshot.test.ts
+++ b/packages/cli/src/commands/snapshot.test.ts
@@ -1027,3 +1027,37 @@ describe("snapshot composite recapture", () => {
expect(finalCapture).toBeGreaterThan(recapture);
});
});
+
+describe("snapshot media identity", () => {
+ it("keys two videos that share an author id apart, as the render path does", async () => {
+ const project = mkdtempSync(join(tmpdir(), "hf-snapshot-dup-ids-"));
+ const clip = (src: string) =>
+ ``;
+ writeFileSync(
+ join(project, "index.html"),
+ `
` +
+ `${clip("a.mp4")}${clip("b.mp4")}
`,
+ );
+ snapshotState.openSettledPage.mockClear();
+ 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();
+ const [html, , options] = snapshotState.openSettledPage.mock.calls[0] as unknown as [
+ string,
+ string,
+ { beforeNavigate?: unknown },
+ ];
+ expect(html).toContain('data-hf-render-id="clip"');
+ expect(html).toContain('data-hf-render-id="clip__hf2"');
+ expect(typeof options.beforeNavigate).toBe("function");
+ } finally {
+ log.mockRestore();
+ rmSync(project, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts
index e382eeabed..2b693c8305 100644
--- a/packages/cli/src/commands/snapshot.ts
+++ b/packages/cli/src/commands/snapshot.ts
@@ -36,13 +36,17 @@ import {
type ZoomTarget,
} from "../capture/captureCompositionFrame.js";
import {
+ assignMediaRenderIds,
isClipVisibleAt,
isInClipWindow,
+ MEDIA_RENDER_ID_ATTR,
readElementRateSpec,
sourceTimeAt,
timeAtSourceTime,
type RateSpec,
} from "@hyperframes/core";
+import { parseHTML } from "linkedom";
+import type { Page } from "puppeteer-core";
import { resolveProject } from "../utils/project.js";
import {
definitiveEntryMismatchComposition,
@@ -491,6 +495,18 @@ export function serveCapturePage(projectDir: string, html: string, proxy: boolea
return serveStaticProjectHtml(projectDir, html, undefined, [], proxy === true);
}
+/** Two clips sharing an author id must stay two frames, keyed as the render path keys them. */
+function withMediaRenderIds(html: string): string {
+ const { document } = parseHTML(html);
+ assignMediaRenderIds(document);
+ return document.toString();
+}
+
+async function installMediaRenderIdBridge(page: Page): Promise {
+ const engine = await import("@hyperframes/engine").catch(() => null);
+ await engine?.installMediaRenderIdBridge(page);
+}
+
/**
* Render key frames from a composition as PNG screenshots.
* The agent can Read these to verify its output visually.
@@ -541,7 +557,7 @@ async function captureSnapshots(
// Localize fonts (embed remote @font-face as data URIs, matching the render
// path) so snapshots render the real font instead of a fallback sans.
- const html = await bundleWithLocalizedFonts(projectDir);
+ const html = withMediaRenderIds(await bundleWithLocalizedFonts(projectDir));
const server = await serveCapturePage(projectDir, html, opts.autoProxy);
const savedPaths: string[] = [];
@@ -555,6 +571,7 @@ async function captureSnapshots(
renderReadyTimeoutMs: opts.timeout ?? 5000,
renderReadyWarningSuffix: "snapshots may be inaccurate",
browserGpuMode: opts.browserGpuMode,
+ beforeNavigate: installMediaRenderIdBridge,
});
try {
@@ -755,7 +772,7 @@ async function captureSnapshots(
if (cameraExpr) await page.evaluate(cameraExpr);
if (injectVideoFramesBatch && syncVideoFrameVisibility) {
- const candidates = await page.evaluate(() => {
+ const candidates = await page.evaluate((renderIdAttr: string) => {
const runtimeWindow = window as Window & {
__hfResolveMediaStartSeconds?: (element: Element) => number;
};
@@ -769,7 +786,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: v.getAttribute(renderIdAttr) || v.id,
src: v.currentSrc || v.src,
authoredStart,
authoredRate: v.dataset.playbackRate,
@@ -788,7 +805,7 @@ async function captureSnapshots(
devicePixelRatio: window.devicePixelRatio,
};
});
- });
+ }, MEDIA_RENDER_ID_ATTR);
const compositionDuration = duration;
const active = candidates.flatMap((candidate) => {
const start = resolveSnapshotVideoClipStart(candidate);
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 9d5a4aac57..fab66385fb 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -173,6 +173,7 @@ export {
DOM_LAYER_MASK_STYLE_ID,
type BeginFrameResult,
} from "./services/screenshotService.js";
+export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
// ── Encoding ───────────────────────────────────────────────────────────────────
export {
diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts
index 9fd1d9a0bb..ea0725c698 100644
--- a/packages/engine/src/services/screenshotService.test.ts
+++ b/packages/engine/src/services/screenshotService.test.ts
@@ -16,6 +16,7 @@ import {
initTransparentBackground,
DOM_LAYER_MASK_STYLE_ID,
} from "./screenshotService.js";
+import { installMediaRenderIdBridge } from "./mediaRenderIdBridge.js";
// These DOM tests isolate layout/decode from the CDP transport, exercised in Chrome separately.
vi.mock("./videoFrameTransport.js", () => ({
@@ -621,6 +622,33 @@ describe("video-frame injection respects ancestor visibility", () => {
expect(seededImg.style.visibility).toBe("hidden");
});
+ it("keeps a frame injected by render id visible through the sync, as snapshot calls them", async () => {
+ const { teardown, setup } = withGlobals(
+ setupHostHiddenScenario({}, { hostAttribute: "data-start" }),
+ );
+ setup.video.setAttribute("data-hf-render-id", "pip__hf2");
+ try {
+ await installMediaRenderIdBridge({
+ evaluateOnNewDocument: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
+ fn(...args),
+ } as unknown as Page);
+ await injectVideoFramesBatch(passthroughPage(), [
+ {
+ videoId: "pip__hf2",
+ dataUri:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
+ },
+ ]);
+ await syncVideoFrameVisibility(passthroughPage(), ["pip__hf2"]);
+ } finally {
+ teardown();
+ }
+
+ const img = setup.video.nextElementSibling as HTMLElement | null;
+ expect(img?.classList.contains("__render_frame__")).toBe(true);
+ expect(img?.style.visibility).toBe("visible");
+ });
+
it("syncVideoFrameVisibility hides the replacement
for ancestor-hidden actives", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
const seededImg = setup.document.createElement("img");
diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts
index d702f8bf17..abf1bac548 100644
--- a/packages/engine/src/services/screenshotService.ts
+++ b/packages/engine/src/services/screenshotService.ts
@@ -889,7 +889,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
, hide native