Skip to content

Commit 3d208f2

Browse files
Merge branch 'main' into fix/producer-progress-from-first-frame
2 parents 74548b5 + aa2c2c6 commit 3d208f2

26 files changed

Lines changed: 477 additions & 29 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "hyperframes",
33
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
4-
"version": "0.8.71",
4+
"version": "0.8.72",
55
"author": {
66
"name": "HeyGen",
77
"email": "hyperframes@heygen.com",

‎.codex-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "hyperframes",
33
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
4-
"version": "0.8.71",
4+
"version": "0.8.72",
55
"author": {
66
"name": "HeyGen",
77
"email": "hyperframes@heygen.com",

‎.cursor-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "hyperframes",
44
"displayName": "HyperFrames by HeyGen",
55
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
6-
"version": "0.8.71",
6+
"version": "0.8.72",
77
"author": {
88
"name": "HeyGen",
99
"email": "hyperframes@heygen.com"

‎docs/changelog.mdx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
88

99
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
1010

11+
<Update
12+
label="HyperFrames v0.8.72"
13+
description="Released - 2026-09-24"
14+
tags={["Release", "Studio", "Core"]}
15+
>
16+
Clicking an element inside a section now selects that element, even when the section's root sets pointer-events to none. A frame grabbed right after the first seek on a fresh preview now shows the video at the time you asked for. Apps that embed Studio's preview can now draw the film edge to edge in its box.
17+
18+
## Features
19+
20+
- **Studio:** Let an embedder draw the preview edge to edge with fillBox ([326df8def](https://github.com/heygen-com/hyperframes/commit/326df8def036e708d25a0ca0e069f91172767b0e), [#4408](https://github.com/heygen-com/hyperframes/pull/4408)).
21+
22+
## Fixes
23+
24+
- **Core:** Frame captures wait for a seeking video to land its frame ([261af22c1](https://github.com/heygen-com/hyperframes/commit/261af22c1684d79ed30cc72f7f328f698fea2adb), [#4406](https://github.com/heygen-com/hyperframes/pull/4406)).
25+
- **Core:** A section whose root sets pointer-events:none stays pickable ([115332f4f](https://github.com/heygen-com/hyperframes/commit/115332f4f6114c078ba4f6d3b252387545c96c03), [#4399](https://github.com/heygen-com/hyperframes/pull/4399)).
26+
27+
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.8.71...v0.8.72).
28+
</Update>
29+
1130
<Update
1231
label="HyperFrames v0.8.71"
1332
description="Released - 2026-09-24"

‎packages/aws-lambda/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/aws-lambda",
3-
"version": "0.8.71",
3+
"version": "0.8.72",
44
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
55
"repository": {
66
"type": "git",

‎packages/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/cli",
3-
"version": "0.8.71",
3+
"version": "0.8.72",
44
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
55
"license": "Apache-2.0",
66
"repository": {

‎packages/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/core",
3-
"version": "0.8.71",
3+
"version": "0.8.72",
44
"description": "",
55
"repository": {
66
"type": "git",

‎packages/core/src/runtime/media.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./media";
99
import type { RuntimeMediaClip } from "./media";
1010
import { resolveNaturalMediaTimelineDuration } from "./playbackRate";
11+
import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch";
1112
import { sourceTimeAt } from "../speedRamp";
1213
import type { HfAutomationLane } from "../audioAutomation";
1314

@@ -704,6 +705,43 @@ describe("syncRuntimeMedia", () => {
704705
});
705706
});
706707

708+
describe("video seek completion", () => {
709+
afterEach(() => resetSeekDispatchState());
710+
711+
function seekColdVideo(): RuntimeMediaClip {
712+
const clip = createMockClip({ start: 7.1, end: 18.24 });
713+
Object.defineProperty(clip.el, "seeking", { value: true, configurable: true });
714+
syncRuntimeMedia({ clips: [clip], timeSeconds: 12, playing: false, playbackRate: 1 });
715+
return clip;
716+
}
717+
718+
async function barrierSettled(barrier: Promise<void>): Promise<boolean> {
719+
let settled = false;
720+
void barrier.then(() => (settled = true));
721+
await new Promise((resolve) => setTimeout(resolve, 0));
722+
return settled;
723+
}
724+
725+
it("holds the seek-completion barrier until a seeking video lands its frame", async () => {
726+
const clip = seekColdVideo();
727+
expect(clip.el.currentTime).toBeCloseTo(4.9);
728+
const barrier = waitForSeekCompletion();
729+
expect(await barrierSettled(barrier)).toBe(false);
730+
clip.el.dispatchEvent(new Event("seeked"));
731+
expect(await barrierSettled(barrier)).toBe(true);
732+
});
733+
734+
it.each(["error", "emptied", "abort"])(
735+
"releases the barrier when the seek ends in %s",
736+
async (type) => {
737+
const clip = seekColdVideo();
738+
const barrier = waitForSeekCompletion();
739+
clip.el.dispatchEvent(new Event(type));
740+
expect(await barrierSettled(barrier)).toBe(true);
741+
},
742+
);
743+
});
744+
707745
describe("play() storm guard (unplayable elements)", () => {
708746
it("does not play() an element with a media error", () => {
709747
const clip = createMockClip({ start: 0, end: 10 });

‎packages/core/src/runtime/media.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { rateAt, sourceTimeAt, timeAtSourceTime, type RateSpec } from "../speedR
88
import { clampAudioGain } from "../audioGain.js";
99
import { isMemberGroupHidden } from "../audioGroups.js";
1010
import { findInjectedRenderFrame } from "./renderFrameSibling.js";
11+
import { registerSeekCompletion } from "./adapters/seek-dispatch.js";
1112
export {
1213
readElementPlaybackRate,
1314
readElementRateSpec,
@@ -18,6 +19,22 @@ export function readElementPlaybackStart(el: Element): number {
1819
return readMediaStart(el);
1920
}
2021

22+
const SEEK_END_EVENTS = ["seeked", "error", "emptied", "abort"] as const;
23+
24+
// A seeking video still paints its previous frame; frame captures wait on the seek barrier until it lands.
25+
function holdSeekBarrierUntilVideoLands(el: HTMLMediaElement): void {
26+
if (el.tagName !== "VIDEO" || !el.seeking) return;
27+
registerSeekCompletion(
28+
new Promise<void>((resolve) => {
29+
const done = () => {
30+
for (const type of SEEK_END_EVENTS) el.removeEventListener(type, done);
31+
resolve();
32+
};
33+
for (const type of SEEK_END_EVENTS) el.addEventListener(type, done);
34+
}),
35+
);
36+
}
37+
2138
/**
2239
* Resolve a media element's timeline window without conflating a video's
2340
* authored display slot with the amount of source left to decode.
@@ -502,6 +519,7 @@ export function syncRuntimeMedia(params: {
502519
swallow("runtime.media.site3", err);
503520
}
504521
}
522+
holdSeekBarrierUntilVideoLands(el);
505523
}
506524
playRequested.delete(el);
507525
}

‎packages/core/src/runtime/picker.test.ts‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ beforeAll(() => {
2525
}
2626
});
2727

28+
function ancestorsOf(el: Element): Element[] {
29+
const chain: Element[] = [];
30+
for (let at = el.parentElement; at; at = at.parentElement) chain.push(at);
31+
return chain;
32+
}
33+
34+
// Like a browser: the paint stack under the point, top first, minus computed pointer-events:none.
35+
function emulateHitTest(painted: () => Element[]): () => void {
36+
const original = document.elementsFromPoint;
37+
Object.defineProperty(document, "elementsFromPoint", {
38+
configurable: true,
39+
value: vi.fn(() => painted().filter((el) => getComputedStyle(el).pointerEvents !== "none")),
40+
});
41+
return () =>
42+
Object.defineProperty(document, "elementsFromPoint", { configurable: true, value: original });
43+
}
44+
2845
function createMockPostMessage() {
2946
return vi.fn();
3047
}
@@ -150,6 +167,163 @@ describe("createPickerModule", () => {
150167
}
151168
});
152169

170+
it("picks inside a mounted section whose root sets pointer-events:none", () => {
171+
const picker = createPickerModule({ postMessage: createMockPostMessage() });
172+
picker.installPickerApi();
173+
// The mount rescopes the section's own root rule onto its inner root.
174+
document.head.innerHTML =
175+
'<style>[data-composition-id="intro"] > [data-hf-inner-root] { pointer-events: none }' +
176+
" .vignette { pointer-events: none }</style>";
177+
document.body.innerHTML = `<div data-composition-id="intro" data-composition-src="intro.html">
178+
<div data-hf-inner-root="true"><div class="card"><code id="code">tl.to()</code></div>
179+
<div class="vignette" id="vignette"></div></div></div>`;
180+
const code = document.getElementById("code")!;
181+
// Paint order under the point, top first; like a browser, drop what has pointer-events:none.
182+
const painted = [document.getElementById("vignette")!, code, code.parentElement!];
183+
const originalElementsFromPoint = document.elementsFromPoint;
184+
Object.defineProperty(document, "elementsFromPoint", {
185+
configurable: true,
186+
value: vi.fn(() =>
187+
[...painted, ...ancestorsOf(code.parentElement!)].filter(
188+
(el) => getComputedStyle(el).pointerEvents !== "none",
189+
),
190+
),
191+
});
192+
193+
const api = (window as any).__HF_PICKER_API;
194+
try {
195+
expect(api.getCandidatesAtPoint(10, 10)[0]?.selector).toBe("#code");
196+
expect(api.pickAtPoint(10, 10)?.selector).toBe("#code");
197+
expect(document.head.querySelectorAll("style")).toHaveLength(1);
198+
} finally {
199+
Object.defineProperty(document, "elementsFromPoint", {
200+
configurable: true,
201+
value: originalElementsFromPoint,
202+
});
203+
}
204+
});
205+
206+
it("picks inside a composition opened on its own whose root sets pointer-events:none", () => {
207+
const picker = createPickerModule({ postMessage: createMockPostMessage() });
208+
picker.installPickerApi();
209+
document.head.innerHTML = "<style>#root { pointer-events: none }</style>";
210+
document.body.innerHTML = `<div id="root" data-composition-id="intro"><div id="card">
211+
<code id="code">tl.to()</code></div></div>`;
212+
const at = (id: string) => document.getElementById(id)!;
213+
const restore = emulateHitTest(() => [at("code"), at("card"), at("root")]);
214+
const api = (window as any).__HF_PICKER_API;
215+
try {
216+
expect(api.getCandidatesAtPoint(10, 10).map((c: any) => c.selector)).toEqual([
217+
"#code",
218+
"#card",
219+
]);
220+
} finally {
221+
restore();
222+
}
223+
});
224+
225+
it("adopts the override only for the hit test, leaving the DOM and a saved outerHTML untouched", () => {
226+
const adopted: CSSStyleSheet[] = [];
227+
Object.defineProperty(document, "adoptedStyleSheets", {
228+
configurable: true,
229+
get: () => adopted.slice(),
230+
set: (next: CSSStyleSheet[]) => adopted.splice(0, adopted.length, ...next),
231+
});
232+
const picker = createPickerModule({ postMessage: createMockPostMessage() });
233+
picker.installPickerApi();
234+
document.body.innerHTML =
235+
'<div data-composition-id="intro"><div data-hf-inner-root="true" style="pointer-events: none">' +
236+
'<span id="t">hi</span></div></div>';
237+
const during: string[][] = [];
238+
const originalElementsFromPoint = document.elementsFromPoint;
239+
Object.defineProperty(document, "elementsFromPoint", {
240+
configurable: true,
241+
value: vi.fn(() => {
242+
during.push(adopted.flatMap((sheet) => [...sheet.cssRules].map((rule) => rule.cssText)));
243+
return [document.getElementById("t")!];
244+
}),
245+
});
246+
const observer = new MutationObserver(() => {});
247+
observer.observe(document, { childList: true, subtree: true, attributes: true });
248+
const html = document.documentElement.outerHTML;
249+
250+
const api = (window as any).__HF_PICKER_API;
251+
try {
252+
// jsdom ignores adopted sheets for style, so this checks the sheet's lifecycle, not what it picks.
253+
api.getCandidatesAtPoint(10, 10);
254+
api.pickAtPoint(10, 10);
255+
expect(during).toHaveLength(2);
256+
expect(during[0]?.join()).toContain("pointer-events: auto !important");
257+
expect(adopted).toEqual([]);
258+
expect(observer.takeRecords()).toEqual([]);
259+
expect(document.documentElement.outerHTML).toBe(html);
260+
// With no pass-through root there is nothing to override, so nothing is adopted and nothing restyles.
261+
(document.querySelector("[data-hf-inner-root]") as HTMLElement).style.pointerEvents = "";
262+
api.getCandidatesAtPoint(10, 10);
263+
expect(during.at(-1)).toEqual([]);
264+
} finally {
265+
observer.disconnect();
266+
Object.defineProperty(document, "elementsFromPoint", {
267+
configurable: true,
268+
value: originalElementsFromPoint,
269+
});
270+
delete (document as { adoptedStyleSheets?: unknown }).adoptedStyleSheets;
271+
}
272+
});
273+
274+
it("keeps a click-through overlay host passing through to what is under it", () => {
275+
const picker = createPickerModule({ postMessage: createMockPostMessage() });
276+
picker.installPickerApi();
277+
document.body.innerHTML = `<div id="root" data-composition-id="main"><video id="aroll"></video>
278+
<div id="ovl" data-composition-id="intro_outro" data-composition-src="intro.html"
279+
style="pointer-events: none"><div data-hf-inner-root="true"><h1 id="t">Hi</h1></div></div></div>`;
280+
const at = (id: string) => document.getElementById(id)!;
281+
const inner = document.querySelector("[data-hf-inner-root]")!;
282+
const restore = emulateHitTest(() => [at("t"), inner, at("ovl"), at("aroll"), at("root")]);
283+
const api = (window as any).__HF_PICKER_API;
284+
try {
285+
expect(api.getCandidatesAtPoint(10, 10).map((c: any) => c.selector)).toEqual([
286+
"#aroll",
287+
"#root",
288+
]);
289+
} finally {
290+
restore();
291+
}
292+
});
293+
294+
it("a section background click picks the host, and an inner root never gets a bare tag selector", () => {
295+
const picker = createPickerModule({ postMessage: createMockPostMessage() });
296+
picker.installPickerApi();
297+
document.head.innerHTML =
298+
'<style>[data-composition-id="intro"] > [data-hf-inner-root] { pointer-events: none }</style>';
299+
document.body.innerHTML = `<div id="root" data-composition-id="main"><div id="host"
300+
data-composition-id="intro" data-composition-src="intro.html"><div data-hf-inner-root="true">
301+
</div></div><div data-composition-id="outro"><div data-hf-inner-root="true"></div></div></div>`;
302+
const [intro, outro] = Array.from(document.querySelectorAll("[data-hf-inner-root]"));
303+
const api = (window as any).__HF_PICKER_API;
304+
let restore = emulateHitTest(() => [
305+
intro!,
306+
intro!.parentElement!,
307+
document.getElementById("root")!,
308+
]);
309+
try {
310+
expect(api.getCandidatesAtPoint(10, 10).map((c: any) => c.selector)).toEqual([
311+
"#host",
312+
"#root",
313+
]);
314+
} finally {
315+
restore();
316+
}
317+
restore = emulateHitTest(() => [outro!, outro!.parentElement!]);
318+
try {
319+
expect(api.getCandidatesAtPoint(10, 10)[0]?.selector).toBe(
320+
'[data-composition-id="outro"] > [data-hf-inner-root]',
321+
);
322+
} finally {
323+
restore();
324+
}
325+
});
326+
153327
it("pickAtPoint returns null for invalid coords", () => {
154328
const picker = createPickerModule({ postMessage: createMockPostMessage() });
155329
picker.installPickerApi();

0 commit comments

Comments
 (0)