@hyperframes/studio 0.1.13 → 0.1.15

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 (31) hide show
  1. package/dist/assets/index-CLmYRLY-.css +1 -0
  2. package/dist/assets/index-CRvFpc0E.js +84 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/App.tsx +139 -657
  6. package/src/components/LintModal.tsx +149 -0
  7. package/src/components/MediaPreview.tsx +79 -0
  8. package/src/components/editor/FileTree.tsx +50 -40
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/nle/NLELayout.tsx +59 -43
  11. package/src/components/renders/RenderQueue.tsx +19 -16
  12. package/src/components/renders/RenderQueueItem.tsx +13 -8
  13. package/src/components/sidebar/AssetsTab.tsx +34 -144
  14. package/src/components/sidebar/CompositionsTab.tsx +47 -161
  15. package/src/components/sidebar/LeftSidebar.tsx +79 -8
  16. package/src/components/ui/VideoFrameThumbnail.tsx +1 -5
  17. package/src/index.ts +0 -3
  18. package/src/player/components/CompositionThumbnail.tsx +20 -94
  19. package/src/player/components/EditModal.tsx +5 -5
  20. package/src/player/components/PlayerControls.tsx +56 -3
  21. package/src/player/components/Timeline.tsx +13 -17
  22. package/src/player/components/TimelineClip.tsx +0 -1
  23. package/src/player/index.ts +0 -1
  24. package/src/player/store/playerStore.ts +3 -28
  25. package/src/utils/mediaTypes.ts +9 -0
  26. package/dist/assets/index-2uBPlHR_.css +0 -1
  27. package/dist/assets/index-uQ8cgxb3.js +0 -92
  28. package/src/components/ui/ExpandOnHover.tsx +0 -194
  29. package/src/components/ui/ExpandedVideoPreview.tsx +0 -37
  30. package/src/hooks/useCodeEditor.ts +0 -88
  31. package/src/player/components/PreviewPanel.tsx +0 -181
@@ -0,0 +1,149 @@
1
+ import { useState } from "react";
2
+ import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
3
+
4
+ export interface LintFinding {
5
+ severity: "error" | "warning";
6
+ message: string;
7
+ file?: string;
8
+ fixHint?: string;
9
+ }
10
+
11
+ export function LintModal({
12
+ findings,
13
+ projectId,
14
+ onClose,
15
+ }: {
16
+ findings: LintFinding[];
17
+ projectId: string;
18
+ onClose: () => void;
19
+ }) {
20
+ const errors = findings.filter((f) => f.severity === "error");
21
+ const warnings = findings.filter((f) => f.severity === "warning");
22
+ const hasIssues = findings.length > 0;
23
+ const [copied, setCopied] = useState(false);
24
+
25
+ const handleCopyToAgent = async () => {
26
+ const lines = findings.map((f) => {
27
+ let line = `[${f.severity}] ${f.message}`;
28
+ if (f.file) line += `\n File: ${f.file}`;
29
+ if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
30
+ return line;
31
+ });
32
+ const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
33
+ try {
34
+ await navigator.clipboard.writeText(text);
35
+ setCopied(true);
36
+ setTimeout(() => setCopied(false), 2000);
37
+ } catch {
38
+ // ignore
39
+ }
40
+ };
41
+
42
+ return (
43
+ <div
44
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
45
+ onClick={onClose}
46
+ >
47
+ <div
48
+ className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
49
+ onClick={(e) => e.stopPropagation()}
50
+ >
51
+ {/* Header */}
52
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
53
+ <div className="flex items-center gap-3">
54
+ {hasIssues ? (
55
+ <div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
56
+ <WarningIcon size={18} className="text-red-400" weight="fill" />
57
+ </div>
58
+ ) : (
59
+ <div className="w-8 h-8 rounded-full bg-studio-accent/10 flex items-center justify-center">
60
+ <CheckCircleIcon size={18} className="text-studio-accent" weight="fill" />
61
+ </div>
62
+ )}
63
+ <div>
64
+ <h2 className="text-sm font-semibold text-neutral-200">
65
+ {hasIssues
66
+ ? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
67
+ : "All checks passed"}
68
+ </h2>
69
+ <p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
70
+ </div>
71
+ </div>
72
+ <button
73
+ onClick={onClose}
74
+ className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
75
+ >
76
+ <XIcon size={16} />
77
+ </button>
78
+ </div>
79
+
80
+ {/* Copy to agent + findings */}
81
+ {hasIssues && (
82
+ <div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
83
+ <button
84
+ onClick={handleCopyToAgent}
85
+ className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
86
+ copied
87
+ ? "bg-green-600 text-white"
88
+ : "bg-studio-accent hover:bg-studio-accent/80 text-white"
89
+ }`}
90
+ >
91
+ {copied ? "Copied!" : "Copy to Agent"}
92
+ </button>
93
+ </div>
94
+ )}
95
+ <div className="flex-1 overflow-y-auto px-5 py-3">
96
+ {!hasIssues && (
97
+ <div className="py-8 text-center text-neutral-500 text-sm">
98
+ No errors or warnings found. Your composition looks good!
99
+ </div>
100
+ )}
101
+ {errors.map((f, i) => (
102
+ <div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
103
+ <div className="flex items-start gap-2">
104
+ <WarningIcon
105
+ size={14}
106
+ className="text-red-400 flex-shrink-0 mt-0.5"
107
+ weight="fill"
108
+ />
109
+ <div className="min-w-0">
110
+ <p className="text-sm text-neutral-200">{f.message}</p>
111
+ {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
112
+ {f.fixHint && (
113
+ <div className="flex items-start gap-1 mt-1.5">
114
+ <CaretRightIcon
115
+ size={10}
116
+ className="text-studio-accent flex-shrink-0 mt-0.5"
117
+ />
118
+ <p className="text-xs text-studio-accent">{f.fixHint}</p>
119
+ </div>
120
+ )}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ ))}
125
+ {warnings.map((f, i) => (
126
+ <div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
127
+ <div className="flex items-start gap-2">
128
+ <WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
129
+ <div className="min-w-0">
130
+ <p className="text-sm text-neutral-300">{f.message}</p>
131
+ {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
132
+ {f.fixHint && (
133
+ <div className="flex items-start gap-1 mt-1.5">
134
+ <CaretRightIcon
135
+ size={10}
136
+ className="text-studio-accent flex-shrink-0 mt-0.5"
137
+ />
138
+ <p className="text-xs text-studio-accent">{f.fixHint}</p>
139
+ </div>
140
+ )}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,79 @@
1
+ import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
2
+
3
+ export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
4
+ const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
5
+ const name = filePath.split("/").pop() ?? filePath;
6
+
7
+ if (IMAGE_EXT.test(filePath)) {
8
+ return (
9
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
10
+ <img
11
+ src={serveUrl}
12
+ alt={name}
13
+ className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
14
+ />
15
+ <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ if (VIDEO_EXT.test(filePath)) {
21
+ return (
22
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
23
+ <video
24
+ src={serveUrl}
25
+ controls
26
+ className="max-w-full max-h-[70%] rounded border border-neutral-800"
27
+ />
28
+ <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ if (AUDIO_EXT.test(filePath)) {
34
+ return (
35
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-3">
36
+ <svg
37
+ width="48"
38
+ height="48"
39
+ viewBox="0 0 24 24"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth="1.5"
43
+ className="text-neutral-600"
44
+ >
45
+ <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
46
+ <circle cx="6" cy="18" r="3" />
47
+ <circle cx="18" cy="16" r="3" />
48
+ </svg>
49
+ <audio src={serveUrl} controls className="w-full max-w-[280px]" />
50
+ <span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ // Fonts and other binary — show info instead of binary dump
56
+ return (
57
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
58
+ <svg
59
+ width="40"
60
+ height="40"
61
+ viewBox="0 0 24 24"
62
+ fill="none"
63
+ stroke="currentColor"
64
+ strokeWidth="1.5"
65
+ className="text-neutral-600"
66
+ >
67
+ <path
68
+ d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
69
+ strokeLinecap="round"
70
+ strokeLinejoin="round"
71
+ />
72
+ <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
73
+ </svg>
74
+ <span className="text-sm text-neutral-400 font-medium">{name}</span>
75
+ <span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
76
+ <span className="text-[10px] text-neutral-600">Binary file — preview not available</span>
77
+ </div>
78
+ );
79
+ }
@@ -1,13 +1,24 @@
1
- import { memo, useState, useCallback } from "react";
1
+ import { memo, useState, useCallback, useMemo } from "react";
2
2
  import {
3
+ FileHtml,
4
+ FileCss,
5
+ FileJs,
6
+ FileJsx,
7
+ FileTs,
8
+ FileTsx,
9
+ FileTxt,
10
+ FileMd,
11
+ FileSvg,
12
+ FilePng,
13
+ FileJpg,
14
+ FileVideo,
3
15
  FileCode,
4
- Image,
5
- Film,
6
- Music,
7
16
  File,
8
- ChevronDown,
9
- ChevronRight,
10
- } from "../../icons/SystemIcons";
17
+ Waveform,
18
+ TextAa,
19
+ Image as PhImage,
20
+ } from "@phosphor-icons/react";
21
+ import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
11
22
 
12
23
  interface FileTreeProps {
13
24
  files: string[];
@@ -15,34 +26,37 @@ interface FileTreeProps {
15
26
  onSelectFile: (path: string) => void;
16
27
  }
17
28
 
18
- const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
19
- html: { icon: FileCode, color: "#3B82F6" },
20
- css: { icon: FileCode, color: "#A855F7" },
21
- js: { icon: FileCode, color: "#F59E0B" },
22
- ts: { icon: FileCode, color: "#3B82F6" },
23
- json: { icon: File, color: "#22C55E" },
24
- md: { icon: File, color: "#737373" },
25
- png: { icon: Image, color: "#22C55E" },
26
- jpg: { icon: Image, color: "#22C55E" },
27
- jpeg: { icon: Image, color: "#22C55E" },
28
- webp: { icon: Image, color: "#22C55E" },
29
- gif: { icon: Image, color: "#22C55E" },
30
- svg: { icon: Image, color: "#F97316" },
31
- mp4: { icon: Film, color: "#A855F7" },
32
- webm: { icon: Film, color: "#A855F7" },
33
- mov: { icon: Film, color: "#A855F7" },
34
- mp3: { icon: Music, color: "#F59E0B" },
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" },
41
- };
29
+ const SZ = 14;
30
+ const W = "duotone" as const;
42
31
 
43
- function getFileIcon(path: string) {
32
+ function FileIcon({ path }: { path: string }) {
44
33
  const ext = path.split(".").pop()?.toLowerCase() ?? "";
45
- return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
34
+ const c = "flex-shrink-0";
35
+ if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
36
+ if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
37
+ if (ext === "js" || ext === "mjs" || ext === "cjs")
38
+ return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
39
+ if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
40
+ if (ext === "ts" || ext === "mts")
41
+ return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
42
+ if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
43
+ if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
44
+ if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
45
+ if (ext === "md" || ext === "mdx")
46
+ return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
47
+ if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
48
+ if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
49
+ if (ext === "jpg" || ext === "jpeg")
50
+ return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
51
+ if (ext === "webp" || ext === "gif" || ext === "ico")
52
+ return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
53
+ if (ext === "mp4" || ext === "webm" || ext === "mov")
54
+ return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
55
+ if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
56
+ return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
57
+ if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
58
+ return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
59
+ return <File size={SZ} weight={W} color="#6B7280" className={c} />;
46
60
  }
47
61
 
48
62
  interface TreeNode {
@@ -160,7 +174,6 @@ function TreeFile({
160
174
  activeFile: string | null;
161
175
  onSelectFile: (path: string) => void;
162
176
  }) {
163
- const { icon: Icon, color } = getFileIcon(node.name);
164
177
  const isActive = node.fullPath === activeFile;
165
178
 
166
179
  return (
@@ -173,7 +186,7 @@ function TreeFile({
173
186
  }`}
174
187
  style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
175
188
  >
176
- <Icon size={12} style={{ color }} className="flex-shrink-0" />
189
+ <FileIcon path={node.name} />
177
190
  <span className="truncate">{node.name}</span>
178
191
  </button>
179
192
  );
@@ -189,14 +202,11 @@ function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
189
202
  }
190
203
 
191
204
  export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
192
- const tree = buildTree(files);
193
- const children = sortChildren(tree.children);
205
+ const tree = useMemo(() => buildTree(files), [files]);
206
+ const children = useMemo(() => sortChildren(tree.children), [tree]);
194
207
 
195
208
  return (
196
209
  <div className="flex flex-col h-full min-h-0">
197
- <div className="px-2.5 py-1.5 border-b border-neutral-800 flex-shrink-0">
198
- <span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
199
- </div>
200
210
  <div className="flex-1 overflow-y-auto py-1">
201
211
  {children.map((child) =>
202
212
  child.isFile && child.children.size === 0 ? (
@@ -94,7 +94,7 @@ export const PropertyPanel = memo(function PropertyPanel({
94
94
  variant="secondary"
95
95
  size="sm"
96
96
  onClick={isPickMode ? onDisablePick : onEnablePick}
97
- className={`mt-3 ${isPickMode ? "bg-blue-500/20 text-blue-400 border-blue-500/30" : ""}`}
97
+ className={`mt-3 ${isPickMode ? "bg-studio-accent/20 text-studio-accent border-studio-accent/30" : ""}`}
98
98
  >
99
99
  {isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
100
100
  </Button>
@@ -109,7 +109,7 @@ export const PropertyPanel = memo(function PropertyPanel({
109
109
  {/* Header */}
110
110
  <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 flex-shrink-0">
111
111
  <div className="flex items-center gap-1.5 min-w-0">
112
- <span className="text-2xs font-mono text-blue-400 truncate">{element.selector}</span>
112
+ <span className="text-2xs font-mono text-studio-accent truncate">{element.selector}</span>
113
113
  </div>
114
114
  <div className="flex items-center gap-1">
115
115
  <IconButton
@@ -117,7 +117,7 @@ export const PropertyPanel = memo(function PropertyPanel({
117
117
  aria-label={isPickMode ? "Disable pick mode" : "Enable pick mode"}
118
118
  size="sm"
119
119
  onClick={isPickMode ? onDisablePick : onEnablePick}
120
- className={isPickMode ? "text-blue-400 bg-blue-500/10" : ""}
120
+ className={isPickMode ? "text-studio-accent bg-studio-accent/10" : ""}
121
121
  />
122
122
  <IconButton
123
123
  icon={<X size={11} />}
@@ -29,6 +29,10 @@ interface NLELayoutProps {
29
29
  ) => ReactNode;
30
30
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
31
31
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
32
+ /** Whether the timeline panel is visible (default: true) */
33
+ timelineVisible?: boolean;
34
+ /** Callback to toggle timeline visibility */
35
+ onToggleTimeline?: () => void;
32
36
  }
33
37
 
34
38
  const MIN_TIMELINE_H = 100;
@@ -47,6 +51,8 @@ export const NLELayout = memo(function NLELayout({
47
51
  onCompositionChange,
48
52
  renderClipContent,
49
53
  onCompIdToSrcChange,
54
+ timelineVisible,
55
+ onToggleTimeline,
50
56
  }: NLELayoutProps) {
51
57
  const {
52
58
  iframeRef,
@@ -311,31 +317,20 @@ export const NLELayout = memo(function NLELayout({
311
317
  onKeyDown={handleKeyDown}
312
318
  tabIndex={-1}
313
319
  >
314
- {/* Preview — takes remaining space above timeline */}
315
- <div className="flex-1 min-h-0 relative">
316
- <NLEPreview
317
- projectId={projectId}
318
- iframeRef={iframeRef}
319
- onIframeLoad={onIframeLoad}
320
- portrait={portrait}
321
- directUrl={directUrl}
322
- refreshKey={refreshKey}
323
- />
324
- {previewOverlay}
325
- </div>
326
-
327
- {/* Resize divider */}
328
- <div
329
- className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-row-resize transition-colors active:bg-blue-400 z-10"
330
- style={{ touchAction: "none" }}
331
- onPointerDown={handleDividerPointerDown}
332
- onPointerMove={handleDividerPointerMove}
333
- onPointerUp={handleDividerPointerUp}
334
- />
335
-
336
- {/* Timeline section — fixed height, resizable */}
337
- <div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
338
- {/* Breadcrumb + Player controls */}
320
+ {/* Preview + player controls — takes remaining space above timeline */}
321
+ <div className="flex-1 min-h-0 flex flex-col">
322
+ <div className="flex-1 min-h-0 relative">
323
+ <NLEPreview
324
+ projectId={projectId}
325
+ iframeRef={iframeRef}
326
+ onIframeLoad={onIframeLoad}
327
+ portrait={portrait}
328
+ directUrl={directUrl}
329
+ refreshKey={refreshKey}
330
+ />
331
+ {previewOverlay}
332
+ </div>
333
+ {/* Player controls always visible, regardless of timeline state */}
339
334
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
340
335
  {compositionStack.length > 1 && (
341
336
  <CompositionBreadcrumb
@@ -343,28 +338,49 @@ export const NLELayout = memo(function NLELayout({
343
338
  onNavigate={handleNavigateComposition}
344
339
  />
345
340
  )}
346
- <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
347
- </div>
348
-
349
- {/* Timeline tracks */}
350
- <div
351
- className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
352
- onDoubleClick={(e) => {
353
- if ((e.target as HTMLElement).closest("[data-clip]")) return;
354
- if (compositionStack.length > 1) {
355
- updateCompositionStack((prev) => prev.slice(0, -1));
356
- }
357
- }}
358
- >
359
- {timelineToolbar}
360
- <Timeline
341
+ <PlayerControls
342
+ onTogglePlay={togglePlay}
361
343
  onSeek={seek}
362
- onDrillDown={handleDrillDown}
363
- renderClipContent={renderClipContent}
344
+ timelineVisible={timelineVisible ?? true}
345
+ onToggleTimeline={onToggleTimeline}
364
346
  />
365
- {timelineFooter}
366
347
  </div>
367
348
  </div>
349
+
350
+ {(timelineVisible ?? true) && (
351
+ <>
352
+ {/* Resize divider */}
353
+ <div
354
+ className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-row-resize transition-colors active:bg-studio-accent/80 z-10"
355
+ style={{ touchAction: "none" }}
356
+ onPointerDown={handleDividerPointerDown}
357
+ onPointerMove={handleDividerPointerMove}
358
+ onPointerUp={handleDividerPointerUp}
359
+ />
360
+
361
+ {/* Timeline section — fixed height, resizable */}
362
+ <div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
363
+ {/* Timeline tracks */}
364
+ <div
365
+ className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
366
+ onDoubleClick={(e) => {
367
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
368
+ if (compositionStack.length > 1) {
369
+ updateCompositionStack((prev) => prev.slice(0, -1));
370
+ }
371
+ }}
372
+ >
373
+ {timelineToolbar}
374
+ <Timeline
375
+ onSeek={seek}
376
+ onDrillDown={handleDrillDown}
377
+ renderClipContent={renderClipContent}
378
+ />
379
+ {timelineFooter}
380
+ </div>
381
+ </div>
382
+ </>
383
+ )}
368
384
  </div>
369
385
  );
370
386
  });
@@ -1,9 +1,10 @@
1
- import { memo, useState, useRef } from "react";
1
+ import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
3
  import type { RenderJob } from "./useRenderQueue";
4
4
 
5
5
  interface RenderQueueProps {
6
6
  jobs: RenderJob[];
7
+ projectId: string;
7
8
  onDelete: (jobId: string) => void;
8
9
  onClearCompleted: () => void;
9
10
  onStartRender: (format: "mp4" | "webm") => void;
@@ -33,7 +34,7 @@ function FormatExportButton({
33
34
  <button
34
35
  onClick={() => onStartRender(format)}
35
36
  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
+ 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"
37
38
  >
38
39
  {isRendering ? "Rendering..." : "Export"}
39
40
  </button>
@@ -43,31 +44,28 @@ function FormatExportButton({
43
44
 
44
45
  export const RenderQueue = memo(function RenderQueue({
45
46
  jobs,
47
+ projectId,
46
48
  onDelete,
47
49
  onClearCompleted,
48
50
  onStartRender,
49
51
  isRendering,
50
52
  }: RenderQueueProps) {
51
53
  const listRef = useRef<HTMLDivElement>(null);
52
- const prevCount = useRef(jobs.length);
53
54
 
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;
55
+ // Auto-scroll to bottom when new jobs are added.
56
+ // Runs in an effect to avoid side effects during the render phase.
57
+ useEffect(() => {
58
+ if (listRef.current) {
59
+ listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
60
+ }
61
+ }, [jobs.length]);
61
62
 
62
63
  const completedCount = jobs.filter((j) => j.status !== "rendering").length;
63
64
 
64
65
  return (
65
66
  <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>
67
+ {/* Header — no title, already shown in header button */}
68
+ <div className="flex items-center justify-end px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
71
69
  <div className="flex items-center gap-1.5">
72
70
  {completedCount > 0 && (
73
71
  <button
@@ -114,7 +112,12 @@ export const RenderQueue = memo(function RenderQueue({
114
112
  </div>
115
113
  ) : (
116
114
  jobs.map((job) => (
117
- <RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
115
+ <RenderQueueItem
116
+ key={job.id}
117
+ job={job}
118
+ projectId={projectId}
119
+ onDelete={() => onDelete(job.id)}
120
+ />
118
121
  ))
119
122
  )}
120
123
  </div>
@@ -4,6 +4,7 @@ import type { RenderJob } from "./useRenderQueue";
4
4
 
5
5
  interface RenderQueueItemProps {
6
6
  job: RenderJob;
7
+ projectId: string;
7
8
  onDelete: () => void;
8
9
  }
9
10
 
@@ -24,26 +25,30 @@ function formatTimeAgo(timestamp: number): string {
24
25
 
25
26
  export const RenderQueueItem = memo(function RenderQueueItem({
26
27
  job,
28
+ projectId,
27
29
  onDelete,
28
30
  }: RenderQueueItemProps) {
29
31
  const [hovered, setHovered] = useState(false);
30
32
 
33
+ // Direct file URL — serves from disk, survives server restarts
34
+ const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
35
+
31
36
  const handleOpen = useCallback(() => {
32
- window.open(`/api/render/${job.id}/view`, "_blank");
33
- }, [job.id]);
37
+ window.open(fileSrc, "_blank");
38
+ }, [fileSrc]);
34
39
 
35
40
  const handleDownload = useCallback(
36
41
  (e: React.MouseEvent) => {
37
42
  e.stopPropagation();
38
43
  const a = document.createElement("a");
39
- a.href = `/api/render/${job.id}/download`;
44
+ a.href = fileSrc;
40
45
  a.download = job.filename;
41
46
  a.click();
42
47
  },
43
- [job.id, job.filename],
48
+ [fileSrc, job.filename],
44
49
  );
45
50
 
46
- const viewSrc = `/api/render/${job.id}/view`;
51
+ const viewSrc = fileSrc;
47
52
  const isComplete = job.status === "complete";
48
53
 
49
54
  return (
@@ -85,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
85
90
  )}
86
91
  {job.status === "rendering" && (
87
92
  <div className="w-full h-full flex items-center justify-center">
88
- <div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
93
+ <div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
89
94
  </div>
90
95
  )}
91
96
  {job.status === "failed" && (
@@ -117,11 +122,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
117
122
  <div className="mt-1">
118
123
  <div className="flex items-center justify-between mb-0.5">
119
124
  <span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
120
- <span className="text-[9px] font-mono text-[#3CE6AC]">{job.progress}%</span>
125
+ <span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
121
126
  </div>
122
127
  <div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
123
128
  <div
124
- className="h-full bg-[#3CE6AC] rounded-full transition-all duration-300"
129
+ className="h-full bg-studio-accent rounded-full transition-all duration-300"
125
130
  style={{ width: `${job.progress}%` }}
126
131
  />
127
132
  </div>