@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.
Files changed (38) hide show
  1. package/dist/assets/index-Bj0pPj_X.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +133 -0
  11. package/src/components/renders/useRenderQueue.ts +161 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
@@ -1,5 +1,13 @@
1
- import { memo } from "react";
2
- import { FileCode, Image, Film, Music, File } from "../../icons/SystemIcons";
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
- export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
30
- const sorted = [...files].sort((a, b) => {
31
- // index.html first, then alphabetical
32
- if (a === "index.html") return -1;
33
- if (b === "index.html") return 1;
34
- return a.localeCompare(b);
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
- {sorted.map((path) => {
44
- const { icon: Icon, color } = getFileIcon(path);
45
- const isActive = path === activeFile;
46
- const name = path.split("/").pop() ?? path;
47
- const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") + "/" : "";
48
-
49
- return (
50
- <button
51
- key={path}
52
- onClick={() => onSelectFile(path)}
53
- className={`w-full flex items-center gap-2 px-2.5 py-1 min-h-7 text-left transition-all duration-press text-xs ${
54
- isActive
55
- ? "bg-neutral-800/60 text-neutral-200"
56
- : "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300 active:scale-[0.98]"
57
- }`}
58
- >
59
- <Icon size={12} style={{ color }} className="flex-shrink-0" />
60
- <span className="truncate">
61
- {dir && <span className="text-neutral-600">{dir}</span>}
62
- {name}
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({ typescript: language === "typescript" || language === "ts" });
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, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
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 below the timeline tracks (e.g., agent activity swim lanes) */
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
- useEffect(() => {
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
- }, [projectId]);
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
- setCompositionStack((prev) => {
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 -- iframeRef_ is a stable ref; .current mutates and should not be a dep
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
- setCompositionStack((prev) => prev.slice(0, index + 1));
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
- const prevActiveCompRef = useRef<string | null>(null);
156
- if (activeCompositionPath && activeCompositionPath !== prevActiveCompRef.current) {
157
- prevActiveCompRef.current = activeCompositionPath;
158
- queueMicrotask(() => usePlayerStore.getState().setElements([]));
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
- setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
161
- } else if (activeCompositionPath.startsWith("compositions/")) {
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
- setCompositionStack((prev) => {
165
- if (prev[prev.length - 1].id === activeCompositionPath) return prev;
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
- } else if (!activeCompositionPath && prevActiveCompRef.current) {
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
- setCompositionStack((prev) => prev.slice(0, -1));
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
- setCompositionStack((prev) => prev.slice(0, -1));
355
+ updateCompositionStack((prev) => prev.slice(0, -1));
259
356
  }
260
357
  }}
261
358
  >
262
- <Timeline onSeek={seek} onDrillDown={handleDrillDown} />
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
+ });