@hyperframes/studio 0.6.0 → 0.6.2

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 (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. package/dist/assets/index-DUqUmaoH.js +0 -117
@@ -4,7 +4,8 @@ import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../
4
4
  import type { TimelineElement } from "../../player";
5
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
6
6
  import { NLEPreview } from "./NLEPreview";
7
- import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
7
+ import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
8
+ import { useCompositionStack } from "./useCompositionStack";
8
9
  import {
9
10
  TIMELINE_TOGGLE_SHORTCUT_LABEL,
10
11
  getTimelineToggleTitle,
@@ -101,19 +102,14 @@ export const NLELayout = memo(function NLELayout({
101
102
  saveSeekPosition,
102
103
  } = useTimelinePlayer();
103
104
 
104
- // Reset timeline state when the project changes to prevent stale data from a
105
- // previous project leaking into the new one.
105
+ // Reset timeline state when the project changes
106
106
  const prevProjectIdRef = useRef(projectId);
107
107
  if (prevProjectIdRef.current !== projectId) {
108
108
  prevProjectIdRef.current = projectId;
109
- // Only reset Zustand state during render (safe — pure state update).
110
- // Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
111
109
  usePlayerStore.getState().reset();
112
110
  }
113
111
 
114
- // Save seek position before the Player component creates a new player
115
- // on refreshKey change. The Player handles the actual reload via the
116
- // dual-player crossfade; we just need to persist the current time.
112
+ // Save seek position before refresh
117
113
  const prevRefreshKeyRef = useRef(refreshKey);
118
114
  useEffect(() => {
119
115
  if (refreshKey === prevRefreshKeyRef.current) return;
@@ -121,14 +117,61 @@ export const NLELayout = memo(function NLELayout({
121
117
  saveSeekPosition();
122
118
  }, [refreshKey, saveSeekPosition]);
123
119
 
124
- // Wrap onIframeLoad to also notify parent of iframe ref
125
120
  const onIframeLoad = useCallback(() => {
126
121
  baseOnIframeLoad();
127
122
  onIframeRef?.(iframeRef.current);
128
123
  }, [baseOnIframeLoad, iframeRef, onIframeRef]);
129
124
 
130
- // Composition ID → actual file path mapping, built from the raw index.html
131
- const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
125
+ const {
126
+ compositionStack,
127
+ updateCompositionStack,
128
+ handleNavigateComposition,
129
+ handleDrillDown: drillDown,
130
+ masterSeekRef,
131
+ compIdToSrc,
132
+ setCompIdToSrc,
133
+ } = useCompositionStack({
134
+ projectId,
135
+ activeCompositionPath,
136
+ onCompositionChange,
137
+ });
138
+
139
+ // Wrap handleDrillDown to also scan the iframe DOM for data-composition-src
140
+ const iframeRef_ = iframeRef;
141
+ const handleDrillDown = useCallback(
142
+ (element: TimelineElement) => {
143
+ if (!element.compositionSrc) return;
144
+ // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
145
+ const compId = element.id;
146
+ let resolvedPath = compIdToSrc.get(compId);
147
+ if (!resolvedPath) {
148
+ try {
149
+ const doc = iframeRef_.current?.contentDocument;
150
+ if (doc) {
151
+ const host = doc.querySelector(
152
+ `[data-composition-id="${compId}"][data-composition-src]`,
153
+ );
154
+ if (host) {
155
+ resolvedPath = host.getAttribute("data-composition-src") || undefined;
156
+ }
157
+ }
158
+ } catch {
159
+ /* cross-origin */
160
+ }
161
+ }
162
+ // Delegate with the resolved compositionSrc (may be same as original)
163
+ drillDown({
164
+ id: compId,
165
+ compositionSrc: resolvedPath ?? element.compositionSrc,
166
+ });
167
+ },
168
+ [compIdToSrc, drillDown, iframeRef_],
169
+ );
170
+
171
+ // Composition ID → file path map from raw index.html
172
+ const compIdToSrcRef = useRef(compIdToSrc);
173
+ compIdToSrcRef.current = compIdToSrc;
174
+
132
175
  useMountEffect(() => {
133
176
  fetch(`/api/projects/${projectId}/files/index.html`)
134
177
  .then((r) => r.json())
@@ -150,12 +193,6 @@ export const NLELayout = memo(function NLELayout({
150
193
  });
151
194
 
152
195
  // Patch elements with compositionSrc whenever elements or compIdToSrc change.
153
- // The runtime strips data-composition-src from the DOM after loading, so elements
154
- // arrive without it. This bridges the gap using the map built from raw HTML.
155
- // Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
156
- // DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
157
- const compIdToSrcRef = useRef(compIdToSrc);
158
- compIdToSrcRef.current = compIdToSrc;
159
196
  // eslint-disable-next-line no-restricted-syntax
160
197
  useEffect(() => {
161
198
  if (compIdToSrc.size === 0) return;
@@ -165,7 +202,6 @@ export const NLELayout = memo(function NLELayout({
165
202
  let patched = false;
166
203
  const updated = elements.map((el) => {
167
204
  if (el.compositionSrc) return el;
168
- // Try exact match, then strip common suffixes (-host, -comp, -layer)
169
205
  const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
170
206
  if (src) {
171
207
  patched = true;
@@ -175,15 +211,12 @@ export const NLELayout = memo(function NLELayout({
175
211
  });
176
212
  return patched ? updated : null;
177
213
  };
178
- // Patch current elements immediately
179
214
  const patched = patchElements(usePlayerStore.getState().elements);
180
215
  if (patched) usePlayerStore.getState().setElements(patched);
181
- // Subscribe for future element updates — use a flag to prevent re-entrant patching
182
216
  let patching = false;
183
217
  return usePlayerStore.subscribe((state, prev) => {
184
218
  if (patching) return;
185
219
  if (state.elements === prev.elements || state.elements.length === 0) return;
186
- // Skip if all elements already have compositionSrc
187
220
  if (state.elements.every((el) => el.compositionSrc)) return;
188
221
  patching = true;
189
222
  const result = patchElements(state.elements);
@@ -192,23 +225,6 @@ export const NLELayout = memo(function NLELayout({
192
225
  });
193
226
  }, [compIdToSrc]);
194
227
 
195
- // Composition drill-down stack
196
- const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
197
- { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
198
- ]);
199
-
200
- // Wrap setCompositionStack to auto-notify parent on composition change
201
- const onCompositionChangeRef = useRef(onCompositionChange);
202
- onCompositionChangeRef.current = onCompositionChange;
203
- const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
204
- setCompositionStack((prev) => {
205
- const next = typeof action === "function" ? action(prev) : action;
206
- const id = next[next.length - 1]?.id;
207
- queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
208
- return next;
209
- });
210
- }, []);
211
-
212
228
  // Resizable timeline height
213
229
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
214
230
  const hasLoadedOnceRef = useRef(false);
@@ -223,11 +239,11 @@ export const NLELayout = memo(function NLELayout({
223
239
  useEffect(() => {
224
240
  onCompositionLoadingChangeParent?.(compositionLoading);
225
241
  }, [compositionLoading, onCompositionLoadingChangeParent]);
242
+
226
243
  const isTimelineVisible = timelineVisible ?? true;
227
244
  const isDragging = useRef(false);
228
245
  const containerRef = useRef<HTMLDivElement>(null);
229
246
 
230
- // Current preview URL — derived from composition stack
231
247
  const currentLevel = compositionStack[compositionStack.length - 1];
232
248
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
233
249
 
@@ -235,106 +251,6 @@ export const NLELayout = memo(function NLELayout({
235
251
  onIframeRef?.(iframeRef.current);
236
252
  }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
237
253
 
238
- // Save master seek position before drilling down so we can restore it on back-navigation.
239
- // saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
240
- const masterSeekRef = useRef(0);
241
-
242
- // Drill-down: push a sub-composition onto the stack
243
- const iframeRef_ = iframeRef; // stable ref for the callback
244
- const handleDrillDown = useCallback(
245
- (element: TimelineElement) => {
246
- if (!element.compositionSrc) return;
247
- // Save current master playback position for back-navigation
248
- masterSeekRef.current = usePlayerStore.getState().currentTime;
249
- saveSeekPosition();
250
- // compositionSrc may be a full URL (from runtime manifest) or a relative path
251
- // Extract the element's composition ID from its timeline ID
252
- const compId = element.id;
253
-
254
- // 1. Check compIdToSrc map (from index.html)
255
- // 2. Scan the current iframe DOM for data-composition-src attribute
256
- // 3. Fall back to stripping the compositionSrc to a relative path
257
- let resolvedPath = compIdToSrc.get(compId);
258
- if (!resolvedPath) {
259
- try {
260
- const doc = iframeRef_.current?.contentDocument;
261
- if (doc) {
262
- const host = doc.querySelector(
263
- `[data-composition-id="${compId}"][data-composition-src]`,
264
- );
265
- if (host) {
266
- resolvedPath = host.getAttribute("data-composition-src") || undefined;
267
- }
268
- }
269
- } catch {
270
- /* cross-origin */
271
- }
272
- }
273
- if (!resolvedPath) {
274
- // Strip full URL to relative path if needed
275
- const src = element.compositionSrc;
276
- const compMatch = src.match(/compositions\/.*\.html/);
277
- resolvedPath = compMatch ? compMatch[0] : src;
278
- }
279
-
280
- usePlayerStore.getState().setElements([]);
281
-
282
- // Toggle: if already viewing this composition, go back to parent (like Premiere)
283
- updateCompositionStack((prev) => {
284
- const currentId = prev[prev.length - 1].id;
285
- if (currentId === resolvedPath && prev.length > 1) {
286
- return prev.slice(0, -1);
287
- }
288
- // Extract a clean label from the path (strip directories and extension)
289
- const label =
290
- resolvedPath
291
- .split("/")
292
- .pop()
293
- ?.replace(/\.html$/, "") || resolvedPath;
294
- const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
295
- return [...prev, { id: resolvedPath, label, previewUrl }];
296
- });
297
- },
298
- // eslint-disable-next-line react-hooks/exhaustive-deps
299
- [projectId, compIdToSrc],
300
- );
301
-
302
- // Navigate back to a specific breadcrumb level
303
- const handleNavigateComposition = useCallback((index: number) => {
304
- // When going back to master (index 0), restore the saved master position
305
- if (index === 0 && masterSeekRef.current > 0) {
306
- usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
307
- }
308
- saveSeekPosition();
309
- usePlayerStore.getState().setElements([]);
310
- updateCompositionStack((prev) => prev.slice(0, index + 1));
311
- // eslint-disable-next-line react-hooks/exhaustive-deps
312
- }, []);
313
-
314
- // Navigate to a composition when activeCompositionPath changes.
315
- // Uses useEffect to ensure state updates happen after render commit,
316
- // avoiding render-time mutations that React can swallow during batching.
317
- // eslint-disable-next-line no-restricted-syntax
318
- useEffect(() => {
319
- if (activeCompositionPath === "index.html") {
320
- usePlayerStore.getState().setElements([]);
321
- updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
322
- } else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
323
- const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
324
- const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
325
- usePlayerStore.getState().setElements([]);
326
- updateCompositionStack((prev) => {
327
- if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
328
- return [
329
- { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
330
- { id: activeCompositionPath, label, previewUrl },
331
- ];
332
- });
333
- } else if (!activeCompositionPath) {
334
- usePlayerStore.getState().setElements([]);
335
- }
336
- }, [activeCompositionPath, projectId, updateCompositionStack]);
337
-
338
254
  // Resize divider handlers
339
255
  const handleDividerPointerDown = useCallback(
340
256
  (e: React.PointerEvent) => {
@@ -377,6 +293,9 @@ export const NLELayout = memo(function NLELayout({
377
293
  [compositionStack.length],
378
294
  );
379
295
 
296
+ // Suppress TS unused-var warning for masterSeekRef (used inside useCompositionStack)
297
+ void masterSeekRef;
298
+
380
299
  return (
381
300
  <div
382
301
  ref={containerRef}
@@ -384,7 +303,7 @@ export const NLELayout = memo(function NLELayout({
384
303
  onKeyDown={handleKeyDown}
385
304
  tabIndex={-1}
386
305
  >
387
- {/* Preview + player controls — takes remaining space above timeline */}
306
+ {/* Preview + player controls */}
388
307
  <div className="flex-1 min-h-0 flex flex-col">
389
308
  <div className="flex-1 min-h-0 relative">
390
309
  <NLEPreview
@@ -399,7 +318,6 @@ export const NLELayout = memo(function NLELayout({
399
318
  />
400
319
  {previewOverlay}
401
320
  </div>
402
- {/* Player controls always visible, regardless of timeline state */}
403
321
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
404
322
  {compositionStack.length > 1 && (
405
323
  <CompositionBreadcrumb
@@ -424,15 +342,13 @@ export const NLELayout = memo(function NLELayout({
424
342
  <div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
425
343
  </div>
426
344
 
427
- {/* Timeline section — fixed height, resizable */}
345
+ {/* Timeline section */}
428
346
  <div
429
347
  className="relative flex flex-col flex-shrink-0"
430
348
  style={{ height: timelineH }}
431
349
  aria-disabled={timelineDisabled || undefined}
432
350
  >
433
- {/* Timeline tracks */}
434
351
  <div
435
- // flex-col: toolbar takes natural height, Timeline fills remainder.
436
352
  className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
437
353
  onDoubleClick={(e) => {
438
354
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
@@ -0,0 +1,126 @@
1
+ // Composition drill-down stack management for NLELayout
2
+ import { useState, useCallback, useRef, useEffect } from "react";
3
+ import { usePlayerStore } from "../../player";
4
+ import type { CompositionLevel } from "./CompositionBreadcrumb";
5
+
6
+ interface UseCompositionStackOptions {
7
+ projectId: string;
8
+ activeCompositionPath?: string | null;
9
+ onCompositionChange?: (compositionPath: string | null) => void;
10
+ }
11
+
12
+ interface UseCompositionStackResult {
13
+ compositionStack: CompositionLevel[];
14
+ updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
15
+ handleNavigateComposition: (index: number) => void;
16
+ handleDrillDown: (element: { id: string; compositionSrc?: string }) => void;
17
+ masterSeekRef: React.MutableRefObject<number>;
18
+ compIdToSrc: Map<string, string>;
19
+ setCompIdToSrc: React.Dispatch<React.SetStateAction<Map<string, string>>>;
20
+ }
21
+
22
+ export function useCompositionStack({
23
+ projectId,
24
+ activeCompositionPath,
25
+ onCompositionChange,
26
+ }: UseCompositionStackOptions): UseCompositionStackResult {
27
+ const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
28
+ { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
29
+ ]);
30
+
31
+ const onCompositionChangeRef = useRef(onCompositionChange);
32
+ onCompositionChangeRef.current = onCompositionChange;
33
+
34
+ const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
35
+ setCompositionStack((prev) => {
36
+ const next = typeof action === "function" ? action(prev) : action;
37
+ const id = next[next.length - 1]?.id;
38
+ queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
39
+ return next;
40
+ });
41
+ }, []);
42
+
43
+ const masterSeekRef = useRef(0);
44
+ const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
45
+
46
+ const compIdToSrcRef = useRef(compIdToSrc);
47
+ compIdToSrcRef.current = compIdToSrc;
48
+
49
+ const handleNavigateComposition = useCallback(
50
+ (index: number) => {
51
+ if (index === 0 && masterSeekRef.current > 0) {
52
+ usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
53
+ }
54
+ usePlayerStore.getState().setElements([]);
55
+ updateCompositionStack((prev) => prev.slice(0, index + 1));
56
+ },
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ [],
59
+ );
60
+
61
+ const handleDrillDown = useCallback(
62
+ (element: { id: string; compositionSrc?: string }) => {
63
+ if (!element.compositionSrc) return;
64
+ masterSeekRef.current = usePlayerStore.getState().currentTime;
65
+
66
+ const compId = element.id;
67
+ let resolvedPath = compIdToSrcRef.current.get(compId);
68
+
69
+ if (!resolvedPath) {
70
+ const src = element.compositionSrc;
71
+ const compMatch = src.match(/compositions\/.*\.html/);
72
+ resolvedPath = compMatch ? compMatch[0] : src;
73
+ }
74
+
75
+ usePlayerStore.getState().setElements([]);
76
+
77
+ updateCompositionStack((prev) => {
78
+ const currentId = prev[prev.length - 1].id;
79
+ if (currentId === resolvedPath && prev.length > 1) {
80
+ return prev.slice(0, -1);
81
+ }
82
+ const label =
83
+ resolvedPath
84
+ .split("/")
85
+ .pop()
86
+ ?.replace(/\.html$/, "") || resolvedPath;
87
+ const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
88
+ return [...prev, { id: resolvedPath, label, previewUrl }];
89
+ });
90
+ },
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ [projectId],
93
+ );
94
+
95
+ // Navigate to a composition when activeCompositionPath changes.
96
+ // eslint-disable-next-line no-restricted-syntax
97
+ useEffect(() => {
98
+ if (activeCompositionPath === "index.html") {
99
+ usePlayerStore.getState().setElements([]);
100
+ updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
101
+ } else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
102
+ const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
103
+ const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
104
+ usePlayerStore.getState().setElements([]);
105
+ updateCompositionStack((prev) => {
106
+ if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
107
+ return [
108
+ { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
109
+ { id: activeCompositionPath, label, previewUrl },
110
+ ];
111
+ });
112
+ } else if (!activeCompositionPath) {
113
+ usePlayerStore.getState().setElements([]);
114
+ }
115
+ }, [activeCompositionPath, projectId, updateCompositionStack]);
116
+
117
+ return {
118
+ compositionStack,
119
+ updateCompositionStack,
120
+ handleNavigateComposition,
121
+ handleDrillDown,
122
+ masterSeekRef,
123
+ compIdToSrc,
124
+ setCompIdToSrc,
125
+ };
126
+ }
@@ -0,0 +1,20 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+ import type { AppToast } from "../utils/studioHelpers";
4
+
5
+ export function useToast() {
6
+ const [appToast, setAppToast] = useState<AppToast | null>(null);
7
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
8
+
9
+ const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
10
+ if (timerRef.current) clearTimeout(timerRef.current);
11
+ setAppToast({ message, tone });
12
+ timerRef.current = setTimeout(() => setAppToast(null), 4000);
13
+ }, []);
14
+
15
+ useMountEffect(() => () => {
16
+ if (timerRef.current) clearTimeout(timerRef.current);
17
+ });
18
+
19
+ return { appToast, showToast };
20
+ }