@hyperframes/studio 0.8.139 → 0.8.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
  5. package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
  6. package/dist/index.d.ts +19 -3
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +6498 -5813
  9. package/dist/index.js.map +1 -1
  10. package/dist/player.js +1 -1
  11. package/package.json +8 -8
  12. package/src/components/TimelineToolbar.test.tsx +79 -1
  13. package/src/components/TimelineToolbar.tsx +14 -29
  14. package/src/components/editor/DomEditOverlay.tsx +2 -2
  15. package/src/components/editor/PropertyPanel.tsx +1 -0
  16. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  17. package/src/components/editor/manualEdits.ts +1 -1
  18. package/src/components/editor/manualEditsParsing.ts +14 -2
  19. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  20. package/src/components/editor/propertyPanelFill.tsx +36 -13
  21. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  23. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  24. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  25. package/src/components/nle/NLEPreview.test.ts +44 -1
  26. package/src/components/nle/NLEPreview.tsx +7 -2
  27. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  28. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  29. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  30. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  31. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  32. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  33. package/src/hooks/useSdkSession.ts +4 -1
  34. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  35. package/src/hooks/useThumbnailStripSize.ts +23 -1
  36. package/src/index.ts +1 -0
  37. package/src/player/components/AudioWaveform.test.tsx +153 -1
  38. package/src/player/components/AudioWaveform.tsx +93 -37
  39. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  40. package/src/player/components/Timeline.test.ts +3 -1
  41. package/src/player/components/TimelineCanvas.tsx +4 -0
  42. package/src/player/components/TimelineClip.tsx +20 -6
  43. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  44. package/src/player/components/TimelineClipFades.tsx +4 -4
  45. package/src/player/components/TimelineClipJoins.tsx +23 -18
  46. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  47. package/src/player/components/TimelineGroupRow.tsx +1 -0
  48. package/src/player/components/TimelineLaneClip.tsx +127 -0
  49. package/src/player/components/TimelineLanes.test.tsx +51 -6
  50. package/src/player/components/TimelineLanes.tsx +166 -178
  51. package/src/player/components/TimelineRuler.tsx +2 -0
  52. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  53. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  54. package/src/player/components/VideoThumbnail.tsx +37 -9
  55. package/src/player/components/thumbnailUtils.test.ts +17 -3
  56. package/src/player/components/thumbnailUtils.ts +17 -1
  57. package/src/player/components/timelineLayout.test.ts +3 -3
  58. package/src/player/components/timelineMotion.test.ts +27 -0
  59. package/src/player/components/timelineMotion.ts +62 -0
  60. package/src/player/components/timelineZoomInput.test.ts +438 -0
  61. package/src/player/components/timelineZoomInput.ts +456 -0
  62. package/src/player/components/useTimelineGeometry.ts +0 -3
  63. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  64. package/src/player/components/useTimelinePlayhead.ts +53 -59
  65. package/src/player/components/useTimelineProviderState.tsx +16 -21
  66. package/src/player/components/useTimelineZoom.ts +7 -12
  67. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  68. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  69. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  70. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  71. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  72. /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
@@ -0,0 +1,164 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { StyleSections } from "./propertyPanelStyleSections";
6
+ import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function mount(
15
+ Panel: typeof StyleSections | typeof FlatStyleSection,
16
+ tag = "img",
17
+ sourceFile = "index.html",
18
+ src = "assets/old.png",
19
+ ) {
20
+ const selected = document.createElement(tag);
21
+ selected.id = "picture";
22
+ selected.setAttribute("src", src);
23
+ document.body.append(selected);
24
+ const element: DomEditSelection = {
25
+ element: selected,
26
+ id: "picture",
27
+ selector: "#picture",
28
+ label: "Picture",
29
+ tagName: tag,
30
+ sourceFile,
31
+ compositionPath: sourceFile,
32
+ isCompositionHost: false,
33
+ isInsideLockedComposition: false,
34
+ boundingBox: { x: 0, y: 0, width: 320, height: 180 },
35
+ textContent: "",
36
+ dataAttributes: {},
37
+ inlineStyles: {},
38
+ computedStyles: {},
39
+ textFields: [],
40
+ capabilities: {
41
+ canSelect: true,
42
+ canEditStyles: true,
43
+ canCrop: true,
44
+ canMove: true,
45
+ canResize: true,
46
+ canApplyManualOffset: true,
47
+ canApplyManualSize: true,
48
+ canApplyManualRotation: true,
49
+ },
50
+ };
51
+ const host = document.createElement("div");
52
+ document.body.append(host);
53
+ const root = createRoot(host);
54
+ const onSetStyle = vi.fn((prop: string, value: string) =>
55
+ selected.style.setProperty(prop, value),
56
+ );
57
+ const onSetHtmlAttribute = vi.fn((attr: string, value: string | null) => {
58
+ if (value !== null) selected.setAttribute(attr, value);
59
+ });
60
+ act(() =>
61
+ root.render(
62
+ <Panel
63
+ projectId="fixture"
64
+ element={element}
65
+ styles={{}}
66
+ assets={["assets/old.png", "assets/new.png"]}
67
+ onSetStyle={onSetStyle}
68
+ onSetHtmlAttribute={onSetHtmlAttribute}
69
+ />,
70
+ ),
71
+ );
72
+ const image = Array.from(host.querySelectorAll("button")).find(
73
+ (button) => button.textContent === "Image",
74
+ );
75
+ if (!image) throw new Error("Image control must exist");
76
+ act(() => image.click());
77
+ // The Image asset select is the one whose options include the fixture asset.
78
+ const assetSelect = Array.from(host.querySelectorAll("select")).find((node) =>
79
+ Array.from(node.options).some((option) => option.value === "assets/new.png"),
80
+ );
81
+ if (!assetSelect) throw new Error("Project asset control must exist");
82
+ function choose(value: string) {
83
+ const control = assetSelect;
84
+ if (!control) throw new Error("Asset control must exist");
85
+ act(() => {
86
+ control.value = value;
87
+ control.dispatchEvent(new Event("change", { bubbles: true }));
88
+ });
89
+ }
90
+ return { host, selected, root, choose, assetSelect, onSetStyle, onSetHtmlAttribute };
91
+ }
92
+
93
+ describe.each([
94
+ ["classic", StyleSections],
95
+ ["flat", FlatStyleSection],
96
+ ] as const)("%s Image fill", (_name, Panel) => {
97
+ it("shows the img source as the current project asset", () => {
98
+ const view = mount(Panel);
99
+ expect(view.assetSelect.value).toBe("assets/old.png");
100
+ act(() => view.root.unmount());
101
+ });
102
+ it.each([
103
+ "assets/100%.png",
104
+ 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100%"></svg>',
105
+ ])("shows an authored source containing a literal percent: %s", (src) => {
106
+ const view = mount(Panel, "img", "index.html", src);
107
+ const input = Array.from(view.host.querySelectorAll("label"))
108
+ .find((node) => node.querySelector("span")?.textContent === "External URL")
109
+ ?.querySelector("input");
110
+ expect(input?.value).toBe(src);
111
+ act(() => view.root.unmount());
112
+ });
113
+ it("replaces the img source rather than writing a hidden background", () => {
114
+ const view = mount(Panel);
115
+ view.choose("assets/new.png");
116
+ expect(view.selected.getAttribute("src")).toBe("assets/new.png");
117
+ expect(view.onSetHtmlAttribute).toHaveBeenCalledExactlyOnceWith("src", "assets/new.png");
118
+ expect(view.onSetStyle).not.toHaveBeenCalled();
119
+ act(() => view.root.unmount());
120
+ });
121
+ it("replaces an img with an external URL without CSS wrapping", () => {
122
+ const view = mount(Panel);
123
+ const row = Array.from(view.host.querySelectorAll("label")).find(
124
+ (node) =>
125
+ node.querySelector("span")?.textContent === "External URL" && node.querySelector("input"),
126
+ );
127
+ const input = row?.querySelector("input");
128
+ if (!input) throw new Error("External URL input must exist");
129
+ act(() => {
130
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
131
+ input,
132
+ "https://example.com/new.png",
133
+ );
134
+ input.dispatchEvent(new Event("input", { bubbles: true }));
135
+ });
136
+ act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
137
+ expect(view.selected.getAttribute("src")).toBe("https://example.com/new.png");
138
+ expect(view.onSetStyle).not.toHaveBeenCalled();
139
+ act(() => view.root.unmount());
140
+ });
141
+ it("keeps the img source when cleared", () => {
142
+ const view = mount(Panel);
143
+ view.choose("");
144
+ expect(view.selected.getAttribute("src")).toBe("assets/old.png");
145
+ expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
146
+ expect(view.onSetStyle).not.toHaveBeenCalled();
147
+ act(() => view.root.unmount());
148
+ });
149
+ it("uses source-relative image paths in nested compositions", () => {
150
+ const view = mount(Panel, "img", "compositions/scene.html");
151
+ view.choose("assets/new.png");
152
+ expect(view.selected.getAttribute("src")).toBe("../assets/new.png");
153
+ act(() => view.root.unmount());
154
+ });
155
+ it("keeps background-image replacement and clearing for ordinary elements", () => {
156
+ const view = mount(Panel, "div");
157
+ view.choose("assets/new.png");
158
+ expect(view.selected.style.backgroundImage).toBe('url("assets/new.png")');
159
+ view.choose("");
160
+ expect(view.selected.style.backgroundImage).toBe("none");
161
+ expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
162
+ act(() => view.root.unmount());
163
+ });
164
+ });
@@ -45,6 +45,7 @@ export function StyleSections({
45
45
  styles,
46
46
  assets,
47
47
  onSetStyle,
48
+ onSetHtmlAttribute,
48
49
  onImportAssets,
49
50
  gsapBorderRadius,
50
51
  hideFlex = false,
@@ -54,6 +55,7 @@ export function StyleSections({
54
55
  styles: Record<string, string>;
55
56
  assets: string[];
56
57
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
58
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
57
59
  onImportAssets?: (files: FileList) => Promise<string[]>;
58
60
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
59
61
  // When true, the Flex `Section` is suppressed. The flat inspector renders
@@ -108,12 +110,12 @@ export function StyleSections({
108
110
  const backgroundImage = styles["background-image"] ?? "none";
109
111
  const hasTextControls = isTextEditableSelection(element);
110
112
 
111
- const fillMode =
112
- backgroundImage && backgroundImage !== "none"
113
- ? backgroundImage.includes("gradient")
114
- ? "Gradient"
115
- : "Image"
116
- : "Solid";
113
+ let fillMode = "Solid";
114
+ if (element.tagName === "img") {
115
+ fillMode = "Image";
116
+ } else if (backgroundImage && backgroundImage !== "none") {
117
+ fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
118
+ }
117
119
  const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
118
120
  const imageUrl = extractBackgroundImageUrl(backgroundImage);
119
121
  // Remember the last authored gradient so an exploratory Solid↔Gradient
@@ -488,7 +490,8 @@ export function StyleSections({
488
490
  ) : (
489
491
  <ImageFillField
490
492
  projectId={projectId}
491
- sourceFile={element.sourceFile}
493
+ element={element}
494
+ onSetHtmlAttribute={onSetHtmlAttribute}
492
495
  value={imageUrl}
493
496
  assets={assets}
494
497
  disabled={styleEditingDisabled}
@@ -12,6 +12,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
12
12
  const playerMounts: string[] = [];
13
13
  let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
14
14
 
15
+ const measured = vi.hoisted(() => ({ size: null as { width: number; height: number } | null }));
16
+ vi.mock("../../utils/previewCompositionSize", async (original) => ({
17
+ ...(await original<typeof import("../../utils/previewCompositionSize")>()),
18
+ readPreviewCompositionSize: () => measured.size,
19
+ }));
20
+
15
21
  vi.mock("../../player", async () => {
16
22
  const React = await import("react");
17
23
 
@@ -81,7 +87,12 @@ function renderPreview(
81
87
  {
82
88
  box = { width: 800, height: 600 },
83
89
  fillBox,
84
- }: { box?: { width: number; height: number }; fillBox?: boolean } = {},
90
+ compositionSizeHint,
91
+ }: {
92
+ box?: { width: number; height: number };
93
+ fillBox?: boolean;
94
+ compositionSizeHint?: { width: number; height: number };
95
+ } = {},
85
96
  ) {
86
97
  resizeCallbacks = [];
87
98
  const host = document.createElement("div");
@@ -103,6 +114,7 @@ function renderPreview(
103
114
  setShadowIframeNode: () => {},
104
115
  resetPreviewSlots: () => {},
105
116
  fillBox,
117
+ compositionSizeHint,
106
118
  }),
107
119
  );
108
120
  });
@@ -378,6 +390,37 @@ describe("NLEPreview", () => {
378
390
  filled.cleanup();
379
391
  });
380
392
 
393
+ it("takes the host's composition size as the stage's shape until the preview measures its own", () => {
394
+ const box = { width: 800, height: 600 };
395
+ const plain = renderPreview(undefined, { box });
396
+ expect([plain.stage.style.width, plain.stage.style.height]).toEqual(["784px", "441px"]);
397
+ plain.cleanup();
398
+
399
+ const hinted = renderPreview(undefined, {
400
+ box,
401
+ compositionSizeHint: { width: 1276, height: 1078 },
402
+ });
403
+ const shape = parseFloat(hinted.stage.style.width) / parseFloat(hinted.stage.style.height);
404
+ expect(shape).toBeCloseTo(1276 / 1078, 3);
405
+ expect(hinted.stage.style.height).toBe("584px");
406
+ hinted.cleanup();
407
+ });
408
+
409
+ it("lets the size the preview measures win over the host's hint", () => {
410
+ measured.size = { width: 1920, height: 1080 };
411
+ try {
412
+ const box = { width: 800, height: 600 };
413
+ const hinted = renderPreview(undefined, {
414
+ box,
415
+ compositionSizeHint: { width: 1276, height: 1078 },
416
+ });
417
+ expect([hinted.stage.style.width, hinted.stage.style.height]).toEqual(["784px", "441px"]);
418
+ hinted.cleanup();
419
+ } finally {
420
+ measured.size = null;
421
+ }
422
+ });
423
+
381
424
  it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
382
425
  const view = renderPreview([
383
426
  { gen: 0, role: "live" },
@@ -50,6 +50,7 @@ interface NLEPreviewProps extends Pick<
50
50
  onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
51
51
  /** Draws the picture edge to edge in this box, without Studio's inset band. */
52
52
  fillBox?: boolean;
53
+ compositionSizeHint?: PreviewCompositionSize;
53
54
  }
54
55
 
55
56
  export function getPreviewPlayerKey({
@@ -127,6 +128,7 @@ export const NLEPreview = memo(function NLEPreview({
127
128
  onStageRef,
128
129
  onCompositionSizeChange,
129
130
  fillBox,
131
+ compositionSizeHint,
130
132
  }: NLEPreviewProps) {
131
133
  const activeKey = getPreviewPlayerKey({ projectId, directUrl });
132
134
  const viewportRef = useRef<HTMLDivElement>(null);
@@ -151,6 +153,8 @@ export const NLEPreview = memo(function NLEPreview({
151
153
  const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
152
154
  const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
153
155
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
156
+ const hintWidth = compositionSizeHint?.width;
157
+ const hintHeight = compositionSizeHint?.height;
154
158
 
155
159
  const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
156
160
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
@@ -183,7 +187,8 @@ export const NLEPreview = memo(function NLEPreview({
183
187
  resolvePreviewStageSize(
184
188
  rect.width,
185
189
  rect.height,
186
- compositionSize,
190
+ compositionSize ??
191
+ (hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
187
192
  portrait,
188
193
  gutterPx,
189
194
  insetPx,
@@ -195,7 +200,7 @@ export const NLEPreview = memo(function NLEPreview({
195
200
  const observer = new ResizeObserver(updateStageSize);
196
201
  observer.observe(viewport);
197
202
  return () => observer.disconnect();
198
- }, [compositionSize, portrait, gutterPx, insetPx]);
203
+ }, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
199
204
 
200
205
  const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
201
206
  onCompositionSizeChangeRef.current = onCompositionSizeChange;
@@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => {
21
21
  new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
22
22
  "utf8",
23
23
  );
24
- // vitest defines `import.meta.hot`, so the selection is the one claim left to
25
- // the source; the channel's own behaviour is exercised below.
26
- expect(coordinator.match(/sseFileChangeChannel\(handler\)/g)).toHaveLength(1);
24
+ expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1);
25
+ });
26
+
27
+ it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => {
28
+ const listeners = new Map<string, (event: unknown) => void>();
29
+ class FakeEventSource {
30
+ addEventListener(type: string, listener: (event: unknown) => void) {
31
+ listeners.set(type, listener);
32
+ }
33
+ close = vi.fn();
34
+ }
35
+ vi.stubGlobal("EventSource", FakeEventSource);
36
+ try {
37
+ const onReconnect = vi.fn();
38
+ sseFileChangeChannel(vi.fn(), onReconnect);
39
+ listeners.get("open")?.(new Event("open"));
40
+ expect(onReconnect).not.toHaveBeenCalled();
41
+ listeners.get("error")?.(new Event("error"));
42
+ listeners.get("open")?.(new Event("open"));
43
+ listeners.get("open")?.(new Event("open"));
44
+ expect(onReconnect).toHaveBeenCalledTimes(2);
45
+ } finally {
46
+ vi.unstubAllGlobals();
47
+ }
27
48
  });
28
49
 
29
50
  it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
@@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => {
42
63
  vi.stubGlobal("EventSource", FakeEventSource);
43
64
  try {
44
65
  const onDelivery = vi.fn();
45
- const stop = sseFileChangeChannel(onDelivery);
66
+ const stop = sseFileChangeChannel(onDelivery, vi.fn());
46
67
  const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
47
68
  listeners.get("file-change")?.(event);
48
69
  expect(opened).toEqual(["/api/events"]);
@@ -1,18 +1,14 @@
1
1
  // @vitest-environment happy-dom
2
2
  // Real Chrome with the built runtime: its seek, not a plain GSAP seek, decides what a saved edit shows.
3
- import { readFileSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
3
  import { gsap } from "gsap";
6
4
  import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
7
5
  import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
8
- import puppeteer from "puppeteer-core";
9
6
  import { expect, it, vi } from "vitest";
10
- import { findSystemChrome } from "../../vite.browser";
7
+ import { launchTestChrome, showWithRuntime } from "../../tests/chromeTestUtils";
11
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
9
  import { usePlayerStore } from "../player/store/playerStore";
13
10
  import { planValueEdit } from "./gsapValueAtPlayhead";
14
11
 
15
- const require = createRequire(import.meta.url);
16
12
  vi.setConfig({ testTimeout: 60_000 });
17
13
 
18
14
  const SRC = `var tl = gsap.timeline({ paused: true });
@@ -49,25 +45,10 @@ const page = (script: string) => `<!doctype html><html><body>
49
45
 
50
46
  it("a drag on a fromTo's start shows the dropped x there once the runtime seeks", async () => {
51
47
  const written = writeDrag(90, 2);
52
- const executablePath = findSystemChrome();
53
- if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
54
- const browser = await puppeteer.launch({
55
- executablePath,
56
- headless: true,
57
- args: ["--no-sandbox"],
58
- });
48
+ const browser = await launchTestChrome();
59
49
  try {
60
50
  const tab = await browser.newPage();
61
- await tab.setRequestInterception(true);
62
- // Only the GSAP script is served; anything else is refused, never fetched.
63
- tab.on("request", (request) =>
64
- request.url().endsWith("/gsap.min.js")
65
- ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
66
- : request.abort("blockedbyclient"),
67
- );
68
- await tab.setContent(page(written), { waitUntil: "load" });
69
- await tab.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
70
- await tab.waitForFunction(() => "__player" in window);
51
+ await showWithRuntime(tab, page(written));
71
52
  const shown = await tab.evaluate(() => {
72
53
  const w = window as unknown as {
73
54
  __player: { seek(t: number): void };