@hyperframes/studio 0.1.9 → 0.1.11
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-Bj0pPj_X.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +133 -0
- package/src/components/renders/useRenderQueue.ts +161 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- package/src/player/lib/useMountEffect.ts +0 -10
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { memo, useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
FileCode,
|
|
4
|
+
Image,
|
|
5
|
+
Film,
|
|
6
|
+
Music,
|
|
7
|
+
File,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
} from "../../icons/SystemIcons";
|
|
3
11
|
|
|
4
12
|
interface FileTreeProps {
|
|
5
13
|
files: string[];
|
|
@@ -13,12 +21,23 @@ const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
|
|
|
13
21
|
js: { icon: FileCode, color: "#F59E0B" },
|
|
14
22
|
ts: { icon: FileCode, color: "#3B82F6" },
|
|
15
23
|
json: { icon: File, color: "#22C55E" },
|
|
24
|
+
md: { icon: File, color: "#737373" },
|
|
16
25
|
png: { icon: Image, color: "#22C55E" },
|
|
17
26
|
jpg: { icon: Image, color: "#22C55E" },
|
|
27
|
+
jpeg: { icon: Image, color: "#22C55E" },
|
|
28
|
+
webp: { icon: Image, color: "#22C55E" },
|
|
29
|
+
gif: { icon: Image, color: "#22C55E" },
|
|
18
30
|
svg: { icon: Image, color: "#F97316" },
|
|
19
31
|
mp4: { icon: Film, color: "#A855F7" },
|
|
32
|
+
webm: { icon: Film, color: "#A855F7" },
|
|
33
|
+
mov: { icon: Film, color: "#A855F7" },
|
|
20
34
|
mp3: { icon: Music, color: "#F59E0B" },
|
|
21
35
|
wav: { icon: Music, color: "#F59E0B" },
|
|
36
|
+
ogg: { icon: Music, color: "#F59E0B" },
|
|
37
|
+
m4a: { icon: Music, color: "#F59E0B" },
|
|
38
|
+
woff: { icon: File, color: "#525252" },
|
|
39
|
+
woff2: { icon: File, color: "#525252" },
|
|
40
|
+
ttf: { icon: File, color: "#525252" },
|
|
22
41
|
};
|
|
23
42
|
|
|
24
43
|
function getFileIcon(path: string) {
|
|
@@ -26,13 +45,152 @@ function getFileIcon(path: string) {
|
|
|
26
45
|
return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
interface TreeNode {
|
|
49
|
+
name: string;
|
|
50
|
+
fullPath: string;
|
|
51
|
+
children: Map<string, TreeNode>;
|
|
52
|
+
isFile: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildTree(files: string[]): TreeNode {
|
|
56
|
+
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const parts = file.split("/");
|
|
59
|
+
let current = root;
|
|
60
|
+
for (let i = 0; i < parts.length; i++) {
|
|
61
|
+
const part = parts[i];
|
|
62
|
+
const isLast = i === parts.length - 1;
|
|
63
|
+
const fullPath = parts.slice(0, i + 1).join("/");
|
|
64
|
+
if (!current.children.has(part)) {
|
|
65
|
+
current.children.set(part, {
|
|
66
|
+
name: part,
|
|
67
|
+
fullPath,
|
|
68
|
+
children: new Map(),
|
|
69
|
+
isFile: isLast,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
current = current.children.get(part)!;
|
|
73
|
+
if (isLast) current.isFile = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return root;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
|
|
80
|
+
return Array.from(children.values()).sort((a, b) => {
|
|
81
|
+
// index.html always first
|
|
82
|
+
if (a.name === "index.html") return -1;
|
|
83
|
+
if (b.name === "index.html") return 1;
|
|
84
|
+
// Directories before files
|
|
85
|
+
if (!a.isFile && b.isFile) return -1;
|
|
86
|
+
if (a.isFile && !b.isFile) return 1;
|
|
87
|
+
return a.name.localeCompare(b.name);
|
|
35
88
|
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function TreeFolder({
|
|
92
|
+
node,
|
|
93
|
+
depth,
|
|
94
|
+
activeFile,
|
|
95
|
+
onSelectFile,
|
|
96
|
+
defaultOpen,
|
|
97
|
+
}: {
|
|
98
|
+
node: TreeNode;
|
|
99
|
+
depth: number;
|
|
100
|
+
activeFile: string | null;
|
|
101
|
+
onSelectFile: (path: string) => void;
|
|
102
|
+
defaultOpen: boolean;
|
|
103
|
+
}) {
|
|
104
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
105
|
+
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
|
106
|
+
const children = sortChildren(node.children);
|
|
107
|
+
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<button
|
|
112
|
+
onClick={toggle}
|
|
113
|
+
className="w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors"
|
|
114
|
+
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
|
115
|
+
>
|
|
116
|
+
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
|
|
117
|
+
<span className="truncate font-medium">{node.name}</span>
|
|
118
|
+
</button>
|
|
119
|
+
{isOpen &&
|
|
120
|
+
children.map((child) =>
|
|
121
|
+
child.isFile && child.children.size === 0 ? (
|
|
122
|
+
<TreeFile
|
|
123
|
+
key={child.fullPath}
|
|
124
|
+
node={child}
|
|
125
|
+
depth={depth + 1}
|
|
126
|
+
activeFile={activeFile}
|
|
127
|
+
onSelectFile={onSelectFile}
|
|
128
|
+
/>
|
|
129
|
+
) : child.children.size > 0 ? (
|
|
130
|
+
<TreeFolder
|
|
131
|
+
key={child.fullPath}
|
|
132
|
+
node={child}
|
|
133
|
+
depth={depth + 1}
|
|
134
|
+
activeFile={activeFile}
|
|
135
|
+
onSelectFile={onSelectFile}
|
|
136
|
+
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
137
|
+
/>
|
|
138
|
+
) : (
|
|
139
|
+
<TreeFile
|
|
140
|
+
key={child.fullPath}
|
|
141
|
+
node={child}
|
|
142
|
+
depth={depth + 1}
|
|
143
|
+
activeFile={activeFile}
|
|
144
|
+
onSelectFile={onSelectFile}
|
|
145
|
+
/>
|
|
146
|
+
),
|
|
147
|
+
)}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function TreeFile({
|
|
153
|
+
node,
|
|
154
|
+
depth,
|
|
155
|
+
activeFile,
|
|
156
|
+
onSelectFile,
|
|
157
|
+
}: {
|
|
158
|
+
node: TreeNode;
|
|
159
|
+
depth: number;
|
|
160
|
+
activeFile: string | null;
|
|
161
|
+
onSelectFile: (path: string) => void;
|
|
162
|
+
}) {
|
|
163
|
+
const { icon: Icon, color } = getFileIcon(node.name);
|
|
164
|
+
const isActive = node.fullPath === activeFile;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<button
|
|
168
|
+
onClick={() => onSelectFile(node.fullPath)}
|
|
169
|
+
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
|
|
170
|
+
isActive
|
|
171
|
+
? "bg-neutral-800/60 text-neutral-200"
|
|
172
|
+
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
|
|
173
|
+
}`}
|
|
174
|
+
style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
|
|
175
|
+
>
|
|
176
|
+
<Icon size={12} style={{ color }} className="flex-shrink-0" />
|
|
177
|
+
<span className="truncate">{node.name}</span>
|
|
178
|
+
</button>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
|
183
|
+
if (!activeFile) return false;
|
|
184
|
+
if (node.fullPath === activeFile) return true;
|
|
185
|
+
for (const child of node.children.values()) {
|
|
186
|
+
if (isActiveInSubtree(child, activeFile)) return true;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
|
192
|
+
const tree = buildTree(files);
|
|
193
|
+
const children = sortChildren(tree.children);
|
|
36
194
|
|
|
37
195
|
return (
|
|
38
196
|
<div className="flex flex-col h-full min-h-0">
|
|
@@ -40,30 +198,26 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
|
|
|
40
198
|
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
|
|
41
199
|
</div>
|
|
42
200
|
<div className="flex-1 overflow-y-auto py-1">
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
</span>
|
|
64
|
-
</button>
|
|
65
|
-
);
|
|
66
|
-
})}
|
|
201
|
+
{children.map((child) =>
|
|
202
|
+
child.isFile && child.children.size === 0 ? (
|
|
203
|
+
<TreeFile
|
|
204
|
+
key={child.fullPath}
|
|
205
|
+
node={child}
|
|
206
|
+
depth={0}
|
|
207
|
+
activeFile={activeFile}
|
|
208
|
+
onSelectFile={onSelectFile}
|
|
209
|
+
/>
|
|
210
|
+
) : (
|
|
211
|
+
<TreeFolder
|
|
212
|
+
key={child.fullPath}
|
|
213
|
+
node={child}
|
|
214
|
+
depth={0}
|
|
215
|
+
activeFile={activeFile}
|
|
216
|
+
onSelectFile={onSelectFile}
|
|
217
|
+
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
218
|
+
/>
|
|
219
|
+
),
|
|
220
|
+
)}
|
|
67
221
|
</div>
|
|
68
222
|
</div>
|
|
69
223
|
);
|
|
@@ -26,7 +26,9 @@ function getLanguageExtension(language: string) {
|
|
|
26
26
|
case "js":
|
|
27
27
|
case "typescript":
|
|
28
28
|
case "ts":
|
|
29
|
-
return javascript({
|
|
29
|
+
return javascript({
|
|
30
|
+
typescript: language === "typescript" || language === "ts",
|
|
31
|
+
});
|
|
30
32
|
default:
|
|
31
33
|
return html();
|
|
32
34
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
2
3
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
3
4
|
import type { TimelineElement } from "../../player";
|
|
4
5
|
import { NLEPreview } from "./NLEPreview";
|
|
@@ -9,7 +10,9 @@ interface NLELayoutProps {
|
|
|
9
10
|
portrait?: boolean;
|
|
10
11
|
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
|
11
12
|
previewOverlay?: ReactNode;
|
|
12
|
-
/** Slot rendered
|
|
13
|
+
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
|
|
14
|
+
timelineToolbar?: ReactNode;
|
|
15
|
+
/** Slot rendered below the timeline tracks */
|
|
13
16
|
timelineFooter?: ReactNode;
|
|
14
17
|
/** Increment to force the preview to reload (e.g., after file writes) */
|
|
15
18
|
refreshKey?: number;
|
|
@@ -17,6 +20,15 @@ interface NLELayoutProps {
|
|
|
17
20
|
activeCompositionPath?: string | null;
|
|
18
21
|
/** Callback to expose the iframe ref (for element picker, etc.) */
|
|
19
22
|
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
|
|
23
|
+
/** Callback when the viewed composition changes (drill-down/back) */
|
|
24
|
+
onCompositionChange?: (compositionPath: string | null) => void;
|
|
25
|
+
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
|
26
|
+
renderClipContent?: (
|
|
27
|
+
element: TimelineElement,
|
|
28
|
+
style: { clip: string; label: string },
|
|
29
|
+
) => ReactNode;
|
|
30
|
+
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
31
|
+
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
const MIN_TIMELINE_H = 100;
|
|
@@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({
|
|
|
27
39
|
projectId,
|
|
28
40
|
portrait,
|
|
29
41
|
previewOverlay,
|
|
42
|
+
timelineToolbar,
|
|
30
43
|
timelineFooter,
|
|
31
44
|
refreshKey,
|
|
32
45
|
activeCompositionPath,
|
|
33
46
|
onIframeRef,
|
|
47
|
+
onCompositionChange,
|
|
48
|
+
renderClipContent,
|
|
49
|
+
onCompIdToSrcChange,
|
|
34
50
|
}: NLELayoutProps) {
|
|
35
51
|
const {
|
|
36
52
|
iframeRef,
|
|
@@ -40,6 +56,16 @@ export const NLELayout = memo(function NLELayout({
|
|
|
40
56
|
saveSeekPosition,
|
|
41
57
|
} = useTimelinePlayer();
|
|
42
58
|
|
|
59
|
+
// Reset timeline state when the project changes to prevent stale data from a
|
|
60
|
+
// previous project leaking into the new one.
|
|
61
|
+
const prevProjectIdRef = useRef(projectId);
|
|
62
|
+
if (prevProjectIdRef.current !== projectId) {
|
|
63
|
+
prevProjectIdRef.current = projectId;
|
|
64
|
+
// Only reset Zustand state during render (safe — pure state update).
|
|
65
|
+
// Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
|
|
66
|
+
usePlayerStore.getState().reset();
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
|
44
70
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
45
71
|
if (refreshKey !== prevRefreshKeyRef.current) {
|
|
@@ -55,7 +81,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
55
81
|
|
|
56
82
|
// Composition ID → actual file path mapping, built from the raw index.html
|
|
57
83
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
58
|
-
|
|
84
|
+
useMountEffect(() => {
|
|
59
85
|
fetch(`/api/projects/${projectId}/files/index.html`)
|
|
60
86
|
.then((r) => r.json())
|
|
61
87
|
.then((data: { content?: string }) => {
|
|
@@ -70,15 +96,71 @@ export const NLELayout = memo(function NLELayout({
|
|
|
70
96
|
if (id && src) map.set(id, src);
|
|
71
97
|
}
|
|
72
98
|
setCompIdToSrc(map);
|
|
99
|
+
onCompIdToSrcChange?.(map);
|
|
73
100
|
})
|
|
74
101
|
.catch(() => {});
|
|
75
|
-
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
|
|
105
|
+
// The runtime strips data-composition-src from the DOM after loading, so elements
|
|
106
|
+
// arrive without it. This bridges the gap using the map built from raw HTML.
|
|
107
|
+
// Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
|
|
108
|
+
// DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
|
|
109
|
+
const compIdToSrcRef = useRef(compIdToSrc);
|
|
110
|
+
compIdToSrcRef.current = compIdToSrc;
|
|
111
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (compIdToSrc.size === 0) return;
|
|
114
|
+
const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => {
|
|
115
|
+
const map = compIdToSrcRef.current;
|
|
116
|
+
if (map.size === 0) return null;
|
|
117
|
+
let patched = false;
|
|
118
|
+
const updated = elements.map((el) => {
|
|
119
|
+
if (el.compositionSrc) return el;
|
|
120
|
+
// Try exact match, then strip common suffixes (-host, -comp, -layer)
|
|
121
|
+
const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
|
|
122
|
+
if (src) {
|
|
123
|
+
patched = true;
|
|
124
|
+
return { ...el, compositionSrc: src };
|
|
125
|
+
}
|
|
126
|
+
return el;
|
|
127
|
+
});
|
|
128
|
+
return patched ? updated : null;
|
|
129
|
+
};
|
|
130
|
+
// Patch current elements immediately
|
|
131
|
+
const patched = patchElements(usePlayerStore.getState().elements);
|
|
132
|
+
if (patched) usePlayerStore.getState().setElements(patched);
|
|
133
|
+
// Subscribe for future element updates — use a flag to prevent re-entrant patching
|
|
134
|
+
let patching = false;
|
|
135
|
+
return usePlayerStore.subscribe((state, prev) => {
|
|
136
|
+
if (patching) return;
|
|
137
|
+
if (state.elements === prev.elements || state.elements.length === 0) return;
|
|
138
|
+
// Skip if all elements already have compositionSrc
|
|
139
|
+
if (state.elements.every((el) => el.compositionSrc)) return;
|
|
140
|
+
patching = true;
|
|
141
|
+
const result = patchElements(state.elements);
|
|
142
|
+
if (result) state.setElements(result);
|
|
143
|
+
patching = false;
|
|
144
|
+
});
|
|
145
|
+
}, [compIdToSrc]);
|
|
76
146
|
|
|
77
147
|
// Composition drill-down stack
|
|
78
148
|
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
|
79
149
|
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
80
150
|
]);
|
|
81
151
|
|
|
152
|
+
// Wrap setCompositionStack to auto-notify parent on composition change
|
|
153
|
+
const onCompositionChangeRef = useRef(onCompositionChange);
|
|
154
|
+
onCompositionChangeRef.current = onCompositionChange;
|
|
155
|
+
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
|
156
|
+
setCompositionStack((prev) => {
|
|
157
|
+
const next = typeof action === "function" ? action(prev) : action;
|
|
158
|
+
const id = next[next.length - 1]?.id;
|
|
159
|
+
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
82
164
|
// Resizable timeline height
|
|
83
165
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
84
166
|
const isDragging = useRef(false);
|
|
@@ -88,11 +170,18 @@ export const NLELayout = memo(function NLELayout({
|
|
|
88
170
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
89
171
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
90
172
|
|
|
173
|
+
// Save master seek position before drilling down so we can restore it on back-navigation.
|
|
174
|
+
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
|
175
|
+
const masterSeekRef = useRef(0);
|
|
176
|
+
|
|
91
177
|
// Drill-down: push a sub-composition onto the stack
|
|
92
178
|
const iframeRef_ = iframeRef; // stable ref for the callback
|
|
93
179
|
const handleDrillDown = useCallback(
|
|
94
180
|
(element: TimelineElement) => {
|
|
95
181
|
if (!element.compositionSrc) return;
|
|
182
|
+
// Save current master playback position for back-navigation
|
|
183
|
+
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
|
184
|
+
saveSeekPosition();
|
|
96
185
|
// compositionSrc may be a full URL (from runtime manifest) or a relative path
|
|
97
186
|
// Extract the element's composition ID from its timeline ID
|
|
98
187
|
const compId = element.id;
|
|
@@ -126,7 +215,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
126
215
|
usePlayerStore.getState().setElements([]);
|
|
127
216
|
|
|
128
217
|
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
|
129
|
-
|
|
218
|
+
updateCompositionStack((prev) => {
|
|
130
219
|
const currentId = prev[prev.length - 1].id;
|
|
131
220
|
if (currentId === resolvedPath && prev.length > 1) {
|
|
132
221
|
return prev.slice(0, -1);
|
|
@@ -141,38 +230,45 @@ export const NLELayout = memo(function NLELayout({
|
|
|
141
230
|
return [...prev, { id: resolvedPath, label, previewUrl }];
|
|
142
231
|
});
|
|
143
232
|
},
|
|
144
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
233
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
234
|
[projectId, compIdToSrc],
|
|
146
235
|
);
|
|
147
236
|
|
|
148
237
|
// Navigate back to a specific breadcrumb level
|
|
149
238
|
const handleNavigateComposition = useCallback((index: number) => {
|
|
239
|
+
// When going back to master (index 0), restore the saved master position
|
|
240
|
+
if (index === 0 && masterSeekRef.current > 0) {
|
|
241
|
+
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
|
242
|
+
}
|
|
243
|
+
saveSeekPosition();
|
|
150
244
|
usePlayerStore.getState().setElements([]);
|
|
151
|
-
|
|
245
|
+
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
|
246
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
152
247
|
}, []);
|
|
153
248
|
|
|
154
|
-
// Navigate to a composition when activeCompositionPath changes
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
249
|
+
// Navigate to a composition when activeCompositionPath changes.
|
|
250
|
+
// Uses useEffect to ensure state updates happen after render commit,
|
|
251
|
+
// avoiding render-time mutations that React can swallow during batching.
|
|
252
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
253
|
+
useEffect(() => {
|
|
159
254
|
if (activeCompositionPath === "index.html") {
|
|
160
|
-
|
|
161
|
-
|
|
255
|
+
usePlayerStore.getState().setElements([]);
|
|
256
|
+
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
|
257
|
+
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
|
|
162
258
|
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
163
259
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
|
164
|
-
|
|
165
|
-
|
|
260
|
+
usePlayerStore.getState().setElements([]);
|
|
261
|
+
updateCompositionStack((prev) => {
|
|
262
|
+
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
|
|
166
263
|
return [
|
|
167
264
|
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
168
265
|
{ id: activeCompositionPath, label, previewUrl },
|
|
169
266
|
];
|
|
170
267
|
});
|
|
268
|
+
} else if (!activeCompositionPath) {
|
|
269
|
+
usePlayerStore.getState().setElements([]);
|
|
171
270
|
}
|
|
172
|
-
}
|
|
173
|
-
prevActiveCompRef.current = null;
|
|
174
|
-
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
|
175
|
-
}
|
|
271
|
+
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
176
272
|
|
|
177
273
|
// Resize divider handlers
|
|
178
274
|
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
|
|
@@ -201,9 +297,10 @@ export const NLELayout = memo(function NLELayout({
|
|
|
201
297
|
const handleKeyDown = useCallback(
|
|
202
298
|
(e: React.KeyboardEvent) => {
|
|
203
299
|
if (e.key === "Escape" && compositionStack.length > 1) {
|
|
204
|
-
|
|
300
|
+
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
205
301
|
}
|
|
206
302
|
},
|
|
303
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
304
|
[compositionStack.length],
|
|
208
305
|
);
|
|
209
306
|
|
|
@@ -255,11 +352,16 @@ export const NLELayout = memo(function NLELayout({
|
|
|
255
352
|
onDoubleClick={(e) => {
|
|
256
353
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
257
354
|
if (compositionStack.length > 1) {
|
|
258
|
-
|
|
355
|
+
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
259
356
|
}
|
|
260
357
|
}}
|
|
261
358
|
>
|
|
262
|
-
|
|
359
|
+
{timelineToolbar}
|
|
360
|
+
<Timeline
|
|
361
|
+
onSeek={seek}
|
|
362
|
+
onDrillDown={handleDrillDown}
|
|
363
|
+
renderClipContent={renderClipContent}
|
|
364
|
+
/>
|
|
263
365
|
{timelineFooter}
|
|
264
366
|
</div>
|
|
265
367
|
</div>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { memo, useState, useRef } from "react";
|
|
2
|
+
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
|
+
import type { RenderJob } from "./useRenderQueue";
|
|
4
|
+
|
|
5
|
+
interface RenderQueueProps {
|
|
6
|
+
jobs: RenderJob[];
|
|
7
|
+
onDelete: (jobId: string) => void;
|
|
8
|
+
onClearCompleted: () => void;
|
|
9
|
+
onStartRender: (format: "mp4" | "webm") => void;
|
|
10
|
+
isRendering: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function FormatExportButton({
|
|
14
|
+
onStartRender,
|
|
15
|
+
isRendering,
|
|
16
|
+
}: {
|
|
17
|
+
onStartRender: (format: "mp4" | "webm") => void;
|
|
18
|
+
isRendering: boolean;
|
|
19
|
+
}) {
|
|
20
|
+
const [format, setFormat] = useState<"mp4" | "webm">("mp4");
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex items-center gap-0.5">
|
|
24
|
+
<select
|
|
25
|
+
value={format}
|
|
26
|
+
onChange={(e) => setFormat(e.target.value as "mp4" | "webm")}
|
|
27
|
+
disabled={isRendering}
|
|
28
|
+
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
29
|
+
>
|
|
30
|
+
<option value="mp4">MP4</option>
|
|
31
|
+
<option value="webm">WebM</option>
|
|
32
|
+
</select>
|
|
33
|
+
<button
|
|
34
|
+
onClick={() => onStartRender(format)}
|
|
35
|
+
disabled={isRendering}
|
|
36
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-[#3CE6AC] text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
|
37
|
+
>
|
|
38
|
+
{isRendering ? "Rendering..." : "Export"}
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const RenderQueue = memo(function RenderQueue({
|
|
45
|
+
jobs,
|
|
46
|
+
onDelete,
|
|
47
|
+
onClearCompleted,
|
|
48
|
+
onStartRender,
|
|
49
|
+
isRendering,
|
|
50
|
+
}: RenderQueueProps) {
|
|
51
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
52
|
+
const prevCount = useRef(jobs.length);
|
|
53
|
+
|
|
54
|
+
// Auto-scroll to bottom when new jobs are added (adjust during render)
|
|
55
|
+
if (jobs.length > prevCount.current && listRef.current) {
|
|
56
|
+
queueMicrotask(() => {
|
|
57
|
+
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
prevCount.current = jobs.length;
|
|
61
|
+
|
|
62
|
+
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex flex-col h-full">
|
|
66
|
+
{/* Header */}
|
|
67
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
|
|
68
|
+
<span className="text-[11px] font-medium text-neutral-500 uppercase tracking-wider">
|
|
69
|
+
Renders ({jobs.length})
|
|
70
|
+
</span>
|
|
71
|
+
<div className="flex items-center gap-1.5">
|
|
72
|
+
{completedCount > 0 && (
|
|
73
|
+
<button
|
|
74
|
+
onClick={onClearCompleted}
|
|
75
|
+
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
|
|
76
|
+
>
|
|
77
|
+
Clear
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
<FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Job list */}
|
|
85
|
+
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
86
|
+
{jobs.length === 0 ? (
|
|
87
|
+
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
|
88
|
+
<svg
|
|
89
|
+
width="20"
|
|
90
|
+
height="20"
|
|
91
|
+
viewBox="0 0 24 24"
|
|
92
|
+
fill="none"
|
|
93
|
+
stroke="currentColor"
|
|
94
|
+
strokeWidth="1.5"
|
|
95
|
+
className="text-neutral-700"
|
|
96
|
+
>
|
|
97
|
+
<rect
|
|
98
|
+
x="2"
|
|
99
|
+
y="2"
|
|
100
|
+
width="20"
|
|
101
|
+
height="20"
|
|
102
|
+
rx="2.18"
|
|
103
|
+
ry="2.18"
|
|
104
|
+
strokeLinecap="round"
|
|
105
|
+
strokeLinejoin="round"
|
|
106
|
+
/>
|
|
107
|
+
<path
|
|
108
|
+
d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5"
|
|
109
|
+
strokeLinecap="round"
|
|
110
|
+
strokeLinejoin="round"
|
|
111
|
+
/>
|
|
112
|
+
</svg>
|
|
113
|
+
<p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
|
|
114
|
+
</div>
|
|
115
|
+
) : (
|
|
116
|
+
jobs.map((job) => (
|
|
117
|
+
<RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
|
|
118
|
+
))
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
});
|