@hyperframes/studio 0.1.0
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-B1830ANq.js +78 -0
- package/dist/assets/index-KoBceNoU.css +1 -0
- package/dist/icons/timeline/audio.svg +7 -0
- package/dist/icons/timeline/captions.svg +5 -0
- package/dist/icons/timeline/composition.svg +12 -0
- package/dist/icons/timeline/image.svg +18 -0
- package/dist/icons/timeline/music.svg +10 -0
- package/dist/icons/timeline/text.svg +3 -0
- package/dist/index.html +13 -0
- package/package.json +50 -0
- package/src/App.tsx +557 -0
- package/src/components/editor/FileTree.tsx +70 -0
- package/src/components/editor/PropertyPanel.tsx +209 -0
- package/src/components/editor/SourceEditor.tsx +116 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
- package/src/components/nle/NLELayout.tsx +252 -0
- package/src/components/nle/NLEPreview.tsx +37 -0
- package/src/components/ui/Button.tsx +123 -0
- package/src/components/ui/index.ts +2 -0
- package/src/hooks/useCodeEditor.ts +82 -0
- package/src/hooks/useElementPicker.ts +338 -0
- package/src/hooks/useMountEffect.ts +18 -0
- package/src/icons/SystemIcons.tsx +130 -0
- package/src/index.ts +31 -0
- package/src/main.tsx +10 -0
- package/src/player/components/AgentActivityTrack.tsx +98 -0
- package/src/player/components/Player.tsx +120 -0
- package/src/player/components/PlayerControls.tsx +181 -0
- package/src/player/components/PreviewPanel.tsx +149 -0
- package/src/player/components/Timeline.tsx +431 -0
- package/src/player/hooks/useTimelinePlayer.ts +465 -0
- package/src/player/index.ts +17 -0
- package/src/player/lib/time.ts +5 -0
- package/src/player/lib/useMountEffect.ts +10 -0
- package/src/player/store/playerStore.ts +93 -0
- package/src/styles/studio.css +31 -0
- package/src/utils/sourcePatcher.ts +149 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import { NLELayout } from "./components/nle/NLELayout";
|
|
3
|
+
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
4
|
+
import { FileTree } from "./components/editor/FileTree";
|
|
5
|
+
import {
|
|
6
|
+
XIcon,
|
|
7
|
+
CodeIcon,
|
|
8
|
+
WarningIcon,
|
|
9
|
+
CheckCircleIcon,
|
|
10
|
+
CaretRightIcon,
|
|
11
|
+
} from "@phosphor-icons/react";
|
|
12
|
+
|
|
13
|
+
interface EditingFile {
|
|
14
|
+
path: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ProjectEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface LintFinding {
|
|
25
|
+
severity: "error" | "warning";
|
|
26
|
+
message: string;
|
|
27
|
+
file?: string;
|
|
28
|
+
fixHint?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Project Picker ──
|
|
32
|
+
|
|
33
|
+
function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
|
|
34
|
+
const [projects, setProjects] = useState<ProjectEntry[]>([]);
|
|
35
|
+
const [loading, setLoading] = useState(true);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
fetch("/api/projects")
|
|
39
|
+
.then((r) => r.json())
|
|
40
|
+
.then((data: { projects?: ProjectEntry[] }) => {
|
|
41
|
+
setProjects(data.projects ?? []);
|
|
42
|
+
setLoading(false);
|
|
43
|
+
})
|
|
44
|
+
.catch(() => setLoading(false));
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="h-screen w-screen bg-neutral-950 overflow-y-auto">
|
|
49
|
+
<div className="max-w-lg w-full mx-auto px-4 py-12">
|
|
50
|
+
<h1 className="text-xl font-semibold text-neutral-200 mb-1">HyperFrames Studio</h1>
|
|
51
|
+
<p className="text-sm text-neutral-500 mb-6">Select a project to open</p>
|
|
52
|
+
{loading ? (
|
|
53
|
+
<div className="text-sm text-neutral-600">Loading projects...</div>
|
|
54
|
+
) : projects.length === 0 ? (
|
|
55
|
+
<div className="text-sm text-neutral-600">No projects found.</div>
|
|
56
|
+
) : (
|
|
57
|
+
<div className="flex flex-col gap-1.5">
|
|
58
|
+
{projects.map((p) => (
|
|
59
|
+
<button
|
|
60
|
+
key={p.id}
|
|
61
|
+
onClick={() => onSelect(p.id)}
|
|
62
|
+
className="text-left px-4 py-3 rounded-lg bg-neutral-900 border border-neutral-800 hover:border-neutral-600 hover:bg-neutral-800/80 transition-all group"
|
|
63
|
+
>
|
|
64
|
+
<div className="text-sm text-neutral-200 truncate">{p.title ?? p.id}</div>
|
|
65
|
+
<div className="text-[11px] text-neutral-600 font-mono truncate mt-0.5 group-hover:text-neutral-500">
|
|
66
|
+
{p.id}
|
|
67
|
+
</div>
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Lint Modal ──
|
|
78
|
+
|
|
79
|
+
function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () => void }) {
|
|
80
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
81
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
82
|
+
const hasIssues = findings.length > 0;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
87
|
+
onClick={onClose}
|
|
88
|
+
>
|
|
89
|
+
<div
|
|
90
|
+
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"
|
|
91
|
+
onClick={(e) => e.stopPropagation()}
|
|
92
|
+
>
|
|
93
|
+
{/* Header */}
|
|
94
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
|
95
|
+
<div className="flex items-center gap-3">
|
|
96
|
+
{hasIssues ? (
|
|
97
|
+
<div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
|
|
98
|
+
<WarningIcon size={18} className="text-red-400" weight="fill" />
|
|
99
|
+
</div>
|
|
100
|
+
) : (
|
|
101
|
+
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
|
|
102
|
+
<CheckCircleIcon size={18} className="text-green-400" weight="fill" />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
<div>
|
|
106
|
+
<h2 className="text-sm font-semibold text-neutral-200">
|
|
107
|
+
{hasIssues
|
|
108
|
+
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
|
|
109
|
+
: "All checks passed"}
|
|
110
|
+
</h2>
|
|
111
|
+
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<button
|
|
115
|
+
onClick={onClose}
|
|
116
|
+
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
|
117
|
+
>
|
|
118
|
+
<XIcon size={16} />
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Findings */}
|
|
123
|
+
<div className="flex-1 overflow-y-auto px-5 py-3">
|
|
124
|
+
{!hasIssues && (
|
|
125
|
+
<div className="py-8 text-center text-neutral-500 text-sm">
|
|
126
|
+
No errors or warnings found. Your composition looks good!
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
{errors.map((f, i) => (
|
|
130
|
+
<div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
|
131
|
+
<div className="flex items-start gap-2">
|
|
132
|
+
<WarningIcon
|
|
133
|
+
size={14}
|
|
134
|
+
className="text-red-400 flex-shrink-0 mt-0.5"
|
|
135
|
+
weight="fill"
|
|
136
|
+
/>
|
|
137
|
+
<div className="min-w-0">
|
|
138
|
+
<p className="text-sm text-neutral-200">{f.message}</p>
|
|
139
|
+
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
|
140
|
+
{f.fixHint && (
|
|
141
|
+
<div className="flex items-start gap-1 mt-1.5">
|
|
142
|
+
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
|
|
143
|
+
<p className="text-xs text-blue-400">{f.fixHint}</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
{warnings.map((f, i) => (
|
|
151
|
+
<div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
|
152
|
+
<div className="flex items-start gap-2">
|
|
153
|
+
<WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
|
154
|
+
<div className="min-w-0">
|
|
155
|
+
<p className="text-sm text-neutral-300">{f.message}</p>
|
|
156
|
+
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
|
157
|
+
{f.fixHint && (
|
|
158
|
+
<div className="flex items-start gap-1 mt-1.5">
|
|
159
|
+
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
|
|
160
|
+
<p className="text-xs text-blue-400">{f.fixHint}</p>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Main App ──
|
|
174
|
+
|
|
175
|
+
export function StudioApp() {
|
|
176
|
+
const [projectId, setProjectId] = useState<string | null>(null);
|
|
177
|
+
const [resolving, setResolving] = useState(true);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const hash = window.location.hash;
|
|
181
|
+
const projectMatch = hash.match(/project\/([^/]+)/);
|
|
182
|
+
const sessionMatch = hash.match(/session\/([^/]+)/);
|
|
183
|
+
if (projectMatch) {
|
|
184
|
+
setProjectId(projectMatch[1]);
|
|
185
|
+
setResolving(false);
|
|
186
|
+
} else if (sessionMatch) {
|
|
187
|
+
fetch(`/api/resolve-session/${sessionMatch[1]}`)
|
|
188
|
+
.then((r) => r.json())
|
|
189
|
+
.then((data: { projectId?: string }) => {
|
|
190
|
+
if (data.projectId) {
|
|
191
|
+
window.location.hash = `#project/${data.projectId}`;
|
|
192
|
+
setProjectId(data.projectId);
|
|
193
|
+
}
|
|
194
|
+
setResolving(false);
|
|
195
|
+
})
|
|
196
|
+
.catch(() => setResolving(false));
|
|
197
|
+
} else {
|
|
198
|
+
setResolving(false);
|
|
199
|
+
}
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
203
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
204
|
+
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
205
|
+
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
|
206
|
+
const [linting, setLinting] = useState(false);
|
|
207
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
208
|
+
const [renderState, setRenderState] = useState<"idle" | "rendering" | "complete" | "error">(
|
|
209
|
+
"idle",
|
|
210
|
+
);
|
|
211
|
+
const [renderProgress, setRenderProgress] = useState(0);
|
|
212
|
+
const [renderError, setRenderError] = useState<string | null>(null);
|
|
213
|
+
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
214
|
+
const projectIdRef = useRef(projectId);
|
|
215
|
+
|
|
216
|
+
// Listen for external file changes (user editing HTML outside the editor)
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!import.meta.hot) return;
|
|
219
|
+
const handler = () => {
|
|
220
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
221
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
222
|
+
};
|
|
223
|
+
import.meta.hot.on("hf:file-change", handler);
|
|
224
|
+
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
225
|
+
}, []);
|
|
226
|
+
projectIdRef.current = projectId;
|
|
227
|
+
|
|
228
|
+
// Load file tree when projectId changes
|
|
229
|
+
const prevProjectIdRef = useRef<string | null>(null);
|
|
230
|
+
if (projectId && projectId !== prevProjectIdRef.current) {
|
|
231
|
+
prevProjectIdRef.current = projectId;
|
|
232
|
+
fetch(`/api/projects/${projectId}`)
|
|
233
|
+
.then((r) => r.json())
|
|
234
|
+
.then((data: { files?: string[] }) => {
|
|
235
|
+
if (data.files) setFileTree(data.files);
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const handleSelectProject = useCallback((id: string) => {
|
|
241
|
+
window.location.hash = `#project/${id}`;
|
|
242
|
+
setProjectId(id);
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const handleFileSelect = useCallback((path: string) => {
|
|
246
|
+
const pid = projectIdRef.current;
|
|
247
|
+
if (!pid) return;
|
|
248
|
+
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
|
249
|
+
.then((r) => r.json())
|
|
250
|
+
.then((data: { content?: string }) => {
|
|
251
|
+
if (data.content != null) {
|
|
252
|
+
setEditingFile({ path, content: data.content });
|
|
253
|
+
setSidebarOpen(true);
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
.catch(() => {});
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
259
|
+
const editingPathRef = useRef(editingFile?.path);
|
|
260
|
+
editingPathRef.current = editingFile?.path;
|
|
261
|
+
|
|
262
|
+
const handleContentChange = useCallback((content: string) => {
|
|
263
|
+
const pid = projectIdRef.current;
|
|
264
|
+
const path = editingPathRef.current;
|
|
265
|
+
if (!pid || !path) return;
|
|
266
|
+
// Don't update editingFile state — the editor manages its own content.
|
|
267
|
+
// Only save to disk and refresh the preview.
|
|
268
|
+
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
269
|
+
method: "PUT",
|
|
270
|
+
headers: { "Content-Type": "text/plain" },
|
|
271
|
+
body: content,
|
|
272
|
+
})
|
|
273
|
+
.then(() => {
|
|
274
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
275
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
276
|
+
})
|
|
277
|
+
.catch(() => {});
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
const handleLint = useCallback(async () => {
|
|
281
|
+
const pid = projectIdRef.current;
|
|
282
|
+
if (!pid) return;
|
|
283
|
+
setLinting(true);
|
|
284
|
+
try {
|
|
285
|
+
// Fetch all HTML files and lint them client-side using the core linter
|
|
286
|
+
const res = await fetch(`/api/projects/${pid}`);
|
|
287
|
+
const data = await res.json();
|
|
288
|
+
const files: string[] = data.files?.filter((f: string) => f.endsWith(".html")) ?? [];
|
|
289
|
+
|
|
290
|
+
const findings: LintFinding[] = [];
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
const fileRes = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(file)}`);
|
|
293
|
+
const fileData = await fileRes.json();
|
|
294
|
+
if (!fileData.content) continue;
|
|
295
|
+
|
|
296
|
+
// Basic lint checks (subset of the full linter)
|
|
297
|
+
const html = fileData.content as string;
|
|
298
|
+
|
|
299
|
+
if (file === "index.html") {
|
|
300
|
+
// Check for root composition
|
|
301
|
+
if (!html.includes("data-composition-id")) {
|
|
302
|
+
findings.push({
|
|
303
|
+
severity: "error",
|
|
304
|
+
message: "No element with `data-composition-id` found.",
|
|
305
|
+
file,
|
|
306
|
+
fixHint: "Add `data-composition-id` to the root composition wrapper.",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// Check for timeline registration
|
|
310
|
+
if (!html.includes("__timelines")) {
|
|
311
|
+
findings.push({
|
|
312
|
+
severity: "error",
|
|
313
|
+
message: "Missing `window.__timelines` registration.",
|
|
314
|
+
file,
|
|
315
|
+
fixHint: 'Add: window.__timelines["compositionId"] = tl;',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// Check for TARGET_DURATION
|
|
319
|
+
if (
|
|
320
|
+
html.includes("gsap.timeline") &&
|
|
321
|
+
!html.includes("TARGET_DURATION") &&
|
|
322
|
+
!html.includes("tl.set({}, {},")
|
|
323
|
+
) {
|
|
324
|
+
findings.push({
|
|
325
|
+
severity: "warning",
|
|
326
|
+
message: "No TARGET_DURATION spacer found. Video may be shorter than intended.",
|
|
327
|
+
file,
|
|
328
|
+
fixHint:
|
|
329
|
+
"Add: const TARGET_DURATION = 30; if (tl.duration() < TARGET_DURATION) { tl.set({}, {}, TARGET_DURATION); }",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Check for composition hosts missing dimensions
|
|
335
|
+
const hostRe = /data-composition-src=["']([^"']+)["']/g;
|
|
336
|
+
let hostMatch;
|
|
337
|
+
while ((hostMatch = hostRe.exec(html)) !== null) {
|
|
338
|
+
const surrounding = html.slice(
|
|
339
|
+
Math.max(0, hostMatch.index - 300),
|
|
340
|
+
hostMatch.index + hostMatch[0].length + 50,
|
|
341
|
+
);
|
|
342
|
+
const hasDataDims =
|
|
343
|
+
/data-width\s*=/i.test(surrounding) && /data-height\s*=/i.test(surrounding);
|
|
344
|
+
const hasStyleDims = /style\s*=.*width:\s*\d+px.*height:\s*\d+px/i.test(surrounding);
|
|
345
|
+
if (!hasDataDims && !hasStyleDims) {
|
|
346
|
+
findings.push({
|
|
347
|
+
severity: "warning",
|
|
348
|
+
message: `Composition host for "${hostMatch[1]}" missing data-width/data-height. May render with zero dimensions.`,
|
|
349
|
+
file,
|
|
350
|
+
fixHint:
|
|
351
|
+
'Add data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px"',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check for repeat: -1
|
|
357
|
+
if (/repeat\s*:\s*-\s*1/.test(html)) {
|
|
358
|
+
findings.push({
|
|
359
|
+
severity: "error",
|
|
360
|
+
message: "GSAP `repeat: -1` found — infinite loop breaks timeline duration.",
|
|
361
|
+
file,
|
|
362
|
+
fixHint: "Use a finite repeat count or CSS animation.",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Check script syntax
|
|
367
|
+
const scriptRe = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
368
|
+
let scriptMatch;
|
|
369
|
+
while ((scriptMatch = scriptRe.exec(html)) !== null) {
|
|
370
|
+
const js = scriptMatch[1]?.trim();
|
|
371
|
+
if (!js) continue;
|
|
372
|
+
try {
|
|
373
|
+
new Function(js);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
findings.push({
|
|
376
|
+
severity: "error",
|
|
377
|
+
message: `Script syntax error: ${e instanceof Error ? e.message : String(e)}`,
|
|
378
|
+
file,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setLintModal(findings);
|
|
385
|
+
} catch {
|
|
386
|
+
setLintModal([{ severity: "error", message: "Failed to run lint." }]);
|
|
387
|
+
} finally {
|
|
388
|
+
setLinting(false);
|
|
389
|
+
}
|
|
390
|
+
}, []);
|
|
391
|
+
|
|
392
|
+
const handleRender = useCallback(async () => {
|
|
393
|
+
const pid = projectIdRef.current;
|
|
394
|
+
if (!pid || renderState === "rendering") return;
|
|
395
|
+
setRenderState("rendering");
|
|
396
|
+
setRenderProgress(0);
|
|
397
|
+
setRenderError(null);
|
|
398
|
+
try {
|
|
399
|
+
// Start render via studio backend
|
|
400
|
+
const res = await fetch(`/api/projects/${pid}/render`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: { "Content-Type": "application/json" },
|
|
403
|
+
body: JSON.stringify({}),
|
|
404
|
+
});
|
|
405
|
+
if (!res.ok) throw new Error(`Render failed: ${res.status}`);
|
|
406
|
+
const { jobId } = await res.json();
|
|
407
|
+
|
|
408
|
+
// Subscribe to progress via SSE
|
|
409
|
+
const eventSource = new EventSource(`/api/render/${jobId}/progress`);
|
|
410
|
+
eventSource.addEventListener("progress", (event) => {
|
|
411
|
+
try {
|
|
412
|
+
const data = JSON.parse(event.data);
|
|
413
|
+
setRenderProgress(data.progress ?? 0);
|
|
414
|
+
if (data.status === "complete") {
|
|
415
|
+
setRenderState("complete");
|
|
416
|
+
eventSource.close();
|
|
417
|
+
// Auto-download
|
|
418
|
+
window.open(`/api/render/${jobId}/download`, "_blank");
|
|
419
|
+
} else if (data.status === "failed") {
|
|
420
|
+
setRenderState("error");
|
|
421
|
+
setRenderError(data.error || "Render failed");
|
|
422
|
+
eventSource.close();
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
/* ignore */
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
eventSource.onerror = () => {
|
|
429
|
+
setRenderState("error");
|
|
430
|
+
setRenderError("Lost connection to render server");
|
|
431
|
+
eventSource.close();
|
|
432
|
+
};
|
|
433
|
+
} catch (err) {
|
|
434
|
+
setRenderState("error");
|
|
435
|
+
setRenderError(err instanceof Error ? err.message : "Render failed");
|
|
436
|
+
}
|
|
437
|
+
}, [renderState]);
|
|
438
|
+
|
|
439
|
+
if (resolving) {
|
|
440
|
+
return (
|
|
441
|
+
<div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
|
|
442
|
+
<div className="text-sm text-neutral-500">Loading...</div>
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!projectId) {
|
|
448
|
+
return <ProjectPicker onSelect={handleSelectProject} />;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div className="flex h-screen w-screen bg-neutral-950">
|
|
453
|
+
{/* NLE: Preview + Timeline */}
|
|
454
|
+
<div className="flex-1 relative min-w-0">
|
|
455
|
+
<NLELayout
|
|
456
|
+
projectId={projectId}
|
|
457
|
+
refreshKey={refreshKey}
|
|
458
|
+
activeCompositionPath={
|
|
459
|
+
editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
|
|
460
|
+
}
|
|
461
|
+
/>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{/* Action buttons — positioned based on sidebar state */}
|
|
465
|
+
{!sidebarOpen && (
|
|
466
|
+
<div className="absolute top-3 right-3 z-50 flex items-center gap-1.5">
|
|
467
|
+
<button
|
|
468
|
+
onClick={() => setSidebarOpen(true)}
|
|
469
|
+
className="h-8 px-3 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-neutral-200 transition-colors flex items-center justify-center"
|
|
470
|
+
title="Source editor"
|
|
471
|
+
>
|
|
472
|
+
<CodeIcon size={16} />
|
|
473
|
+
</button>
|
|
474
|
+
<button
|
|
475
|
+
onClick={handleLint}
|
|
476
|
+
disabled={linting}
|
|
477
|
+
className="h-8 px-3 rounded-lg bg-neutral-900 border border-neutral-800 text-xs font-medium text-neutral-400 hover:text-amber-300 hover:border-amber-800/50 transition-colors disabled:opacity-40"
|
|
478
|
+
>
|
|
479
|
+
{linting ? "Linting..." : "Lint"}
|
|
480
|
+
</button>
|
|
481
|
+
<button
|
|
482
|
+
onClick={handleRender}
|
|
483
|
+
disabled={renderState === "rendering"}
|
|
484
|
+
className="h-8 px-3 rounded-lg bg-blue-600 border border-blue-500 text-xs font-semibold text-white hover:bg-blue-500 transition-colors disabled:opacity-60 tabular-nums"
|
|
485
|
+
>
|
|
486
|
+
{renderState === "rendering"
|
|
487
|
+
? `${Math.round(renderProgress)}%`
|
|
488
|
+
: renderState === "complete"
|
|
489
|
+
? "Done!"
|
|
490
|
+
: "Export MP4"}
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
|
|
495
|
+
{/* Source editor sidebar */}
|
|
496
|
+
{sidebarOpen && (
|
|
497
|
+
<div className="w-[420px] flex flex-col border-l border-neutral-800 bg-neutral-900">
|
|
498
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 gap-2">
|
|
499
|
+
<span className="text-xs font-medium text-neutral-500 truncate min-w-0 flex-1">
|
|
500
|
+
{editingFile?.path ?? "Source"}
|
|
501
|
+
</span>
|
|
502
|
+
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
503
|
+
<button
|
|
504
|
+
onClick={handleLint}
|
|
505
|
+
disabled={linting}
|
|
506
|
+
className="px-2 py-1 rounded text-[11px] font-medium text-neutral-500 hover:text-amber-300 transition-colors disabled:opacity-40"
|
|
507
|
+
>
|
|
508
|
+
{linting ? "..." : "Lint"}
|
|
509
|
+
</button>
|
|
510
|
+
<button
|
|
511
|
+
onClick={handleRender}
|
|
512
|
+
disabled={renderState === "rendering"}
|
|
513
|
+
className="px-2 py-1 rounded text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition-colors disabled:opacity-60 tabular-nums"
|
|
514
|
+
>
|
|
515
|
+
{renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
|
|
516
|
+
</button>
|
|
517
|
+
<button
|
|
518
|
+
onClick={() => setSidebarOpen(false)}
|
|
519
|
+
className="p-1 rounded text-neutral-600 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
|
520
|
+
title="Close source panel"
|
|
521
|
+
>
|
|
522
|
+
<XIcon size={14} />
|
|
523
|
+
</button>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
{fileTree.length > 0 && (
|
|
528
|
+
<div className="border-b border-neutral-800 max-h-40 overflow-y-auto">
|
|
529
|
+
<FileTree
|
|
530
|
+
files={fileTree}
|
|
531
|
+
activeFile={editingFile?.path ?? null}
|
|
532
|
+
onSelectFile={handleFileSelect}
|
|
533
|
+
/>
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
|
|
537
|
+
<div className="flex-1 overflow-hidden">
|
|
538
|
+
{editingFile ? (
|
|
539
|
+
<SourceEditor
|
|
540
|
+
content={editingFile.content}
|
|
541
|
+
filePath={editingFile.path}
|
|
542
|
+
onChange={handleContentChange}
|
|
543
|
+
/>
|
|
544
|
+
) : (
|
|
545
|
+
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
|
|
546
|
+
Select a file to edit
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
{/* Lint modal */}
|
|
554
|
+
{lintModal !== null && <LintModal findings={lintModal} onClose={() => setLintModal(null)} />}
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { FileCode, Image, Film, Music, File } from "../../icons/SystemIcons";
|
|
3
|
+
|
|
4
|
+
interface FileTreeProps {
|
|
5
|
+
files: string[];
|
|
6
|
+
activeFile: string | null;
|
|
7
|
+
onSelectFile: (path: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
|
|
11
|
+
html: { icon: FileCode, color: "#3B82F6" },
|
|
12
|
+
css: { icon: FileCode, color: "#A855F7" },
|
|
13
|
+
js: { icon: FileCode, color: "#F59E0B" },
|
|
14
|
+
ts: { icon: FileCode, color: "#3B82F6" },
|
|
15
|
+
json: { icon: File, color: "#22C55E" },
|
|
16
|
+
png: { icon: Image, color: "#22C55E" },
|
|
17
|
+
jpg: { icon: Image, color: "#22C55E" },
|
|
18
|
+
svg: { icon: Image, color: "#F97316" },
|
|
19
|
+
mp4: { icon: Film, color: "#A855F7" },
|
|
20
|
+
mp3: { icon: Music, color: "#F59E0B" },
|
|
21
|
+
wav: { icon: Music, color: "#F59E0B" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function getFileIcon(path: string) {
|
|
25
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
26
|
+
return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
|
|
27
|
+
}
|
|
28
|
+
|
|
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);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-col h-full min-h-0">
|
|
39
|
+
<div className="px-2.5 py-1.5 border-b border-neutral-800 flex-shrink-0">
|
|
40
|
+
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
|
|
41
|
+
</div>
|
|
42
|
+
<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
|
+
})}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
});
|