@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
@@ -8,7 +8,7 @@ import { usePlayerStore } from "../player";
8
8
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
10
10
  import { getTimelineElementLabel } from "../utils/studioHelpers";
11
- import { buildPatchTarget } from "./timelineEditingHelpers";
11
+ import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
12
12
  import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
13
13
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
14
14
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
@@ -157,7 +157,6 @@ export function useTimelineDeleteOps({
157
157
  await saveProjectFilesWithHistory({
158
158
  projectId: pid,
159
159
  label: deleteHistoryLabel,
160
- kind: "timeline",
161
160
  coalesceKey,
162
161
  files: { [targetPath]: patchedContent },
163
162
  readFile: async () => originalContent,
@@ -172,6 +171,7 @@ export function useTimelineDeleteOps({
172
171
  throw error;
173
172
  }
174
173
 
174
+ removeIframeTimelineElements(previewIframeRef.current, sameFile, activeCompPath);
175
175
  const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
176
176
  const survivors = timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id));
177
177
 
@@ -184,10 +184,11 @@ export function useTimelineDeleteOps({
184
184
  sameFile,
185
185
  usePlayerStore.getState().rippleEditEnabled,
186
186
  );
187
- let rippleApplied: TimelineGroupMoveChange[] | null = null;
187
+ const rippleChanges = rippleShifts ? resolveShiftedElements(survivors, rippleShifts) : null;
188
+ // The timeline follows the delete's write at once, as the owner of the ripple's optimistic update.
189
+ usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleChanges));
188
190
  let rippleFailed = false;
189
- if (rippleShifts) {
190
- const rippleChanges = resolveShiftedElements(survivors, rippleShifts);
191
+ if (rippleChanges) {
191
192
  try {
192
193
  await handleTimelineGroupMove(rippleChanges, {
193
194
  coalesceKey,
@@ -200,18 +201,17 @@ export function useTimelineDeleteOps({
200
201
  // the generic one would tell the user about one action twice.
201
202
  suppressFailureToast: true,
202
203
  });
203
- rippleApplied = rippleChanges;
204
204
  } catch (error) {
205
205
  // The delete already committed; a failed ripple leaves the gap the
206
206
  // toggle-off behaviour would have left anyway — not worth undoing
207
207
  // an otherwise-successful delete over.
208
208
  rippleFailed = true;
209
+ usePlayerStore.getState().setElements(survivors);
209
210
  console.error("[Timeline] ripple-edit failed to persist after delete", error);
210
211
  showToast("Clip deleted, but the gap could not be closed.", "error");
211
212
  }
212
213
  }
213
214
 
214
- usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
215
215
  usePlayerStore.getState().setSelectedElementId(null);
216
216
  usePlayerStore.getState().setSelectedElementIds(new Set());
217
217
  forceReloadSdkSession?.();
@@ -224,7 +224,7 @@ export function useTimelineDeleteOps({
224
224
  "info",
225
225
  );
226
226
  }
227
- if (rippleApplied && !rippleNoticeShownRef.current) {
227
+ if (rippleChanges && !rippleFailed && !rippleNoticeShownRef.current) {
228
228
  rippleNoticeShownRef.current = true;
229
229
  showToast(
230
230
  "Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
@@ -112,7 +112,6 @@ function renderTimelineEditingHook(input: {
112
112
  writeProjectFile?: (path: string, content: string) => Promise<void>;
113
113
  recordEdit?: (input: {
114
114
  label: string;
115
- kind: string;
116
115
  coalesceKey?: string;
117
116
  files: Record<string, { before: string; after: string }>;
118
117
  }) => Promise<void>;
@@ -2,13 +2,11 @@ import type { MutableRefObject, RefObject } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
5
- import type { EditHistoryKind } from "../utils/editHistory";
6
5
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
6
  import type { CanEditTimelineElement } from "./timelineEditPermission";
8
7
 
9
8
  interface RecordEditInput {
10
9
  label: string;
11
- kind: EditHistoryKind;
12
10
  coalesceKey?: string;
13
11
  files: Record<string, { before: string; after: string }>;
14
12
  }
@@ -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 &&
@@ -11,6 +11,9 @@
11
11
  */
12
12
  import { PLAYHEAD_HEAD_W } from "./timelineLayout";
13
13
 
14
+ /** Widest part of the playhead, centred on its line. */
15
+ export const PLAYHEAD_GLOW_W = 13;
16
+
14
17
  interface PlayheadIndicatorProps {
15
18
  /** CSS color, defaults to the HF accent variable */
16
19
  color?: string;
@@ -50,7 +53,7 @@ export function PlayheadIndicator({
50
53
  className="absolute top-0 bottom-0"
51
54
  style={{
52
55
  left: "50%",
53
- width: 13,
56
+ width: PLAYHEAD_GLOW_W,
54
57
  transform: "translateX(-50%)",
55
58
  background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
56
59
  }}
@@ -88,9 +88,7 @@ function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: str
88
88
  if (!ruler) throw new Error("Missing timeline ruler");
89
89
  const rulerOrigin = ruler.previousElementSibling;
90
90
  if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
91
- const playhead = Array.from(host.querySelectorAll<HTMLElement>("div")).find(
92
- (node) => node.style.zIndex === "100",
93
- );
91
+ const playhead = host.querySelector<HTMLElement>("[data-timeline-playhead-layer] > div");
94
92
  if (!playhead) throw new Error("Missing timeline playhead");
95
93
  return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
96
94
  }
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
6
7
 
7
8
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
9
 
@@ -430,6 +431,26 @@ describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
430
431
  };
431
432
  }
432
433
 
434
+ it("clips a scrolled-off playhead at the track headers once the scroll settles", async () => {
435
+ const { host, dispose } = await renderUnvirtualizedTimeline();
436
+ try {
437
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
438
+ const layer = () => host.querySelector<HTMLElement>("[data-timeline-playhead-layer]");
439
+ await scrollTimelineHorizontally(scroller, 166);
440
+ expect(layer()?.style.clipPath).toBe("");
441
+ await act(async () => {
442
+ await new Promise((resolve) => setTimeout(resolve, TIMELINE_SCROLL_SETTLE_MS + 20));
443
+ });
444
+ // The ruler's corner is as wide as the track headers; the glow may overhang by 6.5px.
445
+ const headerWidth = parseFloat(
446
+ host.querySelector<HTMLElement>(".sticky.left-0")!.style.width,
447
+ );
448
+ expect(layer()?.style.clipPath).toContain(`${166 + headerWidth - 6.5}px)`);
449
+ } finally {
450
+ dispose();
451
+ }
452
+ });
453
+
433
454
  it("mounts every clip rather than a window", async () => {
434
455
  const { host, dispose } = await renderUnvirtualizedTimeline();
435
456
  try {
@@ -1,6 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { TimelineRulerPart } from "./TimelineRulerPart";
3
3
  import { PlayheadIndicator } from "./PlayheadIndicator";
4
+ import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
4
5
  import {
5
6
  RULER_H,
6
7
  CLIP_Y,
@@ -236,18 +237,19 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
236
237
  the half-head offset baked into getTimelinePlayheadLeft keep the
237
238
  inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
238
239
  ticks' center), instead of relying on shrink-wrap sizing. */}
239
- <div
240
- ref={props.playheadRef}
241
- className="absolute top-0 bottom-0 pointer-events-none"
242
- style={{
243
- left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
244
- width: PLAYHEAD_HEAD_W,
245
- zIndex: 100,
246
- display: beatDragging ? "none" : undefined,
247
- }}
248
- >
249
- <PlayheadIndicator scrubbing={props.isScrubbing} />
250
- </div>
240
+ <TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
241
+ <div
242
+ ref={props.playheadRef}
243
+ className="absolute top-0 bottom-0 pointer-events-none"
244
+ style={{
245
+ left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
246
+ width: PLAYHEAD_HEAD_W,
247
+ display: beatDragging ? "none" : undefined,
248
+ }}
249
+ >
250
+ <PlayheadIndicator scrubbing={props.isScrubbing} />
251
+ </div>
252
+ </TimelinePlayheadLayer>
251
253
  </div>
252
254
  );
253
255
  });
@@ -0,0 +1,33 @@
1
+ import type { ReactNode, RefObject } from "react";
2
+ import { PLAYHEAD_GLOW_W } from "./PlayheadIndicator";
3
+ import { useSettledScrollLeft } from "./useSettledScrollLeft";
4
+
5
+ // Clips the playhead at the track headers' edge once a scroll settles, so one scrolled off to
6
+ // the left does not draw over the track names. Half the glow may pass the edge so 00:00 looks
7
+ // as before; mid-scroll it is not clipped.
8
+ export function TimelinePlayheadLayer({
9
+ scrollRef,
10
+ contentOrigin,
11
+ children,
12
+ }: {
13
+ scrollRef: RefObject<HTMLDivElement | null>;
14
+ contentOrigin: number;
15
+ children: ReactNode;
16
+ }) {
17
+ const settledScrollLeft = useSettledScrollLeft(scrollRef);
18
+ return (
19
+ <div
20
+ data-timeline-playhead-layer=""
21
+ className="absolute inset-0 pointer-events-none"
22
+ style={{
23
+ zIndex: 100,
24
+ clipPath:
25
+ settledScrollLeft === null
26
+ ? undefined
27
+ : `inset(0 0 0 ${settledScrollLeft + contentOrigin - PLAYHEAD_GLOW_W / 2}px)`,
28
+ }}
29
+ >
30
+ {children}
31
+ </div>
32
+ );
33
+ }
@@ -1,5 +1,5 @@
1
- import { memo, useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
2
- import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
1
+ import { memo, type RefObject } from "react";
2
+ import { useSettledScrollLeft } from "./useSettledScrollLeft";
3
3
  import type { TimelineTheme } from "./timelineTheme";
4
4
  import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
5
5
  import { formatTimelineTickLabel } from "./timelineRulerGeometry";
@@ -41,11 +41,8 @@ function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number):
41
41
  return found;
42
42
  }
43
43
 
44
- /**
45
- * Paints over the one label the header corner would slice, once a scroll settles. Moving it
46
- * on every tick crossing cost 2-3x the scroll budget on a large timeline, so mid-scroll a
47
- * fragment can still show.
48
- */
44
+ // Paints over the one label the header corner would slice, once a scroll settles; mid-scroll a
45
+ // fragment can still show.
49
46
  const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
50
47
  scrollRef,
51
48
  major,
@@ -57,39 +54,9 @@ const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
57
54
  pps: number;
58
55
  background: string;
59
56
  }) {
60
- const scrollingRef = useRef(false);
61
- const subscribe = useCallback(
62
- (onChange: () => void) => {
63
- const el = scrollRef.current;
64
- let settle: ReturnType<typeof setTimeout> | undefined;
65
- const onScroll = () => {
66
- if (!scrollingRef.current) {
67
- scrollingRef.current = true;
68
- onChange();
69
- }
70
- clearTimeout(settle);
71
- settle = setTimeout(() => {
72
- scrollingRef.current = false;
73
- onChange();
74
- }, TIMELINE_SCROLL_SETTLE_MS);
75
- };
76
- el?.addEventListener("scroll", onScroll, { passive: true });
77
- return () => {
78
- clearTimeout(settle);
79
- scrollingRef.current = false;
80
- el?.removeEventListener("scroll", onScroll);
81
- };
82
- },
83
- [scrollRef],
84
- );
85
- const index = useSyncExternalStore(
86
- subscribe,
87
- () =>
88
- scrollingRef.current
89
- ? -1
90
- : tickIndexUnderHeader(major, pps, scrollRef.current?.scrollLeft ?? 0),
91
- () => -1,
92
- );
57
+ const settledScrollLeft = useSettledScrollLeft(scrollRef);
58
+ const index =
59
+ settledScrollLeft === null ? -1 : tickIndexUnderHeader(major, pps, settledScrollLeft);
93
60
  if (index < 0) return null;
94
61
  const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
95
62
  const next = major[index + 1];