@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -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,
@@ -52,9 +53,6 @@ interface NLELayoutProps {
52
53
  ) => Promise<void> | void;
53
54
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
55
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
55
- onInspectTimelineElement?: (element: TimelineElement) => void;
56
- inspectedTimelineElementId?: string | null;
57
- timelineLayerChildCounts?: ReadonlyMap<string, number>;
58
56
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
59
57
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
60
58
  /** Whether the timeline panel is visible (default: true) */
@@ -91,9 +89,6 @@ export const NLELayout = memo(function NLELayout({
91
89
  onResizeElement,
92
90
  onBlockedEditAttempt,
93
91
  onSelectTimelineElement,
94
- onInspectTimelineElement,
95
- inspectedTimelineElementId,
96
- timelineLayerChildCounts,
97
92
  onCompIdToSrcChange,
98
93
  timelineVisible,
99
94
  onToggleTimeline,
@@ -107,19 +102,14 @@ export const NLELayout = memo(function NLELayout({
107
102
  saveSeekPosition,
108
103
  } = useTimelinePlayer();
109
104
 
110
- // Reset timeline state when the project changes to prevent stale data from a
111
- // previous project leaking into the new one.
105
+ // Reset timeline state when the project changes
112
106
  const prevProjectIdRef = useRef(projectId);
113
107
  if (prevProjectIdRef.current !== projectId) {
114
108
  prevProjectIdRef.current = projectId;
115
- // Only reset Zustand state during render (safe — pure state update).
116
- // Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
117
109
  usePlayerStore.getState().reset();
118
110
  }
119
111
 
120
- // Save seek position before the Player component creates a new player
121
- // on refreshKey change. The Player handles the actual reload via the
122
- // dual-player crossfade; we just need to persist the current time.
112
+ // Save seek position before refresh
123
113
  const prevRefreshKeyRef = useRef(refreshKey);
124
114
  useEffect(() => {
125
115
  if (refreshKey === prevRefreshKeyRef.current) return;
@@ -127,14 +117,61 @@ export const NLELayout = memo(function NLELayout({
127
117
  saveSeekPosition();
128
118
  }, [refreshKey, saveSeekPosition]);
129
119
 
130
- // Wrap onIframeLoad to also notify parent of iframe ref
131
120
  const onIframeLoad = useCallback(() => {
132
121
  baseOnIframeLoad();
133
122
  onIframeRef?.(iframeRef.current);
134
123
  }, [baseOnIframeLoad, iframeRef, onIframeRef]);
135
124
 
136
- // Composition ID → actual file path mapping, built from the raw index.html
137
- 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
+
138
175
  useMountEffect(() => {
139
176
  fetch(`/api/projects/${projectId}/files/index.html`)
140
177
  .then((r) => r.json())
@@ -156,12 +193,6 @@ export const NLELayout = memo(function NLELayout({
156
193
  });
157
194
 
158
195
  // Patch elements with compositionSrc whenever elements or compIdToSrc change.
159
- // The runtime strips data-composition-src from the DOM after loading, so elements
160
- // arrive without it. This bridges the gap using the map built from raw HTML.
161
- // Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
162
- // DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
163
- const compIdToSrcRef = useRef(compIdToSrc);
164
- compIdToSrcRef.current = compIdToSrc;
165
196
  // eslint-disable-next-line no-restricted-syntax
166
197
  useEffect(() => {
167
198
  if (compIdToSrc.size === 0) return;
@@ -171,7 +202,6 @@ export const NLELayout = memo(function NLELayout({
171
202
  let patched = false;
172
203
  const updated = elements.map((el) => {
173
204
  if (el.compositionSrc) return el;
174
- // Try exact match, then strip common suffixes (-host, -comp, -layer)
175
205
  const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
176
206
  if (src) {
177
207
  patched = true;
@@ -181,15 +211,12 @@ export const NLELayout = memo(function NLELayout({
181
211
  });
182
212
  return patched ? updated : null;
183
213
  };
184
- // Patch current elements immediately
185
214
  const patched = patchElements(usePlayerStore.getState().elements);
186
215
  if (patched) usePlayerStore.getState().setElements(patched);
187
- // Subscribe for future element updates — use a flag to prevent re-entrant patching
188
216
  let patching = false;
189
217
  return usePlayerStore.subscribe((state, prev) => {
190
218
  if (patching) return;
191
219
  if (state.elements === prev.elements || state.elements.length === 0) return;
192
- // Skip if all elements already have compositionSrc
193
220
  if (state.elements.every((el) => el.compositionSrc)) return;
194
221
  patching = true;
195
222
  const result = patchElements(state.elements);
@@ -198,36 +225,25 @@ export const NLELayout = memo(function NLELayout({
198
225
  });
199
226
  }, [compIdToSrc]);
200
227
 
201
- // Composition drill-down stack
202
- const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
203
- { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
204
- ]);
205
-
206
- // Wrap setCompositionStack to auto-notify parent on composition change
207
- const onCompositionChangeRef = useRef(onCompositionChange);
208
- onCompositionChangeRef.current = onCompositionChange;
209
- const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
210
- setCompositionStack((prev) => {
211
- const next = typeof action === "function" ? action(prev) : action;
212
- const id = next[next.length - 1]?.id;
213
- queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
214
- return next;
215
- });
216
- }, []);
217
-
218
228
  // Resizable timeline height
219
229
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
220
- const [compositionLoading, setCompositionLoading] = useState(true);
230
+ const hasLoadedOnceRef = useRef(false);
231
+ const [compositionLoading, setCompositionLoadingRaw] = useState(true);
232
+ const setCompositionLoading = useCallback((loading: boolean) => {
233
+ if (!loading) hasLoadedOnceRef.current = true;
234
+ if (loading && hasLoadedOnceRef.current) return;
235
+ setCompositionLoadingRaw(loading);
236
+ }, []);
221
237
  const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
222
238
 
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
@@ -395,10 +314,10 @@ export const NLELayout = memo(function NLELayout({
395
314
  portrait={portrait}
396
315
  directUrl={directUrl}
397
316
  refreshKey={refreshKey}
317
+ suppressLoadingOverlay={hasLoadedOnceRef.current}
398
318
  />
399
319
  {previewOverlay}
400
320
  </div>
401
- {/* Player controls always visible, regardless of timeline state */}
402
321
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
403
322
  {compositionStack.length > 1 && (
404
323
  <CompositionBreadcrumb
@@ -423,15 +342,13 @@ export const NLELayout = memo(function NLELayout({
423
342
  <div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
424
343
  </div>
425
344
 
426
- {/* Timeline section — fixed height, resizable */}
345
+ {/* Timeline section */}
427
346
  <div
428
347
  className="relative flex flex-col flex-shrink-0"
429
348
  style={{ height: timelineH }}
430
349
  aria-disabled={timelineDisabled || undefined}
431
350
  >
432
- {/* Timeline tracks */}
433
351
  <div
434
- // flex-col: toolbar takes natural height, Timeline fills remainder.
435
352
  className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
436
353
  onDoubleClick={(e) => {
437
354
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
@@ -453,10 +370,6 @@ export const NLELayout = memo(function NLELayout({
453
370
  onResizeElement={onResizeElement}
454
371
  onBlockedEditAttempt={onBlockedEditAttempt}
455
372
  onSelectElement={onSelectTimelineElement}
456
- onInspectElement={onInspectTimelineElement}
457
- inspectedElementId={inspectedTimelineElementId}
458
- layerChildCounts={timelineLayerChildCounts}
459
- disabled={timelineDisabled}
460
373
  />
461
374
  </div>
462
375
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -9,6 +9,7 @@ interface NLEPreviewProps {
9
9
  portrait?: boolean;
10
10
  directUrl?: string;
11
11
  refreshKey?: number;
12
+ suppressLoadingOverlay?: boolean;
12
13
  }
13
14
 
14
15
  export function getPreviewPlayerKey({
@@ -41,6 +42,7 @@ export const NLEPreview = memo(function NLEPreview({
41
42
  portrait,
42
43
  directUrl,
43
44
  refreshKey,
45
+ suppressLoadingOverlay,
44
46
  }: NLEPreviewProps) {
45
47
  const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
46
48
  const prevRefreshKeyRef = useRef(refreshKey);
@@ -93,6 +95,7 @@ export const NLEPreview = memo(function NLEPreview({
93
95
  onCompositionLoadingChange={onCompositionLoadingChange}
94
96
  portrait={portrait}
95
97
  style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
98
+ suppressLoadingOverlay={suppressLoadingOverlay}
96
99
  />
97
100
  </div>
98
101
  </div>
@@ -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
+ }