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
3 changes: 3 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/capture/captureCompositionFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
2 changes: 1 addition & 1 deletion packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/compiler/__goldens__/pageBuild.preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -95,7 +95,7 @@ <h1 data-hf-id="hf-bowk" class="title intro">Title</h1>
</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">
Expand All @@ -105,7 +105,7 @@ <h1 data-hf-id="hf-bowk" class="title intro">Title</h1>
</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">
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/compiler/__goldens__/pageBuild.render.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -121,7 +121,7 @@ <h1 class="title intro">Title</h1>
</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">
Expand All @@ -131,7 +131,7 @@ <h1 class="title intro">Title</h1>
</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">
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/compiler/htmlBundler.mediaIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
},
);
2 changes: 2 additions & 0 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";

Expand Down Expand Up @@ -1486,6 +1487,7 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
}
}

assignMediaRenderIds(document);
return document.toString();
}

Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/compiler/mediaRenderIds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading