@hyperframes/studio 0.8.75 → 0.8.77

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 (130) hide show
  1. package/dist/assets/hyperframes-player-BSDTJ9EF.js +464 -0
  2. package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
  3. package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
  4. package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
  5. package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
  6. package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
  7. package/dist/chunk-5VANA2TG.js.map +1 -0
  8. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
  9. package/dist/index.d.ts +34 -69
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5287 -5277
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +7 -7
  15. package/src/components/StudioRightPanels.types.ts +0 -2
  16. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  17. package/src/components/editor/LayersPanel.tsx +3 -14
  18. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  19. package/src/components/editor/domEditing.ts +1 -0
  20. package/src/components/editor/domEditingElement.ts +13 -0
  21. package/src/components/editor/domEditingLayers.test.ts +27 -0
  22. package/src/components/editor/domEditingLayers.ts +9 -0
  23. package/src/components/editor/gsapLivePreview.test.ts +63 -0
  24. package/src/components/editor/gsapLivePreview.ts +2 -14
  25. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  26. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  27. package/src/components/editor/overlayFrameLoop.ts +11 -0
  28. package/src/components/editor/propertyPanelHelpers.ts +4 -6
  29. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  30. package/src/components/nle/NLEPreview.test.ts +202 -22
  31. package/src/components/nle/NLEPreview.tsx +65 -46
  32. package/src/components/nle/PreviewPoster.tsx +99 -0
  33. package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
  34. package/src/components/nle/previewZoom.test.ts +42 -0
  35. package/src/components/nle/previewZoom.ts +33 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  37. package/src/components/panels/VariablesPanel.tsx +0 -2
  38. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  39. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  40. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  41. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  42. package/src/components/studioColorGradingScope.ts +0 -3
  43. package/src/hooks/domEditDeleteMembers.ts +0 -2
  44. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  45. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  46. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  47. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  48. package/src/hooks/timelineEditingHelpers.ts +0 -3
  49. package/src/hooks/timelineTimingSync.ts +0 -1
  50. package/src/hooks/timelineTrackVisibility.ts +0 -1
  51. package/src/hooks/useBlockHandlers.ts +0 -2
  52. package/src/hooks/useClipboard.test.ts +69 -10
  53. package/src/hooks/useClipboard.ts +28 -19
  54. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  55. package/src/hooks/useDomEditCommits.ts +0 -4
  56. package/src/hooks/useDomEditWiring.ts +1 -3
  57. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  58. package/src/hooks/useEditHistoryActions.ts +12 -7
  59. package/src/hooks/useEditorSave.ts +0 -3
  60. package/src/hooks/useElementLifecycleOps.ts +0 -1
  61. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  62. package/src/hooks/useFileManager.ts +0 -2
  63. package/src/hooks/useGestureRecording.test.tsx +1 -1
  64. package/src/hooks/useGestureRecording.ts +6 -10
  65. package/src/hooks/useGroupCommits.ts +0 -1
  66. package/src/hooks/useGsapScriptCommits.ts +0 -1
  67. package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
  68. package/src/hooks/useGsapTweenCache.ts +5 -13
  69. package/src/hooks/useLintModal.test.tsx +34 -1
  70. package/src/hooks/useLintModal.ts +32 -30
  71. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  72. package/src/hooks/usePersistentEditHistory.ts +155 -342
  73. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  74. package/src/hooks/usePreviewPersistence.ts +0 -2
  75. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  76. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  77. package/src/hooks/useRazorSplit.test.ts +17 -47
  78. package/src/hooks/useRenderClipContent.test.ts +29 -0
  79. package/src/hooks/useRenderClipContent.ts +6 -3
  80. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  81. package/src/hooks/useSdkSession.ts +8 -2
  82. package/src/hooks/useSlideshowPersist.ts +0 -2
  83. package/src/hooks/useStudioContextValue.ts +6 -1
  84. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  85. package/src/hooks/useTimelineDeleteOps.ts +0 -1
  86. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  87. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  88. package/src/hostMount.test.tsx +53 -22
  89. package/src/main.tsx +3 -0
  90. package/src/player/components/CompositionThumbnail.tsx +14 -0
  91. package/src/player/components/Player.test.ts +49 -0
  92. package/src/player/components/Player.tsx +21 -7
  93. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  94. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  95. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  96. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  97. package/src/player/hooks/useShadowPreviewReload.ts +1 -0
  98. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  99. package/src/player/hooks/useTimelinePlayer.ts +1 -0
  100. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  101. package/src/player/lib/automationStoreSync.test.ts +24 -0
  102. package/src/player/lib/automationStoreSync.ts +3 -9
  103. package/src/player/lib/playbackScrub.ts +1 -1
  104. package/src/player/lib/timelineDOM.test.ts +28 -0
  105. package/src/player/lib/timelineDOM.ts +4 -2
  106. package/src/player/lib/timelineElementHelpers.ts +78 -9
  107. package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
  108. package/src/player/lib/timelineIframeHelpers.ts +13 -12
  109. package/src/player/store/playerStore.test.ts +35 -1
  110. package/src/player/store/playerStore.ts +29 -0
  111. package/src/player/store/readinessSlice.ts +34 -6
  112. package/src/utils/blockInstaller.ts +0 -3
  113. package/src/utils/previewPrefetch.test.ts +22 -0
  114. package/src/utils/previewPrefetch.ts +14 -0
  115. package/src/utils/razorSplitTransaction.test.ts +0 -1
  116. package/src/utils/razorSplitTransaction.ts +1 -1
  117. package/src/utils/sdkEditTransaction.ts +0 -3
  118. package/src/utils/studioFileHistory.test.ts +0 -9
  119. package/src/utils/studioFileHistory.ts +1 -5
  120. package/src/utils/studioUiPreferences.test.ts +9 -3
  121. package/src/utils/studioUiPreferences.ts +0 -20
  122. package/src/utils/timelineCompositionInsert.ts +0 -1
  123. package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
  124. package/dist/chunk-7BP32G62.js.map +0 -1
  125. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  126. package/src/utils/editHistory.test.ts +0 -534
  127. package/src/utils/editHistory.ts +0 -236
  128. package/src/utils/editHistoryStorage.test.ts +0 -37
  129. package/src/utils/editHistoryStorage.ts +0 -99
  130. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
@@ -8,8 +8,15 @@ import { createRoot, type Root } from "react-dom/client";
8
8
  import { afterEach, describe, expect, it, vi } from "vitest";
9
9
  // Backend/storage mocking only, standing in for a host's own server and
10
10
  // persistence — not part of the host's integration code under test.
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ createStudioApi,
16
+ openProjectHistory,
17
+ type StudioApiAdapter,
18
+ } from "@hyperframes/studio-server";
11
19
  import { studioFileContentVersion } from "./utils/studioFileVersion";
12
- import { createMemoryEditHistoryStorage } from "./utils/editHistoryStorage";
13
20
  import {
14
21
  flushStudioPendingEdits,
15
22
  useTimelineEditing,
@@ -27,45 +34,66 @@ import {
27
34
  const ORIGINAL_HTML = '<div id="clip" data-start="0" data-track-index="0"></div>';
28
35
 
29
36
  let root: Root | null = null;
30
- afterEach(() => {
37
+ const cleanups: Array<() => unknown> = [];
38
+ afterEach(async () => {
31
39
  act(() => root?.unmount());
32
40
  document.body.innerHTML = "";
33
41
  vi.unstubAllGlobals();
42
+ for (const step of cleanups.splice(0).reverse()) await step();
34
43
  });
35
44
 
36
- /** A minimal disk with etag-guarded PUT, faithful enough to exercise a real
37
- * conflict: a stale If-Match gets a 409 with the current version + content. */
38
- function stubHostProjectDisk(
45
+ function tempDir(prefix: string): string {
46
+ const dir = mkdtempSync(join(tmpdir(), prefix));
47
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
48
+ return dir;
49
+ }
50
+
51
+ /** A project folder with etag-guarded PUT, faithful enough to exercise a real
52
+ * conflict: a stale If-Match gets a 409 with the current version + content.
53
+ * History requests go to the real history routes over the real engine. */
54
+ async function stubHostProjectDisk(
39
55
  initial: Record<string, string>,
40
56
  options?: { editElsewhereAfterFirstRead?: boolean },
41
57
  ) {
42
- const content = { ...initial };
58
+ const dir = tempDir("hf-host-mount-");
59
+ for (const [path, text] of Object.entries(initial)) writeFileSync(join(dir, path), text);
60
+ const history = await openProjectHistory({
61
+ projectDir: dir,
62
+ historyRoot: tempDir("hf-host-mount-history-"),
63
+ });
64
+ cleanups.push(() => history.close());
65
+ const api = createStudioApi({
66
+ listProjects: () => [],
67
+ resolveProject: (id: string) => (id === "p1" ? { id, dir } : null),
68
+ history: () => history,
69
+ } as unknown as StudioApiAdapter);
43
70
  const readsSeen = new Set<string>();
71
+ const read = (path: string) => readFileSync(join(dir, path), "utf8");
44
72
  // The real backend's etag is a content hash (useProjectFileWriter derives
45
73
  // If-Match the same way) — an arbitrary counter here would make every
46
74
  // write from a host that hasn't just read the file look stale.
47
- const versionOf = (path: string) => studioFileContentVersion(content[path]);
75
+ const versionOf = (path: string) => studioFileContentVersion(read(path));
48
76
 
49
77
  async function handlePut(path: string, init: RequestInit | undefined) {
50
78
  const ifMatch = new Headers(init?.headers).get("If-Match");
51
79
  const currentVersion = await versionOf(path);
52
80
  if (ifMatch && ifMatch !== currentVersion) {
53
- return new Response(JSON.stringify({ currentVersion, currentContent: content[path] }), {
81
+ return new Response(JSON.stringify({ currentVersion, currentContent: read(path) }), {
54
82
  status: 409,
55
83
  });
56
84
  }
57
- content[path] = String(init?.body ?? "");
85
+ writeFileSync(join(dir, path), String(init?.body ?? ""));
58
86
  return new Response(JSON.stringify({ version: await versionOf(path) }), { status: 200 });
59
87
  }
60
88
 
61
89
  async function handleGet(path: string) {
62
- const served = content[path];
90
+ const served = read(path);
63
91
  // Simulates another writer landing between this read and the caller's
64
92
  // own write: content moves on right after being served once, so the
65
93
  // caller's hash of what it just read no longer matches disk.
66
94
  if (options?.editElsewhereAfterFirstRead && !readsSeen.has(path)) {
67
95
  readsSeen.add(path);
68
- content[path] = `${served} <!--edited elsewhere-->`;
96
+ writeFileSync(join(dir, path), `${served} <!--edited elsewhere-->`);
69
97
  }
70
98
  return new Response(
71
99
  JSON.stringify({ content: served, version: await studioFileContentVersion(served) }),
@@ -74,12 +102,15 @@ function stubHostProjectDisk(
74
102
  }
75
103
 
76
104
  const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
77
- const match = String(input).match(/\/files\/([^?]+)/);
78
- const path = match ? decodeURIComponent(match[1]) : "";
105
+ const url = String(input);
106
+ if (url.includes("/history")) return api.request(url.replace(/^\/api/, ""), init);
107
+ const match = url.match(/\/files\/([^?]+)/);
108
+ if (!match) return new Response("{}", { status: 404 });
109
+ const path = decodeURIComponent(match[1]);
79
110
  return (init?.method ?? "GET") === "PUT" ? handlePut(path, init) : handleGet(path);
80
111
  });
81
112
  vi.stubGlobal("fetch", fetchMock);
82
- return { content, fetchMock };
113
+ return { read, fetchMock };
83
114
  }
84
115
 
85
116
  type TimelineEditingHandle = {
@@ -108,13 +139,10 @@ function mountHost(
108
139
  const bannerHost = document.createElement("div");
109
140
  document.body.append(bannerHost);
110
141
  const bannerRoot = createRoot(bannerHost);
111
- // Created once: a fresh storage object on every render would change
112
- // usePersistentEditHistory's effect deps each time and loop forever.
113
- const storage = createMemoryEditHistoryStorage();
114
142
 
115
143
  function Harness() {
116
144
  const writer = useProjectFileWriter({ projectId: "p1" });
117
- const editHistory = usePersistentEditHistory({ projectId: "p1", storage });
145
+ const editHistory = usePersistentEditHistory({ projectId: "p1" });
118
146
  const historyActions = useEditHistoryActions({
119
147
  editHistory,
120
148
  readOptionalProjectFile: writer.readOptionalProjectFile,
@@ -170,7 +198,7 @@ function mountHost(
170
198
 
171
199
  describe("public export surface: a host mounting hand editing outside EditorShell", () => {
172
200
  it("writes a move through the history path and undo restores it", async () => {
173
- const disk = stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
201
+ const disk = await stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
174
202
  const iframe = document.createElement("iframe");
175
203
  document.body.append(iframe);
176
204
  iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
@@ -187,16 +215,19 @@ describe("public export surface: a host mounting hand editing outside EditorShel
187
215
  await act(async () => {
188
216
  await timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 });
189
217
  });
190
- expect(disk.content["index.html"]).toContain('data-start="5"');
218
+ expect(disk.read("index.html")).toContain('data-start="5"');
191
219
 
192
220
  await act(async () => {
193
221
  await undo();
194
222
  });
195
- expect(disk.content["index.html"]).toContain('data-start="0"');
223
+ expect(disk.read("index.html")).toContain('data-start="0"');
196
224
  });
197
225
 
198
226
  it("surfaces the conflict banner on a 409", async () => {
199
- stubHostProjectDisk({ "index.html": ORIGINAL_HTML }, { editElsewhereAfterFirstRead: true });
227
+ await stubHostProjectDisk(
228
+ { "index.html": ORIGINAL_HTML },
229
+ { editElsewhereAfterFirstRead: true },
230
+ );
200
231
  const iframe = document.createElement("iframe");
201
232
  document.body.append(iframe);
202
233
  iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
package/src/main.tsx CHANGED
@@ -5,8 +5,11 @@ import { StudioApp } from "./App";
5
5
  import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
6
6
  import { readIconTokens } from "./styles/iconTokens";
7
7
  import { trackStudioEvent } from "./utils/studioTelemetry";
8
+ import { prefetchPreviewForHash } from "./utils/previewPrefetch";
8
9
  import "./styles/studio.css";
9
10
 
11
+ prefetchPreviewForHash(window.location.hash);
12
+ window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
10
13
  trackStudioEvent("session_start");
11
14
 
12
15
  function errorProps(value: unknown): {
@@ -24,6 +24,20 @@ interface CompositionThumbnailProps {
24
24
 
25
25
  const CLIP_HEIGHT = 66;
26
26
  const THUMBNAIL_URL_VERSION = "v3";
27
+ export const THUMBNAIL_SEEK_TIME_SECONDS = 3;
28
+
29
+ export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
30
+ if (
31
+ Number.isFinite(durationSeconds) &&
32
+ durationSeconds != null &&
33
+ durationSeconds > 0 &&
34
+ durationSeconds <= THUMBNAIL_SEEK_TIME_SECONDS
35
+ ) {
36
+ return durationSeconds / 2;
37
+ }
38
+
39
+ return THUMBNAIL_SEEK_TIME_SECONDS;
40
+ }
27
41
 
28
42
  export function buildCompositionThumbnailUrl({
29
43
  previewUrl,
@@ -9,6 +9,7 @@ import {
9
9
  readPreviewErrorMessage,
10
10
  shouldShowCompositionLoadingOverlay,
11
11
  } from "./Player";
12
+ import { usePlayerStore } from "../store/playerStore";
12
13
 
13
14
  vi.mock("@hyperframes/player", () => ({}));
14
15
 
@@ -271,6 +272,54 @@ describe("ready to show", () => {
271
272
  expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
272
273
  });
273
274
 
275
+ it("marks the preview booted when it can show and play, not at ready", async () => {
276
+ usePlayerStore.setState({ previewBooted: false });
277
+ const { player } = await mountPlayer({});
278
+ const el = player as TestHyperframesPlayer;
279
+
280
+ loadAndReady(el);
281
+ act(() => void el.dispatchEvent(new Event("assetsready")));
282
+ await twoFrames();
283
+ expect(usePlayerStore.getState().previewBooted).toBe(false);
284
+
285
+ painted(el);
286
+ await twoFrames();
287
+ expect(usePlayerStore.getState().previewBooted).toBe(true);
288
+ });
289
+
290
+ it("marks the preview booted at its first frame while media is still buffering", async () => {
291
+ usePlayerStore.setState({ previewBooted: false });
292
+ const onReadyToShowChange = vi.fn();
293
+ const { player } = await mountPlayer({ onReadyToShowChange });
294
+ const el = player as TestHyperframesPlayer;
295
+ document.body.appendChild(el.iframeElement);
296
+ const doc = el.iframeElement.contentDocument!;
297
+ const audio = doc.createElement("audio");
298
+ Object.defineProperty(audio, "readyState", { value: 0, configurable: true });
299
+ Object.defineProperty(audio, "networkState", { value: 2, configurable: true });
300
+ doc.body.appendChild(audio);
301
+
302
+ loadAndReady(el);
303
+ painted(el);
304
+ await twoFrames();
305
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
306
+ expect(usePlayerStore.getState().previewBooted).toBe(true);
307
+ });
308
+
309
+ it("stops deferring editing work when the preview never shows", async () => {
310
+ vi.useFakeTimers();
311
+ try {
312
+ usePlayerStore.setState({ previewBooted: false });
313
+ await mountPlayer({});
314
+ act(() => void vi.advanceTimersByTime(4999));
315
+ expect(usePlayerStore.getState().previewBooted).toBe(false);
316
+ act(() => void vi.advanceTimersByTime(1));
317
+ expect(usePlayerStore.getState().previewBooted).toBe(true);
318
+ } finally {
319
+ vi.useRealTimers();
320
+ }
321
+ });
322
+
274
323
  it("reports the document start time with the painted iframe", async () => {
275
324
  const onPainted = vi.fn();
276
325
  const now = vi.spyOn(performance, "now").mockReturnValue(100);
@@ -4,6 +4,7 @@ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readin
4
4
  import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
6
6
  import { HyperframesLoader } from "../../components/ui";
7
+ import { usePlayerStore } from "../store/playerStore";
7
8
  // Importing "@hyperframes/player" registers a class extending HTMLElement at
8
9
  // module load, which throws under SSR, hence the dynamic import behind a
9
10
  // `typeof window` guard. Kicking it here rather than in the mount effect puts
@@ -57,6 +58,7 @@ function getShaderTransitionLoading(event: Event): boolean | null {
57
58
  }
58
59
 
59
60
  const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
61
+ const PREVIEW_BOOT_DEADLINE_MS = 5000;
60
62
  const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
61
63
 
62
64
  export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
@@ -427,13 +429,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
427
429
  setAssetsLoading(false);
428
430
  };
429
431
 
430
- const readyToShow =
431
- loaded &&
432
- painted &&
433
- !compositionLoading &&
434
- !shaderTransitionLoading &&
435
- !assetsLoading &&
436
- !previewError;
432
+ const firstFrameShown =
433
+ loaded && painted && !compositionLoading && !shaderTransitionLoading && !previewError;
434
+ const readyToShow = firstFrameShown && !assetsLoading;
435
+ // Work waiting on the boot starts at the first frame, while media still buffers.
436
+ useEffect(() => {
437
+ if (firstFrameShown) usePlayerStore.getState().markPreviewBooted();
438
+ }, [firstFrameShown]);
437
439
  // `painted` means the player's own loader has finished fading; two frames of grace on top.
438
440
  useEffect(() => {
439
441
  if (!readyToShow) {
@@ -450,6 +452,18 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
450
452
  };
451
453
  }, [readyToShow]);
452
454
 
455
+ useEffect(() => {
456
+ if (previewError) usePlayerStore.getState().markPreviewBooted();
457
+ }, [previewError]);
458
+
459
+ useEffect(() => {
460
+ const timer = setTimeout(
461
+ () => usePlayerStore.getState().markPreviewBooted(),
462
+ PREVIEW_BOOT_DEADLINE_MS,
463
+ );
464
+ return () => clearTimeout(timer);
465
+ }, [projectId]);
466
+
453
467
  const showCompositionOverlay =
454
468
  !suppressLoadingOverlay &&
455
469
  !compositionOverlayDeferred &&
@@ -8,11 +8,6 @@ import {
8
8
  type DragCommitDeps,
9
9
  type TimelineMoveEdit,
10
10
  } from "./timelineClipDragCommit";
11
- import {
12
- buildEditHistoryEntry,
13
- createEmptyEditHistory,
14
- pushEditHistoryEntry,
15
- } from "../../utils/editHistory";
16
11
  import { normalizeToZones } from "./timelineZones";
17
12
  import { resolveZMirrorLaneMove } from "./timelineZMirror";
18
13
  import type { StackingPatch } from "./timelineStackingSync";
@@ -1151,48 +1146,21 @@ describe("commitDraggedClipMove", () => {
1151
1146
  return { onMoveElements, onStackingPatches };
1152
1147
  };
1153
1148
 
1154
- it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the two records merge into a single undo entry", async () => {
1149
+ it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the server can fold them into a single undo entry", async () => {
1155
1150
  const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
1156
1151
  await flushMicrotasks();
1157
1152
 
1158
- // Both sides receive the SAME non-empty gesture key (second arg).
1153
+ // Both sides receive the SAME non-empty gesture key (second arg). That
1154
+ // shared, non-empty coalesceKey is what lets the server
1155
+ // (projectHistory.ts, "claim: a writer that records after writing")
1156
+ // fold the "Move timeline clips" write and the "Reorder layers" z patch,
1157
+ // both to the same file, into one undo entry — folding itself is the
1158
+ // server's own tested behaviour, not re-proven here via a reducer.
1159
1159
  const moveKey = onMoveElements.mock.calls[0][1];
1160
1160
  const zKey = onStackingPatches.mock.calls[0][1];
1161
1161
  expect(typeof moveKey).toBe("string");
1162
1162
  expect(moveKey).not.toBe("");
1163
1163
  expect(zKey).toBe(moveKey);
1164
-
1165
- // With that shared key, editHistory folds the two consecutive records (the
1166
- // "Move timeline clips" write + the "Reorder layers" z patch, same file,
1167
- // inside the coalesce window) into ONE undo entry spanning before→after.
1168
- const now = 1_000;
1169
- const moveEntry = buildEditHistoryEntry({
1170
- id: "m",
1171
- projectId: "p",
1172
- label: "Move timeline clips",
1173
- kind: "timeline",
1174
- coalesceKey: moveKey,
1175
- now,
1176
- files: { "index.html": { before: "<v0>", after: "<v1>" } },
1177
- });
1178
- const zEntry = buildEditHistoryEntry({
1179
- id: "z",
1180
- projectId: "p",
1181
- label: "Reorder layers",
1182
- kind: "timeline",
1183
- coalesceKey: zKey,
1184
- now: now + 50,
1185
- files: { "index.html": { before: "<v1>", after: "<v2>" } },
1186
- });
1187
- const state = pushEditHistoryEntry(
1188
- pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
1189
- zEntry,
1190
- );
1191
- expect(state.undo).toHaveLength(1);
1192
- expect(state.undo[0].files["index.html"]).toMatchObject({
1193
- before: "<v0>",
1194
- after: "<v2>",
1195
- });
1196
1164
  });
1197
1165
 
1198
1166
  it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
@@ -47,6 +47,56 @@ describe("live DOM manifest hydration", () => {
47
47
  ).toHaveLength(2);
48
48
  }
49
49
  });
50
+ it("binds a root clip to its own element when a mounted sub-composition repeats its id", () => {
51
+ // The sub-composition's copy comes first in document order, so a bare id lookup finds it.
52
+ const doc = documentWith(
53
+ '<div id="strip" data-composition-id="strip" data-composition-src="compositions/strip.html" data-start="0" data-duration="4" data-track-index="1">' +
54
+ '<video id="frame-1" data-hf-id="hf-inner" data-start="0"></video></div>' +
55
+ '<video id="frame-1" data-hf-id="hf-root" data-start="6" data-duration="1" data-track-index="7"></video>',
56
+ );
57
+ const [element] = buildTimelineElementsFromClips(
58
+ [
59
+ clip({
60
+ id: "frame-1",
61
+ tagName: "video",
62
+ kind: "video",
63
+ start: 6,
64
+ duration: 1,
65
+ track: 7,
66
+ compositionAncestors: ["main"],
67
+ }),
68
+ ],
69
+ doc,
70
+ );
71
+ expect([element.hfId, element.sourceFile]).toEqual(["hf-root", undefined]);
72
+ });
73
+ it("keeps a unique id bound while its host's composition id is healed after the manifest", () => {
74
+ // The wrapper's data-composition-id arrived after the runtime recorded the clip's chain as ["main"].
75
+ const doc = documentWith(
76
+ '<div id="ho" data-hf-id="ho" data-start="0" data-duration="4"></div>' +
77
+ '<div id="hs-layer" data-composition-id="hs-comp">' +
78
+ '<div id="hs" data-hf-id="hs" data-start="0" data-duration="4"></div></div>',
79
+ );
80
+ const [element] = buildTimelineElementsFromClips(
81
+ [clip({ id: "hs", compositionAncestors: ["main"] })],
82
+ doc,
83
+ );
84
+ expect(element.hfId).toBe("hs");
85
+ });
86
+ it("binds an id-less host by its composition id over a lone id inside that sub-composition", () => {
87
+ const doc = documentWith(
88
+ '<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="0" data-duration="4">' +
89
+ '<div data-hf-inner-root><div id="card" data-start="0" data-duration="4"></div></div></div>',
90
+ );
91
+ const host = doc.querySelector('[data-composition-src="compositions/card.html"]');
92
+ const card = clip({
93
+ id: "card",
94
+ kind: "composition",
95
+ compositionId: "card",
96
+ compositionAncestors: ["main"],
97
+ });
98
+ expect(findTimelineDomNodeForClip(doc, card, 0)).toBe(host);
99
+ });
50
100
  it("does not bind a cross-tag direct identity", () => {
51
101
  const doc = documentWith('<div id="collision" data-start="0" data-duration="4"></div>');
52
102
  expect(
@@ -43,7 +43,7 @@ function mountScene(): Document {
43
43
 
44
44
  // A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
45
45
  // (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
46
- // the host with getElementById(clip.id) exactly as the sibling walk does.
46
+ // the host by clip.id in the clip's own composition, exactly as the sibling walk does.
47
47
  const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];
48
48
 
49
49
  describe("collectSubCompositionHostState", () => {
@@ -83,6 +83,30 @@ describe("collectSubCompositionHostState", () => {
83
83
  expect(state.get("scene-2-video")?.hidden).toBe(true);
84
84
  });
85
85
 
86
+ it("reads its own host when an earlier sub-composition repeats the host's id", () => {
87
+ document.body.innerHTML = `
88
+ <main data-composition-id="main">
89
+ <div id="intro-slot" data-composition-id="intro" data-composition-src="intro.html">
90
+ <div data-hf-inner-root data-composition-id="intro">
91
+ <div id="scene-2-slot"><p id="intro-note" data-hidden></p></div>
92
+ </div>
93
+ </div>
94
+ <div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
95
+ <div data-hf-inner-root><div id="scene-2-title" data-hidden></div></div>
96
+ </div>
97
+ </main>`;
98
+ const host = clip({
99
+ id: "scene-2-slot",
100
+ kind: "composition",
101
+ compositionId: "scene-2",
102
+ compositionAncestors: ["main"],
103
+ });
104
+
105
+ expect([...collectSubCompositionHostState(document, [host]).keys()]).toEqual(["scene-2-title"]);
106
+ const rows = collectSubCompositionDomChildren(document, [host], new Map());
107
+ expect(rows.map((child) => child.id)).toEqual(["scene-2-title"]);
108
+ });
109
+
86
110
  it("returns empty without a document, rather than throwing", () => {
87
111
  expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
88
112
  });
@@ -9,7 +9,7 @@
9
9
  * an argument, so each is callable — and readable — on its own.
10
10
  */
11
11
 
12
- import { createTimelineDomNodeResolver } from "../lib/timelineElementHelpers";
12
+ import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers";
13
13
  import { usePlayerStore } from "../store/playerStore";
14
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
15
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
@@ -128,7 +128,7 @@ export function collectSubCompositionDomChildren(
128
128
  if (!iframeDoc) return out;
129
129
  for (const clip of clips) {
130
130
  if (clip.kind !== "composition" || !clip.id) continue;
131
- const hostEl = iframeDoc.getElementById(clip.id);
131
+ const hostEl = findClipElementById(iframeDoc, clip);
132
132
  if (!hostEl) continue;
133
133
  const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
134
134
  collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
@@ -172,7 +172,7 @@ export function collectSubCompositionHostState(
172
172
  if (!iframeDoc) return out;
173
173
  for (const clip of clips) {
174
174
  if (clip.kind !== "composition" || !clip.id) continue;
175
- const hostEl = iframeDoc.getElementById(clip.id);
175
+ const hostEl = findClipElementById(iframeDoc, clip);
176
176
  if (!hostEl) continue;
177
177
  for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
178
178
  const state = readSubCompositionHostState(el);
@@ -219,6 +219,7 @@ export function useShadowPreviewReload({
219
219
  shadowIframeRef.current = null;
220
220
  isRefreshingRef.current = false;
221
221
  pendingSeekRef.current = null;
222
+ usePlayerStore.getState().setTimelineReady(false);
222
223
  setPreviewSlots(planShadowDiscard);
223
224
  thumbnailScheduler.setPreviewReloading(false);
224
225
  }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
@@ -0,0 +1,120 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import {
6
+ makeAdapterWindow,
7
+ makeFakeIframe,
8
+ renderTimelinePlayerHarness,
9
+ resetPlayerStore,
10
+ } from "./timelinePlayerTestHarness";
11
+ import { usePlayerStore } from "../store/playerStore";
12
+
13
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ afterEach(() => {
16
+ document.body.innerHTML = "";
17
+ resetPlayerStore();
18
+ });
19
+
20
+ it("does not start playback before the timeline is ready, so iframe load cannot swallow it", () => {
21
+ const { api, root } = renderTimelinePlayerHarness();
22
+ const { adapter, win } = makeAdapterWindow();
23
+ // The runtime has booted (adapter exists) but the iframe's load event has not fired yet.
24
+ act(() => {
25
+ api.iframeRef.current = makeFakeIframe(win);
26
+ });
27
+
28
+ act(() => api.play());
29
+ expect(adapter.play).not.toHaveBeenCalled();
30
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
31
+
32
+ act(() => {
33
+ api.onIframeLoad();
34
+ usePlayerStore.setState({ timelineReady: true });
35
+ });
36
+ act(() => api.play());
37
+ expect(adapter.play).toHaveBeenCalledTimes(1);
38
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
39
+ act(() => root.unmount());
40
+ });
41
+
42
+ it("does not start playback while a switched-to composition's preview is still loading", () => {
43
+ const { api, root } = renderTimelinePlayerHarness();
44
+ act(() => {
45
+ api.iframeRef.current = makeFakeIframe(makeAdapterWindow().win);
46
+ api.onIframeLoad();
47
+ usePlayerStore.setState({ timelineReady: true });
48
+ });
49
+
50
+ // Drill into another composition: a fresh preview whose runtime is up but whose load step hasn't run.
51
+ const next = makeAdapterWindow();
52
+ act(() => {
53
+ api.resetPreviewSlots();
54
+ api.iframeRef.current = makeFakeIframe(next.win);
55
+ });
56
+ act(() => api.play());
57
+ expect(next.adapter.play).not.toHaveBeenCalled();
58
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
59
+ act(() => root.unmount());
60
+ });
61
+
62
+ function postFromRuntime(win: object, data: Record<string, unknown>) {
63
+ act(() => {
64
+ window.dispatchEvent(
65
+ new MessageEvent("message", {
66
+ source: win as unknown as Window,
67
+ data: { source: "hf-preview", ...data },
68
+ }),
69
+ );
70
+ });
71
+ }
72
+
73
+ async function expectPlayStillOff() {
74
+ await new Promise((resolve) => setTimeout(resolve, 300));
75
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
76
+ }
77
+
78
+ // Duration 0: the adapter cannot enable Play itself, so only the message path can, after the load step.
79
+ it.each([30, 0])(
80
+ "waits for the preview's load step before a timeline message enables Play (adapter duration %i)",
81
+ async (duration) => {
82
+ const { api, root } = renderTimelinePlayerHarness();
83
+ const { win } = makeAdapterWindow({ duration });
84
+ act(() => {
85
+ api.iframeRef.current = makeFakeIframe(win);
86
+ });
87
+ // The runtime reports its timeline before the document's load event, as a film with large images does.
88
+ const clip = { id: "clip", label: "Clip", start: 0, duration: 1, track: 0, kind: "element" };
89
+ postFromRuntime(win, { type: "timeline", clips: [clip], durationInFrames: 30, fps: 30 });
90
+ await expectPlayStillOff();
91
+
92
+ act(() => api.onIframeLoad());
93
+ await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
94
+ act(() => root.unmount());
95
+ },
96
+ );
97
+
98
+ it("does not let the blank page's load step enable Play for the preview that replaces it", async () => {
99
+ const { api, root } = renderTimelinePlayerHarness();
100
+ // The iframe's first load is about:blank, before the runtime exists; its load step waits for runtime messages.
101
+ const iframe = makeFakeIframe({ ...makeAdapterWindow().win, __player: undefined });
102
+ act(() => {
103
+ api.iframeRef.current = iframe;
104
+ api.onIframeLoad();
105
+ });
106
+
107
+ // The same iframe navigates to the preview, whose runtime reports ready before its own load event.
108
+ const { win } = makeAdapterWindow();
109
+ Object.defineProperty(iframe, "contentWindow", { value: win, configurable: true });
110
+ Object.defineProperty(iframe, "contentDocument", {
111
+ value: document.implementation.createHTMLDocument("preview"),
112
+ configurable: true,
113
+ });
114
+ postFromRuntime(win, { type: "state" });
115
+ await expectPlayStillOff();
116
+
117
+ act(() => api.onIframeLoad());
118
+ await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
119
+ act(() => root.unmount());
120
+ });
@@ -180,6 +180,7 @@ export function useTimelinePlayer({
180
180
  applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
181
181
  }, []);
182
182
  const play = useCallback(() => {
183
+ if (!usePlayerStore.getState().timelineReady) return;
183
184
  stopRAFLoop();
184
185
  stopReverseLoop();
185
186
  stopScrubPreviewAudio();
@@ -295,6 +295,8 @@ export function useTimelineSyncCallbacks({
295
295
 
296
296
  const onIframeLoad = useCallback(
297
297
  (context?: number) => {
298
+ const loadedDoc = safeContentDocument(iframeRef.current);
299
+ if (loadedDoc) usePlayerStore.getState().markPreviewLoadStep(loadedDoc);
298
300
  applyPreviewAudioState();
299
301
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
300
302
  stopWaitingRef.current?.();