@hyperframes/studio 0.8.139 → 0.8.141

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 (76) hide show
  1. package/dist/assets/hyperframes-player-5Vx2AiUz.js +473 -0
  2. package/dist/assets/{index-DsEkE5_G.js → index-BAnh7cSQ.js} +1 -1
  3. package/dist/assets/{index-Br1Whc0f.js → index-C0thd4eM.js} +204 -204
  4. package/dist/assets/{index-BmIPrb8I.js → index-P8fgj_Xa.js} +1 -1
  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 +6518 -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 +60 -1
  26. package/src/components/nle/NLEPreview.tsx +9 -2
  27. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +43 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +17 -0
  29. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  30. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  31. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  32. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  33. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  34. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  35. package/src/hooks/useSdkSession.ts +4 -1
  36. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  37. package/src/hooks/useThumbnailStripSize.ts +23 -1
  38. package/src/index.ts +1 -0
  39. package/src/player/components/AudioWaveform.test.tsx +153 -1
  40. package/src/player/components/AudioWaveform.tsx +93 -37
  41. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  42. package/src/player/components/Timeline.test.ts +3 -1
  43. package/src/player/components/TimelineCanvas.tsx +4 -0
  44. package/src/player/components/TimelineClip.tsx +20 -6
  45. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  46. package/src/player/components/TimelineClipFades.tsx +4 -4
  47. package/src/player/components/TimelineClipJoins.tsx +23 -18
  48. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  49. package/src/player/components/TimelineGroupRow.tsx +1 -0
  50. package/src/player/components/TimelineLaneClip.tsx +127 -0
  51. package/src/player/components/TimelineLanes.test.tsx +51 -6
  52. package/src/player/components/TimelineLanes.tsx +166 -178
  53. package/src/player/components/TimelineRuler.tsx +2 -0
  54. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  55. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  56. package/src/player/components/VideoThumbnail.tsx +37 -9
  57. package/src/player/components/thumbnailUtils.test.ts +17 -3
  58. package/src/player/components/thumbnailUtils.ts +17 -1
  59. package/src/player/components/timelineLayout.test.ts +3 -3
  60. package/src/player/components/timelineMotion.test.ts +27 -0
  61. package/src/player/components/timelineMotion.ts +62 -0
  62. package/src/player/components/timelineZoomInput.test.ts +438 -0
  63. package/src/player/components/timelineZoomInput.ts +456 -0
  64. package/src/player/components/useTimelineGeometry.ts +0 -3
  65. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  66. package/src/player/components/useTimelinePlayhead.ts +53 -59
  67. package/src/player/components/useTimelineProviderState.tsx +16 -21
  68. package/src/player/components/useTimelineZoom.ts +7 -12
  69. package/src/player/lib/runtimeProtocol.ts +6 -0
  70. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  71. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  72. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  73. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  74. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  75. package/dist/assets/hyperframes-player-CckOoCJ8.js +0 -473
  76. /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
  });
@@ -332,6 +344,22 @@ describe("NLEPreview", () => {
332
344
  view.cleanup();
333
345
  });
334
346
 
347
+ it("tells the preview how large it shows once a zoom settles, which the player cannot see", () => {
348
+ const view = renderPreview();
349
+ const frame = view.host.querySelector<HTMLElement>('[data-testid="mock-player"]')!;
350
+ const postMessage = vi.fn();
351
+ Object.defineProperty(frame, "contentWindow", { value: { postMessage } });
352
+ Object.defineProperty(frame, "offsetWidth", { value: 800 });
353
+ frame.getBoundingClientRect = () => ({ width: 1600 }) as DOMRect;
354
+
355
+ pinchIn(view, 10);
356
+ expect(postMessage).toHaveBeenLastCalledWith(
357
+ expect.objectContaining({ action: "set-display-scale", scale: 2 }),
358
+ "*",
359
+ );
360
+ view.cleanup();
361
+ });
362
+
335
363
  it("says how far it is zoomed, shows where in the frame, and Fit puts it back", () => {
336
364
  const view = renderPreview();
337
365
  expect([chip(view), navigator(view)]).toEqual([null, null]);
@@ -378,6 +406,37 @@ describe("NLEPreview", () => {
378
406
  filled.cleanup();
379
407
  });
380
408
 
409
+ it("takes the host's composition size as the stage's shape until the preview measures its own", () => {
410
+ const box = { width: 800, height: 600 };
411
+ const plain = renderPreview(undefined, { box });
412
+ expect([plain.stage.style.width, plain.stage.style.height]).toEqual(["784px", "441px"]);
413
+ plain.cleanup();
414
+
415
+ const hinted = renderPreview(undefined, {
416
+ box,
417
+ compositionSizeHint: { width: 1276, height: 1078 },
418
+ });
419
+ const shape = parseFloat(hinted.stage.style.width) / parseFloat(hinted.stage.style.height);
420
+ expect(shape).toBeCloseTo(1276 / 1078, 3);
421
+ expect(hinted.stage.style.height).toBe("584px");
422
+ hinted.cleanup();
423
+ });
424
+
425
+ it("lets the size the preview measures win over the host's hint", () => {
426
+ measured.size = { width: 1920, height: 1080 };
427
+ try {
428
+ const box = { width: 800, height: 600 };
429
+ const hinted = renderPreview(undefined, {
430
+ box,
431
+ compositionSizeHint: { width: 1276, height: 1078 },
432
+ });
433
+ expect([hinted.stage.style.width, hinted.stage.style.height]).toEqual(["784px", "441px"]);
434
+ hinted.cleanup();
435
+ } finally {
436
+ measured.size = null;
437
+ }
438
+ });
439
+
381
440
  it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
382
441
  const view = renderPreview([
383
442
  { gen: 0, role: "live" },
@@ -12,6 +12,7 @@ import {
12
12
  type RefObject,
13
13
  } from "react";
14
14
  import { Player } from "../../player";
15
+ import { postFrameDisplayScale } from "../../player/lib/runtimeProtocol";
15
16
  import type { NLEContextValue } from "./NLEContext";
16
17
  import {
17
18
  DEFAULT_PREVIEW_ZOOM,
@@ -50,6 +51,7 @@ interface NLEPreviewProps extends Pick<
50
51
  onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
51
52
  /** Draws the picture edge to edge in this box, without Studio's inset band. */
52
53
  fillBox?: boolean;
54
+ compositionSizeHint?: PreviewCompositionSize;
53
55
  }
54
56
 
55
57
  export function getPreviewPlayerKey({
@@ -127,6 +129,7 @@ export const NLEPreview = memo(function NLEPreview({
127
129
  onStageRef,
128
130
  onCompositionSizeChange,
129
131
  fillBox,
132
+ compositionSizeHint,
130
133
  }: NLEPreviewProps) {
131
134
  const activeKey = getPreviewPlayerKey({ projectId, directUrl });
132
135
  const viewportRef = useRef<HTMLDivElement>(null);
@@ -151,9 +154,12 @@ export const NLEPreview = memo(function NLEPreview({
151
154
  const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
152
155
  const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
153
156
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
157
+ const hintWidth = compositionSizeHint?.width;
158
+ const hintHeight = compositionSizeHint?.height;
154
159
 
155
160
  const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
156
161
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
162
+ useEffect(() => postFrameDisplayScale(previewIframeRef.current), [settledZoom]);
157
163
  const hudRef = useRef<HTMLDivElement>(null);
158
164
  const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
159
165
  const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -183,7 +189,8 @@ export const NLEPreview = memo(function NLEPreview({
183
189
  resolvePreviewStageSize(
184
190
  rect.width,
185
191
  rect.height,
186
- compositionSize,
192
+ compositionSize ??
193
+ (hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
187
194
  portrait,
188
195
  gutterPx,
189
196
  insetPx,
@@ -195,7 +202,7 @@ export const NLEPreview = memo(function NLEPreview({
195
202
  const observer = new ResizeObserver(updateStageSize);
196
203
  observer.observe(viewport);
197
204
  return () => observer.disconnect();
198
- }, [compositionSize, portrait, gutterPx, insetPx]);
205
+ }, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
199
206
 
200
207
  const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
201
208
  onCompositionSizeChangeRef.current = onCompositionSizeChange;
@@ -247,6 +247,49 @@ describe("composition card thumbnails", () => {
247
247
  expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-2");
248
248
  });
249
249
 
250
+ it("tell a hover preview how large it shows, so its videos are not copied at source size", () => {
251
+ vi.useFakeTimers();
252
+ try {
253
+ const host = mount();
254
+ const card = host.querySelector<HTMLElement>('[aria-label^="Open composition"]')!;
255
+ act(() => {
256
+ card.dispatchEvent(
257
+ new MouseEvent("pointerover", { bubbles: true, relatedTarget: document.body }),
258
+ );
259
+ });
260
+ act(() => vi.advanceTimersByTime(300));
261
+ const frame = host.querySelector("iframe")!;
262
+ const postMessage = vi.fn();
263
+ Object.defineProperty(frame, "contentWindow", { value: { postMessage } });
264
+ Object.defineProperty(frame, "offsetWidth", { value: 1920 });
265
+ frame.getBoundingClientRect = () => ({ width: 80 }) as DOMRect;
266
+ act(() => {
267
+ frame.dispatchEvent(new Event("load"));
268
+ });
269
+ expect(postMessage).toHaveBeenCalledWith(
270
+ expect.objectContaining({ action: "set-display-scale", scale: 80 / 1920 }),
271
+ "*",
272
+ );
273
+
274
+ // A module-script composition starts listening only after load, then says it is ready.
275
+ postMessage.mockClear();
276
+ act(() => {
277
+ window.dispatchEvent(
278
+ new MessageEvent("message", {
279
+ source: frame.contentWindow,
280
+ data: { source: "hf-preview", type: "ready" },
281
+ }),
282
+ );
283
+ });
284
+ expect(postMessage).toHaveBeenCalledWith(
285
+ expect.objectContaining({ action: "set-display-scale", scale: 80 / 1920 }),
286
+ "*",
287
+ );
288
+ } finally {
289
+ vi.useRealTimers();
290
+ }
291
+ });
292
+
250
293
  it("show a fallback when the render fails, and retry it at the next content revision", async () => {
251
294
  const host = mount();
252
295
  renders[0]!.answer(500);
@@ -13,6 +13,7 @@ import {
13
13
  THUMBNAIL_SEEK_TIME_SECONDS,
14
14
  } from "../../player/components/CompositionThumbnail";
15
15
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
16
+ import { postFrameDisplayScale } from "../../player/lib/runtimeProtocol";
16
17
  import { usePlayerStore } from "../../player/store/playerStore";
17
18
  import { thumbnailRevisionOf } from "../../player/store/thumbnailSlice";
18
19
  import { encodePreviewPath } from "../../player/components/thumbnailUtils";
@@ -258,6 +259,22 @@ function CompCard({
258
259
  if (hovered) requestIframePlaybackSync(true);
259
260
  }, [hovered, requestIframePlaybackSync]);
260
261
 
262
+ useEffect(() => {
263
+ if (livePreviewLoaded) postFrameDisplayScale(iframeRef.current);
264
+ }, [livePreviewLoaded, previewScale]);
265
+
266
+ useEffect(() => {
267
+ if (!hovered) return;
268
+ const onRuntimeReady = (event: MessageEvent) => {
269
+ const frame = iframeRef.current;
270
+ if (frame && event.source === frame.contentWindow && event.data?.type === "ready") {
271
+ postFrameDisplayScale(frame);
272
+ }
273
+ };
274
+ window.addEventListener("message", onRuntimeReady);
275
+ return () => window.removeEventListener("message", onRuntimeReady);
276
+ }, [hovered]);
277
+
261
278
  useEffect(() => {
262
279
  return () => {
263
280
  if (hoverTimer.current) clearTimeout(hoverTimer.current);
@@ -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 };