@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.
- package/dist/assets/index-CLmYRLY-.css +1 -0
- package/dist/assets/index-CRvFpc0E.js +84 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +139 -657
- package/src/components/LintModal.tsx +149 -0
- package/src/components/MediaPreview.tsx +79 -0
- package/src/components/editor/FileTree.tsx +50 -40
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/nle/NLELayout.tsx +59 -43
- package/src/components/renders/RenderQueue.tsx +19 -16
- package/src/components/renders/RenderQueueItem.tsx +13 -8
- package/src/components/sidebar/AssetsTab.tsx +34 -144
- package/src/components/sidebar/CompositionsTab.tsx +47 -161
- package/src/components/sidebar/LeftSidebar.tsx +79 -8
- package/src/components/ui/VideoFrameThumbnail.tsx +1 -5
- package/src/index.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +20 -94
- package/src/player/components/EditModal.tsx +5 -5
- package/src/player/components/PlayerControls.tsx +56 -3
- package/src/player/components/Timeline.tsx +13 -17
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/index.ts +0 -1
- package/src/player/store/playerStore.ts +3 -28
- package/src/utils/mediaTypes.ts +9 -0
- package/dist/assets/index-2uBPlHR_.css +0 -1
- package/dist/assets/index-uQ8cgxb3.js +0 -92
- package/src/components/ui/ExpandOnHover.tsx +0 -194
- package/src/components/ui/ExpandedVideoPreview.tsx +0 -37
- package/src/hooks/useCodeEditor.ts +0 -88
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
19
|
-
|
|
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
|
|
32
|
+
function FileIcon({ path }: { path: string }) {
|
|
44
33
|
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
45
|
-
|
|
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
|
-
<
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
347
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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-
|
|
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
|
|
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(
|
|
33
|
-
}, [
|
|
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 =
|
|
44
|
+
a.href = fileSrc;
|
|
40
45
|
a.download = job.filename;
|
|
41
46
|
a.click();
|
|
42
47
|
},
|
|
43
|
-
[
|
|
48
|
+
[fileSrc, job.filename],
|
|
44
49
|
);
|
|
45
50
|
|
|
46
|
-
const viewSrc =
|
|
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-
|
|
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-
|
|
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-
|
|
129
|
+
className="h-full bg-studio-accent rounded-full transition-all duration-300"
|
|
125
130
|
style={{ width: `${job.progress}%` }}
|
|
126
131
|
/>
|
|
127
132
|
</div>
|