@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.
- package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|