@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -1,15 +1,10 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
- import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
4
+ import { getTimelineElementLabel } from "../utils/studioHelpers";
6
5
  import { trackStudioRazorSplit } from "../telemetry/events";
7
- import {
8
- canSplitElementAt,
9
- selectSplittableElements,
10
- buildPatchTarget,
11
- readFileContent,
12
- } from "../utils/timelineElementSplit";
6
+ import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
7
+ import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
13
8
  import type { RecordEditInput } from "./timelineEditingHelpers";
14
9
 
15
10
  interface UseRazorSplitOptions {
@@ -21,246 +16,10 @@ interface UseRazorSplitOptions {
21
16
  recordEdit: (input: RecordEditInput) => Promise<void>;
22
17
  domEditSaveTimestampRef: React.MutableRefObject<number>;
23
18
  reloadPreview: () => void;
24
- /**
25
- * Resync the in-memory SDK session after the server-side split write (the
26
- * split-element / split-gsap endpoints write the file directly, so the SDK's
27
- * linkedom doc is now stale). This reload is read-only; the split endpoint owns
28
- * the final on-disk bytes and history baseline. Every other server-side-write
29
- * timeline path (move / resize / delete / drop / visibility) also resyncs.
30
- */
31
19
  forceReloadSdkSession?: () => void;
32
20
  isRecordingRef?: React.RefObject<boolean>;
33
21
  }
34
22
 
35
- function generateSplitId(existingIds: string[], baseId: string): string {
36
- let newId = `${baseId}-split`;
37
- let suffix = 2;
38
- while (existingIds.includes(newId)) {
39
- newId = `${baseId}-split-${suffix++}`;
40
- }
41
- return newId;
42
- }
43
-
44
- async function splitHtmlElement(
45
- projectId: string,
46
- targetPath: string,
47
- patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
48
- splitTime: number,
49
- newId: string,
50
- elementStart: number,
51
- elementDuration: number,
52
- ): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
53
- const response = await fetch(
54
- `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
55
- {
56
- method: "POST",
57
- headers: { "Content-Type": "application/json" },
58
- body: JSON.stringify({
59
- target: patchTarget,
60
- splitTime,
61
- newId,
62
- elementStart,
63
- elementDuration,
64
- }),
65
- },
66
- );
67
- if (!response.ok) throw new Error("Split request failed");
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 };
77
- }
78
-
79
- // fallow-ignore-next-line complexity
80
- async function splitGsapAnimations(
81
- projectId: string,
82
- targetPath: string,
83
- originalId: string,
84
- newId: string,
85
- splitTime: number,
86
- elementStart: number,
87
- elementDuration: number,
88
- ): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
89
- const response = await fetch(
90
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
91
- {
92
- method: "POST",
93
- headers: { "Content-Type": "application/json" },
94
- body: JSON.stringify({
95
- type: "split-animations",
96
- originalId,
97
- newId,
98
- splitTime,
99
- elementStart,
100
- elementDuration,
101
- }),
102
- },
103
- );
104
- if (!response.ok) {
105
- const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
106
- if (errorBody?.error === "no GSAP script found in file") {
107
- return { content: null };
108
- }
109
- throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
110
- }
111
- const data = (await response.json()) as {
112
- ok?: boolean;
113
- after?: string;
114
- version?: string;
115
- skippedSelectors?: string[];
116
- };
117
- return {
118
- content: data.ok && data.after ? data.after : null,
119
- version: data.version ?? response.headers.get("etag") ?? undefined,
120
- skippedSelectors: data.skippedSelectors,
121
- };
122
- }
123
-
124
- function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
125
- const original = originals.get(path);
126
- if (original === undefined) {
127
- throw new Error(`Missing original contents for ${path}`);
128
- }
129
- return original;
130
- }
131
-
132
- async function restoreFilesToOriginal(
133
- originals: ReadonlyMap<string, string>,
134
- snapshots: ReadonlyMap<string, { before: string; after: string }>,
135
- writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
136
- ): Promise<void> {
137
- for (const [path, snapshot] of snapshots) {
138
- await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
139
- }
140
- }
141
-
142
- async function readOriginalFiles(
143
- pid: string,
144
- elements: TimelineElement[],
145
- activeCompPath: string | null,
146
- ): Promise<Map<string, string>> {
147
- const originals = new Map<string, string>();
148
- for (const element of elements) {
149
- const path = element.sourceFile || activeCompPath || "index.html";
150
- if (!originals.has(path)) {
151
- originals.set(path, await readFileContent(pid, path));
152
- }
153
- }
154
- return originals;
155
- }
156
-
157
- async function splitElementsAtTime(
158
- pid: string,
159
- elements: TimelineElement[],
160
- splitTime: number,
161
- activeCompPath: string | null,
162
- originals: ReadonlyMap<string, string>,
163
- snapshots: Map<string, { before: string; after: string }>,
164
- writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
165
- observeProjectFileVersion?: (path: string, version: string | null) => void,
166
- ): Promise<number> {
167
- let count = 0;
168
- for (const element of elements) {
169
- const result = await executeSplit(
170
- pid,
171
- element,
172
- splitTime,
173
- activeCompPath,
174
- writeProjectFile,
175
- observeProjectFileVersion,
176
- );
177
- if (!result.changed) continue;
178
- snapshots.set(result.targetPath, {
179
- before: getOriginalContent(originals, result.targetPath),
180
- after: result.patchedContent,
181
- });
182
- await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
183
- count++;
184
- }
185
- return count;
186
- }
187
-
188
- // fallow-ignore-next-line complexity
189
- async function executeSplit(
190
- pid: string,
191
- element: TimelineElement,
192
- splitTime: number,
193
- activeCompPath: string | null,
194
- writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
195
- observeProjectFileVersion?: (path: string, version: string | null) => void,
196
- ): Promise<{
197
- targetPath: string;
198
- originalContent: string;
199
- patchedContent: string;
200
- changed: boolean;
201
- skippedSelectors?: string[];
202
- }> {
203
- const patchTarget = buildPatchTarget(element);
204
- if (!patchTarget) throw new Error("Clip is missing a patchable target.");
205
-
206
- const targetPath = element.sourceFile || activeCompPath || "index.html";
207
- const originalContent = await readFileContent(pid, targetPath);
208
- const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
209
-
210
- // An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
211
- // `start` and the incoming `splitTime` are offset by the host's master start
212
- // (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
213
- // authored in LOCAL time. Rebase both onto local time before the server patches
214
- // the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
215
- // Root-level clips (no expandedParentStart) are already local, so pass through.
216
- const basis = element.expandedParentStart;
217
- const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
218
- const localElementStart = basis === undefined ? element.start : element.start - basis;
219
-
220
- const splitResult = await splitHtmlElement(
221
- pid,
222
- targetPath,
223
- patchTarget,
224
- localSplitTime,
225
- newId,
226
- localElementStart,
227
- element.duration,
228
- );
229
- if (!splitResult.ok) throw new Error("Failed to split clip.");
230
- if (!splitResult.changed) {
231
- return { targetPath, originalContent, patchedContent: originalContent, changed: false };
232
- }
233
- observeProjectFileVersion?.(targetPath, splitResult.version);
234
-
235
- let patchedContent =
236
- typeof splitResult.content === "string" ? splitResult.content : originalContent;
237
-
238
- let skippedSelectors: string[] | undefined;
239
- if (element.domId) {
240
- try {
241
- const gsapResult = await splitGsapAnimations(
242
- pid,
243
- targetPath,
244
- element.domId,
245
- newId,
246
- localSplitTime,
247
- localElementStart,
248
- element.duration,
249
- );
250
- if (gsapResult.content) patchedContent = gsapResult.content;
251
- if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
252
- if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
253
- } catch (gsapError) {
254
- // GSAP mutation failed — the HTML split already wrote to disk.
255
- // Restore the original content to avoid a corrupt half-split state.
256
- await writeProjectFile(targetPath, originalContent, patchedContent);
257
- throw gsapError;
258
- }
259
- }
260
-
261
- return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
262
- }
263
-
264
23
  export function useRazorSplit({
265
24
  projectId,
266
25
  activeCompPath,
@@ -276,140 +35,109 @@ export function useRazorSplit({
276
35
  const projectIdRef = useRef(projectId);
277
36
  projectIdRef.current = projectId;
278
37
 
38
+ const synchronize = useCallback(() => {
39
+ let failure: unknown;
40
+ try {
41
+ forceReloadSdkSession?.();
42
+ } catch (error) {
43
+ failure = error;
44
+ }
45
+ try {
46
+ reloadPreview();
47
+ } catch (error) {
48
+ failure ??= error;
49
+ }
50
+ if (failure) throw failure;
51
+ }, [forceReloadSdkSession, reloadPreview]);
52
+
53
+ const runCut = useCallback(
54
+ async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
55
+ const pid = projectIdRef.current;
56
+ if (!pid || elements.length === 0) return;
57
+ const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
58
+ const requestedCount = intents.reduce((count, file) => count + file.targets.length, 0);
59
+ const label =
60
+ mode === "single"
61
+ ? "Split timeline clip"
62
+ : `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
63
+
64
+ // Server writes arrive through the watcher before React can refresh. Keep
65
+ // the existing short self-write window active for this owned transaction.
66
+ domEditSaveTimestampRef.current = Date.now();
67
+ const result = await runAtomicCutTransaction({
68
+ projectId: pid,
69
+ intents,
70
+ label,
71
+ writeProjectFile,
72
+ recordEdit,
73
+ observeProjectFileVersion,
74
+ synchronize,
75
+ });
76
+ trackStudioRazorSplit({ mode, count: result.splitCount });
77
+ if (result.syncFailed) {
78
+ showToast(
79
+ "Cut was saved, but Studio could not refresh it. Reload the preview to resynchronize.",
80
+ "error",
81
+ );
82
+ }
83
+ if (result.skippedSelectors.length > 0) {
84
+ showToast(
85
+ `Some animations use non-ID selectors (${result.skippedSelectors.join(", ")}) and were not retargeted`,
86
+ "info",
87
+ );
88
+ }
89
+ return result;
90
+ },
91
+ [
92
+ activeCompPath,
93
+ domEditSaveTimestampRef,
94
+ observeProjectFileVersion,
95
+ recordEdit,
96
+ showToast,
97
+ synchronize,
98
+ writeProjectFile,
99
+ ],
100
+ );
101
+
279
102
  const handleRazorSplit = useCallback(
280
- // fallow-ignore-next-line complexity
281
103
  async (element: TimelineElement, splitTime: number) => {
282
104
  if (isRecordingRef?.current) {
283
105
  showToast("Cannot edit timeline while recording", "error");
284
106
  return;
285
107
  }
286
-
287
- const pid = projectIdRef.current;
288
- if (!pid || !canSplitElementAt(element, splitTime)) return;
289
-
108
+ if (!canSplitElementAt(element, splitTime)) return;
290
109
  try {
291
- const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
292
- await executeSplit(
293
- pid,
294
- element,
295
- splitTime,
296
- activeCompPath,
297
- writeProjectFile,
298
- observeProjectFileVersion,
299
- );
300
-
301
- if (!changed) {
302
- showToast("Failed to split clip — playhead may be outside the clip", "error");
303
- return;
304
- }
305
-
306
- domEditSaveTimestampRef.current = Date.now();
307
- await saveProjectFilesWithHistory({
308
- projectId: pid,
309
- label: "Split timeline clip",
310
- kind: "timeline",
311
- files: { [targetPath]: patchedContent },
312
- readFile: async () => originalContent,
313
- writeFile: writeProjectFile,
314
- recordEdit,
315
- });
316
-
317
- // Server writes bypass the SDK session, so reopen it before refreshing
318
- // the preview. The split response already owns the final persisted bytes.
319
- forceReloadSdkSession?.();
320
- reloadPreview();
321
- trackStudioRazorSplit({ mode: "single", count: 1 });
110
+ const result = await runCut([element], splitTime, "single");
111
+ if (!result) return;
112
+ if (result.syncFailed) return;
322
113
  showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
323
- if (skippedSelectors?.length) {
324
- showToast(
325
- `Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
326
- "info",
327
- );
328
- }
329
114
  } catch (error) {
330
115
  const message = error instanceof Error ? error.message : "Failed to split timeline clip";
331
116
  showToast(message, "error");
332
117
  }
333
118
  },
334
- [
335
- activeCompPath,
336
- recordEdit,
337
- showToast,
338
- writeProjectFile,
339
- observeProjectFileVersion,
340
- domEditSaveTimestampRef,
341
- reloadPreview,
342
- forceReloadSdkSession,
343
- isRecordingRef,
344
- ],
119
+ [isRecordingRef, runCut, showToast],
345
120
  );
346
121
 
347
122
  const handleRazorSplitAll = useCallback(
348
- // fallow-ignore-next-line complexity
349
123
  async (splitTime: number) => {
350
124
  if (isRecordingRef?.current) {
351
125
  showToast("Cannot edit timeline while recording", "error");
352
126
  return;
353
127
  }
354
-
355
- const pid = projectIdRef.current;
356
- if (!pid) return;
357
- const { elements } = usePlayerStore.getState();
358
- const splittable = selectSplittableElements(elements, splitTime);
128
+ const splittable = selectSplittableElements(usePlayerStore.getState().elements, splitTime);
359
129
  if (splittable.length === 0) return;
360
-
361
- let originals = new Map<string, string>();
362
- const finalSnapshots = new Map<string, { before: string; after: string }>();
363
130
  try {
364
- originals = await readOriginalFiles(pid, splittable, activeCompPath);
365
- const splitCount = await splitElementsAtTime(
366
- pid,
367
- splittable,
368
- splitTime,
369
- activeCompPath,
370
- originals,
371
- finalSnapshots,
372
- writeProjectFile,
373
- observeProjectFileVersion,
374
- );
375
- if (splitCount === 0) return;
376
-
377
- domEditSaveTimestampRef.current = Date.now();
378
- await recordEdit({
379
- label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
380
- kind: "timeline",
381
- files: Object.fromEntries(finalSnapshots),
382
- });
383
-
384
- // Resync the stale SDK doc after the batched server write (see the
385
- // single-split path above for why this precedes the reload).
386
- forceReloadSdkSession?.();
387
- reloadPreview();
388
- trackStudioRazorSplit({ mode: "all", count: splitCount });
389
- showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
131
+ const result = await runCut(splittable, splitTime, "all");
132
+ if (!result) return;
133
+ if (result.syncFailed) return;
134
+ showToast(`Split ${result.splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
390
135
  } catch (error) {
391
- // Best-effort rollback — a failing restore write must not swallow the
392
- // original error's toast, which is what tells the user the split failed.
393
- try {
394
- await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
395
- } catch {
396
- /* leave disk as-is; the original failure is reported below */
397
- }
398
136
  const message = error instanceof Error ? error.message : "Failed to split clips";
399
137
  showToast(message, "error");
400
138
  }
401
139
  },
402
- [
403
- activeCompPath,
404
- recordEdit,
405
- showToast,
406
- writeProjectFile,
407
- observeProjectFileVersion,
408
- domEditSaveTimestampRef,
409
- reloadPreview,
410
- forceReloadSdkSession,
411
- isRecordingRef,
412
- ],
140
+ [isRecordingRef, runCut, showToast],
413
141
  );
414
142
 
415
143
  return { handleRazorSplit, handleRazorSplitAll };
@@ -0,0 +1,96 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { useSlideshowTabState } from "./useSlideshowTabState";
7
+ import type { RightPanelTab } from "../utils/studioHelpers";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const SLIDESHOW_HTML = `<html><body><script type="application/hyperframes-slideshow+json">{"slides":[]}</script></body></html>`;
12
+ const PLAIN_HTML = `<html><body><div id="title">hi</div></body></html>`;
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ function renderHook(params: {
19
+ editingFileContent: string | null | undefined;
20
+ rightPanelTab: RightPanelTab;
21
+ }) {
22
+ const host = document.createElement("div");
23
+ document.body.append(host);
24
+ const root = createRoot(host);
25
+ const setRightPanelTabCalls: RightPanelTab[] = [];
26
+ let current: ReturnType<typeof useSlideshowTabState> | null = null;
27
+
28
+ function Harness() {
29
+ current = useSlideshowTabState({
30
+ editingFileContent: params.editingFileContent,
31
+ previewIframeRef: { current: null },
32
+ refreshKey: 0,
33
+ rightPanelTab: params.rightPanelTab,
34
+ setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
35
+ });
36
+ return null;
37
+ }
38
+
39
+ act(() => {
40
+ root.render(React.createElement(Harness));
41
+ });
42
+
43
+ return {
44
+ getState: (): ReturnType<typeof useSlideshowTabState> => {
45
+ if (!current) throw new Error("useSlideshowTabState did not render");
46
+ return current;
47
+ },
48
+ setRightPanelTabCalls,
49
+ unmount: () => act(() => root.unmount()),
50
+ };
51
+ }
52
+
53
+ describe("useSlideshowTabState", () => {
54
+ it("detects a slideshow composition via the JSON island", () => {
55
+ const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" });
56
+ expect(harness.getState().isSlideshowComposition).toBe(true);
57
+ harness.unmount();
58
+ });
59
+
60
+ it("reports false for a plain (non-slideshow) composition", () => {
61
+ const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" });
62
+ expect(harness.getState().isSlideshowComposition).toBe(false);
63
+ harness.unmount();
64
+ });
65
+
66
+ it("reports false when there is no editing file yet", () => {
67
+ const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" });
68
+ expect(harness.getState().isSlideshowComposition).toBe(false);
69
+ harness.unmount();
70
+ });
71
+
72
+ it("still detects a malformed island — presence-only, not full manifest validation", () => {
73
+ const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
74
+ const harness = renderHook({ editingFileContent: malformed, rightPanelTab: "design" });
75
+ expect(harness.getState().isSlideshowComposition).toBe(true);
76
+ harness.unmount();
77
+ });
78
+
79
+ it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
80
+ const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
81
+ expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
82
+ harness.unmount();
83
+ });
84
+
85
+ it("does not bounce when the composition is a slideshow", () => {
86
+ const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
87
+ expect(harness.setRightPanelTabCalls).toEqual([]);
88
+ harness.unmount();
89
+ });
90
+
91
+ it("does not bounce a tab other than 'slideshow'", () => {
92
+ const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
93
+ expect(harness.setRightPanelTabCalls).toEqual([]);
94
+ harness.unmount();
95
+ });
96
+ });
@@ -0,0 +1,61 @@
1
+ import { useEffect, useMemo, type MutableRefObject } from "react";
2
+ import { SLIDESHOW_ISLAND_TYPE, slideshowIslandRegex } from "@hyperframes/core/slideshow";
3
+ import type { SceneInfo } from "../components/panels/SlideshowPanel";
4
+ import type { IframeWindow } from "../player/lib/playbackTypes";
5
+ import type { RightPanelTab } from "../utils/studioHelpers";
6
+
7
+ /**
8
+ * Derives whether the currently-edited composition is a slideshow (carries
9
+ * the slideshow JSON island — the same definitive marker the CLI's `present`
10
+ * command requires; it refuses to run without one) and the live scene list
11
+ * for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the
12
+ * moment it stops applying (e.g. the user switches to a non-slideshow file
13
+ * while that tab was open) so the panel never shows a dangling active tab
14
+ * whose button is no longer even rendered.
15
+ *
16
+ * Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
17
+ */
18
+ export function useSlideshowTabState(params: {
19
+ editingFileContent: string | null | undefined;
20
+ previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
21
+ refreshKey: number;
22
+ rightPanelTab: RightPanelTab;
23
+ setRightPanelTab: (tab: RightPanelTab) => void;
24
+ }): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
25
+ const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } =
26
+ params;
27
+
28
+ // Presence-only (not full manifest validation): a malformed island should
29
+ // still surface the Slideshow tab so the user can see/fix it, rather than
30
+ // making the whole panel disappear. The plain substring check short-circuits
31
+ // the regex scan on every non-slideshow file (the common case) without
32
+ // paying for a full-content RegExp pass.
33
+ const isSlideshowComposition = useMemo(() => {
34
+ if (!editingFileContent || !editingFileContent.includes(SLIDESHOW_ISLAND_TYPE)) return false;
35
+ return slideshowIslandRegex("i").test(editingFileContent);
36
+ }, [editingFileContent]);
37
+
38
+ // Derive scene list from the live clip manifest in the preview iframe.
39
+ const slideshowScenes = useMemo<SceneInfo[]>(() => {
40
+ try {
41
+ const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
42
+ return (win?.__clipManifest?.scenes ?? []).map((s) => ({
43
+ id: s.id,
44
+ label: s.label,
45
+ start: s.start,
46
+ duration: s.duration,
47
+ }));
48
+ } catch {
49
+ return [];
50
+ }
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [previewIframeRef, rightPanelTab, refreshKey]);
53
+
54
+ useEffect(() => {
55
+ if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
56
+ setRightPanelTab("renders");
57
+ }
58
+ }, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
59
+
60
+ return { isSlideshowComposition, slideshowScenes };
61
+ }
@@ -0,0 +1,15 @@
1
+ import { useCallback } from "react";
2
+ import { usePlayerStore } from "../player";
3
+
4
+ type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
5
+
6
+ export function useTimelineAddAtPlayhead(addAsset: AddAtPlacement, addComposition: AddAtPlacement) {
7
+ const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
8
+ return {
9
+ addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
10
+ addCompositionAtPlayhead: useCallback(
11
+ (path: string) => addComposition(path, placement()),
12
+ [addComposition],
13
+ ),
14
+ };
15
+ }