@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
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/index-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +1120 -0
- package/src/components/editor/domEditing.ts +1117 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +194 -179
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
|
@@ -51,18 +51,28 @@ interface NLELayoutProps {
|
|
|
51
51
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
52
52
|
) => Promise<void> | void;
|
|
53
53
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
54
|
+
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
55
|
+
onInspectTimelineElement?: (element: TimelineElement) => void;
|
|
56
|
+
inspectedTimelineElementId?: string | null;
|
|
57
|
+
timelineLayerChildCounts?: ReadonlyMap<string, number>;
|
|
54
58
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
55
59
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
56
60
|
/** Whether the timeline panel is visible (default: true) */
|
|
57
61
|
timelineVisible?: boolean;
|
|
58
62
|
/** Callback to toggle timeline visibility */
|
|
59
63
|
onToggleTimeline?: () => void;
|
|
64
|
+
/** Notifies parent when composition loading state changes */
|
|
65
|
+
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
const MIN_TIMELINE_H = 100;
|
|
63
69
|
const DEFAULT_TIMELINE_H = 220;
|
|
64
70
|
const MIN_PREVIEW_H = 120;
|
|
65
71
|
|
|
72
|
+
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
|
73
|
+
return compositionLoading;
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
export const NLELayout = memo(function NLELayout({
|
|
67
77
|
projectId,
|
|
68
78
|
portrait,
|
|
@@ -80,16 +90,20 @@ export const NLELayout = memo(function NLELayout({
|
|
|
80
90
|
onMoveElement,
|
|
81
91
|
onResizeElement,
|
|
82
92
|
onBlockedEditAttempt,
|
|
93
|
+
onSelectTimelineElement,
|
|
94
|
+
onInspectTimelineElement,
|
|
95
|
+
inspectedTimelineElementId,
|
|
96
|
+
timelineLayerChildCounts,
|
|
83
97
|
onCompIdToSrcChange,
|
|
84
98
|
timelineVisible,
|
|
85
99
|
onToggleTimeline,
|
|
100
|
+
onCompositionLoadingChange: onCompositionLoadingChangeParent,
|
|
86
101
|
}: NLELayoutProps) {
|
|
87
102
|
const {
|
|
88
103
|
iframeRef,
|
|
89
104
|
togglePlay,
|
|
90
105
|
seek,
|
|
91
106
|
onIframeLoad: baseOnIframeLoad,
|
|
92
|
-
refreshPlayer,
|
|
93
107
|
saveSeekPosition,
|
|
94
108
|
} = useTimelinePlayer();
|
|
95
109
|
|
|
@@ -103,13 +117,15 @@ export const NLELayout = memo(function NLELayout({
|
|
|
103
117
|
usePlayerStore.getState().reset();
|
|
104
118
|
}
|
|
105
119
|
|
|
106
|
-
//
|
|
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.
|
|
107
123
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
108
124
|
useEffect(() => {
|
|
109
125
|
if (refreshKey === prevRefreshKeyRef.current) return;
|
|
110
126
|
prevRefreshKeyRef.current = refreshKey;
|
|
111
|
-
|
|
112
|
-
}, [refreshKey,
|
|
127
|
+
saveSeekPosition();
|
|
128
|
+
}, [refreshKey, saveSeekPosition]);
|
|
113
129
|
|
|
114
130
|
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
115
131
|
const onIframeLoad = useCallback(() => {
|
|
@@ -201,6 +217,18 @@ export const NLELayout = memo(function NLELayout({
|
|
|
201
217
|
|
|
202
218
|
// Resizable timeline height
|
|
203
219
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
220
|
+
const hasLoadedOnceRef = useRef(false);
|
|
221
|
+
const [compositionLoading, setCompositionLoadingRaw] = useState(true);
|
|
222
|
+
const setCompositionLoading = useCallback((loading: boolean) => {
|
|
223
|
+
if (!loading) hasLoadedOnceRef.current = true;
|
|
224
|
+
if (loading && hasLoadedOnceRef.current) return;
|
|
225
|
+
setCompositionLoadingRaw(loading);
|
|
226
|
+
}, []);
|
|
227
|
+
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
onCompositionLoadingChangeParent?.(compositionLoading);
|
|
231
|
+
}, [compositionLoading, onCompositionLoadingChangeParent]);
|
|
204
232
|
const isTimelineVisible = timelineVisible ?? true;
|
|
205
233
|
const isDragging = useRef(false);
|
|
206
234
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -209,6 +237,10 @@ export const NLELayout = memo(function NLELayout({
|
|
|
209
237
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
210
238
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
211
239
|
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
onIframeRef?.(iframeRef.current);
|
|
242
|
+
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
|
|
243
|
+
|
|
212
244
|
// Save master seek position before drilling down so we can restore it on back-navigation.
|
|
213
245
|
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
|
214
246
|
const masterSeekRef = useRef(0);
|
|
@@ -310,23 +342,31 @@ export const NLELayout = memo(function NLELayout({
|
|
|
310
342
|
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
311
343
|
|
|
312
344
|
// Resize divider handlers
|
|
313
|
-
const handleDividerPointerDown = useCallback(
|
|
314
|
-
e.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
345
|
+
const handleDividerPointerDown = useCallback(
|
|
346
|
+
(e: React.PointerEvent) => {
|
|
347
|
+
if (timelineDisabled) return;
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
isDragging.current = true;
|
|
350
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
351
|
+
},
|
|
352
|
+
[timelineDisabled],
|
|
353
|
+
);
|
|
318
354
|
|
|
319
|
-
const handleDividerPointerMove = useCallback(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
Math.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
355
|
+
const handleDividerPointerMove = useCallback(
|
|
356
|
+
(e: React.PointerEvent) => {
|
|
357
|
+
if (timelineDisabled) return;
|
|
358
|
+
if (!isDragging.current || !containerRef.current) return;
|
|
359
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
360
|
+
const mouseY = e.clientY - rect.top;
|
|
361
|
+
const containerH = rect.height;
|
|
362
|
+
const newTimelineH = Math.max(
|
|
363
|
+
MIN_TIMELINE_H,
|
|
364
|
+
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
365
|
+
);
|
|
366
|
+
setTimelineH(newTimelineH);
|
|
367
|
+
},
|
|
368
|
+
[timelineDisabled],
|
|
369
|
+
);
|
|
330
370
|
|
|
331
371
|
const handleDividerPointerUp = useCallback(() => {
|
|
332
372
|
isDragging.current = false;
|
|
@@ -357,9 +397,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
357
397
|
projectId={projectId}
|
|
358
398
|
iframeRef={iframeRef}
|
|
359
399
|
onIframeLoad={onIframeLoad}
|
|
400
|
+
onCompositionLoadingChange={setCompositionLoading}
|
|
360
401
|
portrait={portrait}
|
|
361
402
|
directUrl={directUrl}
|
|
362
403
|
refreshKey={refreshKey}
|
|
404
|
+
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
|
363
405
|
/>
|
|
364
406
|
{previewOverlay}
|
|
365
407
|
</div>
|
|
@@ -371,7 +413,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
371
413
|
onNavigate={handleNavigateComposition}
|
|
372
414
|
/>
|
|
373
415
|
)}
|
|
374
|
-
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
416
|
+
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
|
|
375
417
|
</div>
|
|
376
418
|
</div>
|
|
377
419
|
|
|
@@ -389,13 +431,18 @@ export const NLELayout = memo(function NLELayout({
|
|
|
389
431
|
</div>
|
|
390
432
|
|
|
391
433
|
{/* Timeline section — fixed height, resizable */}
|
|
392
|
-
<div
|
|
434
|
+
<div
|
|
435
|
+
className="relative flex flex-col flex-shrink-0"
|
|
436
|
+
style={{ height: timelineH }}
|
|
437
|
+
aria-disabled={timelineDisabled || undefined}
|
|
438
|
+
>
|
|
393
439
|
{/* Timeline tracks */}
|
|
394
440
|
<div
|
|
395
441
|
// flex-col: toolbar takes natural height, Timeline fills remainder.
|
|
396
442
|
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
|
397
443
|
onDoubleClick={(e) => {
|
|
398
444
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
445
|
+
if (timelineDisabled) return;
|
|
399
446
|
if (compositionStack.length > 1) {
|
|
400
447
|
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
401
448
|
}
|
|
@@ -412,9 +459,24 @@ export const NLELayout = memo(function NLELayout({
|
|
|
412
459
|
onMoveElement={onMoveElement}
|
|
413
460
|
onResizeElement={onResizeElement}
|
|
414
461
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
462
|
+
onSelectElement={onSelectTimelineElement}
|
|
463
|
+
onInspectElement={onInspectTimelineElement}
|
|
464
|
+
inspectedElementId={inspectedTimelineElementId}
|
|
465
|
+
layerChildCounts={timelineLayerChildCounts}
|
|
466
|
+
disabled={timelineDisabled}
|
|
415
467
|
/>
|
|
416
468
|
</div>
|
|
417
469
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
470
|
+
{timelineDisabled && (
|
|
471
|
+
<div
|
|
472
|
+
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
|
|
473
|
+
data-testid="timeline-loading-disabled-overlay"
|
|
474
|
+
aria-hidden="true"
|
|
475
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
476
|
+
onDragOver={(event) => event.preventDefault()}
|
|
477
|
+
onDrop={(event) => event.preventDefault()}
|
|
478
|
+
/>
|
|
479
|
+
)}
|
|
418
480
|
</div>
|
|
419
481
|
</>
|
|
420
482
|
) : onToggleTimeline ? (
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { memo, type Ref } from "react";
|
|
1
|
+
import { memo, useRef, useState, type Ref } from "react";
|
|
2
2
|
import { Player } from "../../player";
|
|
3
3
|
|
|
4
4
|
interface NLEPreviewProps {
|
|
5
5
|
projectId: string;
|
|
6
6
|
iframeRef: Ref<HTMLIFrameElement>;
|
|
7
7
|
onIframeLoad: () => void;
|
|
8
|
+
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
8
9
|
portrait?: boolean;
|
|
9
10
|
directUrl?: string;
|
|
10
11
|
refreshKey?: number;
|
|
12
|
+
suppressLoadingOverlay?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function getPreviewPlayerKey({
|
|
@@ -21,30 +23,79 @@ export function getPreviewPlayerKey({
|
|
|
21
23
|
return directUrl ?? projectId;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Manages the composition preview with crossfade on reload.
|
|
28
|
+
*
|
|
29
|
+
* When refreshKey changes, a new Player is mounted alongside the old one.
|
|
30
|
+
* The old Player stays visible (opacity 1) until the new one fires onLoad,
|
|
31
|
+
* at which point the old is removed. This avoids the flash that a simple
|
|
32
|
+
* key-swap remount would cause.
|
|
33
|
+
*
|
|
34
|
+
* Uses the render-time state adjustment pattern (React-sanctioned) to detect
|
|
35
|
+
* refreshKey changes — no useEffect needed.
|
|
36
|
+
*/
|
|
24
37
|
export const NLEPreview = memo(function NLEPreview({
|
|
25
38
|
projectId,
|
|
26
39
|
iframeRef,
|
|
27
40
|
onIframeLoad,
|
|
41
|
+
onCompositionLoadingChange,
|
|
28
42
|
portrait,
|
|
29
43
|
directUrl,
|
|
30
44
|
refreshKey,
|
|
45
|
+
suppressLoadingOverlay,
|
|
31
46
|
}: NLEPreviewProps) {
|
|
32
|
-
const
|
|
47
|
+
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
|
48
|
+
const prevRefreshKeyRef = useRef(refreshKey);
|
|
49
|
+
const [retiringKey, setRetiringKey] = useState<string | null>(null);
|
|
50
|
+
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
51
|
+
|
|
52
|
+
// Detect refreshKey change during render (React-sanctioned derived state pattern).
|
|
53
|
+
// When the key changes, the current active player becomes the retiring player
|
|
54
|
+
// and a new active player is mounted alongside it.
|
|
55
|
+
if (refreshKey !== prevRefreshKeyRef.current) {
|
|
56
|
+
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
|
|
57
|
+
prevRefreshKeyRef.current = refreshKey;
|
|
58
|
+
setRetiringKey(oldKey);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
|
|
62
|
+
|
|
63
|
+
const handleNewPlayerLoad = () => {
|
|
64
|
+
onIframeLoad();
|
|
65
|
+
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
|
66
|
+
retiringTimerRef.current = setTimeout(() => {
|
|
67
|
+
setRetiringKey(null);
|
|
68
|
+
retiringTimerRef.current = null;
|
|
69
|
+
}, 160);
|
|
70
|
+
};
|
|
33
71
|
|
|
34
72
|
return (
|
|
35
73
|
<div className="flex flex-col h-full min-h-0">
|
|
36
74
|
<div
|
|
37
|
-
className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
75
|
+
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
38
76
|
tabIndex={0}
|
|
39
77
|
aria-label="Composition preview"
|
|
40
78
|
>
|
|
79
|
+
{retiringKey && (
|
|
80
|
+
<Player
|
|
81
|
+
key={retiringKey}
|
|
82
|
+
projectId={directUrl ? undefined : projectId}
|
|
83
|
+
directUrl={directUrl}
|
|
84
|
+
onLoad={() => {}}
|
|
85
|
+
portrait={portrait}
|
|
86
|
+
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
41
89
|
<Player
|
|
42
|
-
key={
|
|
90
|
+
key={activeKey}
|
|
43
91
|
ref={iframeRef}
|
|
44
92
|
projectId={directUrl ? undefined : projectId}
|
|
45
93
|
directUrl={directUrl}
|
|
46
|
-
onLoad={onIframeLoad}
|
|
94
|
+
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
|
|
95
|
+
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
47
96
|
portrait={portrait}
|
|
97
|
+
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
98
|
+
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
48
99
|
/>
|
|
49
100
|
</div>
|
|
50
101
|
</div>
|
|
@@ -2,16 +2,19 @@ import { memo, useState, useRef, useEffect } from "react";
|
|
|
2
2
|
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
3
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
|
4
4
|
|
|
5
|
+
type StartRenderHandler = (
|
|
6
|
+
format: "mp4" | "webm" | "mov",
|
|
7
|
+
quality: "draft" | "standard" | "high",
|
|
8
|
+
resolution: ResolutionPreset | "auto",
|
|
9
|
+
fps: 24 | 30 | 60,
|
|
10
|
+
) => void | Promise<void>;
|
|
11
|
+
|
|
5
12
|
interface RenderQueueProps {
|
|
6
13
|
jobs: RenderJob[];
|
|
7
14
|
projectId: string;
|
|
8
15
|
onDelete: (jobId: string) => void;
|
|
9
16
|
onClearCompleted: () => void;
|
|
10
|
-
onStartRender:
|
|
11
|
-
format: "mp4" | "webm" | "mov",
|
|
12
|
-
quality: "draft" | "standard" | "high",
|
|
13
|
-
resolution: ResolutionPreset | "auto",
|
|
14
|
-
) => void;
|
|
17
|
+
onStartRender: StartRenderHandler;
|
|
15
18
|
isRendering: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -125,16 +128,13 @@ function FormatExportButton({
|
|
|
125
128
|
onStartRender,
|
|
126
129
|
isRendering,
|
|
127
130
|
}: {
|
|
128
|
-
onStartRender:
|
|
129
|
-
format: "mp4" | "webm" | "mov",
|
|
130
|
-
quality: "draft" | "standard" | "high",
|
|
131
|
-
resolution: ResolutionPreset | "auto",
|
|
132
|
-
) => void;
|
|
131
|
+
onStartRender: StartRenderHandler;
|
|
133
132
|
isRendering: boolean;
|
|
134
133
|
}) {
|
|
135
134
|
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
|
|
136
135
|
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
|
|
137
136
|
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
|
|
137
|
+
const [fps, setFps] = useState<24 | 30 | 60>(30);
|
|
138
138
|
|
|
139
139
|
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
|
140
140
|
const showQuality = format !== "mov";
|
|
@@ -174,6 +174,17 @@ function FormatExportButton({
|
|
|
174
174
|
))}
|
|
175
175
|
</select>
|
|
176
176
|
)}
|
|
177
|
+
<select
|
|
178
|
+
value={fps}
|
|
179
|
+
onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
|
|
180
|
+
disabled={isRendering}
|
|
181
|
+
title="Frames per second"
|
|
182
|
+
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
183
|
+
>
|
|
184
|
+
<option value={24}>24fps</option>
|
|
185
|
+
<option value={30}>30fps</option>
|
|
186
|
+
<option value={60}>60fps</option>
|
|
187
|
+
</select>
|
|
177
188
|
<select
|
|
178
189
|
value={format}
|
|
179
190
|
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
|
|
@@ -185,7 +196,9 @@ function FormatExportButton({
|
|
|
185
196
|
<option value="webm">WebM</option>
|
|
186
197
|
</select>
|
|
187
198
|
<button
|
|
188
|
-
onClick={() =>
|
|
199
|
+
onClick={() => {
|
|
200
|
+
void onStartRender(format, quality, resolution, fps);
|
|
201
|
+
}}
|
|
189
202
|
disabled={isRendering}
|
|
190
203
|
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
|
191
204
|
>
|
|
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
|
|
6
7
|
interface AssetsTabProps {
|
|
7
8
|
projectId: string;
|
|
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
298
299
|
);
|
|
299
300
|
|
|
300
301
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
const copied = await copyTextToClipboard(path);
|
|
303
|
+
if (copied) {
|
|
303
304
|
setCopiedPath(path);
|
|
304
305
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
305
|
-
} catch {
|
|
306
|
-
// ignore
|
|
307
306
|
}
|
|
308
307
|
}, []);
|
|
309
308
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveCompositionPreviewScale } from "./CompositionsTab";
|
|
2
|
+
import { resolveCompositionPreviewScale, resolveThumbnailSeekTime } from "./CompositionsTab";
|
|
3
3
|
|
|
4
4
|
describe("resolveCompositionPreviewScale", () => {
|
|
5
5
|
it("scales a 16:9 stage to fit the composition card", () => {
|
|
@@ -35,3 +35,18 @@ describe("resolveCompositionPreviewScale", () => {
|
|
|
35
35
|
).toBeCloseTo(80 / 1920);
|
|
36
36
|
});
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
describe("resolveThumbnailSeekTime", () => {
|
|
40
|
+
it("uses the default 3s frame for compositions longer than 3s", () => {
|
|
41
|
+
expect(resolveThumbnailSeekTime(6)).toBe(3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("uses the midpoint for compositions shorter than 3s", () => {
|
|
45
|
+
expect(resolveThumbnailSeekTime(2)).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to the default 3s frame when duration is unknown", () => {
|
|
49
|
+
expect(resolveThumbnailSeekTime(null)).toBe(3);
|
|
50
|
+
expect(resolveThumbnailSeekTime(Number.NaN)).toBe(3);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useRef, useState } from "react";
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
interface CompositionsTabProps {
|
|
4
4
|
projectId: string;
|
|
@@ -8,6 +8,17 @@ interface CompositionsTabProps {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
|
|
11
|
+
const THUMBNAIL_SEEK_TIME_SECONDS = 3;
|
|
12
|
+
const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
|
|
13
|
+
|
|
14
|
+
type PreviewWindow = Window & {
|
|
15
|
+
__player?: {
|
|
16
|
+
play?: () => void;
|
|
17
|
+
pause?: () => void;
|
|
18
|
+
seek?: (time: number) => void;
|
|
19
|
+
getDuration?: () => number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
11
22
|
|
|
12
23
|
export function resolveCompositionPreviewScale(input: {
|
|
13
24
|
cardWidth: number;
|
|
@@ -28,6 +39,54 @@ export function resolveCompositionPreviewScale(input: {
|
|
|
28
39
|
return Math.min(scaleX, scaleY);
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
|
|
43
|
+
if (
|
|
44
|
+
Number.isFinite(durationSeconds) &&
|
|
45
|
+
durationSeconds != null &&
|
|
46
|
+
durationSeconds > 0 &&
|
|
47
|
+
durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
|
|
48
|
+
) {
|
|
49
|
+
return durationSeconds / 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return THUMBNAIL_SEEK_TIME_SECONDS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parsePositiveNumber(value: string | null): number | null {
|
|
56
|
+
if (value == null) return null;
|
|
57
|
+
const parsed = Number.parseFloat(value);
|
|
58
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
|
|
62
|
+
const win = iframe?.contentWindow as PreviewWindow | null;
|
|
63
|
+
const playerDuration = win?.__player?.getDuration?.();
|
|
64
|
+
if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
|
|
65
|
+
return playerDuration;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const doc = iframe?.contentDocument;
|
|
69
|
+
const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
|
|
70
|
+
return (
|
|
71
|
+
parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
|
|
72
|
+
parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
|
|
77
|
+
const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
|
|
78
|
+
if (!player) return false;
|
|
79
|
+
|
|
80
|
+
if (shouldPlay) {
|
|
81
|
+
player.play?.();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
player.pause?.();
|
|
86
|
+
player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
31
90
|
function CompCard({
|
|
32
91
|
projectId,
|
|
33
92
|
comp,
|
|
@@ -41,7 +100,25 @@ function CompCard({
|
|
|
41
100
|
}) {
|
|
42
101
|
const [hovered, setHovered] = useState(false);
|
|
43
102
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
103
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
44
104
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
105
|
+
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
106
|
+
|
|
107
|
+
const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
|
|
108
|
+
if (syncTimer.current) {
|
|
109
|
+
clearTimeout(syncTimer.current);
|
|
110
|
+
syncTimer.current = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sync = (remainingAttempts: number) => {
|
|
114
|
+
if (syncIframePlayback(iframeRef.current, shouldPlay) || remainingAttempts <= 0) return;
|
|
115
|
+
|
|
116
|
+
syncTimer.current = setTimeout(() => sync(remainingAttempts - 1), 100);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
sync(THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
45
122
|
const handleEnter = () => {
|
|
46
123
|
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
|
47
124
|
};
|
|
@@ -53,7 +130,6 @@ function CompCard({
|
|
|
53
130
|
setHovered(false);
|
|
54
131
|
};
|
|
55
132
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
56
|
-
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
|
57
133
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
58
134
|
const previewScale = resolveCompositionPreviewScale({
|
|
59
135
|
cardWidth: 80,
|
|
@@ -62,6 +138,17 @@ function CompCard({
|
|
|
62
138
|
stageHeight: stageSize.height,
|
|
63
139
|
});
|
|
64
140
|
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
requestIframePlaybackSync(hovered);
|
|
143
|
+
}, [hovered, requestIframePlaybackSync]);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
return () => {
|
|
147
|
+
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
148
|
+
if (syncTimer.current) clearTimeout(syncTimer.current);
|
|
149
|
+
};
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
65
152
|
return (
|
|
66
153
|
<div
|
|
67
154
|
onClick={onSelect}
|
|
@@ -74,49 +161,34 @@ function CompCard({
|
|
|
74
161
|
}`}
|
|
75
162
|
>
|
|
76
163
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{/* Static thumbnail — hidden while hovering */}
|
|
106
|
-
<div
|
|
107
|
-
className="absolute inset-0 transition-opacity duration-150"
|
|
108
|
-
style={{ opacity: hovered ? 0 : 1 }}
|
|
109
|
-
>
|
|
110
|
-
<img
|
|
111
|
-
src={thumbnailUrl}
|
|
112
|
-
alt={name}
|
|
113
|
-
loading="lazy"
|
|
114
|
-
className="w-full h-full object-contain"
|
|
115
|
-
onError={(e) => {
|
|
116
|
-
(e.target as HTMLImageElement).style.display = "none";
|
|
117
|
-
}}
|
|
118
|
-
/>
|
|
119
|
-
</div>
|
|
164
|
+
<iframe
|
|
165
|
+
ref={iframeRef}
|
|
166
|
+
src={previewUrl}
|
|
167
|
+
sandbox="allow-scripts allow-same-origin"
|
|
168
|
+
loading="lazy"
|
|
169
|
+
className="absolute left-0 top-0 border-none pointer-events-none"
|
|
170
|
+
style={{
|
|
171
|
+
transformOrigin: "0 0",
|
|
172
|
+
width: stageSize.width,
|
|
173
|
+
height: stageSize.height,
|
|
174
|
+
transform: `scale(${previewScale})`,
|
|
175
|
+
}}
|
|
176
|
+
onLoad={(e) => {
|
|
177
|
+
try {
|
|
178
|
+
const iframe = e.currentTarget;
|
|
179
|
+
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
|
180
|
+
const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
|
181
|
+
const height =
|
|
182
|
+
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
|
183
|
+
setStageSize({ width, height });
|
|
184
|
+
requestIframePlaybackSync(hovered);
|
|
185
|
+
} catch {
|
|
186
|
+
setStageSize(DEFAULT_PREVIEW_STAGE);
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
title={`${name} preview`}
|
|
190
|
+
tabIndex={-1}
|
|
191
|
+
/>
|
|
120
192
|
</div>
|
|
121
193
|
<div className="min-w-0 flex-1">
|
|
122
194
|
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|