@hyperframes/studio 0.8.74 → 0.8.76

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 (116) hide show
  1. package/dist/assets/hyperframes-player-BkDxgSKG.js +464 -0
  2. package/dist/assets/{index-vccPjvCl.js → index-COX0scwJ.js} +1 -1
  3. package/dist/assets/{index-DhTyI9zH.js → index-DeHir1up.js} +178 -178
  4. package/dist/assets/{index-B86QTmD2.js → index-FmKUmYhC.js} +1 -1
  5. package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
  6. package/dist/chunk-IYDE66BY.js.map +1 -0
  7. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
  8. package/dist/index.d.ts +35 -70
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +4518 -4568
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/components/StudioRightPanels.types.ts +0 -2
  14. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  15. package/src/components/editor/LayersPanel.tsx +3 -14
  16. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  17. package/src/components/editor/domEditing.ts +1 -0
  18. package/src/components/editor/domEditingLayers.test.ts +27 -0
  19. package/src/components/editor/domEditingLayers.ts +9 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  21. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  22. package/src/components/editor/overlayFrameLoop.ts +11 -0
  23. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  24. package/src/components/nle/NLEPreview.test.ts +94 -17
  25. package/src/components/nle/NLEPreview.tsx +14 -0
  26. package/src/components/nle/PreviewPoster.tsx +99 -0
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  28. package/src/components/panels/VariablesPanel.tsx +0 -2
  29. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  30. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  31. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  32. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  33. package/src/components/studioColorGradingScope.ts +0 -3
  34. package/src/hooks/domEditDeleteMembers.ts +0 -2
  35. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  36. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  37. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  38. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  39. package/src/hooks/timelineEditingHelpers.ts +11 -3
  40. package/src/hooks/timelineTimingSync.ts +0 -1
  41. package/src/hooks/timelineTrackVisibility.ts +0 -1
  42. package/src/hooks/useBlockHandlers.ts +0 -2
  43. package/src/hooks/useClipboard.test.ts +69 -10
  44. package/src/hooks/useClipboard.ts +28 -19
  45. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  48. package/src/hooks/useEditHistoryActions.ts +12 -7
  49. package/src/hooks/useEditorSave.ts +0 -3
  50. package/src/hooks/useElementLifecycleOps.ts +0 -1
  51. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  52. package/src/hooks/useFileManager.ts +0 -2
  53. package/src/hooks/useGroupCommits.ts +0 -1
  54. package/src/hooks/useGsapScriptCommits.ts +0 -1
  55. package/src/hooks/useLintModal.test.tsx +34 -1
  56. package/src/hooks/useLintModal.ts +32 -30
  57. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  58. package/src/hooks/usePersistentEditHistory.ts +155 -342
  59. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  60. package/src/hooks/usePreviewPersistence.ts +0 -2
  61. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  62. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  63. package/src/hooks/useRazorSplit.test.ts +17 -47
  64. package/src/hooks/useRenderClipContent.test.ts +29 -0
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  67. package/src/hooks/useSdkSession.ts +8 -2
  68. package/src/hooks/useSlideshowPersist.ts +0 -2
  69. package/src/hooks/useStudioContextValue.ts +6 -1
  70. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
  72. package/src/hooks/useTimelineDeleteOps.ts +8 -8
  73. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  74. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  75. package/src/hostMount.test.tsx +53 -22
  76. package/src/main.tsx +3 -0
  77. package/src/player/components/CompositionThumbnail.tsx +14 -0
  78. package/src/player/components/Player.test.ts +49 -0
  79. package/src/player/components/Player.tsx +21 -7
  80. package/src/player/components/PlayheadIndicator.tsx +4 -1
  81. package/src/player/components/Timeline.test.ts +1 -3
  82. package/src/player/components/Timeline.virtualization.test.tsx +21 -0
  83. package/src/player/components/TimelineCanvas.tsx +14 -12
  84. package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
  85. package/src/player/components/TimelineRuler.tsx +7 -40
  86. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  87. package/src/player/components/useSettledScrollLeft.ts +38 -0
  88. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  89. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  90. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  91. package/src/player/hooks/useShadowPreviewReload.ts +20 -1
  92. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  93. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
  94. package/src/player/hooks/useTimelinePlayer.ts +15 -3
  95. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  96. package/src/player/lib/timelineElementHelpers.ts +45 -9
  97. package/src/player/store/playerStore.test.ts +35 -1
  98. package/src/player/store/playerStore.ts +29 -0
  99. package/src/player/store/readinessSlice.ts +34 -6
  100. package/src/utils/blockInstaller.ts +0 -3
  101. package/src/utils/previewPrefetch.test.ts +22 -0
  102. package/src/utils/previewPrefetch.ts +14 -0
  103. package/src/utils/razorSplitTransaction.test.ts +0 -1
  104. package/src/utils/razorSplitTransaction.ts +1 -1
  105. package/src/utils/sdkEditTransaction.ts +0 -3
  106. package/src/utils/studioFileHistory.test.ts +0 -9
  107. package/src/utils/studioFileHistory.ts +1 -5
  108. package/src/utils/timelineCompositionInsert.ts +0 -1
  109. package/dist/assets/hyperframes-player-Bs9qG2hg.js +0 -464
  110. package/dist/chunk-7BP32G62.js.map +0 -1
  111. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  112. package/src/utils/editHistory.test.ts +0 -534
  113. package/src/utils/editHistory.ts +0 -236
  114. package/src/utils/editHistoryStorage.test.ts +0 -37
  115. package/src/utils/editHistoryStorage.ts +0 -99
  116. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.74",
3
+ "version": "0.8.76",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,11 +52,11 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.74",
56
- "@hyperframes/parsers": "0.8.74",
57
- "@hyperframes/sdk": "0.8.74",
58
- "@hyperframes/studio-server": "0.8.74",
59
- "@hyperframes/player": "0.8.74"
55
+ "@hyperframes/parsers": "0.8.76",
56
+ "@hyperframes/core": "0.8.76",
57
+ "@hyperframes/sdk": "0.8.76",
58
+ "@hyperframes/studio-server": "0.8.76",
59
+ "@hyperframes/player": "0.8.76"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@tailwindcss/vite": "^4.3.3",
@@ -72,7 +72,7 @@
72
72
  "vite": "^6.4.2",
73
73
  "vitest": "^4.1.11",
74
74
  "zustand": "^5.0.0",
75
- "@hyperframes/producer": "0.8.74"
75
+ "@hyperframes/producer": "0.8.76"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "react": "19",
@@ -9,7 +9,6 @@
9
9
  import type { StudioEditPersistenceProps } from "./panels/VariablesPanel";
10
10
  import type { BlockParam } from "@hyperframes/core/registry";
11
11
  import type { Composition } from "@hyperframes/sdk";
12
- import type { EditHistoryKind } from "../utils/editHistory";
13
12
  import type { UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
14
13
  import type { AddMediaOverlayHandler } from "./editor/propertyPanelTypes";
15
14
  import type { ToggleHiddenHandler } from "../utils/studioHelpers";
@@ -46,7 +45,6 @@ export interface StudioRightPanelsProps extends StudioEditPersistenceProps {
46
45
  reloadPreview: () => void;
47
46
  recordEdit: (entry: {
48
47
  label: string;
49
- kind: EditHistoryKind;
50
48
  files: Record<string, { before: string; after: string }>;
51
49
  }) => Promise<void>;
52
50
  onToggleElementHidden?: ToggleHiddenHandler;
@@ -18,6 +18,7 @@ import {
18
18
  hoverCacheDescribesPoint,
19
19
  resolveResizeCenterAnchorOffset,
20
20
  } from "./domEditOverlayGestures";
21
+ import { usePlayerStore } from "../../player/store/playerStore";
21
22
 
22
23
  // React 19 warns unless the test environment opts into act().
23
24
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -242,6 +243,10 @@ function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80
242
243
  });
243
244
  }
244
245
 
246
+ beforeEach(() => {
247
+ usePlayerStore.setState({ previewBooted: true });
248
+ });
249
+
245
250
  describe("focusDomEditOverlayElement", () => {
246
251
  it("focuses the canvas overlay without scrolling", () => {
247
252
  const calls: Array<FocusOptions | undefined> = [];
@@ -3,6 +3,7 @@ import { memo, useState, useCallback, useEffect, useRef } from "react";
3
3
  import {
4
4
  collectDomEditLayerItems,
5
5
  getDomEditLayerKey,
6
+ liveLayerElement,
6
7
  resolveDomEditSelection,
7
8
  type DomEditLayerItem,
8
9
  } from "./domEditing";
@@ -190,20 +191,8 @@ export const LayersPanel = memo(function LayersPanel() {
190
191
 
191
192
  const resolveSelection = useCallback(
192
193
  (layer: DomEditLayerItem) => {
193
- // Re-find the element from the live DOM — layer.element may be stale
194
- // after soft reload (which replaces scripts without reloading the iframe).
195
- let el = layer.element;
196
- if (!el.isConnected) {
197
- const iframe = previewIframeRef.current;
198
- const doc = iframe?.contentDocument;
199
- if (doc) {
200
- const found =
201
- (layer.id ? doc.getElementById(layer.id) : null) ??
202
- (layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
203
- doc.getElementById(layer.key);
204
- if (found instanceof HTMLElement) el = found;
205
- }
206
- }
194
+ const doc = previewIframeRef.current?.contentDocument;
195
+ const el = liveLayerElement(layer, doc, activeCompPath);
207
196
  return resolveDomEditSelection(el, {
208
197
  activeCompositionPath: activeCompPath,
209
198
  isMasterView,
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import { GridOverlay } from "./GridOverlay";
7
7
  import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
8
8
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
9
+ import { usePlayerStore } from "../../player/store/playerStore";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -27,6 +28,10 @@ function compositionFrame() {
27
28
  return iframe;
28
29
  }
29
30
 
31
+ beforeEach(() => {
32
+ usePlayerStore.setState({ previewBooted: true });
33
+ });
34
+
30
35
  describe("PreviewOverlayProvider", () => {
31
36
  it("derives grid geometry after the iframe prop becomes available", async () => {
32
37
  window.localStorage.setItem(
@@ -37,6 +37,7 @@ export {
37
37
  getDomEditNonEditableReason,
38
38
  getDomEditTargetKey,
39
39
  isTextEditableSelection,
40
+ liveLayerElement,
40
41
  readHfId,
41
42
  refreshDomEditSelection,
42
43
  resolveDomEditCapabilities,
@@ -6,6 +6,7 @@ import {
6
6
  buildDomEditPatchTarget,
7
7
  buildTextFieldChildLocator,
8
8
  readHfId,
9
+ liveLayerElement,
9
10
  } from "./domEditingLayers";
10
11
  import type { DomEditTextField } from "./domEditingTypes";
11
12
 
@@ -305,3 +306,29 @@ describe("collectDomEditLayerItems selector-index cost", () => {
305
306
  expect(classSelectorQueries(12)).toBe(1);
306
307
  });
307
308
  });
309
+
310
+ describe("liveLayerElement", () => {
311
+ it("finds a replaced layer again in its own file when a sub-composition repeats its id", () => {
312
+ document.body.innerHTML =
313
+ '<div data-composition-id="main">' +
314
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
315
+ '<div id="card-1">strip</div></div><div id="card-1">root</div></div>';
316
+ const stale = document.createElement("div");
317
+ const layer = {
318
+ key: "index.html:card-1:0",
319
+ element: stale,
320
+ label: "card-1",
321
+ tagName: "div",
322
+ depth: 0,
323
+ childCount: 0,
324
+ id: "card-1",
325
+ sourceFile: "index.html",
326
+ };
327
+
328
+ expect(liveLayerElement(layer, document, "index.html").textContent).toBe("root");
329
+ expect(
330
+ liveLayerElement({ ...layer, sourceFile: "compositions/strip.html" }, document, "index.html")
331
+ .textContent,
332
+ ).toBe("strip");
333
+ });
334
+ });
@@ -491,6 +491,15 @@ export function collectDomEditLayerItems(
491
491
  return items;
492
492
  }
493
493
 
494
+ export function liveLayerElement(
495
+ layer: DomEditLayerItem,
496
+ doc: Document | null | undefined,
497
+ activeCompositionPath: string | null,
498
+ ): HTMLElement {
499
+ if (layer.element.isConnected || !doc) return layer.element;
500
+ return findElementForSelection(doc, layer, activeCompositionPath) ?? layer.element;
501
+ }
502
+
494
503
  // ─── Patch operations ────────────────────────────────────────────────────────
495
504
 
496
505
  export function buildDomEditStylePatchOperation(
@@ -2,9 +2,10 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { DomEditOverlay } from "./DomEditOverlay";
7
7
  import { RECOMPUTE_INTERVAL_MS } from "./offCanvasIndicatorRefresh";
8
+ import { usePlayerStore } from "../../player/store/playerStore";
8
9
 
9
10
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
11
 
@@ -59,6 +60,30 @@ async function flushAnimationFrames(): Promise<void> {
59
60
  });
60
61
  }
61
62
 
63
+ // The refresh waits on MutationObserver delivery and then on its own throttle, so no fixed
64
+ // number of frames is guaranteed to be enough on a loaded runner. Flush until the indicator
65
+ // reaches the state under test.
66
+ async function untilIndicator(host: HTMLElement, check: (indicator: Element | null) => void) {
67
+ await vi.waitFor(
68
+ async () => {
69
+ await act(async () => {
70
+ await flushAnimationFrames();
71
+ });
72
+ check(host.querySelector(INDICATOR));
73
+ },
74
+ { timeout: 4000, interval: 0 },
75
+ );
76
+ }
77
+
78
+ // Delivers each batch of mutation records `ms` late, as a busy runner can.
79
+ function lateObserver(Base: typeof MutationObserver, ms: number): typeof MutationObserver {
80
+ return class extends Base {
81
+ constructor(callback: MutationCallback) {
82
+ super((records, observer) => setTimeout(() => callback(records, observer), ms));
83
+ }
84
+ };
85
+ }
86
+
62
87
  interface OverlayHarness {
63
88
  host: HTMLElement;
64
89
  movedElement: HTMLElement;
@@ -68,7 +93,7 @@ interface OverlayHarness {
68
93
  // Mount DomEditOverlay over an iframe whose #headline sits at `initialLeft`, with a
69
94
  // getBoundingClientRect stub that reads the element's live inline geometry (so a
70
95
  // style mutation moves it) and reports the composition/overlay as 800x450 at origin.
71
- function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
96
+ function mountOverlayWithHeadline(initialLeft: number, observerDelayMs = 0): OverlayHarness {
72
97
  const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
73
98
  const host = document.createElement("div");
74
99
  document.body.append(host);
@@ -85,6 +110,9 @@ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
85
110
  `;
86
111
  const movedElement = doc.getElementById("headline");
87
112
  if (!movedElement) throw new Error("Expected test element");
113
+ const view = doc.defaultView?.MutationObserver ? doc.defaultView : globalThis;
114
+ const RealObserver = view.MutationObserver;
115
+ if (observerDelayMs > 0) view.MutationObserver = lateObserver(RealObserver, observerDelayMs);
88
116
 
89
117
  Element.prototype.getBoundingClientRect = function (): DOMRect {
90
118
  if (this === movedElement) {
@@ -125,58 +153,52 @@ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
125
153
  cleanup: () => {
126
154
  act(() => root.unmount());
127
155
  Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
156
+ view.MutationObserver = RealObserver;
128
157
  iframe.remove();
129
158
  host.remove();
130
159
  },
131
160
  };
132
161
  }
133
162
 
134
- describe("off-canvas indicator refresh", () => {
135
- it("removes the indicator when an off-canvas element moves in-canvas (off->on)", async () => {
136
- const h = mountOverlayWithHeadline(760);
137
- try {
138
- await act(async () => {
139
- await flushAnimationFrames();
140
- });
141
- expect(h.host.querySelector(INDICATOR)).toBeTruthy();
142
-
143
- act(() => {
144
- h.movedElement.style.left = "120px";
145
- });
146
- await act(async () => {
147
- await Promise.resolve();
148
- await flushAnimationFrames();
149
- });
163
+ beforeEach(() => {
164
+ usePlayerStore.setState({ previewBooted: true });
165
+ });
150
166
 
151
- expect(h.host.querySelector(INDICATOR)).toBeNull();
152
- } finally {
153
- h.cleanup();
154
- }
155
- });
167
+ describe("off-canvas indicator refresh", () => {
168
+ for (const [label, delayMs] of [
169
+ ["", 0],
170
+ [" when mutation delivery lags past a fixed wait", 300],
171
+ ] as const) {
172
+ it(`removes the indicator when an off-canvas element moves in-canvas (off->on)${label}`, async () => {
173
+ const h = mountOverlayWithHeadline(760, delayMs);
174
+ try {
175
+ await untilIndicator(h.host, (indicator) => expect(indicator).toBeTruthy());
176
+
177
+ act(() => {
178
+ h.movedElement.style.left = "120px";
179
+ });
180
+ await untilIndicator(h.host, (indicator) => expect(indicator).toBeNull());
181
+ } finally {
182
+ h.cleanup();
183
+ }
184
+ });
185
+ }
156
186
 
157
187
  it("tracks the indicator to the new position when it stays off-canvas (off->off)", async () => {
158
188
  const h = mountOverlayWithHeadline(760);
159
189
  try {
160
- await act(async () => {
161
- await flushAnimationFrames();
162
- });
190
+ await untilIndicator(h.host, (indicator) => expect(indicator).toBeTruthy());
163
191
  const before = h.host.querySelector(INDICATOR);
164
- expect(before).toBeTruthy();
165
192
  const leftBefore = (before!.parentElement as HTMLElement).style.left;
166
193
 
167
194
  // Move further off-canvas (still outside the 800px-wide composition).
168
195
  act(() => {
169
196
  h.movedElement.style.left = "1200px";
170
197
  });
171
- await act(async () => {
172
- await Promise.resolve();
173
- await flushAnimationFrames();
198
+ await untilIndicator(h.host, (indicator) => {
199
+ expect(indicator).toBeTruthy();
200
+ expect((indicator!.parentElement as HTMLElement).style.left).not.toEqual(leftBefore);
174
201
  });
175
-
176
- const after = h.host.querySelector(INDICATOR);
177
- expect(after).toBeTruthy();
178
- const leftAfter = (after!.parentElement as HTMLElement).style.left;
179
- expect(leftAfter).not.toEqual(leftBefore);
180
202
  } finally {
181
203
  h.cleanup();
182
204
  }
@@ -7,6 +7,7 @@ import {
7
7
  resetOverlayFrameLoopForTests,
8
8
  subscribeOverlayFrame,
9
9
  } from "./overlayFrameLoop";
10
+ import { usePlayerStore } from "../../player/store/playerStore";
10
11
 
11
12
  /**
12
13
  * The editor's overlay polls run on one shared, parkable frame loop. The two
@@ -37,6 +38,7 @@ describe("overlay frame loop", () => {
37
38
  };
38
39
 
39
40
  beforeEach(() => {
41
+ usePlayerStore.setState({ previewBooted: true });
40
42
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
41
43
  queued = [];
42
44
  window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
@@ -55,6 +57,20 @@ describe("overlay frame loop", () => {
55
57
  window.cancelAnimationFrame = originalCancelRaf;
56
58
  });
57
59
 
60
+ it("does not poll the preview before it boots, and wakes when it does", () => {
61
+ usePlayerStore.setState({ previewBooted: false });
62
+ let runs = 0;
63
+ subscribeOverlayFrame(() => {
64
+ runs += 1;
65
+ });
66
+ framesOver(1000);
67
+ expect(runs).toBe(0);
68
+
69
+ usePlayerStore.getState().markPreviewBooted();
70
+ framesOver(32);
71
+ expect(runs).toBeGreaterThan(0);
72
+ });
73
+
58
74
  it("runs every subscriber on one frame, not one frame each", () => {
59
75
  const calls: string[] = [];
60
76
  subscribeOverlayFrame(() => calls.push("a"));
@@ -25,6 +25,8 @@
25
25
  * the next couple of hundred milliseconds, and no further event is dispatched
26
26
  * while it does.
27
27
  */
28
+ import { usePlayerStore } from "../../player/store/playerStore";
29
+
28
30
  const AWAKE_MS = 400;
29
31
  /** How often a parked loop runs one frame anyway, in case a wake was missed. */
30
32
  export const IDLE_POLL_MS = 250;
@@ -35,6 +37,7 @@ let frameId: number | null = null;
35
37
  let idleTimerId: ReturnType<typeof setTimeout> | null = null;
36
38
  let awakeUntil = 0;
37
39
  let listenersAttached = false;
40
+ let stopBootWake: (() => void) | null = null;
38
41
 
39
42
  /** Wake sources. All passive reads; none of them can be cancelled by us. */
40
43
  const WINDOW_EVENTS = [
@@ -111,6 +114,9 @@ function runFrame(): void {
111
114
  // it — and a shared loop makes that failure four overlays wide plus the
112
115
  // idle-poll safety net, permanently, rather than one overlay's own problem.
113
116
  schedule();
117
+ // Nothing to track until the live preview boots, and a poll before then forces layout
118
+ // on a document that is still loading, on the thread the boot runs on.
119
+ if (!usePlayerStore.getState().previewBooted) return;
114
120
  for (const subscriber of subscribers) {
115
121
  try {
116
122
  subscriber();
@@ -154,6 +160,9 @@ function attachListeners(): void {
154
160
  window.addEventListener(type, requestOverlayFrames, { capture: true, passive: true });
155
161
  }
156
162
  window.addEventListener("message", onPreviewMessage, { capture: true, passive: true });
163
+ stopBootWake = usePlayerStore.subscribe((state, prev) => {
164
+ if (state.previewBooted && !prev.previewBooted) requestOverlayFrames();
165
+ });
157
166
  }
158
167
 
159
168
  function detachListeners(): void {
@@ -163,6 +172,8 @@ function detachListeners(): void {
163
172
  window.removeEventListener(type, requestOverlayFrames, { capture: true });
164
173
  }
165
174
  window.removeEventListener("message", onPreviewMessage, { capture: true });
175
+ stopBootWake?.();
176
+ stopBootWake = null;
166
177
  }
167
178
 
168
179
  /**
@@ -2,12 +2,13 @@
2
2
 
3
3
  import { act, useRef, useState } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
7
7
  import {
8
8
  usePreviewCompositionRect,
9
9
  type PreviewCompositionRect,
10
10
  } from "./usePreviewCompositionRect";
11
+ import { usePlayerStore } from "../../player/store/playerStore";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
@@ -59,6 +60,10 @@ function compositionFrame(box: [number, number, number, number], width: number,
59
60
 
60
61
  const settle = () => new Promise((r) => setTimeout(r, 400));
61
62
 
63
+ beforeEach(() => {
64
+ usePlayerStore.setState({ previewBooted: true });
65
+ });
66
+
62
67
  describe("usePreviewCompositionRect", () => {
63
68
  it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
64
69
  const probe = mountProbe();
@@ -10,6 +10,7 @@ import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTe
10
10
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
11
 
12
12
  const playerMounts: string[] = [];
13
+ let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
13
14
 
14
15
  vi.mock("../../player", async () => {
15
16
  const React = await import("react");
@@ -18,11 +19,13 @@ vi.mock("../../player", async () => {
18
19
  Player: React.forwardRef(function MockPlayer(
19
20
  props: {
20
21
  onLoad?: () => void;
22
+ onReadyToShowChange?: (ready: boolean) => void;
21
23
  suppressLoadingOverlay?: boolean;
22
24
  style?: React.CSSProperties;
23
25
  },
24
26
  ref: React.ForwardedRef<HTMLIFrameElement>,
25
27
  ) {
28
+ if (!props.suppressLoadingOverlay) livePlayerProps = props;
26
29
  React.useEffect(() => {
27
30
  props.onLoad?.();
28
31
  }, [props]);
@@ -90,23 +93,25 @@ function renderPreview(
90
93
  document.body.append(host);
91
94
  const root = createRoot(host);
92
95
  const iframeRef = createRef<HTMLIFrameElement>();
93
-
94
- act(() => {
95
- root.render(
96
- React.createElement(NLEPreview, {
97
- projectId: "timeline-edit-playground",
98
- iframeRef,
99
- onIframeLoad: () => {},
100
- previewSlots,
101
- onShadowIframeLoad: () => {},
102
- onShadowReadyChange: () => {},
103
- onShadowError: () => {},
104
- setShadowIframeNode: () => {},
105
- resetPreviewSlots: () => {},
106
- fillBox,
107
- }),
108
- );
109
- });
96
+ const render = (directUrl?: string) =>
97
+ act(() => {
98
+ root.render(
99
+ React.createElement(NLEPreview, {
100
+ projectId: "timeline-edit-playground",
101
+ directUrl,
102
+ iframeRef,
103
+ onIframeLoad: () => {},
104
+ previewSlots,
105
+ onShadowIframeLoad: () => {},
106
+ onShadowReadyChange: () => {},
107
+ onShadowError: () => {},
108
+ setShadowIframeNode: () => {},
109
+ resetPreviewSlots: () => {},
110
+ fillBox,
111
+ }),
112
+ );
113
+ });
114
+ render();
110
115
 
111
116
  const viewport = host.querySelector('[aria-label="Composition preview"]') as HTMLDivElement;
112
117
  const stage = host.querySelector('[data-testid="preview-zoom-stage"]') as HTMLDivElement;
@@ -121,6 +126,7 @@ function renderPreview(
121
126
  return {
122
127
  host,
123
128
  root,
129
+ render,
124
130
  viewport,
125
131
  stage,
126
132
  cleanup() {
@@ -283,6 +289,77 @@ describe("NLEPreview", () => {
283
289
  view.cleanup();
284
290
  });
285
291
 
292
+ it("covers the live preview with the cached frame-0 poster until it is ready to show", () => {
293
+ const view = renderPreview();
294
+ const poster = () =>
295
+ view.stage.querySelector<HTMLImageElement>('[data-testid="preview-poster"]');
296
+ expect(poster()?.getAttribute("src")).toBe(
297
+ "/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source&cached=1",
298
+ );
299
+ expect(poster()?.style.zIndex).toBe("2");
300
+
301
+ act(() => livePlayerProps.onReadyToShowChange?.(false));
302
+ expect(poster()?.hidden).toBe(false);
303
+ act(() => livePlayerProps.onReadyToShowChange?.(true));
304
+ expect(poster()?.hidden).toBe(true);
305
+ act(() => poster()?.dispatchEvent(new Event("load")));
306
+ expect(poster()).toBeNull();
307
+ view.cleanup();
308
+ });
309
+
310
+ describe("a missing poster", () => {
311
+ const renderUrl =
312
+ "/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source";
313
+ const fetchSpy = vi.fn(() => Promise.resolve(new Response()));
314
+ beforeEach(() => {
315
+ fetchSpy.mockClear();
316
+ vi.stubGlobal("fetch", fetchSpy);
317
+ });
318
+ afterEach(() => vi.unstubAllGlobals());
319
+
320
+ const settle = (
321
+ view: ReturnType<typeof renderPreview>,
322
+ steps: Array<"ready" | "missing" | "loaded">,
323
+ ) => {
324
+ for (const step of steps) {
325
+ const poster = view.stage.querySelector('[data-testid="preview-poster"]');
326
+ act(() =>
327
+ step === "ready"
328
+ ? livePlayerProps.onReadyToShowChange?.(true)
329
+ : poster?.dispatchEvent(new Event(step === "missing" ? "error" : "load")),
330
+ );
331
+ }
332
+ };
333
+
334
+ it("is rendered for the next open when the live frame is ready first", () => {
335
+ const view = renderPreview();
336
+ settle(view, ["ready", "missing"]);
337
+ expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
338
+ expect(view.stage.querySelector('[data-testid="preview-poster"]')).toBeNull();
339
+ view.cleanup();
340
+ });
341
+
342
+ it("is rendered once the live frame is ready when it is missing first", () => {
343
+ const view = renderPreview();
344
+ settle(view, ["missing"]);
345
+ expect(fetchSpy).not.toHaveBeenCalled();
346
+ settle(view, ["ready", "ready"]);
347
+ expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
348
+ view.cleanup();
349
+ });
350
+
351
+ it("is rendered on a return from a sub-composition when the live frame is ready first", () => {
352
+ const view = renderPreview();
353
+ settle(view, ["loaded", "ready"]);
354
+ view.render("/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html");
355
+ settle(view, ["ready"]);
356
+ view.render();
357
+ settle(view, ["ready", "missing"]);
358
+ expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
359
+ view.cleanup();
360
+ });
361
+ });
362
+
286
363
  it("mounts the live player once when the composition switches", () => {
287
364
  playerMounts.length = 0;
288
365
  const Harness = ({ projectId }: { projectId: string }) => {
@@ -19,6 +19,7 @@ import {
19
19
  import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
20
20
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
21
21
  import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
22
+ import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
22
23
  interface NLEPreviewProps {
23
24
  projectId: string;
24
25
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -154,6 +155,7 @@ export const NLEPreview = memo(function NLEPreview({
154
155
  const liveGenRef = useRef<number | null>(null);
155
156
  const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
156
157
  const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
158
+ const poster = usePreviewPoster(projectId, activeKey, directUrl);
157
159
  const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
158
160
  const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
159
161
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
@@ -522,6 +524,8 @@ export const NLEPreview = memo(function NLEPreview({
522
524
  applyInitialZoom();
523
525
  }}
524
526
  onCompositionLoadingChange={onCompositionLoadingChange}
527
+ onReadyToShowChange={poster.onLiveReadyToShowChange}
528
+ onPreviewError={poster.onPreviewError}
525
529
  onPainted={(details) => reportPreviewFirstFrame(slot, details)}
526
530
  portrait={portrait}
527
531
  suppressLoadingOverlay={suppressLoadingOverlay}
@@ -547,6 +551,16 @@ export const NLEPreview = memo(function NLEPreview({
547
551
  />
548
552
  ),
549
553
  )}
554
+ {poster.mountPoster && (
555
+ <PreviewPoster
556
+ key={activeKey}
557
+ projectId={projectId}
558
+ hidden={poster.hidePoster}
559
+ onSize={(size) => setCompositionSize((prev) => prev ?? size)}
560
+ onLoaded={poster.onPosterLoaded}
561
+ onMissing={poster.onPosterMissing}
562
+ />
563
+ )}
550
564
  </div>
551
565
  </div>
552
566
  <div