@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -16,7 +16,8 @@ interface UseRazorSplitOptions {
16
16
  projectId: string | null;
17
17
  activeCompPath: string | null;
18
18
  showToast: (message: string, tone?: "error" | "info") => void;
19
- writeProjectFile: (path: string, content: string) => Promise<void>;
19
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
20
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
20
21
  recordEdit: (input: RecordEditInput) => Promise<void>;
21
22
  domEditSaveTimestampRef: React.MutableRefObject<number>;
22
23
  reloadPreview: () => void;
@@ -48,7 +49,7 @@ async function splitHtmlElement(
48
49
  newId: string,
49
50
  elementStart: number,
50
51
  elementDuration: number,
51
- ): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
52
+ ): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
52
53
  const response = await fetch(
53
54
  `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
54
55
  {
@@ -64,9 +65,18 @@ async function splitHtmlElement(
64
65
  },
65
66
  );
66
67
  if (!response.ok) throw new Error("Split request failed");
67
- return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
68
+ const data = (await response.json()) as {
69
+ ok: boolean;
70
+ changed?: boolean;
71
+ content?: string;
72
+ version?: string;
73
+ };
74
+ const version = data.version ?? response.headers.get("etag");
75
+ if (!version) throw new Error("Split response did not include a content version");
76
+ return { ...data, version };
68
77
  }
69
78
 
79
+ // fallow-ignore-next-line complexity
70
80
  async function splitGsapAnimations(
71
81
  projectId: string,
72
82
  targetPath: string,
@@ -75,7 +85,7 @@ async function splitGsapAnimations(
75
85
  splitTime: number,
76
86
  elementStart: number,
77
87
  elementDuration: number,
78
- ): Promise<{ content: string | null; skippedSelectors?: string[] }> {
88
+ ): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
79
89
  const response = await fetch(
80
90
  `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
81
91
  {
@@ -101,10 +111,12 @@ async function splitGsapAnimations(
101
111
  const data = (await response.json()) as {
102
112
  ok?: boolean;
103
113
  after?: string;
114
+ version?: string;
104
115
  skippedSelectors?: string[];
105
116
  };
106
117
  return {
107
118
  content: data.ok && data.after ? data.after : null,
119
+ version: data.version ?? response.headers.get("etag") ?? undefined,
108
120
  skippedSelectors: data.skippedSelectors,
109
121
  };
110
122
  }
@@ -119,11 +131,11 @@ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string
119
131
 
120
132
  async function restoreFilesToOriginal(
121
133
  originals: ReadonlyMap<string, string>,
122
- paths: Iterable<string>,
123
- writeProjectFile: (path: string, content: string) => Promise<void>,
134
+ snapshots: ReadonlyMap<string, { before: string; after: string }>,
135
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
124
136
  ): Promise<void> {
125
- for (const path of paths) {
126
- await writeProjectFile(path, getOriginalContent(originals, path));
137
+ for (const [path, snapshot] of snapshots) {
138
+ await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
127
139
  }
128
140
  }
129
141
 
@@ -149,17 +161,25 @@ async function splitElementsAtTime(
149
161
  activeCompPath: string | null,
150
162
  originals: ReadonlyMap<string, string>,
151
163
  snapshots: Map<string, { before: string; after: string }>,
152
- writeProjectFile: (path: string, content: string) => Promise<void>,
164
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
165
+ observeProjectFileVersion?: (path: string, version: string | null) => void,
153
166
  ): Promise<number> {
154
167
  let count = 0;
155
168
  for (const element of elements) {
156
- const result = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
169
+ const result = await executeSplit(
170
+ pid,
171
+ element,
172
+ splitTime,
173
+ activeCompPath,
174
+ writeProjectFile,
175
+ observeProjectFileVersion,
176
+ );
157
177
  if (!result.changed) continue;
158
178
  snapshots.set(result.targetPath, {
159
179
  before: getOriginalContent(originals, result.targetPath),
160
180
  after: result.patchedContent,
161
181
  });
162
- await writeProjectFile(result.targetPath, result.patchedContent);
182
+ await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
163
183
  count++;
164
184
  }
165
185
  return count;
@@ -171,7 +191,8 @@ async function executeSplit(
171
191
  element: TimelineElement,
172
192
  splitTime: number,
173
193
  activeCompPath: string | null,
174
- writeProjectFile: (path: string, content: string) => Promise<void>,
194
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
195
+ observeProjectFileVersion?: (path: string, version: string | null) => void,
175
196
  ): Promise<{
176
197
  targetPath: string;
177
198
  originalContent: string;
@@ -209,6 +230,7 @@ async function executeSplit(
209
230
  if (!splitResult.changed) {
210
231
  return { targetPath, originalContent, patchedContent: originalContent, changed: false };
211
232
  }
233
+ observeProjectFileVersion?.(targetPath, splitResult.version);
212
234
 
213
235
  let patchedContent =
214
236
  typeof splitResult.content === "string" ? splitResult.content : originalContent;
@@ -226,11 +248,12 @@ async function executeSplit(
226
248
  element.duration,
227
249
  );
228
250
  if (gsapResult.content) patchedContent = gsapResult.content;
251
+ if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
229
252
  if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
230
253
  } catch (gsapError) {
231
254
  // GSAP mutation failed — the HTML split already wrote to disk.
232
255
  // Restore the original content to avoid a corrupt half-split state.
233
- await writeProjectFile(targetPath, originalContent);
256
+ await writeProjectFile(targetPath, originalContent, patchedContent);
234
257
  throw gsapError;
235
258
  }
236
259
  }
@@ -243,6 +266,7 @@ export function useRazorSplit({
243
266
  activeCompPath,
244
267
  showToast,
245
268
  writeProjectFile,
269
+ observeProjectFileVersion,
246
270
  recordEdit,
247
271
  domEditSaveTimestampRef,
248
272
  reloadPreview,
@@ -265,7 +289,14 @@ export function useRazorSplit({
265
289
 
266
290
  try {
267
291
  const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
268
- await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
292
+ await executeSplit(
293
+ pid,
294
+ element,
295
+ splitTime,
296
+ activeCompPath,
297
+ writeProjectFile,
298
+ observeProjectFileVersion,
299
+ );
269
300
 
270
301
  if (!changed) {
271
302
  showToast("Failed to split clip — playhead may be outside the clip", "error");
@@ -305,6 +336,7 @@ export function useRazorSplit({
305
336
  recordEdit,
306
337
  showToast,
307
338
  writeProjectFile,
339
+ observeProjectFileVersion,
308
340
  domEditSaveTimestampRef,
309
341
  reloadPreview,
310
342
  forceReloadSdkSession,
@@ -312,8 +344,8 @@ export function useRazorSplit({
312
344
  ],
313
345
  );
314
346
 
315
- // fallow-ignore-next-line complexity
316
347
  const handleRazorSplitAll = useCallback(
348
+ // fallow-ignore-next-line complexity
317
349
  async (splitTime: number) => {
318
350
  if (isRecordingRef?.current) {
319
351
  showToast("Cannot edit timeline while recording", "error");
@@ -338,6 +370,7 @@ export function useRazorSplit({
338
370
  originals,
339
371
  finalSnapshots,
340
372
  writeProjectFile,
373
+ observeProjectFileVersion,
341
374
  );
342
375
  if (splitCount === 0) return;
343
376
 
@@ -358,7 +391,7 @@ export function useRazorSplit({
358
391
  // Best-effort rollback — a failing restore write must not swallow the
359
392
  // original error's toast, which is what tells the user the split failed.
360
393
  try {
361
- await restoreFilesToOriginal(originals, finalSnapshots.keys(), writeProjectFile);
394
+ await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
362
395
  } catch {
363
396
  /* leave disk as-is; the original failure is reported below */
364
397
  }
@@ -371,6 +404,7 @@ export function useRazorSplit({
371
404
  recordEdit,
372
405
  showToast,
373
406
  writeProjectFile,
407
+ observeProjectFileVersion,
374
408
  domEditSaveTimestampRef,
375
409
  reloadPreview,
376
410
  forceReloadSdkSession,
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const openComposition = vi.fn();
7
+
8
+ vi.mock("@hyperframes/sdk", () => ({
9
+ openComposition: (...args: unknown[]) => openComposition(...args),
10
+ }));
11
+
12
+ import type { Composition } from "@hyperframes/sdk";
13
+ import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
14
+
15
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ function fakeSession(): Composition {
18
+ return { dispose: vi.fn() } as unknown as Composition;
19
+ }
20
+
21
+ function response(content: string): Response {
22
+ return { ok: true, json: async () => ({ content }) } as Response;
23
+ }
24
+
25
+ async function flushAsyncEffects(): Promise<void> {
26
+ await act(async () => {
27
+ await new Promise((resolve) => setTimeout(resolve, 0));
28
+ });
29
+ }
30
+
31
+ describe("useSdkSession ownership", () => {
32
+ beforeEach(() => {
33
+ openComposition.mockReset();
34
+ class FakeEventSource {
35
+ addEventListener(): void {}
36
+ close(): void {}
37
+ }
38
+ vi.stubGlobal("EventSource", FakeEventSource);
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ it("hides project A immediately while project B with the same path is still opening", async () => {
46
+ const sessionA = fakeSession();
47
+ const publishedA = fakeSession();
48
+ const sessionB = fakeSession();
49
+ let resolveProjectB: ((value: Response) => void) | undefined;
50
+ const projectBResponse = new Promise<Response>((resolve) => {
51
+ resolveProjectB = resolve;
52
+ });
53
+ vi.stubGlobal(
54
+ "fetch",
55
+ vi.fn((url: string) =>
56
+ url.includes("project-b") ? projectBResponse : Promise.resolve(response("PROJECT_A")),
57
+ ),
58
+ );
59
+ openComposition.mockImplementation(async (content: string) =>
60
+ content === "PROJECT_A" ? sessionA : sessionB,
61
+ );
62
+
63
+ const captured: { handle: SdkSessionHandle | null } = { handle: null };
64
+ function Probe({ projectId }: { projectId: string }) {
65
+ captured.handle = useSdkSession(projectId, "index.html");
66
+ return null;
67
+ }
68
+
69
+ const root = createRoot(document.createElement("div"));
70
+ await act(async () => root.render(<Probe projectId="project-a" />));
71
+ await flushAsyncEffects();
72
+ expect(captured.handle?.session).toBe(sessionA);
73
+
74
+ let publication: ReturnType<SdkSessionHandle["publish"]> | undefined;
75
+ await act(async () => {
76
+ publication = captured.handle?.publish({
77
+ candidate: publishedA,
78
+ expectedSession: sessionA,
79
+ targetPath: "index.html",
80
+ });
81
+ });
82
+ expect(publication).toBe("published");
83
+ expect(captured.handle?.session).toBe(publishedA);
84
+
85
+ await act(async () => root.render(<Probe projectId="project-b" />));
86
+ expect(captured.handle?.session).toBeNull();
87
+ expect(publishedA.dispose).toHaveBeenCalledOnce();
88
+ expect(
89
+ captured.handle?.publish({
90
+ candidate: fakeSession(),
91
+ expectedSession: publishedA,
92
+ targetPath: "index.html",
93
+ }),
94
+ ).toBe("rejected-inactive-target");
95
+
96
+ resolveProjectB?.(response("PROJECT_B"));
97
+ await flushAsyncEffects();
98
+ expect(captured.handle?.session).toBe(sessionB);
99
+
100
+ await act(async () => root.unmount());
101
+ expect(sessionB.dispose).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it("disposes the currently published candidate when its owner unmounts", async () => {
105
+ const opened = fakeSession();
106
+ const published = fakeSession();
107
+ vi.stubGlobal(
108
+ "fetch",
109
+ vi.fn(async () => response("PROJECT_A")),
110
+ );
111
+ openComposition.mockResolvedValue(opened);
112
+
113
+ const captured: { handle: SdkSessionHandle | null } = { handle: null };
114
+ function Probe() {
115
+ captured.handle = useSdkSession("project-a", "index.html");
116
+ return null;
117
+ }
118
+
119
+ const root = createRoot(document.createElement("div"));
120
+ await act(async () => root.render(<Probe />));
121
+ await flushAsyncEffects();
122
+ expect(captured.handle?.session).toBe(opened);
123
+ let publication: ReturnType<SdkSessionHandle["publish"]> | undefined;
124
+ await act(async () => {
125
+ publication = captured.handle?.publish({
126
+ candidate: published,
127
+ expectedSession: opened,
128
+ targetPath: "index.html",
129
+ });
130
+ });
131
+ expect(publication).toBe("published");
132
+ expect(opened.dispose).toHaveBeenCalledOnce();
133
+
134
+ await act(async () => root.unmount());
135
+ expect(published.dispose).toHaveBeenCalledOnce();
136
+ });
137
+ });
@@ -1,9 +1,11 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import type { MutableRefObject } from "react";
3
3
  import { openComposition } from "@hyperframes/sdk";
4
4
  import type { Composition } from "@hyperframes/sdk";
5
5
  import { readStudioFileChangePath } from "../components/editor/manualEdits";
6
6
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
7
+ import { trackStudioEvent } from "../utils/studioTelemetry";
8
+ import type { PublishSdkSession } from "../utils/sdkCutover";
7
9
 
8
10
  /**
9
11
  * Read a project file's content, or undefined on a non-2xx (optional read).
@@ -82,6 +84,8 @@ export function shouldReloadOnFileChange(
82
84
 
83
85
  export interface SdkSessionHandle {
84
86
  session: Composition | null;
87
+ /** Atomically publish a fully persisted candidate session. */
88
+ publish: PublishSdkSession;
85
89
  /**
86
90
  * Force a session reload immediately, bypassing the self-write suppress
87
91
  * window. Call after undo/redo writes the active composition file so the
@@ -90,13 +94,82 @@ export interface SdkSessionHandle {
90
94
  forceReload: () => void;
91
95
  }
92
96
 
97
+ interface SdkSessionOwner {
98
+ projectId: string;
99
+ path: string;
100
+ reloadToken: number;
101
+ generation: number;
102
+ }
103
+
104
+ interface OwnedSdkSession extends SdkSessionOwner {
105
+ session: Composition;
106
+ }
107
+
108
+ function isSessionOwnerActive(
109
+ owner: SdkSessionOwner | undefined,
110
+ projectId: string | null,
111
+ path: string | null,
112
+ targetPath: string,
113
+ ): owner is SdkSessionOwner {
114
+ if (!owner) return false;
115
+ return owner.projectId === projectId && owner.path === path && owner.path === targetPath;
116
+ }
117
+
118
+ function isSessionOwnerCurrent(
119
+ owner: SdkSessionOwner,
120
+ generation: number,
121
+ projectId: string | null,
122
+ path: string | null,
123
+ reloadToken: number,
124
+ ): boolean {
125
+ return (
126
+ owner.generation === generation &&
127
+ owner.projectId === projectId &&
128
+ owner.path === path &&
129
+ owner.reloadToken === reloadToken
130
+ );
131
+ }
132
+
133
+ function ownsExpectedSession(
134
+ current: OwnedSdkSession | null,
135
+ expectedOwner: SdkSessionOwner,
136
+ expectedSession: Composition,
137
+ reloadToken: number,
138
+ ): current is OwnedSdkSession {
139
+ if (!current) return false;
140
+ return (
141
+ current.session === expectedSession &&
142
+ current.generation === expectedOwner.generation &&
143
+ current.reloadToken === reloadToken
144
+ );
145
+ }
146
+
147
+ function disposeSdkSession(session: Composition): void {
148
+ try {
149
+ session.dispose();
150
+ } catch (error) {
151
+ trackStudioEvent("sdk_session_dispose_failed", {
152
+ error: error instanceof Error ? error.message : String(error),
153
+ });
154
+ }
155
+ }
156
+
93
157
  export function useSdkSession(
94
158
  projectId: string | null,
95
159
  activeCompPath: string | null,
96
160
  domEditSaveTimestampRef?: MutableRefObject<number>,
97
161
  ): SdkSessionHandle {
98
- const [session, setSession] = useState<Composition | null>(null);
162
+ const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
163
+ const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
164
+ const sessionOwnersRef = useRef(new WeakMap<Composition, SdkSessionOwner>());
165
+ const generationRef = useRef(0);
166
+ const projectIdRef = useRef(projectId);
167
+ projectIdRef.current = projectId;
168
+ const activeCompPathRef = useRef(activeCompPath);
169
+ activeCompPathRef.current = activeCompPath;
99
170
  const [reloadToken, setReloadToken] = useState(0);
171
+ const reloadTokenRef = useRef(reloadToken);
172
+ reloadTokenRef.current = reloadToken;
100
173
 
101
174
  // ── Re-open on external change to the active composition ──
102
175
  useEffect(() => {
@@ -135,13 +208,28 @@ export function useSdkSession(
135
208
 
136
209
  // ── Open / re-open the session ──
137
210
  useEffect(() => {
211
+ const generation = ++generationRef.current;
212
+ let cancelled = false;
213
+
214
+ // The preceding effect normally released its generation first. Clear any
215
+ // remaining owner defensively so an invalid project/path cannot retain it.
216
+ const previous = ownedSessionRef.current;
217
+ ownedSessionRef.current = null;
218
+ setOwnedSession(null);
219
+ if (previous) disposeSdkSession(previous.session);
220
+
138
221
  if (!projectId || !activeCompPath) {
139
- setSession(null);
140
- return;
222
+ return () => {
223
+ cancelled = true;
224
+ };
141
225
  }
142
226
 
143
- let cancelled = false;
144
- const compRef = { current: null as Composition | null };
227
+ const owner: SdkSessionOwner = {
228
+ projectId,
229
+ path: activeCompPath,
230
+ reloadToken,
231
+ generation,
232
+ };
145
233
 
146
234
  readProjectFileOptional(projectId, activeCompPath)
147
235
  .then(async (content) => {
@@ -157,24 +245,77 @@ export function useSdkSession(
157
245
  const comp = await openComposition(content, { history: false });
158
246
  // Cleanup may have fired while openComposition was awaited; dispose immediately.
159
247
  if (cancelled) {
160
- comp.dispose();
248
+ disposeSdkSession(comp);
249
+ return;
250
+ }
251
+ if (
252
+ !isSessionOwnerCurrent(
253
+ owner,
254
+ generationRef.current,
255
+ projectIdRef.current,
256
+ activeCompPathRef.current,
257
+ reloadTokenRef.current,
258
+ )
259
+ ) {
260
+ disposeSdkSession(comp);
161
261
  return;
162
262
  }
163
- compRef.current = comp;
164
- setSession(comp);
263
+ const displaced = ownedSessionRef.current;
264
+ const installed = { ...owner, session: comp };
265
+ sessionOwnersRef.current.set(comp, owner);
266
+ ownedSessionRef.current = installed;
267
+ setOwnedSession(installed);
268
+ if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
165
269
  })
166
270
  .catch(() => {
167
- if (!cancelled) setSession(null);
271
+ if (!cancelled && generationRef.current === generation) setOwnedSession(null);
168
272
  });
169
273
 
170
274
  return () => {
171
275
  cancelled = true;
172
- // No queue to flush; dispose only. (Flushing here would serialize the
173
- // pre-undo in-memory doc and race the revert write on undo/redo reload.)
174
- compRef.current?.dispose();
276
+ // Publication preserves this generation, so cleanup releases whichever
277
+ // session it currently owns (the initially opened one or its candidate).
278
+ const owned = ownedSessionRef.current;
279
+ if (owned?.generation === generation) {
280
+ ownedSessionRef.current = null;
281
+ disposeSdkSession(owned.session);
282
+ }
175
283
  };
176
284
  }, [projectId, activeCompPath, reloadToken]);
177
285
 
178
286
  const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
179
- return { session, forceReload };
287
+ const publish = useCallback<PublishSdkSession>(({ candidate, expectedSession, targetPath }) => {
288
+ const expectedOwner = sessionOwnersRef.current.get(expectedSession);
289
+ const current = ownedSessionRef.current;
290
+ if (
291
+ !isSessionOwnerActive(
292
+ expectedOwner,
293
+ projectIdRef.current,
294
+ activeCompPathRef.current,
295
+ targetPath,
296
+ )
297
+ ) {
298
+ return "rejected-inactive-target";
299
+ }
300
+ if (!ownsExpectedSession(current, expectedOwner, expectedSession, reloadTokenRef.current)) {
301
+ // The durable write won, but another session was installed for this same
302
+ // path before publication. Its self-write echo will be suppressed, so
303
+ // explicitly re-open it from disk instead of leaving it stale.
304
+ setReloadToken((t) => t + 1);
305
+ return "rejected-active-target";
306
+ }
307
+ const next: OwnedSdkSession = { ...current, session: candidate };
308
+ sessionOwnersRef.current.set(candidate, current);
309
+ ownedSessionRef.current = next;
310
+ setOwnedSession(next);
311
+ if (current.session !== candidate) disposeSdkSession(current.session);
312
+ return "published";
313
+ }, []);
314
+ const session =
315
+ ownedSession?.projectId === projectId &&
316
+ ownedSession.path === activeCompPath &&
317
+ ownedSession.reloadToken === reloadToken
318
+ ? ownedSession.session
319
+ : null;
320
+ return { session, publish, forceReload };
180
321
  }
@@ -2,6 +2,7 @@ import { useCallback, type MutableRefObject } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { SlideshowManifest } from "@hyperframes/core/slideshow";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
+ import type { PublishSdkSession } from "../utils/sdkCutover";
5
6
  import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
6
7
 
7
8
  export interface UseSlideshowPersistParams {
@@ -16,6 +17,8 @@ export interface UseSlideshowPersistParams {
16
17
  }) => Promise<void>;
17
18
  reloadPreview: () => void;
18
19
  domEditSaveTimestampRef: MutableRefObject<number>;
20
+ /** Publish a fully persisted candidate SDK session. */
21
+ publishSdkSession?: PublishSdkSession;
19
22
  /**
20
23
  * When provided, rapid writes with the same key coalesce through the
21
24
  * save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
@@ -33,6 +36,7 @@ export function useSlideshowPersist({
33
36
  recordEdit,
34
37
  reloadPreview,
35
38
  domEditSaveTimestampRef,
39
+ publishSdkSession,
36
40
  coalesceKey,
37
41
  }: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
38
42
  return useCallback(
@@ -42,7 +46,6 @@ export function useSlideshowPersist({
42
46
  const originalContent = await readProjectFile(path);
43
47
  await persistSlideshowManifest({
44
48
  manifest,
45
- sdkSession,
46
49
  originalContent,
47
50
  targetPath: path,
48
51
  deps: {
@@ -50,6 +53,8 @@ export function useSlideshowPersist({
50
53
  writeProjectFile,
51
54
  reloadPreview,
52
55
  domEditSaveTimestampRef,
56
+ readProjectFile,
57
+ publishSession: publishSdkSession,
53
58
  },
54
59
  coalesceKey,
55
60
  });
@@ -62,6 +67,7 @@ export function useSlideshowPersist({
62
67
  recordEdit,
63
68
  reloadPreview,
64
69
  domEditSaveTimestampRef,
70
+ publishSdkSession,
65
71
  coalesceKey,
66
72
  ],
67
73
  );
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type {
3
3
  StoryboardFrame,
4
4
  StoryboardGlobals,
@@ -27,6 +27,8 @@ export interface StoryboardResponse {
27
27
  frames: StoryboardFrameView[];
28
28
  warnings: StoryboardWarning[];
29
29
  script?: StoryboardScript;
30
+ /** Project content signature this payload was derived from (poster cache-bust + change poll). */
31
+ signature?: string;
30
32
  }
31
33
 
32
34
  export interface UseStoryboardResult {
@@ -39,19 +41,30 @@ export interface UseStoryboardResult {
39
41
  /**
40
42
  * Load the parsed storyboard manifest for a project. Markdown stays canonical on
41
43
  * disk; this fetches the server-derived JSON the storyboard view renders.
44
+ *
45
+ * `reload()` refetches in place: the blocking spinner only shows on first load
46
+ * and project switches, so background refreshes (file-change polling, post-save
47
+ * reloads) swap data without flashing the board.
42
48
  */
43
49
  export function useStoryboard(projectId: string | null): UseStoryboardResult {
44
50
  const [data, setData] = useState<StoryboardResponse | null>(null);
45
51
  const [loading, setLoading] = useState(true);
46
52
  const [error, setError] = useState<string | null>(null);
47
53
  const [reloadKey, setReloadKey] = useState(0);
54
+ const hasDataRef = useRef(false);
55
+ const lastProjectRef = useRef<string | null>(null);
48
56
 
49
57
  const reload = useCallback(() => setReloadKey((k) => k + 1), []);
50
58
 
51
59
  useEffect(() => {
52
60
  if (!projectId) return;
53
61
  let cancelled = false;
54
- setLoading(true);
62
+ if (lastProjectRef.current !== projectId) {
63
+ lastProjectRef.current = projectId;
64
+ hasDataRef.current = false;
65
+ setData(null);
66
+ }
67
+ if (!hasDataRef.current) setLoading(true);
55
68
  setError(null);
56
69
 
57
70
  // Route through buildProjectApiPath so the (URL-derived) projectId is encoded
@@ -62,10 +75,16 @@ export function useStoryboard(projectId: string | null): UseStoryboardResult {
62
75
  return res.json() as Promise<StoryboardResponse>;
63
76
  })
64
77
  .then((json) => {
65
- if (!cancelled) setData(json);
78
+ if (cancelled) return;
79
+ hasDataRef.current = true;
80
+ setData(json);
66
81
  })
67
82
  .catch((err: unknown) => {
68
- if (!cancelled) setError(err instanceof Error ? err.message : "failed to load storyboard");
83
+ if (cancelled) return;
84
+ // A failed background refresh keeps the last good board on screen;
85
+ // the blocking error state is only for loads with nothing to show.
86
+ if (hasDataRef.current) return;
87
+ setError(err instanceof Error ? err.message : "failed to load storyboard");
69
88
  })
70
89
  .finally(() => {
71
90
  if (!cancelled) setLoading(false);