@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
@@ -7,13 +7,7 @@ import { splitElementInHtml } from "@hyperframes/studio-server/source-mutation";
7
7
  import type { TimelineElement } from "../player";
8
8
  import { usePlayerStore } from "../player";
9
9
  import { useRazorSplit } from "./useRazorSplit";
10
- import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
11
- import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
12
- import {
13
- createEmptyEditHistory,
14
- hashEditHistoryContent,
15
- undoEditHistory,
16
- } from "../utils/editHistory";
10
+ import type { RecordEditInput } from "./timelineEditingHelpers";
17
11
  import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
18
12
 
19
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -36,7 +30,10 @@ type SplitAll = (splitTime: number) => Promise<void>;
36
30
 
37
31
  interface Harness {
38
32
  disk: Record<string, string>;
39
- store: ReturnType<typeof createPersistentEditHistoryStore>;
33
+ // What the split recorded — folding, undo and undo mismatch guards are the
34
+ // server's own tested behaviour (projectHistory.ts / projectHistory.test.ts,
35
+ // usePersistentEditHistory.test.ts), not re-proven here via a reducer.
36
+ records: RecordEditInput[];
40
37
  splitRef: { current: Split | undefined };
41
38
  root: ReturnType<typeof mountProbe>;
42
39
  expected: string;
@@ -53,18 +50,7 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
53
50
  const disk: Record<string, string> = { "index.html": ORIGINAL };
54
51
  const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
55
52
  const previewWrites: string[] = [];
56
-
57
- const storage = createMemoryEditHistoryStorage();
58
- const store = createPersistentEditHistoryStore({
59
- projectId: "p1",
60
- storage,
61
- initialState: createEmptyEditHistory(),
62
- now: (() => {
63
- let t = 1000;
64
- return () => (t += 10);
65
- })(),
66
- onChange: () => {},
67
- });
53
+ const records: RecordEditInput[] = [];
68
54
 
69
55
  // Faithful stand-in for the atomic server cut: one forward file write and one
70
56
  // response carrying the canonical history snapshots.
@@ -115,7 +101,9 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
115
101
  writeProjectFile: async (path, content) => {
116
102
  disk[path] = content;
117
103
  },
118
- recordEdit: store.recordEdit,
104
+ recordEdit: async (input) => {
105
+ records.push(input);
106
+ },
119
107
  reloadPreview: () => {
120
108
  if (!opts.previewStamp) return;
121
109
  const stamped = ensureHfIds(disk["index.html"]);
@@ -134,16 +122,7 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
134
122
 
135
123
  const root = mountProbe(Component);
136
124
 
137
- return { disk, store, splitRef, root, expected: finalContent, previewWrites };
138
- }
139
-
140
- async function undoViaDisk(harness: Pick<Harness, "disk" | "store">) {
141
- return harness.store.undo({
142
- readFile: async (path) => harness.disk[path],
143
- writeFile: async (path, content) => {
144
- harness.disk[path] = content;
145
- },
146
- });
125
+ return { disk, records, splitRef, root, expected: finalContent, previewWrites };
147
126
  }
148
127
 
149
128
  afterEach(() => {
@@ -159,15 +138,16 @@ describe("useRazorSplit — split is undoable via edit history", () => {
159
138
  await harness.splitRef.current!(element, 2);
160
139
  });
161
140
 
162
- const snapshot = harness.store.snapshot().state;
163
- const entry = snapshot.undo.at(-1)!;
164
- const currentHash = hashEditHistoryContent(harness.disk["index.html"]);
165
- expect(entry.files["index.html"].afterHash).toBe(currentHash);
166
-
167
- const undo = undoEditHistory(snapshot, { "index.html": currentHash }, 2000);
168
- expect(undo.ok).toBe(true);
169
- expect(undo.filesToWrite).toEqual({ "index.html": ORIGINAL });
141
+ // The preview reload found every id already stamped, so it never made a
142
+ // second, unrecorded write that would leave the recorded entry stale...
170
143
  expect(harness.previewWrites).toHaveLength(0);
144
+ // ...meaning the split's own recorded "after" IS the disk's final content,
145
+ // and undoing it (server-side) would restore exactly the pre-split bytes.
146
+ expect(harness.records).toHaveLength(1);
147
+ expect(harness.records[0]!.files["index.html"]).toEqual({
148
+ before: ORIGINAL,
149
+ after: harness.disk["index.html"],
150
+ });
171
151
 
172
152
  act(() => harness.root.unmount());
173
153
  });
@@ -182,7 +162,7 @@ describe("useRazorSplit — split is undoable via edit history", () => {
182
162
  act(() => harness.root.unmount());
183
163
  });
184
164
 
185
- it("records a single 'Split timeline clip' history entry that undo restores", async () => {
165
+ it("records a single 'Split timeline clip' entry with the exact bytes undo restores", async () => {
186
166
  await act(async () => {
187
167
  await harness.splitRef.current!(element, 2);
188
168
  });
@@ -190,15 +170,16 @@ describe("useRazorSplit — split is undoable via edit history", () => {
190
170
  // The split reached disk.
191
171
  expect(harness.disk["index.html"]).toBe(harness.expected);
192
172
 
193
- // The split must be the top of the undo stack — not a prior/other entry.
194
- expect(harness.store.snapshot().canUndo).toBe(true);
195
- expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
196
-
197
- // Undo restores the exact pre-split file.
198
- const result = await undoViaDisk(harness);
199
- expect(result.ok).toBe(true);
200
- expect(result.label).toBe("Split timeline clip");
201
- expect(harness.disk["index.html"]).toBe(ORIGINAL);
173
+ // Exactly one entry, correctly labeled, carrying the pre/post-split bytes —
174
+ // what a real undo (server-side, projectHistory.ts) restores from.
175
+ expect(harness.records).toHaveLength(1);
176
+ expect(harness.records[0]).toMatchObject({
177
+ label: "Split timeline clip",
178
+ });
179
+ expect(harness.records[0]!.files["index.html"]).toEqual({
180
+ before: ORIGINAL,
181
+ after: harness.expected,
182
+ });
202
183
  });
203
184
  });
204
185
  }
@@ -222,20 +203,14 @@ const batchElements: TimelineElement[] = [
222
203
 
223
204
  interface SplitAllHarness {
224
205
  disk: Record<string, string>;
225
- store: ReturnType<typeof createPersistentEditHistoryStore>;
206
+ records: RecordEditInput[];
226
207
  splitAllRef: { current: SplitAll | undefined };
227
208
  root: ReturnType<typeof mountProbe>;
228
209
  }
229
210
 
230
211
  function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
231
212
  const disk: Record<string, string> = { ...BATCH_ORIGINALS };
232
- const store = createPersistentEditHistoryStore({
233
- projectId: "p1",
234
- storage: createMemoryEditHistoryStorage(),
235
- initialState: createEmptyEditHistory(),
236
- now: () => 1000,
237
- onChange: () => {},
238
- });
213
+ const records: RecordEditInput[] = [];
239
214
  let splitCount = 0;
240
215
 
241
216
  vi.stubGlobal(
@@ -255,7 +230,9 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
255
230
  writeProjectFile: async (path, content) => {
256
231
  disk[path] = content;
257
232
  },
258
- recordEdit: store.recordEdit,
233
+ recordEdit: async (input) => {
234
+ records.push(input);
235
+ },
259
236
  reloadPreview: () => {},
260
237
  });
261
238
  splitAllRef.current = handleRazorSplitAll;
@@ -264,7 +241,7 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
264
241
 
265
242
  const root = mountProbe(Component);
266
243
  usePlayerStore.setState({ elements: batchElements });
267
- return { disk, store, splitAllRef, root };
244
+ return { disk, records, splitAllRef, root };
268
245
  }
269
246
 
270
247
  describe("useRazorSplit — split-all batch history", () => {
@@ -280,22 +257,24 @@ describe("useRazorSplit — split-all batch history", () => {
280
257
  return harness;
281
258
  }
282
259
 
283
- it("records one undo entry that restores every split file", async () => {
260
+ it("records one entry whose files cover every split file (one undo restores all of them)", async () => {
284
261
  const harness = await runSplitAll();
285
262
 
286
- expect(harness.store.snapshot().canUndo).toBe(true);
287
- const result = await undoViaDisk(harness);
288
- expect(result.ok).toBe(true);
289
- expect(harness.disk).toEqual(BATCH_ORIGINALS);
290
- expect(harness.store.snapshot().canUndo).toBe(false);
263
+ expect(harness.records).toHaveLength(1);
264
+ const files = harness.records[0]!.files;
265
+ expect(Object.keys(files).sort()).toEqual(Object.keys(BATCH_ORIGINALS).sort());
266
+ for (const path of Object.keys(BATCH_ORIGINALS) as (keyof typeof BATCH_ORIGINALS)[]) {
267
+ expect(files[path]?.before).toBe(BATCH_ORIGINALS[path]);
268
+ expect(files[path]?.after).toBe(harness.disk[path]);
269
+ }
291
270
  act(() => harness.root.unmount());
292
271
  });
293
272
 
294
- it("restores completed writes and records no undo when a later split fails", async () => {
273
+ it("restores completed writes and records no entry when a later split fails", async () => {
295
274
  const harness = await runSplitAll(2);
296
275
 
297
276
  expect(harness.disk).toEqual(BATCH_ORIGINALS);
298
- expect(harness.store.snapshot().canUndo).toBe(false);
277
+ expect(harness.records).toHaveLength(0);
299
278
  act(() => harness.root.unmount());
300
279
  });
301
280
  });
@@ -5,9 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../player";
6
6
  import { usePlayerStore } from "../player";
7
7
  import { useRazorSplit } from "./useRazorSplit";
8
- import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
9
- import { createEmptyEditHistory } from "../utils/editHistory";
10
- import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
8
+ import type { RecordEditInput } from "./timelineEditingHelpers";
11
9
  import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
12
10
 
13
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -164,20 +162,14 @@ describe("useRazorSplit — sub-comp coordinate rebasing", () => {
164
162
 
165
163
  // ── Bug 1: split must resync the SDK session so undo isn't refused ────────────
166
164
 
167
- const memoryStorage = (): EditHistoryStorageAdapter => {
168
- const store = new Map<string, string>();
169
- return {
170
- load: async (k) => store.get(k) ?? null,
171
- save: async (k, v) => {
172
- store.set(k, v);
173
- },
174
- } as unknown as EditHistoryStorageAdapter;
175
- };
176
-
177
165
  interface UndoHarness {
178
166
  singleRef: { current: SingleSplit | undefined };
179
167
  disk: Record<string, string>;
180
- store: ReturnType<typeof createPersistentEditHistoryStore>;
168
+ // What the split recorded, so the server (projectHistory.ts) could fold and
169
+ // later undo it — recording, folding and undo mismatch guards are the
170
+ // server's own tested behaviour (projectHistory.test.ts,
171
+ // usePersistentEditHistory.test.ts), not re-proven here via a reducer.
172
+ records: RecordEditInput[];
181
173
  forceReloadSdkSession: ReturnType<typeof vi.fn>;
182
174
  root: ReturnType<typeof mountProbe>;
183
175
  }
@@ -186,13 +178,7 @@ function mountRazorSplitWithHistory(): UndoHarness {
186
178
  const disk: Record<string, string> = {
187
179
  [ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
188
180
  };
189
- const store = createPersistentEditHistoryStore({
190
- projectId: "p1",
191
- storage: memoryStorage(),
192
- initialState: createEmptyEditHistory(),
193
- now: () => Date.now(),
194
- onChange: () => {},
195
- });
181
+ const records: RecordEditInput[] = [];
196
182
  const forceReloadSdkSession = vi.fn();
197
183
 
198
184
  const fetchMock = createSplitFetchMock(disk);
@@ -207,7 +193,9 @@ function mountRazorSplitWithHistory(): UndoHarness {
207
193
  writeProjectFile: async (path, content) => {
208
194
  disk[path] = content;
209
195
  },
210
- recordEdit: (input) => store.recordEdit(input),
196
+ recordEdit: async (input) => {
197
+ records.push(input);
198
+ },
211
199
  reloadPreview: () => {},
212
200
  forceReloadSdkSession,
213
201
  });
@@ -215,7 +203,7 @@ function mountRazorSplitWithHistory(): UndoHarness {
215
203
  return null;
216
204
  }
217
205
  const root = mountProbe(Component);
218
- return { singleRef, disk, store, forceReloadSdkSession, root };
206
+ return { singleRef, disk, records, forceReloadSdkSession, root };
219
207
  }
220
208
 
221
209
  describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
@@ -227,13 +215,6 @@ describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
227
215
  act(() => h.root.unmount());
228
216
  });
229
217
 
230
- const readFile = () => ({
231
- readFile: async (p: string) => h.disk[p],
232
- writeFile: async (p: string, c: string) => {
233
- h.disk[p] = c;
234
- },
235
- });
236
-
237
218
  it("resyncs the SDK session after a split (matches every other server-write path)", async () => {
238
219
  await act(async () => {
239
220
  await h.singleRef.current!(rootElement, 4);
@@ -241,25 +222,14 @@ describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
241
222
  expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1);
242
223
  });
243
224
 
244
- it("applies undo after a split without an external-change refusal", async () => {
245
- await act(async () => {
246
- await h.singleRef.current!(rootElement, 4);
247
- });
248
- const result = await h.store.undo(readFile());
249
- expect(result.ok).toBe(true);
250
- expect(result.reason).toBeUndefined();
251
- // The file is restored to its pre-split bytes.
252
- expect(h.disk[ROOT_FILE]).not.toContain("<!--split-->");
253
- });
254
-
255
- it("still trips the guard when the file is edited externally after a split", async () => {
225
+ it("records the split as one entry whose file carries the exact pre/post-split bytes a real undo restores", async () => {
226
+ const before = h.disk[ROOT_FILE];
256
227
  await act(async () => {
257
228
  await h.singleRef.current!(rootElement, 4);
258
229
  });
259
- // Simulate the user editing the file in their own editor after the split.
260
- h.disk[ROOT_FILE] = `${h.disk[ROOT_FILE]}<!--hand-edit-->`;
261
- const result = await h.store.undo(readFile());
262
- expect(result.ok).toBe(false);
263
- expect(result.reason).toBe("content-mismatch");
230
+ expect(h.disk[ROOT_FILE]).toContain("<!--split-->");
231
+ expect(h.records).toHaveLength(1);
232
+ expect(h.records[0]).toMatchObject({ label: "Split timeline clip" });
233
+ expect(h.records[0]!.files[ROOT_FILE]).toEqual({ before, after: h.disk[ROOT_FILE] });
264
234
  });
265
235
  });
@@ -7,6 +7,8 @@ import { CompositionThumbnail, VideoThumbnail } from "../player";
7
7
  import { AudioWaveform } from "../player/components/AudioWaveform";
8
8
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
9
9
  import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
10
+ import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail";
11
+ import { compositionCardThumbnailUrl } from "../components/sidebar/CompositionsTab";
10
12
  import { normalizeCompositionSrc } from "./useRenderClipContent";
11
13
  import { useRenderClipContent } from "./useRenderClipContent";
12
14
 
@@ -316,4 +318,31 @@ describe("useRenderClipContent", () => {
316
318
  });
317
319
  }
318
320
  });
321
+
322
+ it.each(["compositions/scene-0.html", "compositions/scene [v2].html", "compositions/100%.html"])(
323
+ "asks for the same thumbnail as the card of %s, so one render serves both",
324
+ (compositionSrc) => {
325
+ usePlayerStore.setState({
326
+ thumbnailMode: "adaptive",
327
+ thumbnailRevisions: { [compositionSrc]: 3 },
328
+ });
329
+
330
+ const content = renderClipContent({
331
+ id: "scene-0",
332
+ tag: "div",
333
+ start: 10,
334
+ duration: 5,
335
+ track: 0,
336
+ compositionSrc,
337
+ });
338
+
339
+ expect(isValidElement(content)).toBe(true);
340
+ if (!isValidElement(content)) return;
341
+ const clipUrl = buildCompositionThumbnailUrl({
342
+ ...(content.props as Parameters<typeof buildCompositionThumbnailUrl>[0]),
343
+ origin: window.location.origin,
344
+ });
345
+ expect(clipUrl).toBe(compositionCardThumbnailUrl("my-project", compositionSrc, 3));
346
+ },
347
+ );
319
348
  });
@@ -2,7 +2,10 @@ import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  import { useCallback, type ReactNode } from "react";
3
3
  import { createElement } from "react";
4
4
  import { CompositionThumbnail, VideoThumbnail } from "../player";
5
- import { compositionPathOfPreviewUrl } from "../player/components/CompositionThumbnail";
5
+ import {
6
+ compositionPathOfPreviewUrl,
7
+ resolveThumbnailSeekTime,
8
+ } from "../player/components/CompositionThumbnail";
6
9
  import type { TimelineElement } from "../player";
7
10
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
8
11
  import { audioPillFlags } from "../player/components/audioClipLink";
@@ -157,8 +160,8 @@ export function useRenderClipContent({
157
160
  label: "",
158
161
  labelColor: style.label,
159
162
 
160
- seekTime: 0,
161
- duration: el.duration,
163
+ seekTime: resolveThumbnailSeekTime(el.duration),
164
+ duration: 0,
162
165
  projectId: pid,
163
166
  sessionEpoch,
164
167
  contentRevision: thumbnailRevisionOf(thumbnailRevisions, compSrc),
@@ -12,6 +12,7 @@ vi.mock("@hyperframes/sdk", () => ({
12
12
 
13
13
  import type { Composition } from "@hyperframes/sdk";
14
14
  import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
15
+ import { usePlayerStore } from "../player/store/playerStore";
15
16
 
16
17
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
17
18
 
@@ -19,6 +20,10 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
19
20
 
20
21
  const trackMock = vi.mocked(trackStudioEvent);
21
22
 
23
+ beforeEach(() => {
24
+ usePlayerStore.setState({ timelineProjectId: "project-a", previewBooted: true });
25
+ });
26
+
22
27
  function Probe({ projectId }: { projectId: string }) {
23
28
  useSdkSession(projectId, "index.html");
24
29
  return null;
@@ -54,6 +59,24 @@ describe("useSdkSession ownership", () => {
54
59
  vi.unstubAllGlobals();
55
60
  });
56
61
 
62
+ it("does not read or parse the composition until the live preview has booted", async () => {
63
+ usePlayerStore.setState({ previewBooted: false });
64
+ const fetchStub = vi.fn(async () => response("PROJECT_A"));
65
+ vi.stubGlobal("fetch", fetchStub);
66
+ openComposition.mockImplementation(async () => fakeSession());
67
+
68
+ const root = createRoot(document.createElement("div"));
69
+ await act(async () => root.render(<Probe projectId="project-a" />));
70
+ await flushAsyncEffects();
71
+ expect(fetchStub).not.toHaveBeenCalled();
72
+ expect(openComposition).not.toHaveBeenCalled();
73
+
74
+ await act(async () => usePlayerStore.getState().markPreviewBooted());
75
+ await flushAsyncEffects();
76
+ expect(openComposition).toHaveBeenCalledWith("PROJECT_A", { history: false });
77
+ act(() => root.unmount());
78
+ });
79
+
57
80
  it("hides project A immediately while project B with the same path is still opening", async () => {
58
81
  const sessionA = fakeSession();
59
82
  const publishedA = fakeSession();
@@ -94,7 +117,11 @@ describe("useSdkSession ownership", () => {
94
117
  expect(publication).toBe("published");
95
118
  expect(captured.handle?.session).toBe(publishedA);
96
119
 
97
- await act(async () => root.render(<Probe projectId="project-b" />));
120
+ await act(async () => {
121
+ usePlayerStore.getState().beginTimelineSession("project-b");
122
+ usePlayerStore.getState().markPreviewBooted();
123
+ root.render(<Probe projectId="project-b" />);
124
+ });
98
125
  expect(captured.handle?.session).toBeNull();
99
126
  expect(publishedA.dispose).toHaveBeenCalledOnce();
100
127
  expect(
@@ -164,6 +191,7 @@ describe("useSdkSession unreachable project", () => {
164
191
  });
165
192
 
166
193
  function probeHandle(projectId: string) {
194
+ usePlayerStore.setState({ timelineProjectId: projectId });
167
195
  const captured: { handle: SdkSessionHandle | null } = { handle: null };
168
196
  function HandleProbe() {
169
197
  captured.handle = useSdkSession(projectId, "index.html");
@@ -684,11 +712,13 @@ describe("useSdkSession unavailable telemetry", () => {
684
712
  ),
685
713
  );
686
714
  await flushAsyncEffects();
687
- await act(async () =>
715
+ await act(async () => {
716
+ usePlayerStore.getState().beginTimelineSession("project-b");
717
+ usePlayerStore.getState().markPreviewBooted();
688
718
  root.render(
689
719
  <HandleProbe projectId="project-b" path="index.html" onAbsentRead={onAbsentRead} />,
690
- ),
691
- );
720
+ );
721
+ });
692
722
  await flushAsyncEffects();
693
723
 
694
724
  expect(onAbsentRead).toHaveBeenCalledTimes(2);
@@ -5,6 +5,7 @@ import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
5
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
+ import { whenPreviewBooted } from "../player/store/playerStore";
8
9
 
9
10
  /**
10
11
  * Why an optional project-file read produced no usable content. `stage: "read"`
@@ -439,9 +440,14 @@ export function useSdkSession(
439
440
  generation,
440
441
  };
441
442
 
442
- readProjectFileOptional(projectId, activeCompPath)
443
+ // Parsing the source for editing takes the main thread for hundreds of ms on a large film;
444
+ // the first frame and play do not need it, so it waits for the live preview to boot.
445
+ whenPreviewBooted(projectId)
446
+ .then((booted) =>
447
+ cancelled || !booted ? null : readProjectFileOptional(projectId, activeCompPath),
448
+ )
443
449
  .then(async (read) => {
444
- if (cancelled) return;
450
+ if (!read || cancelled) return;
445
451
  if (!read.ok) {
446
452
  handleReadFailure(read, projectId, activeCompPath);
447
453
  return;
@@ -1,7 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { SlideshowManifest } from "@hyperframes/core/slideshow";
4
- import type { EditHistoryKind } from "../utils/editHistory";
5
4
  import type { PublishSdkSession } from "../utils/sdkCutover";
6
5
  import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
7
6
 
@@ -12,7 +11,6 @@ export interface UseSlideshowPersistParams {
12
11
  writeProjectFile: (path: string, content: string) => Promise<void>;
13
12
  recordEdit: (entry: {
14
13
  label: string;
15
- kind: EditHistoryKind;
16
14
  files: Record<string, { before: string; after: string }>;
17
15
  }) => Promise<void>;
18
16
  reloadPreview: () => void;
@@ -17,7 +17,12 @@ interface StudioContextInput {
17
17
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
18
18
  timelineElements: StudioContextValue["timelineElements"];
19
19
  isPlaying: boolean;
20
- editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
20
+ editHistory: {
21
+ canUndo: boolean;
22
+ canRedo: boolean;
23
+ undoLabel: string | undefined;
24
+ redoLabel: string | undefined;
25
+ };
21
26
  handleUndo: StudioContextValue["handleUndo"];
22
27
  handleRedo: StudioContextValue["handleRedo"];
23
28
  // Was a second copy of the same shape, which meant every field added to the
@@ -166,7 +166,6 @@ export function useTimelineAssetDropOps({
166
166
  await saveProjectFilesWithHistory({
167
167
  projectId: pid,
168
168
  label: "Add timeline asset",
169
- kind: "timeline",
170
169
  files: { [targetPath]: patchedContent },
171
170
  readFile: async () => originalContent,
172
171
  writeFile: writeProjectFile,
@@ -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,
@@ -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
  }