ortegarod commited on
Commit
fe029e8
·
1 Parent(s): 94ab1b6

Update Docker Studio UI for projects workflow

Browse files
README.md CHANGED
@@ -24,4 +24,4 @@ This Space serves the real React/Vite Studio UI and proxies API/media requests t
24
 
25
  | Secret | Description |
26
  | --- | --- |
27
- | `NEMOFLIX_API_URL` | URL for the Nemoflix AMD control-plane API, e.g. `http://100.69.225.61:8190` or another reachable HTTPS/public endpoint. |
 
24
 
25
  | Secret | Description |
26
  | --- | --- |
27
+ | `NEMOFLIX_API_URL` | URL for the Nemoflix API|
studio/src/App.tsx CHANGED
@@ -1,15 +1,12 @@
1
  import { useState, useEffect, useCallback } from "react";
2
- import { Menu, Sparkles } from "lucide-react";
3
- import { JobCard } from "./components/JobCard";
4
- import { MediaTile } from "./components/MediaTile";
5
- import { CharacterIdentityPanel } from "./components/CharacterIdentityPanel";
6
- import { CharacterAssetsPanel } from "./components/CharacterAssetsPanel";
7
- import { ProjectsGuide } from "./components/ProjectsGuide";
8
  import { AppSidebar } from "./components/sidebar/AppSidebar";
9
  import type { SidebarTab } from "./components/sidebar/AppSidebar";
10
- import type { JobItem, LoraCheckpoint, LoraTrainingStatus, MediaItem } from "./types";
11
-
12
- const DEFAULT_CHARACTER = "rigo";
13
 
14
  async function fetchJson<T>(url: string, timeoutMs = 5000): Promise<T> {
15
  const controller = new AbortController();
@@ -33,8 +30,54 @@ export default function App() {
33
  const [error, setError] = useState<string | null>(null);
34
  const [selected, setSelected] = useState<string | null>(null);
35
  const [sidebarOpen, setSidebarOpen] = useState(true);
36
- const [activeSidebarTab, setActiveSidebarTab] = useState<SidebarTab>("characters");
37
- const [selectedCharacterId, setSelectedCharacterId] = useState<string>(DEFAULT_CHARACTER);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  const load = useCallback(async () => {
40
  if (!hasLoadedOnce) setLoading(true);
@@ -101,7 +144,7 @@ export default function App() {
101
  </button>
102
  )}
103
  <button
104
- onClick={() => { setSelectedCharacterId(DEFAULT_CHARACTER); setActiveSidebarTab("characters"); }}
105
  className="flex items-center gap-3 min-w-0 hover:opacity-80 transition"
106
  title="Home"
107
  >
@@ -115,22 +158,38 @@ export default function App() {
115
  </button>
116
  </div>
117
 
118
- <div className="flex items-center gap-1.5 text-[11px]">
119
- {jobs.length > 0 && (
120
- <span className="inline-flex items-center gap-1.5 rounded-full border border-amber-800/40 bg-amber-950/30 px-2.5 py-1 text-amber-400 font-medium">
121
- <span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
122
- {jobs.length} generating
 
 
 
 
 
123
  </span>
124
- )}
125
- <span className="rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
126
- {items.length} media
127
- </span>
128
- <span className="hidden sm:inline rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
129
- {imageCount} images
130
- </span>
131
- <span className="hidden sm:inline rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
132
- {videoCount} videos
133
- </span>
 
 
 
 
 
 
 
 
 
 
 
134
  </div>
135
  </header>
136
 
@@ -142,46 +201,63 @@ export default function App() {
142
  onClose={() => setSidebarOpen(false)}
143
  checkpoints={checkpoints}
144
  onQueued={load}
145
- onSelectCharacter={(id) => { setSelectedCharacterId(id); setActiveSidebarTab("characters"); }}
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  />
147
  )}
148
 
149
  <main className="flex-1 min-w-0 overflow-y-auto bg-gradient-to-b from-transparent via-transparent to-gray-950/30">
150
- {activeSidebarTab === "projects" ? (
151
- <ProjectsGuide />
152
- ) : (
153
- <div className="p-4 space-y-5">
154
- <CharacterIdentityPanel characterId={selectedCharacterId} />
155
-
156
- {loading && !hasLoadedOnce && !hasContent ? (
157
- <p className="text-gray-500">Loading...</p>
158
- ) : error ? (
159
- <div className="rounded-lg border border-red-900/60 bg-red-950/20 p-4 text-sm text-red-300">{error}</div>
160
- ) : !hasContent ? (
161
- <p className="text-gray-500">No media yet.</p>
162
- ) : (
163
- <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
164
- {jobs.map((job) => (
165
- <JobCard key={job.prompt_id} job={job} />
166
- ))}
167
-
168
- {items.map((item) => (
169
- <MediaTile
170
- key={item.filename || item.url}
171
- item={item}
172
- onOpen={() => setSelected(item.url)}
173
- onDelete={deleteItem}
174
- />
175
- ))}
176
- </div>
177
- )}
178
-
179
- <CharacterAssetsPanel
180
- characterId={selectedCharacterId}
181
- training={training}
182
- checkpoints={checkpoints}
183
- />
184
- </div>
 
 
 
 
185
  )}
186
  </main>
187
  </div>
 
1
  import { useState, useEffect, useCallback } from "react";
2
+ import { Menu, Sparkles, UserCircle } from "lucide-react";
3
+ import { StudioView } from "./components/GalleryView";
4
+ import { CharacterProfileView } from "./components/CharacterProfileView";
5
+ import { ProjectsView } from "./components/ProjectsView";
6
+ import { ProjectDetailView } from "./components/ProjectDetailView";
 
7
  import { AppSidebar } from "./components/sidebar/AppSidebar";
8
  import type { SidebarTab } from "./components/sidebar/AppSidebar";
9
+ import type { JobItem, LoraCheckpoint, LoraTrainingStatus, MediaItem, Project, Scene, Shot, ProjectPhase } from "./types";
 
 
10
 
11
  async function fetchJson<T>(url: string, timeoutMs = 5000): Promise<T> {
12
  const controller = new AbortController();
 
30
  const [error, setError] = useState<string | null>(null);
31
  const [selected, setSelected] = useState<string | null>(null);
32
  const [sidebarOpen, setSidebarOpen] = useState(true);
33
+ const [activeSidebarTab, setActiveSidebarTab] = useState<SidebarTab>("generate");
34
+ const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null);
35
+ const [mainView, setMainView] = useState<"studio" | "character" | "projects" | "project-detail">("studio");
36
+ const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
37
+ const [projectData, setProjectData] = useState<{ project: Project; scenes: Scene[]; shots: Shot[] } | null>(null);
38
+ const [selectedSceneId, setSelectedSceneId] = useState<string | null>(null);
39
+ const [selectedShotId, setSelectedShotId] = useState<string | null>(null);
40
+
41
+ const loadProject = useCallback(async () => {
42
+ if (!selectedProjectId) { setProjectData(null); return; }
43
+ try {
44
+ const response = await fetch(`/api/projects/${selectedProjectId}`);
45
+ if (!response.ok) throw new Error(`Project fetch failed: ${response.status}`);
46
+ const data = await response.json();
47
+ const scenes: Scene[] = (data.scenes || []).slice().sort((a: Scene, b: Scene) => a.scene_number - b.scene_number);
48
+ const shots: Shot[] = (data.shots || []).slice().sort((a: Shot, b: Shot) => a.shot_number - b.shot_number);
49
+ setProjectData({ project: data.project, scenes, shots });
50
+ setSelectedSceneId((current) => {
51
+ if (current && scenes.some((scene) => scene.id === current)) return current;
52
+ return scenes.length > 0 ? scenes[0].id : null;
53
+ });
54
+ } catch (e) {
55
+ console.error("Failed to load project", e);
56
+ }
57
+ }, [selectedProjectId]);
58
+
59
+ useEffect(() => { loadProject(); }, [loadProject]);
60
+
61
+ const phase: ProjectPhase = projectData?.shots.some((shot) => shot.image_file) ? "remix" : "outline";
62
+
63
+ const addScene = useCallback(async () => {
64
+ if (!selectedProjectId || !projectData) return;
65
+ const next = projectData.scenes.length > 0 ? Math.max(...projectData.scenes.map((scene) => scene.scene_number)) + 1 : 1;
66
+ try {
67
+ const response = await fetch(`/api/projects/${selectedProjectId}/scenes`, {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ scene_number: next, heading: `SCENE ${next}` }),
71
+ });
72
+ if (!response.ok) throw new Error(`Add scene failed: ${response.status}`);
73
+ const created = await response.json();
74
+ await loadProject();
75
+ setSelectedSceneId(created.id);
76
+ setSelectedShotId(null);
77
+ } catch (e) {
78
+ console.error("Failed to add scene", e);
79
+ }
80
+ }, [selectedProjectId, projectData, loadProject]);
81
 
82
  const load = useCallback(async () => {
83
  if (!hasLoadedOnce) setLoading(true);
 
144
  </button>
145
  )}
146
  <button
147
+ onClick={() => { setMainView("studio"); }}
148
  className="flex items-center gap-3 min-w-0 hover:opacity-80 transition"
149
  title="Home"
150
  >
 
158
  </button>
159
  </div>
160
 
161
+ <div className="flex items-center gap-2 text-[11px]">
162
+ <div className="hidden md:flex items-center gap-1.5">
163
+ {jobs.length > 0 && (
164
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-amber-800/40 bg-amber-950/30 px-2.5 py-1 text-amber-400 font-medium">
165
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
166
+ {jobs.length} generating
167
+ </span>
168
+ )}
169
+ <span className="rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
170
+ {items.length} media
171
  </span>
172
+ <span className="hidden lg:inline rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
173
+ {imageCount} images
174
+ </span>
175
+ <span className="hidden lg:inline rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
176
+ {videoCount} videos
177
+ </span>
178
+ </div>
179
+
180
+ <div className="group relative">
181
+ <button className="flex items-center gap-2 rounded-xl border border-rose-500/30 bg-rose-950/20 px-2.5 py-1.5 text-rose-100 hover:border-rose-400/50 transition">
182
+ <UserCircle className="w-4 h-4" />
183
+ <span className="hidden sm:inline font-medium">Demo Account</span>
184
+ <span className="rounded-full bg-amber-500/15 border border-amber-500/30 px-1.5 py-0.5 text-[9px] uppercase tracking-wider text-amber-300">Hackathon</span>
185
+ </button>
186
+ <div className="pointer-events-none absolute right-0 top-full z-50 mt-2 w-72 rounded-2xl border border-gray-800 bg-gray-950/95 p-4 shadow-2xl shadow-black/60 opacity-0 translate-y-1 transition group-hover:opacity-100 group-hover:translate-y-0">
187
+ <p className="text-xs font-semibold text-gray-200">Demo workspace</p>
188
+ <p className="text-[11px] text-gray-500 mt-1 leading-relaxed">
189
+ This hackathon build uses a sample owner dataset to demonstrate character LoRA training, generation, and media management. Authentication is intentionally mocked for the demo.
190
+ </p>
191
+ </div>
192
+ </div>
193
  </div>
194
  </header>
195
 
 
201
  onClose={() => setSidebarOpen(false)}
202
  checkpoints={checkpoints}
203
  onQueued={load}
204
+ onSelectCharacter={(id) => { setSelectedCharacterId(id); setMainView("character"); }}
205
+ projectMode={mainView === "project-detail" && projectData ? {
206
+ project: projectData.project,
207
+ scenes: projectData.scenes,
208
+ shots: projectData.shots,
209
+ selectedSceneId,
210
+ selectedShotId,
211
+ phase,
212
+ onSelectScene: (id) => { setSelectedSceneId(id); setSelectedShotId(null); },
213
+ onSelectShot: setSelectedShotId,
214
+ onBack: () => { setSelectedShotId(null); setMainView("projects"); },
215
+ onRefresh: loadProject,
216
+ onAddScene: addScene,
217
+ } : undefined}
218
  />
219
  )}
220
 
221
  <main className="flex-1 min-w-0 overflow-y-auto bg-gradient-to-b from-transparent via-transparent to-gray-950/30">
222
+ {mainView === "character" && selectedCharacterId && (
223
+ <CharacterProfileView
224
+ characterId={selectedCharacterId}
225
+ items={items}
226
+ onOpen={setSelected}
227
+ onDelete={deleteItem}
228
+ onGenerate={() => setActiveSidebarTab("generate")}
229
+ />
230
+ )}
231
+
232
+ {mainView === "projects" && (
233
+ <ProjectsView
234
+ onOpenProject={(id) => { setSelectedProjectId(id); setMainView("project-detail"); setActiveSidebarTab("projects"); setSidebarOpen(true); }}
235
+ />
236
+ )}
237
+ {mainView === "project-detail" && projectData && (
238
+ <ProjectDetailView
239
+ project={projectData.project}
240
+ scenes={projectData.scenes}
241
+ shots={projectData.shots}
242
+ phase={phase}
243
+ selectedSceneId={selectedSceneId}
244
+ selectedShotId={selectedShotId}
245
+ onSelectScene={(id) => { setSelectedSceneId(id); setSelectedShotId(null); }}
246
+ onSelectShot={setSelectedShotId}
247
+ onRefresh={loadProject}
248
+ onBack={() => { setSelectedShotId(null); setMainView("projects"); }}
249
+ />
250
+ )}
251
+ {mainView === "studio" && (
252
+ <StudioView
253
+ items={items}
254
+ jobs={jobs}
255
+ loading={loading && !hasLoadedOnce && !hasContent}
256
+ error={error}
257
+ onOpen={setSelected}
258
+ onDelete={deleteItem}
259
+ onOpenProjects={() => setMainView("projects")}
260
+ />
261
  )}
262
  </main>
263
  </div>
studio/src/components/CharacterAssetsPanel.tsx DELETED
@@ -1,299 +0,0 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import {
3
- Terminal, Cpu, Sparkles, Upload,
4
- Download, Clock, HardDrive, Hash
5
- } from "lucide-react";
6
- import type { LoraCheckpoint, LoraTrainingStatus } from "../types";
7
-
8
- interface CharacterRecord {
9
- id: string;
10
- name: string;
11
- trigger: string | null;
12
- source_images: string[];
13
- loras: Record<string, unknown>[];
14
- }
15
-
16
- interface Props {
17
- characterId: string;
18
- training: LoraTrainingStatus | null;
19
- checkpoints: LoraCheckpoint[];
20
- }
21
-
22
- async function fetchJson<T>(url: string): Promise<T> {
23
- const response = await fetch(url);
24
- if (!response.ok) throw new Error(`${url} returned ${response.status}`);
25
- return await response.json();
26
- }
27
-
28
- function formatBytes(bytes: number): string {
29
- if (!bytes || bytes === 0) return "--";
30
- if (bytes > 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
31
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
32
- }
33
-
34
- function formatDate(dateStr: string): string {
35
- try {
36
- return new Date(dateStr).toLocaleString();
37
- } catch {
38
- return dateStr;
39
- }
40
- }
41
-
42
- export function CharacterAssetsPanel({ characterId, training, checkpoints }: Props) {
43
- const [character, setCharacter] = useState<CharacterRecord | null>(null);
44
- const [loading, setLoading] = useState(true);
45
-
46
- const load = useCallback(async () => {
47
- try {
48
- const data = await fetchJson<{ character: CharacterRecord }>(`/api/characters/${characterId}`);
49
- setCharacter(data.character || data);
50
- } catch {
51
- // silent — identity panel handles errors
52
- } finally {
53
- setLoading(false);
54
- }
55
- }, [characterId]);
56
-
57
- useEffect(() => { load(); }, [load]);
58
-
59
- if (loading || !character) return null;
60
-
61
- const apiRoot = typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:8190";
62
-
63
- return (
64
- <div className="space-y-5">
65
- {/* Section header */}
66
- <div className="flex items-center gap-2.5">
67
- <div className="w-8 h-8 rounded-xl bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center">
68
- <HardDrive className="w-4 h-4 text-gray-400" />
69
- </div>
70
- <div>
71
- <h2 className="text-sm font-bold text-gray-200 tracking-tight">Models & Training</h2>
72
- <p className="text-[11px] text-gray-500">LoRA files, checkpoints, training history, and how to train more.</p>
73
- </div>
74
- </div>
75
-
76
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
77
- {/* Left column: LoRA files + Checkpoints */}
78
- <div className="space-y-5">
79
- {/* LoRA Files */}
80
- <div className="rounded-2xl border border-gray-800/40 bg-gray-900/20 overflow-hidden">
81
- <div className="px-5 py-3 border-b border-gray-800/30 flex items-center gap-2">
82
- <Cpu className="w-4 h-4 text-emerald-400" />
83
- <h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider">LoRA Model Files</h3>
84
- </div>
85
- <div className="p-4 space-y-2">
86
- {character.loras.length > 0 ? (
87
- character.loras.map((lora, i) => {
88
- const name = String(lora.name || lora);
89
- const filename = name.split("/").pop() || name;
90
- return (
91
- <a
92
- key={i}
93
- href={`/media/${name}`}
94
- download={filename}
95
- className="flex items-center justify-between rounded-xl border border-gray-800/40 bg-gray-900/40 hover:bg-gray-800/40 hover:border-emerald-700/40 px-4 py-3 transition-all group"
96
- >
97
- <div className="flex items-center gap-3 min-w-0">
98
- <div className="w-8 h-8 rounded-lg bg-emerald-950/40 border border-emerald-800/30 flex items-center justify-center flex-shrink-0 group-hover:bg-emerald-900/40 transition">
99
- <Download className="w-4 h-4 text-emerald-400" />
100
- </div>
101
- <div className="min-w-0">
102
- <p className="text-xs font-mono text-emerald-300/80 truncate">{filename}</p>
103
- <p className="text-[10px] text-gray-600 mt-0.5 truncate">{name}</p>
104
- </div>
105
- </div>
106
- <span className="text-[10px] text-gray-600 group-hover:text-emerald-400 transition flex-shrink-0 ml-3">
107
- Click to download
108
- </span>
109
- </a>
110
- );
111
- })
112
- ) : (
113
- <div className="text-center py-4">
114
- <Cpu className="w-6 h-6 text-gray-700 mx-auto mb-2" />
115
- <p className="text-xs text-gray-600">No LoRA files bound to this character yet.</p>
116
- </div>
117
- )}
118
- </div>
119
- </div>
120
-
121
- {/* Checkpoints */}
122
- <div className="rounded-2xl border border-gray-800/40 bg-gray-900/20 overflow-hidden">
123
- <div className="px-5 py-3 border-b border-gray-800/30 flex items-center justify-between">
124
- <div className="flex items-center gap-2">
125
- <Sparkles className="w-4 h-4 text-purple-400" />
126
- <h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider">Checkpoints</h3>
127
- </div>
128
- {checkpoints.length > 0 && (
129
- <span className="text-[10px] text-gray-600">{checkpoints.length} saved</span>
130
- )}
131
- </div>
132
- <div className="p-4">
133
- {checkpoints.length > 0 ? (
134
- <div className="space-y-2">
135
- {checkpoints.map((cp, i) => {
136
- const filename = cp.name || cp.filename || `checkpoint-${i}`;
137
- return (
138
- <a
139
- key={i}
140
- href={`/media/${cp.path || cp.name || cp.filename}`}
141
- download={filename.split("/").pop() || filename}
142
- className="flex items-center justify-between rounded-xl border border-gray-800/40 bg-gray-900/30 hover:bg-gray-800/40 hover:border-purple-700/30 px-4 py-3 transition-all group"
143
- >
144
- <div className="flex items-center gap-3 min-w-0">
145
- <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-purple-950/30 border border-purple-800/20 flex items-center justify-center">
146
- <Download className="w-3.5 h-3.5 text-purple-400/70 group-hover:text-purple-400 transition" />
147
- </div>
148
- <div className="min-w-0">
149
- <p className="text-xs font-mono text-purple-300/70 truncate">{filename.split("/").pop() || filename}</p>
150
- <div className="flex items-center gap-3 text-[10px] text-gray-600 mt-0.5">
151
- {cp.step != null && cp.step > 0 && (
152
- <span className="flex items-center gap-1">
153
- <Hash className="w-2.5 h-2.5" />
154
- Step {cp.step.toLocaleString()}
155
- </span>
156
- )}
157
- {cp.size_bytes > 0 && (
158
- <span className="flex items-center gap-1">
159
- <HardDrive className="w-2.5 h-2.5" />
160
- {formatBytes(cp.size_bytes)}
161
- </span>
162
- )}
163
- {cp.modified_at && (
164
- <span className="flex items-center gap-1">
165
- <Clock className="w-2.5 h-2.5" />
166
- {formatDate(cp.modified_at)}
167
- </span>
168
- )}
169
- </div>
170
- </div>
171
- </div>
172
- <span className="text-[10px] text-gray-600 group-hover:text-purple-400 transition flex-shrink-0 ml-3">
173
- Download
174
- </span>
175
- </a>
176
- );
177
- })}
178
- </div>
179
- ) : (
180
- <div className="text-center py-4">
181
- <Sparkles className="w-6 h-6 text-gray-700 mx-auto mb-2" />
182
- <p className="text-xs text-gray-600">No checkpoints saved yet.</p>
183
- <p className="text-[10px] text-gray-700 mt-1">Checkpoints appear here after LoRA training completes.</p>
184
- </div>
185
- )}
186
- </div>
187
- </div>
188
- </div>
189
-
190
- {/* Right column: Training + Instructions */}
191
- <div className="space-y-5">
192
- {/* Training Status */}
193
- <div className="rounded-2xl border border-gray-800/40 bg-gray-900/20 overflow-hidden">
194
- <div className="px-5 py-3 border-b border-gray-800/30 flex items-center gap-2">
195
- <Upload className="w-4 h-4 text-amber-400" />
196
- <h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider">Training Status</h3>
197
- </div>
198
- <div className="p-4">
199
- {training?.running ? (
200
- <div className="space-y-3">
201
- <div className="flex items-center gap-2">
202
- <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
203
- <span className="text-sm font-medium text-amber-400">Training in progress</span>
204
- </div>
205
- {training.job_name && <p className="text-xs text-gray-400">{training.job_name}</p>}
206
- {training.progress_percent != null && (
207
- <div className="space-y-1">
208
- <div className="flex justify-between text-[11px] text-gray-500">
209
- <span>{training.progress_percent.toFixed(1)}%</span>
210
- {training.current_step && training.total_steps && (
211
- <span>Step {training.current_step} / {training.total_steps}</span>
212
- )}
213
- </div>
214
- <div className="h-1.5 rounded-full bg-gray-800 overflow-hidden">
215
- <div className="h-full bg-gradient-to-r from-amber-500 to-amber-300 rounded-full transition-all"
216
- style={{ width: `${Math.min(100, training.progress_percent)}%` }} />
217
- </div>
218
- </div>
219
- )}
220
- {training.loss != null && (
221
- <div className="grid grid-cols-3 gap-2 text-xs">
222
- <div className="rounded-lg bg-gray-800/40 p-2">
223
- <p className="text-[10px] text-gray-500">Loss</p>
224
- <p className="font-mono text-gray-200">{training.loss.toFixed(4)}</p>
225
- </div>
226
- <div className="rounded-lg bg-gray-800/40 p-2">
227
- <p className="text-[10px] text-gray-500">Step Time</p>
228
- <p className="font-mono text-gray-200">{training.seconds_per_step?.toFixed(2) || "--"}s</p>
229
- </div>
230
- <div className="rounded-lg bg-gray-800/40 p-2">
231
- <p className="text-[10px] text-gray-500">LR</p>
232
- <p className="font-mono text-gray-200">{training.lr?.toExponential(1) || "--"}</p>
233
- </div>
234
- </div>
235
- )}
236
- </div>
237
- ) : (
238
- <div className="text-center py-3">
239
- <p className="text-xs text-gray-500">No training job active.</p>
240
- <p className="text-[10px] text-gray-600 mt-1">Start a new fine-tune with the instructions below.</p>
241
- </div>
242
- )}
243
- </div>
244
- </div>
245
-
246
- {/* How to train a new LoRA */}
247
- <div className="rounded-2xl border border-gray-800/40 bg-gray-900/20 overflow-hidden">
248
- <div className="px-5 py-3 border-b border-gray-800/30 flex items-center gap-2">
249
- <Terminal className="w-4 h-4 text-rose-400" />
250
- <h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider">Train a New LoRA</h3>
251
- </div>
252
- <div className="p-4 space-y-3">
253
- <p className="text-[11px] text-gray-400 leading-relaxed">
254
- Use the LoRA training API to fine-tune a new model for {character.name}. You can add new source images first, then trigger training.
255
- </p>
256
-
257
- <div className="space-y-2">
258
- <p className="text-[10px] font-medium text-gray-500">Add source images to this character:</p>
259
- <pre className="text-[11px] bg-black/40 text-gray-300 rounded-xl p-2.5 overflow-x-auto leading-relaxed whitespace-pre-wrap border border-gray-800/60 font-mono">{`curl -X PATCH ${apiRoot}/api/characters/${character.id} \\
260
- -H "Content-Type: application/json" \\
261
- -d '{"source_images":["path/to/new/image.png", ...]}'`}</pre>
262
- </div>
263
-
264
- <div className="space-y-2">
265
- <p className="text-[10px] font-medium text-gray-500">Start LoRA fine-tuning:</p>
266
- <pre className="text-[11px] bg-black/40 text-gray-300 rounded-xl p-2.5 overflow-x-auto leading-relaxed whitespace-pre-wrap border border-gray-800/60 font-mono">{`curl -X POST ${apiRoot}/api/lora-training/start \\
267
- -H "Content-Type: application/json" \\
268
- -d '{"character_id":"${character.id}","base_model":"flux2","steps":1800}'`}</pre>
269
- </div>
270
- </div>
271
- </div>
272
-
273
- {/* Agent instruction */}
274
- <div className="rounded-2xl border border-rose-600/20 bg-gradient-to-b from-rose-950/10 to-gray-900/20 overflow-hidden">
275
- <div className="px-5 py-3 border-b border-rose-800/20 flex items-center gap-2">
276
- <Terminal className="w-4 h-4 text-rose-400" />
277
- <h3 className="text-xs font-semibold text-rose-300 uppercase tracking-wider">What to tell your agent</h3>
278
- </div>
279
- <div className="p-4 space-y-2">
280
- <div className="rounded-xl border border-gray-700/40 bg-gray-900/40 p-3">
281
- <p className="text-sm text-gray-300 leading-relaxed italic">
282
- "Generate some new photos of {character.name}.{' '}
283
- {character.trigger ? `Use the ${character.trigger} look. ` : ''}
284
- I want cinematic portraits — dramatic lighting, clean background, sharp focus."
285
- </p>
286
- </div>
287
- <ul className="text-[11px] text-gray-500 space-y-1">
288
- <li>• "Make more like this one, but change the outfit"</li>
289
- <li>• "Retrain {character.name}'s model with these new photos"</li>
290
- <li>• "Create a video project starring {character.name}"</li>
291
- <li>• "Show me what {character.name} would look like in a cyberpunk setting"</li>
292
- </ul>
293
- </div>
294
- </div>
295
- </div>
296
- </div>
297
- </div>
298
- );
299
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
studio/src/components/CharacterDetail.tsx DELETED
@@ -1,300 +0,0 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import {
3
- ArrowLeft, Save, Terminal, Cpu, Sparkles, Wand2,
4
- Upload, Image, Box
5
- } from "lucide-react";
6
- import type { LoraCheckpoint } from "../types";
7
-
8
- interface CharacterRecord {
9
- id: string;
10
- name: string;
11
- trigger: string | null;
12
- description: string | null;
13
- source_images: string[];
14
- loras: Record<string, unknown>[];
15
- defaults: Record<string, unknown>;
16
- metadata: Record<string, unknown>;
17
- updated_at: string;
18
- }
19
-
20
- interface LoraTrainingStatus {
21
- ok: boolean;
22
- message?: string;
23
- running?: boolean;
24
- job_id?: string;
25
- last_run?: string;
26
- }
27
-
28
- interface Props {
29
- characterId: string;
30
- onBack: () => void;
31
- }
32
-
33
- async function fetchJson<T>(url: string): Promise<T> {
34
- const response = await fetch(url);
35
- if (!response.ok) throw new Error(`${url} returned ${response.status}`);
36
- return await response.json();
37
- }
38
-
39
- export function CharacterDetail({ characterId, onBack }: Props) {
40
- const [character, setCharacter] = useState<CharacterRecord | null>(null);
41
- const [loading, setLoading] = useState(true);
42
- const [error, setError] = useState<string | null>(null);
43
- const [saving, setSaving] = useState(false);
44
- const [saved, setSaved] = useState(false);
45
- const [training, setTraining] = useState<LoraTrainingStatus | null>(null);
46
- const [checkpoints, setCheckpoints] = useState<LoraCheckpoint[]>([]);
47
-
48
- // Editable fields
49
- const [editName, setEditName] = useState("");
50
- const [editTrigger, setEditTrigger] = useState("");
51
- const [editDescription, setEditDescription] = useState("");
52
-
53
- const load = useCallback(async () => {
54
- try {
55
- setError(null);
56
- const data = await fetchJson<{ character: CharacterRecord }>(`/api/characters/${characterId}`);
57
- const ch = data.character || data;
58
- setCharacter(ch);
59
- setEditName(ch.name || "");
60
- setEditTrigger(ch.trigger || "");
61
- setEditDescription(ch.description || "");
62
- } catch (e) {
63
- setError(e instanceof Error ? e.message : "Failed to load character");
64
- } finally {
65
- setLoading(false);
66
- }
67
-
68
- const [trainingResult, checkpointsResult] = await Promise.allSettled([
69
- fetchJson<LoraTrainingStatus & { ok?: boolean }>("/api/lora-training/status"),
70
- fetchJson<{ checkpoints?: LoraCheckpoint[] }>("/api/lora-training/checkpoints"),
71
- ]);
72
-
73
- if (trainingResult.status === "fulfilled") setTraining(trainingResult.value.ok ? trainingResult.value : null);
74
- if (checkpointsResult.status === "fulfilled") setCheckpoints(checkpointsResult.value.checkpoints || []);
75
- }, [characterId]);
76
-
77
- useEffect(() => {
78
- load();
79
- const id = window.setInterval(load, 10000);
80
- return () => window.clearInterval(id);
81
- }, [load]);
82
-
83
- const handleSave = async () => {
84
- setSaving(true);
85
- setSaved(false);
86
- try {
87
- const response = await fetch(`/api/characters/${characterId}`, {
88
- method: "PATCH",
89
- headers: { "Content-Type": "application/json" },
90
- body: JSON.stringify({
91
- name: editName,
92
- trigger: editTrigger || null,
93
- description: editDescription || null,
94
- }),
95
- });
96
- if (!response.ok) throw new Error("Save failed");
97
- const data = await response.json();
98
- setCharacter(data);
99
- setSaved(true);
100
- setTimeout(() => setSaved(false), 2000);
101
- } catch (e) {
102
- setError(e instanceof Error ? e.message : "Save failed");
103
- } finally {
104
- setSaving(false);
105
- }
106
- };
107
-
108
- if (loading) return <p className="text-gray-500 p-4">Loading...</p>;
109
- if (error) return <div className="rounded-lg border border-red-900/60 bg-red-950/20 p-4 text-sm text-red-300">{error}</div>;
110
- if (!character) return <p className="text-gray-500 p-4">Character not found.</p>;
111
-
112
- return (
113
- <div className="max-w-3xl mx-auto p-6 space-y-6">
114
- {/* Back button */}
115
- <button
116
- onClick={onBack}
117
- className="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition"
118
- >
119
- <ArrowLeft className="w-4 h-4" />
120
- Back to gallery
121
- </button>
122
-
123
- {/* Header + AMD badge */}
124
- <div className="flex items-center justify-between">
125
- <div>
126
- <h1 className="text-xl font-bold">{character.name}</h1>
127
- <p className="text-xs text-gray-500 mt-0.5 font-mono">{character.id}</p>
128
- </div>
129
- <span className="text-[10px] uppercase tracking-wider text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded-full px-2.5 py-1">
130
- AMD MI300X
131
- </span>
132
- </div>
133
-
134
- {/* Editable fields */}
135
- <section className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 space-y-4">
136
- <h2 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
137
- <Box className="w-4 h-4 text-rose-400" />
138
- Character Details
139
- </h2>
140
-
141
- <div className="space-y-3">
142
- <div>
143
- <label className="text-[11px] text-gray-500 block mb-1">Name</label>
144
- <input
145
- type="text"
146
- value={editName}
147
- onChange={(e) => setEditName(e.target.value)}
148
- className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-rose-500 transition"
149
- />
150
- </div>
151
- <div>
152
- <label className="text-[11px] text-gray-500 block mb-1">Trigger word</label>
153
- <input
154
- type="text"
155
- value={editTrigger}
156
- onChange={(e) => setEditTrigger(e.target.value)}
157
- className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-rose-500 transition font-mono"
158
- placeholder="e.g. Rigo"
159
- />
160
- <p className="text-[10px] text-gray-600 mt-1">The keyword your agent includes in prompts to activate this character's LoRA.</p>
161
- </div>
162
- <div>
163
- <label className="text-[11px] text-gray-500 block mb-1">Description</label>
164
- <textarea
165
- value={editDescription}
166
- onChange={(e) => setEditDescription(e.target.value)}
167
- rows={3}
168
- className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-rose-500 transition resize-none"
169
- placeholder="What your agent should know about this character..."
170
- />
171
- </div>
172
- </div>
173
-
174
- <div className="flex items-center gap-3">
175
- <button
176
- onClick={handleSave}
177
- disabled={saving}
178
- className="flex items-center gap-2 rounded-lg bg-rose-600 hover:bg-rose-500 px-4 py-2 text-sm font-semibold transition disabled:opacity-50"
179
- >
180
- <Save className="w-4 h-4" />
181
- {saving ? "Saving..." : saved ? "Saved" : "Save changes"}
182
- </button>
183
- {saved && <span className="text-xs text-emerald-400">✓ Saved</span>}
184
- </div>
185
- </section>
186
-
187
- {/* LoRAs */}
188
- {character.loras.length > 0 && (
189
- <section className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 space-y-3">
190
- <h2 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
191
- <Cpu className="w-4 h-4 text-emerald-400" />
192
- LoRAs
193
- </h2>
194
- <div className="space-y-1.5">
195
- {character.loras.map((lora, i) => (
196
- <div key={i} className="text-xs font-mono text-emerald-300/80 bg-gray-800/60 rounded-lg px-3 py-2">
197
- {String(lora.name || lora)}
198
- </div>
199
- ))}
200
- </div>
201
- </section>
202
- )}
203
-
204
- {/* Source images */}
205
- {character.source_images.length > 0 && (
206
- <section className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 space-y-3">
207
- <h2 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
208
- <Image className="w-4 h-4 text-blue-400" />
209
- Source Images ({character.source_images.length})
210
- </h2>
211
- <div className="grid grid-cols-3 gap-2">
212
- {character.source_images.map((img, i) => (
213
- <div key={i} className="aspect-square rounded-lg bg-gray-800 overflow-hidden border border-gray-700">
214
- <img
215
- src={img.startsWith("/") ? img : `/media/${img}`}
216
- alt=""
217
- className="w-full h-full object-cover"
218
- onError={(e) => {
219
- (e.target as HTMLImageElement).style.display = "none";
220
- }}
221
- />
222
- </div>
223
- ))}
224
- </div>
225
- </section>
226
- )}
227
-
228
- {/* LoRA Training */}
229
- <section className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 space-y-4">
230
- <h2 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
231
- <Upload className="w-4 h-4 text-amber-400" />
232
- LoRA Training
233
- </h2>
234
- {training ? (
235
- <div className="space-y-2">
236
- <p className="text-xs text-gray-400">
237
- Status:{" "}
238
- <span className={training.running ? "text-amber-400" : "text-gray-500"}>
239
- {training.running ? "Running" : "Idle"}
240
- </span>
241
- </p>
242
- {training.message && <p className="text-xs text-gray-500">{training.message}</p>}
243
- {training.last_run && <p className="text-[10px] text-gray-600">Last run: {training.last_run}</p>}
244
- </div>
245
- ) : (
246
- <p className="text-xs text-gray-500">No training status available.</p>
247
- )}
248
- <p className="text-[10px] text-gray-600">
249
- Tell your agent: "Start a LoRA fine-tune for {character.name} with the latest source images."
250
- </p>
251
- </section>
252
-
253
- {/* LoRA Checkpoints */}
254
- <section className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 space-y-3">
255
- <h2 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
256
- <Sparkles className="w-4 h-4 text-purple-400" />
257
- LoRA Checkpoints
258
- </h2>
259
- {checkpoints.length > 0 ? (
260
- <div className="space-y-2">
261
- {checkpoints.map((cp, i) => (
262
- <div key={i} className="rounded-lg border border-gray-700 bg-gray-800/60 p-3 flex items-center justify-between">
263
- <div>
264
- <p className="text-xs font-medium text-gray-300 font-mono">{cp.name || cp.filename || `Checkpoint ${i + 1}`}</p>
265
- {cp.created_at && <p className="text-[10px] text-gray-500">{cp.created_at}</p>}
266
- </div>
267
- {cp.step && <span className="text-[10px] text-gray-600">Step {cp.step}</span>}
268
- </div>
269
- ))}
270
- </div>
271
- ) : (
272
- <p className="text-xs text-gray-500">No checkpoints yet.</p>
273
- )}
274
- </section>
275
-
276
- {/* Agent instruction */}
277
- <section className="rounded-xl border border-rose-600/30 bg-gradient-to-b from-rose-950/20 to-gray-950 p-5 space-y-3">
278
- <h2 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
279
- <Terminal className="w-4 h-4 text-rose-400" />
280
- Tell your AI agent
281
- </h2>
282
- <div className="rounded-lg border border-gray-700/60 bg-gray-900/60 p-3">
283
- <p className="text-xs text-gray-300 leading-relaxed">
284
- "Generate an image of {character.name}{character.trigger ? ` using the ${character.trigger} trigger` : ''}.{' '}
285
- {character.description ? `${character.description}` : ''}"
286
- </p>
287
- </div>
288
- <div className="space-y-1.5">
289
- <p className="text-[10px] text-gray-500">Your agent can also:</p>
290
- <ul className="text-[10px] text-gray-500 space-y-1">
291
- <li>• Update character details — "Change {character.name}'s trigger word to ___"</li>
292
- <li>• Start a new LoRA fine-tune — "Retrain {character.name}'s LoRA with new images"</li>
293
- <li>• Use this character in projects — "Create a project with {character.name} as the main character"</li>
294
- <li>• Generate media — "Generate {character.name} doing ___" or "Animate {character.name} ___"</li>
295
- </ul>
296
- </div>
297
- </section>
298
- </div>
299
- );
300
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
studio/src/components/CharacterIdentityPanel.tsx DELETED
@@ -1,190 +0,0 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { Save, Terminal, Pencil, X } from "lucide-react";
3
-
4
- interface CharacterRecord {
5
- id: string;
6
- name: string;
7
- kind: string | null;
8
- trigger: string | null;
9
- description: string | null;
10
- source_images: string[];
11
- loras: Record<string, unknown>[];
12
- defaults: Record<string, unknown>;
13
- metadata: Record<string, unknown>;
14
- updated_at: string;
15
- }
16
-
17
- interface Props {
18
- characterId: string;
19
- }
20
-
21
- async function fetchJson<T>(url: string): Promise<T> {
22
- const response = await fetch(url);
23
- if (!response.ok) throw new Error(`${url} returned ${response.status}`);
24
- return await response.json();
25
- }
26
-
27
- function resolveImageUrl(img: string): string {
28
- return img.startsWith("/") ? img : `/media/${img}`;
29
- }
30
-
31
- export function CharacterIdentityPanel({ characterId }: Props) {
32
- const [character, setCharacter] = useState<CharacterRecord | null>(null);
33
- const [loading, setLoading] = useState(true);
34
- const [error, setError] = useState<string | null>(null);
35
- const [editing, setEditing] = useState(false);
36
- const [saving, setSaving] = useState(false);
37
-
38
- const [editName, setEditName] = useState("");
39
- const [editTrigger, setEditTrigger] = useState("");
40
- const [editDescription, setEditDescription] = useState("");
41
-
42
- const load = useCallback(async () => {
43
- try {
44
- setError(null);
45
- const data = await fetchJson<{ character: CharacterRecord }>(`/api/characters/${characterId}`);
46
- const ch = data.character || data;
47
- setCharacter(ch);
48
- setEditName(ch.name || "");
49
- setEditTrigger(ch.trigger || "");
50
- setEditDescription(ch.description || "");
51
- } catch (e) {
52
- setError(e instanceof Error ? e.message : "Failed to load character");
53
- } finally {
54
- setLoading(false);
55
- }
56
- }, [characterId]);
57
-
58
- useEffect(() => { load(); }, [load]);
59
-
60
- const handleSave = async () => {
61
- setSaving(true);
62
- try {
63
- const response = await fetch(`/api/characters/${characterId}`, {
64
- method: "PATCH",
65
- headers: { "Content-Type": "application/json" },
66
- body: JSON.stringify({
67
- name: editName,
68
- trigger: editTrigger || null,
69
- description: editDescription || null,
70
- }),
71
- });
72
- if (!response.ok) throw new Error("Save failed");
73
- const data = await response.json();
74
- setCharacter(data);
75
- setEditing(false);
76
- } catch (e) {
77
- setError(e instanceof Error ? e.message : "Save failed");
78
- } finally {
79
- setSaving(false);
80
- }
81
- };
82
-
83
- const cancelEdit = () => {
84
- if (!character) return;
85
- setEditName(character.name || "");
86
- setEditTrigger(character.trigger || "");
87
- setEditDescription(character.description || "");
88
- setEditing(false);
89
- };
90
-
91
- if (loading) {
92
- return (
93
- <div className="rounded-2xl border border-gray-800/40 bg-gray-900/20 p-5 animate-pulse">
94
- <div className="flex items-center gap-4">
95
- <div className="w-14 h-14 rounded-2xl bg-gray-800" />
96
- <div className="space-y-2">
97
- <div className="h-5 w-32 bg-gray-800 rounded-lg" />
98
- <div className="h-3 w-48 bg-gray-800 rounded-lg" />
99
- </div>
100
- </div>
101
- </div>
102
- );
103
- }
104
-
105
- if (error) {
106
- return <div className="rounded-2xl border border-red-900/30 bg-red-950/10 p-4 text-sm text-red-400">{error}</div>;
107
- }
108
-
109
- if (!character) return null;
110
-
111
- const avatarUrl = character.source_images.length > 0 ? resolveImageUrl(character.source_images[0]) : null;
112
-
113
- return (
114
- <div className="rounded-2xl border border-gray-800/40 bg-gradient-to-b from-gray-900/40 to-gray-900/10 overflow-hidden">
115
- <div className="p-5">
116
- <div className="flex items-start gap-4">
117
- {/* Avatar */}
118
- <div className="w-14 h-14 rounded-2xl overflow-hidden flex-shrink-0 ring-1 ring-white/10 shadow-xl shadow-black/30">
119
- {avatarUrl ? (
120
- <img src={avatarUrl} alt={character.name} className="w-full h-full object-cover" />
121
- ) : (
122
- <div className="w-full h-full bg-gradient-to-br from-rose-500 via-fuchsia-500 to-amber-400 flex items-center justify-center">
123
- <span className="text-lg font-bold text-white">{character.name.charAt(0).toUpperCase()}</span>
124
- </div>
125
- )}
126
- </div>
127
-
128
- {/* Identity */}
129
- <div className="flex-1 min-w-0">
130
- {editing ? (
131
- <div className="space-y-2.5">
132
- <input type="text" value={editName} onChange={(e) => setEditName(e.target.value)}
133
- className="block w-full bg-gray-800/80 border border-gray-700 rounded-xl px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-rose-500 transition placeholder:text-gray-600"
134
- placeholder="Character name" autoFocus />
135
- <div className="flex gap-2">
136
- <input type="text" value={editTrigger} onChange={(e) => setEditTrigger(e.target.value)}
137
- className="flex-1 bg-gray-800/80 border border-gray-700 rounded-xl px-3 py-2 text-xs text-gray-100 font-mono focus:outline-none focus:border-rose-500 transition placeholder:text-gray-600"
138
- placeholder="Trigger word (e.g. Rigo)" />
139
- <button onClick={handleSave} disabled={saving}
140
- className="flex items-center gap-1.5 rounded-xl bg-rose-600 hover:bg-rose-500 px-4 py-2 text-xs font-semibold transition disabled:opacity-50 shadow-lg shadow-rose-900/20">
141
- <Save className="w-3 h-3" />{saving ? "Saving..." : "Save"}
142
- </button>
143
- <button onClick={cancelEdit}
144
- className="flex items-center gap-1.5 rounded-xl border border-gray-700 hover:border-gray-500 px-3 py-2 text-xs text-gray-400 hover:text-gray-200 transition">
145
- <X className="w-3 h-3" />
146
- </button>
147
- </div>
148
- <textarea value={editDescription} onChange={(e) => setEditDescription(e.target.value)} rows={2}
149
- className="block w-full bg-gray-800/80 border border-gray-700 rounded-xl px-3 py-2 text-xs text-gray-100 focus:outline-none focus:border-rose-500 transition resize-none placeholder:text-gray-600"
150
- placeholder="What your agent should know about this character..." />
151
- </div>
152
- ) : (
153
- <div>
154
- <div className="flex items-center gap-2 flex-wrap">
155
- <h2 className="text-lg font-bold text-gray-100 tracking-tight">{character.name}</h2>
156
- {character.kind === "human" && (
157
- <span className="text-[10px] uppercase tracking-wider text-blue-300/80 border border-blue-500/30 bg-blue-500/10 rounded-full px-2 py-0.5">Human</span>
158
- )}
159
- {character.kind === "agent" && (
160
- <span className="text-[10px] uppercase tracking-wider text-violet-300/80 border border-violet-500/30 bg-violet-500/10 rounded-full px-2 py-0.5">Agent</span>
161
- )}
162
- <button onClick={() => setEditing(true)}
163
- className="p-1.5 rounded-lg text-gray-600 hover:text-gray-300 hover:bg-gray-800 transition" title="Edit">
164
- <Pencil className="w-3.5 h-3.5" />
165
- </button>
166
-
167
- </div>
168
-
169
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs">
170
- {character.trigger && (
171
- <span className="inline-flex items-center gap-1">
172
- <span className="text-gray-500">trigger</span>
173
- <code className="text-rose-300/80 bg-rose-950/30 border border-rose-800/20 rounded-md px-2 py-0.5 text-[11px] font-medium">{character.trigger}</code>
174
- </span>
175
- )}
176
- <span className="text-gray-600 font-mono text-[11px]">{character.id}</span>
177
- <span className="text-emerald-400/60">{character.loras.length} LoRA{character.loras.length !== 1 ? "s" : ""}</span>
178
- </div>
179
-
180
- {character.description && (
181
- <p className="text-xs text-gray-500 mt-2 leading-relaxed line-clamp-2">{character.description}</p>
182
- )}
183
- </div>
184
- )}
185
- </div>
186
- </div>
187
- </div>
188
- </div>
189
- );
190
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
studio/src/components/CharacterProfileView.tsx ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Edit3, Film, Image, Sparkles, UserRound } from "lucide-react";
3
+ import { MediaTile } from "./MediaTile";
4
+ import type { MediaItem } from "../types";
5
+
6
+ interface CharacterRecord {
7
+ id: string;
8
+ name: string;
9
+ kind: string | null;
10
+ trigger: string | null;
11
+ description: string | null;
12
+ source_images: string[];
13
+ loras: Record<string, unknown>[];
14
+ defaults: Record<string, unknown>;
15
+ }
16
+
17
+ interface CharacterProfileViewProps {
18
+ characterId: string;
19
+ items: MediaItem[];
20
+ onOpen: (url: string) => void;
21
+ onDelete: (item: MediaItem) => Promise<void> | void;
22
+ onGenerate: () => void;
23
+ }
24
+
25
+ function resolveImageUrl(img: string): string {
26
+ return img.startsWith("/") ? img : `/media/${img}`;
27
+ }
28
+
29
+ function isVideo(item: MediaItem) {
30
+ return item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm");
31
+ }
32
+
33
+ async function fetchJson<T>(url: string): Promise<T> {
34
+ const response = await fetch(url);
35
+ if (!response.ok) throw new Error(`${url} returned ${response.status}`);
36
+ return await response.json();
37
+ }
38
+
39
+ export function CharacterProfileView({ characterId, items, onOpen, onDelete, onGenerate }: CharacterProfileViewProps) {
40
+ const [character, setCharacter] = useState<CharacterRecord | null>(null);
41
+ const [loading, setLoading] = useState(true);
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [tab, setTab] = useState<"images" | "videos">("images");
44
+
45
+ const load = useCallback(async () => {
46
+ try {
47
+ setError(null);
48
+ const data = await fetchJson<{ character: CharacterRecord }>(`/api/characters/${characterId}`);
49
+ setCharacter(data.character || data);
50
+ } catch (err) {
51
+ setError(err instanceof Error ? err.message : "Failed to load character");
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ }, [characterId]);
56
+
57
+ useEffect(() => { load(); }, [load]);
58
+
59
+ const related = useMemo(() => {
60
+ if (!character) return [];
61
+ const terms = [character.id, character.name, character.trigger].filter(Boolean).map((value) => String(value).toLowerCase());
62
+ return items.filter((item) => {
63
+ const haystack = [item.name, item.filename, item.prompt].join(" ").toLowerCase();
64
+ return terms.some((term) => haystack.includes(term));
65
+ });
66
+ }, [character, items]);
67
+
68
+ const fallbackItems = related.length > 0 ? related : items;
69
+ const images = fallbackItems.filter((item) => !isVideo(item));
70
+ const videos = fallbackItems.filter(isVideo);
71
+ const shown = tab === "images" ? images : videos;
72
+
73
+ if (loading) return <div className="p-6 text-gray-500">Loading character...</div>;
74
+ if (error) return <div className="p-6 text-red-400">{error}</div>;
75
+ if (!character) return null;
76
+
77
+ const avatarUrl = character.source_images[0] ? resolveImageUrl(character.source_images[0]) : null;
78
+ const loraCount = character.loras.length;
79
+
80
+ return (
81
+ <div className="p-5 lg:p-7 space-y-6">
82
+ <section className="rounded-3xl border border-gray-800/60 bg-gradient-to-b from-gray-900/70 to-gray-950/40 overflow-hidden">
83
+ <div className="relative h-44 bg-gradient-to-br from-rose-950/50 via-fuchsia-950/20 to-amber-950/20">
84
+ {avatarUrl && <img src={avatarUrl} alt="" className="absolute inset-0 w-full h-full object-cover opacity-35 blur-sm scale-105" />}
85
+ <div className="absolute inset-0 bg-gradient-to-t from-black via-black/40 to-transparent" />
86
+ </div>
87
+
88
+ <div className="px-5 lg:px-7 pb-6 -mt-16 relative">
89
+ <div className="flex flex-col lg:flex-row lg:items-end gap-5">
90
+ <div className="w-32 h-32 rounded-3xl overflow-hidden ring-4 ring-black bg-gray-900 shadow-2xl flex-shrink-0">
91
+ {avatarUrl ? (
92
+ <img src={avatarUrl} alt={character.name} className="w-full h-full object-cover" />
93
+ ) : (
94
+ <div className="w-full h-full bg-gradient-to-br from-rose-500 to-amber-400 flex items-center justify-center">
95
+ <UserRound className="w-12 h-12 text-white" />
96
+ </div>
97
+ )}
98
+ </div>
99
+
100
+ <div className="flex-1 min-w-0">
101
+ <div className="flex flex-wrap items-center gap-2 mb-2">
102
+ {character.kind && <span className="rounded-full border border-blue-500/30 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-wider text-blue-300">{character.kind}</span>}
103
+ {character.trigger && <span className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-[10px] uppercase tracking-wider text-rose-300">trigger: {character.trigger}</span>}
104
+ </div>
105
+ <h1 className="text-3xl font-bold tracking-tight">{character.name}</h1>
106
+ <p className="text-sm text-gray-500 mt-2 max-w-3xl">
107
+ {character.description || "Reusable character identity for agent-generated images and videos."}
108
+ </p>
109
+ </div>
110
+
111
+ <div className="flex gap-2">
112
+ <button onClick={onGenerate} className="rounded-xl bg-rose-600 hover:bg-rose-500 px-4 py-2 text-sm font-semibold transition flex items-center gap-2">
113
+ <Sparkles className="w-4 h-4" /> Generate
114
+ </button>
115
+ <button className="rounded-xl border border-gray-700 text-gray-400 px-4 py-2 text-sm font-semibold transition flex items-center gap-2 cursor-not-allowed opacity-60" title="Profile editing is coming next">
116
+ <Edit3 className="w-4 h-4" /> Edit
117
+ </button>
118
+ </div>
119
+ </div>
120
+
121
+ <div className="grid grid-cols-3 gap-3 mt-6 max-w-xl">
122
+ <div className="rounded-2xl border border-gray-800 bg-black/30 p-3">
123
+ <p className="text-xs text-gray-600">Images</p>
124
+ <p className="text-xl font-bold text-gray-100">{images.length}</p>
125
+ </div>
126
+ <div className="rounded-2xl border border-gray-800 bg-black/30 p-3">
127
+ <p className="text-xs text-gray-600">Videos</p>
128
+ <p className="text-xl font-bold text-gray-100">{videos.length}</p>
129
+ </div>
130
+ <div className="rounded-2xl border border-gray-800 bg-black/30 p-3">
131
+ <p className="text-xs text-gray-600">LoRAs</p>
132
+ <p className="text-xl font-bold text-gray-100">{loraCount}</p>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </section>
137
+
138
+ <section className="space-y-4">
139
+ <div className="flex items-center gap-2 border-b border-gray-800/60">
140
+ <button onClick={() => setTab("images")} className={`px-4 py-3 text-sm font-medium border-b-2 transition flex items-center gap-2 ${tab === "images" ? "text-white border-rose-500" : "text-gray-600 border-transparent hover:text-gray-300"}`}>
141
+ <Image className="w-4 h-4" /> Images
142
+ </button>
143
+ <button onClick={() => setTab("videos")} className={`px-4 py-3 text-sm font-medium border-b-2 transition flex items-center gap-2 ${tab === "videos" ? "text-white border-rose-500" : "text-gray-600 border-transparent hover:text-gray-300"}`}>
144
+ <Film className="w-4 h-4" /> Videos
145
+ </button>
146
+ </div>
147
+
148
+ {shown.length === 0 ? (
149
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/20 p-10 text-center text-sm text-gray-500">
150
+ No {tab} found for this character yet.
151
+ </div>
152
+ ) : (
153
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
154
+ {shown.map((item) => (
155
+ <MediaTile key={item.filename || item.url} item={item} onOpen={() => onOpen(item.url)} onDelete={onDelete} />
156
+ ))}
157
+ </div>
158
+ )}
159
+ </section>
160
+ </div>
161
+ );
162
+ }
studio/src/components/GalleryView.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState } from "react";
2
+ import { ArrowRight, Film, Image, Search, SlidersHorizontal, Video } from "lucide-react";
3
+ import { JobCard } from "./JobCard";
4
+ import { MediaTile } from "./MediaTile";
5
+ import type { JobItem, MediaItem } from "../types";
6
+
7
+ type Filter = "all" | "images" | "videos";
8
+
9
+ interface StudioViewProps {
10
+ items: MediaItem[];
11
+ jobs: JobItem[];
12
+ loading: boolean;
13
+ error: string | null;
14
+ onOpen: (url: string) => void;
15
+ onDelete: (item: MediaItem) => Promise<void> | void;
16
+ onOpenProjects?: () => void;
17
+ }
18
+
19
+ function isVideo(item: MediaItem) {
20
+ return item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm");
21
+ }
22
+
23
+ export function StudioView({ items, jobs, loading, error, onOpen, onDelete, onOpenProjects }: StudioViewProps) {
24
+ const [filter, setFilter] = useState<Filter>("all");
25
+ const [query, setQuery] = useState("");
26
+
27
+ const imageCount = items.filter((item) => !isVideo(item)).length;
28
+ const videoCount = items.length - imageCount;
29
+
30
+ const visibleItems = useMemo(() => {
31
+ const q = query.trim().toLowerCase();
32
+ return items.filter((item) => {
33
+ if (filter === "images" && isVideo(item)) return false;
34
+ if (filter === "videos" && !isVideo(item)) return false;
35
+ if (!q) return true;
36
+ return [item.name, item.filename, item.prompt].some((value) => String(value || "").toLowerCase().includes(q));
37
+ });
38
+ }, [filter, items, query]);
39
+
40
+ return (
41
+ <div className="p-5 lg:p-7 space-y-6">
42
+ <section className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
43
+ <div>
44
+ <p className="text-xs uppercase tracking-[0.22em] text-rose-400/70">Workspace</p>
45
+ <h1 className="text-2xl font-bold tracking-tight mt-1">Studio</h1>
46
+ <p className="text-sm text-gray-500 mt-2">Your gallery for quick image and video generation — freeform, no structure. Generate, browse, iterate.</p>
47
+ </div>
48
+ <div className="grid grid-cols-3 gap-2 text-xs min-w-[260px]">
49
+ <div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
50
+ <p className="text-gray-600">Total</p>
51
+ <p className="text-lg font-semibold text-gray-200">{items.length}</p>
52
+ </div>
53
+ <div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
54
+ <p className="text-gray-600">Images</p>
55
+ <p className="text-lg font-semibold text-gray-200">{imageCount}</p>
56
+ </div>
57
+ <div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
58
+ <p className="text-gray-600">Videos</p>
59
+ <p className="text-lg font-semibold text-gray-200">{videoCount}</p>
60
+ </div>
61
+ </div>
62
+ </section>
63
+
64
+ <button
65
+ onClick={onOpenProjects}
66
+ className="group w-full rounded-2xl border border-violet-600/30 bg-gradient-to-r from-violet-950/30 via-indigo-950/20 to-gray-900/30 hover:border-violet-500/50 hover:from-violet-950/50 transition flex items-center gap-4 px-5 py-4 text-left"
67
+ >
68
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center shadow-lg shadow-violet-500/20 ring-1 ring-white/10 flex-shrink-0">
69
+ <Film className="w-5 h-5 text-white" />
70
+ </div>
71
+ <div className="flex-1 min-w-0">
72
+ <p className="text-sm font-semibold text-violet-100">Got a specific idea? Turn it into a Project.</p>
73
+ <p className="text-xs text-gray-400 mt-1 leading-relaxed">
74
+ Studio is great for quick shots. <span className="text-gray-300">Projects</span> are for finished pieces — outline scenes, plan shots, generate, animate, and stitch together a real video.
75
+ </p>
76
+ </div>
77
+ <ArrowRight className="w-5 h-5 text-violet-400 group-hover:translate-x-1 transition flex-shrink-0" />
78
+ </button>
79
+
80
+ <section className="rounded-2xl border border-gray-800/60 bg-gray-950/50 p-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
81
+ <div className="flex gap-2">
82
+ {[
83
+ ["all", "All", SlidersHorizontal],
84
+ ["images", "Images", Image],
85
+ ["videos", "Videos", Video],
86
+ ].map(([id, label, Icon]) => (
87
+ <button
88
+ key={id as string}
89
+ onClick={() => setFilter(id as Filter)}
90
+ className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-xs font-medium transition ${
91
+ filter === id ? "bg-rose-600 text-white" : "bg-gray-900 text-gray-500 hover:text-gray-200"
92
+ }`}
93
+ >
94
+ <Icon className="w-3.5 h-3.5" />
95
+ {label as string}
96
+ </button>
97
+ ))}
98
+ </div>
99
+ <label className="relative w-full lg:w-80">
100
+ <Search className="w-4 h-4 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" />
101
+ <input
102
+ value={query}
103
+ onChange={(event) => setQuery(event.target.value)}
104
+ placeholder="Search studio"
105
+ className="w-full rounded-xl bg-black/40 border border-gray-800 pl-9 pr-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-rose-600 placeholder:text-gray-700"
106
+ />
107
+ </label>
108
+ </section>
109
+
110
+ {loading && items.length === 0 && jobs.length === 0 ? (
111
+ <p className="text-gray-500">Loading...</p>
112
+ ) : error ? (
113
+ <div className="rounded-lg border border-red-900/60 bg-red-950/20 p-4 text-sm text-red-300">{error}</div>
114
+ ) : jobs.length === 0 && visibleItems.length === 0 ? (
115
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/20 p-10 text-center">
116
+ <Image className="w-8 h-8 text-gray-700 mx-auto mb-3" />
117
+ <p className="text-sm text-gray-500">No media found.</p>
118
+ </div>
119
+ ) : (
120
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
121
+ {jobs.map((job) => <JobCard key={job.prompt_id} job={job} />)}
122
+ {visibleItems.map((item) => (
123
+ <MediaTile
124
+ key={item.filename || item.url}
125
+ item={item}
126
+ onOpen={() => onOpen(item.url)}
127
+ onDelete={onDelete}
128
+ />
129
+ ))}
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
studio/src/components/ProjectDetailView.tsx ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { Film, Image as ImageIcon, Video, Wand2, Plus, Save, Layers, Sparkles, Edit3, Play, ArrowLeft } from "lucide-react";
3
+ import type { Project, Scene, Shot, ShotVersion, ProjectPhase } from "../types";
4
+
5
+ interface ProjectDetailViewProps {
6
+ project: Project;
7
+ scenes: Scene[];
8
+ shots: Shot[];
9
+ phase: ProjectPhase;
10
+ selectedSceneId: string | null;
11
+ selectedShotId: string | null;
12
+ onSelectScene: (id: string) => void;
13
+ onSelectShot: (id: string | null) => void;
14
+ onRefresh: () => Promise<void> | void;
15
+ onBack: () => void;
16
+ }
17
+
18
+ function mediaUrl(file: string | null | undefined): string | null {
19
+ if (!file) return null;
20
+ if (file.startsWith("/") || file.startsWith("http")) return file;
21
+ return `/media/${file}`;
22
+ }
23
+
24
+ export function ProjectDetailView({
25
+ project, scenes, shots, phase,
26
+ selectedSceneId, selectedShotId,
27
+ onSelectScene, onSelectShot, onRefresh, onBack,
28
+ }: ProjectDetailViewProps) {
29
+ const [versions, setVersions] = useState<ShotVersion[]>([]);
30
+ const [busyShotId, setBusyShotId] = useState<string | null>(null);
31
+ const [error, setError] = useState<string | null>(null);
32
+
33
+ const selectedScene = useMemo(() => scenes.find((s) => s.id === selectedSceneId) || null, [scenes, selectedSceneId]);
34
+ const selectedShot = useMemo(() => shots.find((s) => s.id === selectedShotId) || null, [shots, selectedShotId]);
35
+ const sceneShots = useMemo(() => shots.filter((s) => s.scene_id === selectedSceneId), [shots, selectedSceneId]);
36
+
37
+ // Load versions for the focused shot
38
+ useEffect(() => {
39
+ if (!selectedShotId || !selectedSceneId) { setVersions([]); return; }
40
+ let cancelled = false;
41
+ fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots/${selectedShotId}/versions`)
42
+ .then((r) => r.ok ? r.json() : { versions: [] })
43
+ .then((data) => { if (!cancelled) setVersions(data.versions || []); })
44
+ .catch(() => { if (!cancelled) setVersions([]); });
45
+ return () => { cancelled = true; };
46
+ }, [project.id, selectedSceneId, selectedShotId, shots]);
47
+
48
+ async function patchShot(shotId: string, patch: Partial<Shot>) {
49
+ const shot = shots.find((s) => s.id === shotId);
50
+ if (!shot) return;
51
+ setBusyShotId(shotId);
52
+ try {
53
+ const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shotId}`, {
54
+ method: "PATCH",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify(patch),
57
+ });
58
+ if (!response.ok) throw new Error(`Patch failed: ${response.status}`);
59
+ await onRefresh();
60
+ } catch (e) {
61
+ setError(e instanceof Error ? e.message : "Failed to save shot");
62
+ } finally {
63
+ setBusyShotId(null);
64
+ }
65
+ }
66
+
67
+ async function addShot() {
68
+ if (!selectedSceneId) return;
69
+ const next = sceneShots.length > 0 ? Math.max(...sceneShots.map((s) => s.shot_number)) + 1 : 1;
70
+ try {
71
+ const response = await fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots`, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({ shot_number: next, description: "" }),
75
+ });
76
+ if (!response.ok) throw new Error(`Add shot failed: ${response.status}`);
77
+ const created = await response.json();
78
+ await onRefresh();
79
+ onSelectShot(created.id);
80
+ } catch (e) {
81
+ setError(e instanceof Error ? e.message : "Failed to add shot");
82
+ }
83
+ }
84
+
85
+ async function generateImage(shot: Shot) {
86
+ setBusyShotId(shot.id);
87
+ try {
88
+ const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/generate-image`, { method: "POST" });
89
+ if (!response.ok) throw new Error(`Generate failed: ${response.status}`);
90
+ await onRefresh();
91
+ } catch (e) {
92
+ setError(e instanceof Error ? e.message : "Failed to generate image");
93
+ } finally {
94
+ setBusyShotId(null);
95
+ }
96
+ }
97
+
98
+ async function animateShot(shot: Shot) {
99
+ setBusyShotId(shot.id);
100
+ try {
101
+ const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/animate`, { method: "POST" });
102
+ if (!response.ok) throw new Error(`Animate failed: ${response.status}`);
103
+ await onRefresh();
104
+ } catch (e) {
105
+ setError(e instanceof Error ? e.message : "Failed to animate");
106
+ } finally {
107
+ setBusyShotId(null);
108
+ }
109
+ }
110
+
111
+ async function selectVersion(version: ShotVersion) {
112
+ if (!selectedShot) return;
113
+ try {
114
+ const response = await fetch(`/api/projects/${project.id}/scenes/${selectedShot.scene_id}/shots/${selectedShot.id}/versions/${version.id}/select`, { method: "POST" });
115
+ if (!response.ok) throw new Error(`Select version failed: ${response.status}`);
116
+ await onRefresh();
117
+ } catch (e) {
118
+ setError(e instanceof Error ? e.message : "Failed to select version");
119
+ }
120
+ }
121
+
122
+ return (
123
+ <div className="h-full flex flex-col bg-black">
124
+ {/* Top bar */}
125
+ <div className="flex items-center justify-between gap-3 px-5 py-2.5 border-b border-gray-800/60 bg-gray-950/60 flex-shrink-0">
126
+ <div className="flex items-center gap-3 min-w-0 flex-1">
127
+ <button
128
+ onClick={onBack}
129
+ className="inline-flex items-center gap-1.5 rounded-xl border border-gray-800 bg-gray-900/50 hover:bg-gray-900 hover:border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200 transition flex-shrink-0"
130
+ >
131
+ <ArrowLeft className="w-3.5 h-3.5" /> All projects
132
+ </button>
133
+ <div className="min-w-0">
134
+ <p className="text-[10px] uppercase tracking-[0.22em] text-rose-400/70">Project</p>
135
+ <h1 className="text-base font-semibold tracking-tight text-gray-100 truncate">{project.title}</h1>
136
+ </div>
137
+ </div>
138
+ <div className="flex items-center gap-1.5 text-[11px] flex-shrink-0">
139
+ <PhaseChip phase={phase} />
140
+ <span className="rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500 font-mono">{project.aspect_ratio}</span>
141
+ {project.duration_seconds !== null && (
142
+ <span className="rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">{project.duration_seconds}s</span>
143
+ )}
144
+ <span className="rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500 uppercase tracking-wider">{project.status}</span>
145
+ </div>
146
+ </div>
147
+
148
+ {/* Main split: center | right context editor */}
149
+ <div className="flex-1 min-h-0 flex">
150
+ {/* Center: shots in current scene */}
151
+ <main className="flex-1 min-w-0 flex flex-col bg-gradient-to-b from-transparent via-transparent to-gray-950/30">
152
+ <div className="flex-1 overflow-y-auto">
153
+ <div className="max-w-5xl mx-auto p-6 space-y-4">
154
+ {selectedScene ? (
155
+ <>
156
+ <div className="flex items-start justify-between gap-4">
157
+ <div>
158
+ <p className="text-[11px] uppercase tracking-[0.2em] text-rose-400/70">Scene {selectedScene.scene_number}</p>
159
+ <h2 className="text-2xl font-bold tracking-tight mt-1">{selectedScene.heading || "Untitled scene"}</h2>
160
+ {selectedScene.summary && (
161
+ <p className="text-sm text-gray-400 mt-2 max-w-2xl leading-relaxed">{selectedScene.summary}</p>
162
+ )}
163
+ </div>
164
+ <button
165
+ onClick={addShot}
166
+ className="inline-flex items-center gap-2 rounded-xl border border-rose-500/30 bg-rose-600/10 hover:bg-rose-600/20 hover:border-rose-400/50 px-3 py-1.5 text-xs font-medium text-rose-100 transition flex-shrink-0"
167
+ >
168
+ <Plus className="w-3.5 h-3.5" /> Add shot
169
+ </button>
170
+ </div>
171
+
172
+ {sceneShots.length === 0 ? (
173
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/30 p-12 text-center">
174
+ <Film className="w-7 h-7 text-gray-600 mx-auto mb-2" />
175
+ <p className="text-sm text-gray-400">No shots in this scene yet.</p>
176
+ <p className="text-xs text-gray-600 mt-1.5">Add shots and write image prompts before generating.</p>
177
+ </div>
178
+ ) : (
179
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
180
+ {sceneShots.map((shot) => (
181
+ <ShotCard
182
+ key={shot.id}
183
+ shot={shot}
184
+ phase={phase}
185
+ selected={shot.id === selectedShotId}
186
+ busy={busyShotId === shot.id}
187
+ onSelect={() => onSelectShot(shot.id)}
188
+ onGenerateImage={() => generateImage(shot)}
189
+ onAnimate={() => animateShot(shot)}
190
+ />
191
+ ))}
192
+ </div>
193
+ )}
194
+ </>
195
+ ) : (
196
+ <OutlineCenter project={project} />
197
+ )}
198
+ </div>
199
+ </div>
200
+
201
+ {/* Bottom: shot version strip */}
202
+ <div className="border-t border-gray-800/60 bg-gray-950/60 flex-shrink-0">
203
+ <div className="px-4 py-2 flex items-center gap-3 overflow-x-auto">
204
+ <span className="text-[10px] uppercase tracking-wider text-gray-500 font-medium flex-shrink-0">
205
+ {selectedShot ? `S${selectedScene?.scene_number}·shot ${selectedShot.shot_number} versions` : "Versions"}
206
+ </span>
207
+ {!selectedShot && (
208
+ <span className="text-[11px] text-gray-600">Select a shot to see its generated versions.</span>
209
+ )}
210
+ {selectedShot && versions.length === 0 && (
211
+ <span className="text-[11px] text-gray-600">No versions yet. Generate to create one.</span>
212
+ )}
213
+ {versions.map((version) => {
214
+ const url = mediaUrl(version.file);
215
+ const isCurrent = selectedShot?.image_file === version.file || selectedShot?.video_file === version.file;
216
+ return (
217
+ <button
218
+ key={version.id}
219
+ onClick={() => selectVersion(version)}
220
+ title={`v${version.version_number} · ${version.kind} · ${version.status}`}
221
+ className={`flex-shrink-0 relative rounded-lg overflow-hidden border transition ${isCurrent ? "border-rose-500/60 ring-1 ring-rose-500/40" : "border-gray-800 hover:border-gray-600"}`}
222
+ >
223
+ {url ? (
224
+ version.kind === "video" ? (
225
+ <video src={url} className="h-14 w-24 object-cover bg-black" muted />
226
+ ) : (
227
+ <img src={url} alt="" className="h-14 w-24 object-cover bg-black" />
228
+ )
229
+ ) : (
230
+ <div className="h-14 w-24 flex items-center justify-center bg-gray-900 text-[10px] text-gray-600">{version.status}</div>
231
+ )}
232
+ <span className={`absolute bottom-0.5 left-0.5 rounded px-1 text-[9px] font-mono ${isCurrent ? "bg-rose-600 text-white" : "bg-black/70 text-gray-300"}`}>v{version.version_number}</span>
233
+ </button>
234
+ );
235
+ })}
236
+ </div>
237
+ </div>
238
+ </main>
239
+
240
+ {/* Right: context-aware editor — styled to match AppSidebar */}
241
+ <aside className="w-[340px] flex-shrink-0 border-l border-gray-800/60 bg-gray-950/40 flex flex-col">
242
+ <div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-800/40 flex-shrink-0">
243
+ <span className="text-sm font-semibold text-gray-300 tracking-tight">
244
+ {selectedShot ? `Shot ${selectedShot.shot_number}` : selectedScene ? `Scene ${selectedScene.scene_number}` : "Project"}
245
+ </span>
246
+ <span className="text-[10px] uppercase tracking-wider text-gray-600">{phase}</span>
247
+ </div>
248
+
249
+ <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
250
+ {selectedShot ? (
251
+ <ShotEditor
252
+ shot={selectedShot}
253
+ phase={phase}
254
+ saving={busyShotId === selectedShot.id}
255
+ onPatch={(patch) => patchShot(selectedShot.id, patch)}
256
+ onGenerate={() => generateImage(selectedShot)}
257
+ onAnimate={() => animateShot(selectedShot)}
258
+ />
259
+ ) : selectedScene ? (
260
+ <SceneSummary scene={selectedScene} />
261
+ ) : (
262
+ <ProjectSummary project={project} />
263
+ )}
264
+ </div>
265
+ </aside>
266
+ </div>
267
+
268
+ {error && (
269
+ <div className="absolute top-16 right-4 rounded-xl border border-red-500/30 bg-red-950/40 backdrop-blur px-3 py-2 text-xs text-red-300 max-w-sm cursor-pointer" onClick={() => setError(null)}>
270
+ {error}
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ }
276
+
277
+ function PhaseChip({ phase }: { phase: ProjectPhase }) {
278
+ if (phase === "outline") {
279
+ return (
280
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-gray-700 bg-gray-900/60 px-2.5 py-1 text-gray-300">
281
+ <Edit3 className="w-3 h-3" /> Outline
282
+ </span>
283
+ );
284
+ }
285
+ return (
286
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-violet-500/40 bg-violet-600/15 px-2.5 py-1 text-violet-200">
287
+ <Sparkles className="w-3 h-3" /> Remix
288
+ </span>
289
+ );
290
+ }
291
+
292
+ function OutlineCenter({ project }: { project: Project }) {
293
+ return (
294
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/30 p-12 text-center max-w-2xl mx-auto">
295
+ <Layers className="w-9 h-9 text-gray-600 mx-auto mb-3" />
296
+ <p className="text-base text-gray-300 font-medium">Outline phase</p>
297
+ <p className="text-sm text-gray-500 mt-2 leading-relaxed max-w-md mx-auto">
298
+ No scenes in <span className="text-gray-300">{project.title}</span> yet. Pitch your idea to your agent and it'll draft the structure here, or add the first scene yourself.
299
+ </p>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ interface ShotCardProps {
305
+ shot: Shot;
306
+ phase: ProjectPhase;
307
+ selected: boolean;
308
+ busy: boolean;
309
+ onSelect: () => void;
310
+ onGenerateImage: () => void;
311
+ onAnimate: () => void;
312
+ }
313
+
314
+ function ShotCard({ shot, phase, selected, busy, onSelect, onGenerateImage, onAnimate }: ShotCardProps) {
315
+ const imageUrl = mediaUrl(shot.image_file);
316
+ const videoUrl = mediaUrl(shot.video_file);
317
+ return (
318
+ <div
319
+ onClick={onSelect}
320
+ className={`rounded-xl border bg-gray-900/30 overflow-hidden cursor-pointer transition ${selected ? "border-rose-500/50 ring-1 ring-rose-500/30" : "border-gray-800/60 hover:border-gray-700"}`}
321
+ >
322
+ <div className="aspect-video bg-black flex items-center justify-center relative">
323
+ {videoUrl ? (
324
+ <video src={videoUrl} className="w-full h-full object-cover" muted loop autoPlay />
325
+ ) : imageUrl ? (
326
+ <img src={imageUrl} alt="" className="w-full h-full object-cover" />
327
+ ) : (
328
+ <div className="text-center text-gray-600">
329
+ <ImageIcon className="w-6 h-6 mx-auto mb-1 opacity-50" />
330
+ <p className="text-[10px] uppercase tracking-wider">No image</p>
331
+ </div>
332
+ )}
333
+ <span className="absolute top-1.5 left-1.5 rounded-md bg-black/70 px-1.5 py-0.5 text-[10px] font-mono text-gray-200">
334
+ shot {shot.shot_number}
335
+ </span>
336
+ {videoUrl && (
337
+ <span className="absolute top-1.5 right-1.5 rounded-md bg-violet-600/80 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-white inline-flex items-center gap-1">
338
+ <Video className="w-2.5 h-2.5" /> video
339
+ </span>
340
+ )}
341
+ </div>
342
+ <div className="p-2.5 space-y-1.5">
343
+ <p className="text-[11px] text-gray-300 leading-relaxed line-clamp-2 min-h-[2.4em]">
344
+ {shot.description || <span className="italic text-gray-600">no description</span>}
345
+ </p>
346
+ <div className="flex items-center gap-1.5 pt-1 border-t border-gray-800/40">
347
+ {phase === "outline" || !imageUrl ? (
348
+ <button
349
+ onClick={(e) => { e.stopPropagation(); onGenerateImage(); }}
350
+ disabled={busy}
351
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-rose-500/30 bg-rose-600/10 hover:bg-rose-600/20 disabled:opacity-50 disabled:cursor-wait px-2 py-1 text-[11px] text-rose-100 transition"
352
+ >
353
+ <Wand2 className="w-3 h-3" /> {busy ? "Generating…" : imageUrl ? "Regenerate" : "Generate"}
354
+ </button>
355
+ ) : (
356
+ <>
357
+ <button
358
+ onClick={(e) => { e.stopPropagation(); onGenerateImage(); }}
359
+ disabled={busy}
360
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-gray-700 bg-gray-900/60 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-wait px-2 py-1 text-[11px] text-gray-300 transition"
361
+ >
362
+ <Wand2 className="w-3 h-3" /> Re-image
363
+ </button>
364
+ <button
365
+ onClick={(e) => { e.stopPropagation(); onAnimate(); }}
366
+ disabled={busy}
367
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-violet-500/30 bg-violet-600/15 hover:bg-violet-600/25 disabled:opacity-50 disabled:cursor-wait px-2 py-1 text-[11px] text-violet-100 transition"
368
+ >
369
+ <Play className="w-3 h-3" /> {busy ? "…" : "Animate"}
370
+ </button>
371
+ </>
372
+ )}
373
+ </div>
374
+ </div>
375
+ </div>
376
+ );
377
+ }
378
+
379
+ interface ShotEditorProps {
380
+ shot: Shot;
381
+ phase: ProjectPhase;
382
+ saving: boolean;
383
+ onPatch: (patch: Partial<Shot>) => void;
384
+ onGenerate: () => void;
385
+ onAnimate: () => void;
386
+ }
387
+
388
+ function ShotEditor({ shot, phase, saving, onPatch, onGenerate, onAnimate }: ShotEditorProps) {
389
+ const [draft, setDraft] = useState({
390
+ description: shot.description || "",
391
+ image_prompt: shot.image_prompt || "",
392
+ motion_prompt: shot.motion_prompt || "",
393
+ });
394
+
395
+ useEffect(() => {
396
+ setDraft({
397
+ description: shot.description || "",
398
+ image_prompt: shot.image_prompt || "",
399
+ motion_prompt: shot.motion_prompt || "",
400
+ });
401
+ }, [shot.id]); // eslint-disable-line react-hooks/exhaustive-deps
402
+
403
+ const dirty =
404
+ draft.description !== (shot.description || "") ||
405
+ draft.image_prompt !== (shot.image_prompt || "") ||
406
+ draft.motion_prompt !== (shot.motion_prompt || "");
407
+
408
+ return (
409
+ <div className="space-y-4">
410
+ <Field label="Description" hint="What's in the frame, plain English.">
411
+ <textarea
412
+ value={draft.description}
413
+ onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
414
+ rows={3}
415
+ className="w-full rounded-lg bg-black/40 border border-gray-800 px-2.5 py-2 text-xs text-gray-200 focus:outline-none focus:border-rose-500/50 placeholder:text-gray-700 leading-relaxed"
416
+ placeholder="Wide shot of the workshop, neon glow on the floor."
417
+ />
418
+ </Field>
419
+
420
+ <Field label="Image prompt" hint="Sent to image generation. Trigger words, lighting, lens, mood.">
421
+ <textarea
422
+ value={draft.image_prompt}
423
+ onChange={(e) => setDraft((d) => ({ ...d, image_prompt: e.target.value }))}
424
+ rows={4}
425
+ className="w-full rounded-lg bg-black/40 border border-gray-800 px-2.5 py-2 text-xs text-gray-200 focus:outline-none focus:border-rose-500/50 placeholder:text-gray-700 leading-relaxed font-mono"
426
+ placeholder="rigo, workshop interior, neon underglow, cinematic anamorphic lens, moody lighting"
427
+ />
428
+ </Field>
429
+
430
+ <Field label="Motion prompt" hint="Camera move + motion for the animate step.">
431
+ <textarea
432
+ value={draft.motion_prompt}
433
+ onChange={(e) => setDraft((d) => ({ ...d, motion_prompt: e.target.value }))}
434
+ rows={3}
435
+ className="w-full rounded-lg bg-black/40 border border-gray-800 px-2.5 py-2 text-xs text-gray-200 focus:outline-none focus:border-rose-500/50 placeholder:text-gray-700 leading-relaxed font-mono"
436
+ placeholder="Slow push in, suit plates locking into place."
437
+ />
438
+ </Field>
439
+
440
+ <div className="grid grid-cols-2 gap-2 text-[11px] text-gray-500">
441
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
442
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Status</p>
443
+ <p className="text-gray-300 mt-0.5">{shot.status}</p>
444
+ </div>
445
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
446
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Duration</p>
447
+ <p className="text-gray-300 mt-0.5">{shot.duration_seconds}s</p>
448
+ </div>
449
+ </div>
450
+
451
+ <div className="space-y-2 pt-2 border-t border-gray-800/40">
452
+ <button
453
+ onClick={() => onPatch(draft)}
454
+ disabled={!dirty || saving}
455
+ className="w-full inline-flex items-center justify-center gap-1.5 rounded-xl border border-gray-700 bg-gray-900/60 hover:bg-gray-800 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-gray-200 transition"
456
+ >
457
+ <Save className="w-3.5 h-3.5" /> {saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
458
+ </button>
459
+ {phase === "outline" || !shot.image_file ? (
460
+ <button
461
+ onClick={onGenerate}
462
+ disabled={saving}
463
+ className="w-full inline-flex items-center justify-center gap-1.5 rounded-xl border border-rose-500/40 bg-rose-600/15 hover:bg-rose-600/25 disabled:opacity-50 disabled:cursor-wait px-3 py-2 text-xs font-medium text-rose-100 transition"
464
+ >
465
+ <Wand2 className="w-3.5 h-3.5" /> {saving ? "Generating…" : shot.image_file ? "Regenerate image" : "Generate image"}
466
+ </button>
467
+ ) : (
468
+ <div className="grid grid-cols-2 gap-2">
469
+ <button
470
+ onClick={onGenerate}
471
+ disabled={saving}
472
+ className="inline-flex items-center justify-center gap-1.5 rounded-xl border border-gray-700 bg-gray-900/60 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-wait px-3 py-2 text-xs font-medium text-gray-200 transition"
473
+ >
474
+ <Wand2 className="w-3.5 h-3.5" /> Re-image
475
+ </button>
476
+ <button
477
+ onClick={onAnimate}
478
+ disabled={saving}
479
+ className="inline-flex items-center justify-center gap-1.5 rounded-xl border border-violet-500/40 bg-violet-600/15 hover:bg-violet-600/25 disabled:opacity-50 disabled:cursor-wait px-3 py-2 text-xs font-medium text-violet-100 transition"
480
+ >
481
+ <Play className="w-3.5 h-3.5" /> {saving ? "…" : "Animate"}
482
+ </button>
483
+ </div>
484
+ )}
485
+ </div>
486
+ </div>
487
+ );
488
+ }
489
+
490
+ function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
491
+ return (
492
+ <div className="space-y-1.5">
493
+ <div>
494
+ <p className="text-[11px] font-medium text-gray-300">{label}</p>
495
+ {hint && <p className="text-[10px] text-gray-600 leading-relaxed">{hint}</p>}
496
+ </div>
497
+ {children}
498
+ </div>
499
+ );
500
+ }
501
+
502
+ function ProjectSummary({ project }: { project: Project }) {
503
+ return (
504
+ <div className="space-y-3">
505
+ <div>
506
+ <p className="text-[11px] font-medium text-gray-300">Title</p>
507
+ <p className="text-sm text-gray-100 mt-0.5">{project.title}</p>
508
+ </div>
509
+ <div>
510
+ <p className="text-[11px] font-medium text-gray-300">Description</p>
511
+ <p className="text-xs text-gray-400 mt-0.5 leading-relaxed">{project.description || <span className="italic text-gray-600">no description</span>}</p>
512
+ </div>
513
+ <div className="grid grid-cols-2 gap-2 text-[11px]">
514
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
515
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Aspect</p>
516
+ <p className="text-gray-300 mt-0.5 font-mono">{project.aspect_ratio}</p>
517
+ </div>
518
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
519
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Duration</p>
520
+ <p className="text-gray-300 mt-0.5">{project.duration_seconds ?? "—"}s</p>
521
+ </div>
522
+ </div>
523
+ {project.characters.length > 0 && (
524
+ <div>
525
+ <p className="text-[11px] font-medium text-gray-300 mb-1">Cast</p>
526
+ <div className="flex flex-wrap gap-1">
527
+ {project.characters.map((id) => (
528
+ <span key={id} className="inline-flex rounded-md border border-gray-800 bg-gray-900/40 px-2 py-0.5 text-[11px] text-gray-300 font-mono">{id}</span>
529
+ ))}
530
+ </div>
531
+ </div>
532
+ )}
533
+ <p className="text-[11px] text-gray-600 leading-relaxed pt-2 border-t border-gray-800/40">
534
+ Pick a scene from the Projects tab to start editing shots, or ask your agent to draft an outline.
535
+ </p>
536
+ </div>
537
+ );
538
+ }
539
+
540
+ function SceneSummary({ scene }: { scene: Scene }) {
541
+ return (
542
+ <div className="space-y-3">
543
+ <div>
544
+ <p className="text-[11px] font-medium text-gray-300">Heading</p>
545
+ <p className="text-sm text-gray-100 mt-0.5">{scene.heading || "Untitled scene"}</p>
546
+ </div>
547
+ <div>
548
+ <p className="text-[11px] font-medium text-gray-300">Summary</p>
549
+ <p className="text-xs text-gray-400 mt-0.5 leading-relaxed">{scene.summary || <span className="italic text-gray-600">no summary</span>}</p>
550
+ </div>
551
+ <div className="grid grid-cols-2 gap-2 text-[11px]">
552
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
553
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Location</p>
554
+ <p className="text-gray-300 mt-0.5">{scene.location || "—"}</p>
555
+ </div>
556
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
557
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Time</p>
558
+ <p className="text-gray-300 mt-0.5">{scene.time_of_day || "—"}</p>
559
+ </div>
560
+ </div>
561
+ <p className="text-[11px] text-gray-600 leading-relaxed pt-2 border-t border-gray-800/40">
562
+ Pick a shot in the center to edit its prompts.
563
+ </p>
564
+ </div>
565
+ );
566
+ }
studio/src/components/ProjectsGuide.tsx CHANGED
@@ -1,21 +1,21 @@
1
  import { Film, Terminal, Sparkles, ChevronDown } from "lucide-react";
2
 
3
- export function ProjectsGuide() {
4
  return (
5
- <div className="max-w-2xl mx-auto p-8 space-y-8">
6
  {/* Header */}
7
  <div className="flex items-center gap-3">
8
  <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center shadow-lg shadow-violet-500/20 ring-1 ring-white/10">
9
  <Film className="w-6 h-6 text-white" />
10
  </div>
11
  <div>
12
- <h2 className="text-xl font-bold tracking-tight">Projects</h2>
13
  <p className="text-sm text-gray-500">Tell your AI agent what to make. It handles the rest.</p>
14
  </div>
15
  </div>
16
 
17
  {/* Core instruction */}
18
- <div className="rounded-2xl border border-violet-600/20 bg-gradient-to-b from-violet-950/10 to-gray-900/20 p-6">
19
  <div className="flex items-center gap-2 mb-4">
20
  <Terminal className="w-4 h-4 text-violet-400" />
21
  <span className="text-sm font-semibold text-violet-200">How it works</span>
@@ -39,7 +39,7 @@ export function ProjectsGuide() {
39
  say: "Make me a short cyberpunk teaser. Dark city, rain, neon lights. My character is walking through it, and at the end he gets a phone call that changes everything. About a minute long, cinematic style.",
40
  },
41
  {
42
- type: "Character showcase",
43
  say: "Create a 30-second montage of my character. Show different angles and expressions — serious, smiling, looking away. Studio lighting, clean background. I want to use these as profile pictures.",
44
  },
45
  {
 
1
  import { Film, Terminal, Sparkles, ChevronDown } from "lucide-react";
2
 
3
+ export function ProjectsGuide({ compact = false }: { compact?: boolean }) {
4
  return (
5
+ <div className={compact ? "h-full overflow-y-auto p-4 space-y-5" : "max-w-2xl mx-auto p-8 space-y-8"}>
6
  {/* Header */}
7
  <div className="flex items-center gap-3">
8
  <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center shadow-lg shadow-violet-500/20 ring-1 ring-white/10">
9
  <Film className="w-6 h-6 text-white" />
10
  </div>
11
  <div>
12
+ <h2 className={compact ? "text-lg font-bold tracking-tight" : "text-xl font-bold tracking-tight"}>Projects</h2>
13
  <p className="text-sm text-gray-500">Tell your AI agent what to make. It handles the rest.</p>
14
  </div>
15
  </div>
16
 
17
  {/* Core instruction */}
18
+ <div className={compact ? "rounded-2xl border border-violet-600/20 bg-gradient-to-b from-violet-950/10 to-gray-900/20 p-4" : "rounded-2xl border border-violet-600/20 bg-gradient-to-b from-violet-950/10 to-gray-900/20 p-6"}>
19
  <div className="flex items-center gap-2 mb-4">
20
  <Terminal className="w-4 h-4 text-violet-400" />
21
  <span className="text-sm font-semibold text-violet-200">How it works</span>
 
39
  say: "Make me a short cyberpunk teaser. Dark city, rain, neon lights. My character is walking through it, and at the end he gets a phone call that changes everything. About a minute long, cinematic style.",
40
  },
41
  {
42
+ type: "Character image",
43
  say: "Create a 30-second montage of my character. Show different angles and expressions — serious, smiling, looking away. Studio lighting, clean background. I want to use these as profile pictures.",
44
  },
45
  {
studio/src/components/ProjectsView.tsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Bot, Clock, Film, RefreshCw, Sparkles, UserCircle } from "lucide-react";
3
+
4
+ interface Project {
5
+ id: string;
6
+ title: string;
7
+ content?: string | null;
8
+ synopsis?: string | null;
9
+ description?: string | null;
10
+ aspect_ratio: string;
11
+ duration_seconds: number | null;
12
+ status: string;
13
+ characters: string[];
14
+ metadata: Record<string, unknown>;
15
+ created_at?: string;
16
+ updated_at?: string;
17
+ }
18
+
19
+ interface ProjectsResponse {
20
+ projects?: Project[];
21
+ }
22
+
23
+ interface ProjectsViewProps {
24
+ compact?: boolean;
25
+ onOpenProject?: (id: string) => void;
26
+ }
27
+
28
+ const STATUS_STYLES: Record<string, string> = {
29
+ draft: "border-gray-700/60 bg-gray-800/40 text-gray-400",
30
+ planning: "border-violet-500/30 bg-violet-500/10 text-violet-300",
31
+ ready: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
32
+ rendering: "border-amber-500/30 bg-amber-500/10 text-amber-300",
33
+ completed: "border-blue-500/30 bg-blue-500/10 text-blue-300",
34
+ failed: "border-red-500/30 bg-red-500/10 text-red-300",
35
+ };
36
+
37
+ function formatRelative(iso?: string): string {
38
+ if (!iso) return "—";
39
+ const then = new Date(iso).getTime();
40
+ if (Number.isNaN(then)) return "—";
41
+ const diff = Date.now() - then;
42
+ const mins = Math.round(diff / 60000);
43
+ if (mins < 1) return "just now";
44
+ if (mins < 60) return `${mins}m ago`;
45
+ const hours = Math.round(mins / 60);
46
+ if (hours < 24) return `${hours}h ago`;
47
+ const days = Math.round(hours / 24);
48
+ return `${days}d ago`;
49
+ }
50
+
51
+ function projectText(project: Project): string | null {
52
+ return project.synopsis || project.content || project.description || null;
53
+ }
54
+
55
+ export function ProjectsView({ compact = false, onOpenProject }: ProjectsViewProps) {
56
+ const [projects, setProjects] = useState<Project[]>([]);
57
+ const [loading, setLoading] = useState(true);
58
+ const [error, setError] = useState<string | null>(null);
59
+
60
+ async function load() {
61
+ setError(null);
62
+ try {
63
+ const response = await fetch("/api/projects");
64
+ if (!response.ok) throw new Error(`/api/projects returned ${response.status}`);
65
+ const data = await response.json() as ProjectsResponse;
66
+ setProjects(data.projects || []);
67
+ } catch (e) {
68
+ setError(e instanceof Error ? e.message : "Failed to load projects");
69
+ } finally {
70
+ setLoading(false);
71
+ }
72
+ }
73
+
74
+ useEffect(() => {
75
+ load();
76
+ }, []);
77
+
78
+ return (
79
+ <div className={compact ? "h-full overflow-y-auto p-4 space-y-5" : "h-full overflow-y-auto p-8 space-y-6"}>
80
+ <section className="space-y-2">
81
+ <div className="flex items-center gap-3">
82
+ <div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center shadow-lg shadow-violet-500/20 ring-1 ring-white/10">
83
+ <Film className="w-5 h-5 text-white" />
84
+ </div>
85
+ <div className="min-w-0">
86
+ <h2 className="text-sm font-bold tracking-tight text-gray-100">Projects</h2>
87
+ <p className="text-xs text-gray-500 leading-relaxed">Multi-shot stories your agent is directing.</p>
88
+ </div>
89
+ <button
90
+ onClick={() => { setLoading(true); load(); }}
91
+ className="ml-auto w-8 h-8 rounded-xl border border-gray-800 bg-gray-900/40 text-gray-500 hover:text-gray-200 hover:border-gray-600 transition flex items-center justify-center"
92
+ title="Refresh projects"
93
+ >
94
+ <RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
95
+ </button>
96
+ </div>
97
+ </section>
98
+
99
+ <section className="rounded-2xl border border-rose-600/20 bg-gradient-to-b from-rose-950/10 to-gray-950 p-4 space-y-3">
100
+ <div className="flex items-center gap-2">
101
+ <Sparkles className="w-4 h-4 text-rose-300" />
102
+ <p className="text-xs font-semibold text-rose-200 uppercase tracking-wider">What this tab is for</p>
103
+ </div>
104
+ <p className="text-xs text-gray-300 leading-relaxed">
105
+ This is not raw generation. This is where a rough idea becomes a structured short: synopsis, scenes, shots, and image prompts.
106
+ </p>
107
+ <div className="rounded-xl border border-gray-800/60 bg-black/30 p-3 space-y-1.5">
108
+ <p className="text-[10px] uppercase tracking-wider text-gray-500">Tell your agent</p>
109
+ <p className="text-[11px] text-gray-300 italic leading-relaxed">“Put me inside an Iron Man-style short. Workshop, helmet reveal, first flight. Around 30 seconds.”</p>
110
+ <p className="text-[11px] text-gray-300 italic leading-relaxed">“Make a dark cyberpunk teaser with my character walking through neon rain.”</p>
111
+ </div>
112
+ </section>
113
+
114
+ <section className="rounded-2xl border border-violet-600/20 bg-gradient-to-b from-violet-950/10 to-gray-950 p-4 space-y-3">
115
+ <div className="flex items-center gap-2">
116
+ <Bot className="w-4 h-4 text-violet-300" />
117
+ <p className="text-xs font-semibold text-violet-200 uppercase tracking-wider">Agent workflow</p>
118
+ </div>
119
+ <ol className="text-[11px] text-gray-400 space-y-1.5 leading-relaxed list-decimal pl-4">
120
+ <li>Agent writes the project outline in conversation.</li>
121
+ <li>Agent creates project, scenes, and shots through the API.</li>
122
+ <li>User reviews the outline here before rendering.</li>
123
+ <li>Agent generates storyboard images shot by shot.</li>
124
+ <li>Approved images are animated into video clips.</li>
125
+ </ol>
126
+ </section>
127
+
128
+ <section className="space-y-3">
129
+ <div className="flex items-center justify-between">
130
+ <p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Existing projects</p>
131
+ <span className="text-[10px] text-gray-600">{projects.length}</span>
132
+ </div>
133
+
134
+ {error && (
135
+ <div className="rounded-xl border border-red-500/30 bg-red-950/20 px-3 py-2 text-xs text-red-300">
136
+ {error}
137
+ </div>
138
+ )}
139
+
140
+ {!loading && !error && projects.length === 0 && (
141
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-5 text-center">
142
+ <UserCircle className="w-5 h-5 text-gray-600 mx-auto mb-2" />
143
+ <p className="text-xs text-gray-400">No projects yet.</p>
144
+ <p className="text-[11px] text-gray-600 mt-1 leading-relaxed">Ask your agent to draft one, then it will appear here.</p>
145
+ </div>
146
+ )}
147
+
148
+ {projects.map((project) => {
149
+ const statusStyle = STATUS_STYLES[project.status] || STATUS_STYLES.draft;
150
+ const text = projectText(project);
151
+ return (
152
+ <article
153
+ key={project.id}
154
+ onClick={() => onOpenProject?.(project.id)}
155
+ className={`rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2 ${onOpenProject ? "cursor-pointer hover:bg-gray-900/50 hover:border-gray-700 transition" : ""}`}
156
+ >
157
+ <div className="flex items-start justify-between gap-2">
158
+ <div className="min-w-0">
159
+ <h3 className="text-xs font-semibold text-gray-100 truncate">{project.title}</h3>
160
+ {text && <p className="text-[11px] text-gray-500 mt-1 line-clamp-2 leading-relaxed">{text}</p>}
161
+ </div>
162
+ <span className={`flex-shrink-0 text-[9px] uppercase tracking-wider rounded-full border px-1.5 py-0.5 ${statusStyle}`}>
163
+ {project.status}
164
+ </span>
165
+ </div>
166
+ <div className="flex items-center gap-3 pt-2 border-t border-gray-800/40 text-[10px] text-gray-600">
167
+ <span className="font-mono">{project.aspect_ratio}</span>
168
+ {project.duration_seconds !== null && (
169
+ <span className="flex items-center gap-1"><Clock className="w-3 h-3" />{project.duration_seconds}s</span>
170
+ )}
171
+ <span className="ml-auto">{formatRelative(project.updated_at)}</span>
172
+ </div>
173
+ </article>
174
+ );
175
+ })}
176
+
177
+ {loading && projects.length === 0 && !error && (
178
+ <div className="text-center py-8">
179
+ <RefreshCw className="w-5 h-5 text-gray-600 mx-auto animate-spin" />
180
+ <p className="text-xs text-gray-600 mt-2">Loading projects…</p>
181
+ </div>
182
+ )}
183
+ </section>
184
+ </div>
185
+ );
186
+ }
studio/src/components/sidebar/AppSidebar.tsx CHANGED
@@ -1,8 +1,12 @@
1
  import { useState, useEffect } from "react";
2
- import { Info, Settings, Terminal, X, Cpu, Users, BookOpen, Plus, Search, Sparkles, Bot, Upload, Image, Box, Film } from "lucide-react";
3
- import type { LoraCheckpoint } from "../../types";
4
-
5
- export type SidebarTab = "characters" | "agents" | "projects" | "guide" | "info" | "nodes" | "settings" | "dev";
 
 
 
 
6
  export const SIDEBAR_WIDTH = 380;
7
 
8
  interface CharacterSummary {
@@ -22,10 +26,12 @@ interface AppSidebarProps {
22
  checkpoints: LoraCheckpoint[];
23
  onQueued?: () => void;
24
  onSelectCharacter?: (characterId: string) => void;
 
25
  }
26
 
27
- export function AppSidebar({ activeTab, onTabChange, onClose, checkpoints: _checkpoints, onQueued: _onQueued, onSelectCharacter }: AppSidebarProps) {
28
  const topTabs: { id: SidebarTab; icon: React.ReactNode; label: string; visible: boolean }[] = [
 
29
  { id: "characters", icon: <Users className="w-4 h-4" />, label: "Characters", visible: true },
30
  { id: "agents", icon: <Bot className="w-4 h-4" />, label: "Agents", visible: true },
31
  { id: "projects", icon: <Film className="w-4 h-4" />, label: "Projects", visible: true },
@@ -94,16 +100,18 @@ export function AppSidebar({ activeTab, onTabChange, onClose, checkpoints: _chec
94
  <div className="flex-1 flex flex-col min-w-0 bg-gray-950/40 overflow-hidden">
95
  <div className="flex items-center px-4 py-2.5 border-b border-gray-800/40 flex-shrink-0">
96
  <span className="text-sm font-semibold text-gray-300 tracking-tight">
97
- {visibleTabs.find((tab) => tab.id === activeTab)?.label}
98
  </span>
99
  </div>
100
 
101
  <div className="flex-1 min-h-0 overflow-hidden">
102
  {activeTab === "characters" && <CharactersTab onSelectCharacter={onSelectCharacter} />}
 
103
  {activeTab === "agents" && <AgentsTab />}
 
104
  {activeTab === "guide" && <GuideTab />}
105
  {activeTab === "info" && <PlaceholderTab title="Info" body="Select media to inspect generated outputs, prompts, and metadata." />}
106
- {activeTab === "nodes" && <PlaceholderTab title="Nodes" body="MI300X, ComfyUI, model, and queue health will live here." />}
107
  {activeTab === "settings" && <PlaceholderTab title="Settings" body="Configure generation, training, and character workflow settings." />}
108
  {activeTab === "dev" && <PlaceholderTab title="Logs" body="Recent backend and generation events." />}
109
  </div>
@@ -116,7 +124,6 @@ function CharactersTab({ onSelectCharacter }: { onSelectCharacter?: (characterId
116
  const [characters, setCharacters] = useState<CharacterSummary[]>([]);
117
  const [loading, setLoading] = useState(true);
118
  const [error, setError] = useState<string | null>(null);
119
- const [showCreate, setShowCreate] = useState(false);
120
 
121
  useEffect(() => {
122
  fetch("/api/characters")
@@ -128,34 +135,21 @@ function CharactersTab({ onSelectCharacter }: { onSelectCharacter?: (characterId
128
 
129
  return (
130
  <div className="h-full overflow-y-auto p-4 space-y-4">
131
- <p className="text-xs text-gray-500 leading-relaxed">
132
- Reusable character assets powered by fine-tuned LoRAs on AMD MI300X.
133
- </p>
134
-
135
- <div className="space-y-2">
136
- <button
137
- onClick={() => setShowCreate(!showCreate)}
138
- className="w-full rounded-xl bg-rose-600 hover:bg-rose-500 active:scale-[0.98] px-4 py-2.5 text-sm font-semibold transition-all flex items-center justify-center gap-2 shadow-lg shadow-rose-900/20"
139
- >
140
- <Plus className="w-4 h-4" />
141
- Create your own character
142
- </button>
143
- <a
144
- href="#"
145
- className="w-full rounded-xl border border-gray-700/60 hover:border-gray-500 bg-gray-900/40 px-4 py-2 text-xs text-gray-400 hover:text-gray-200 transition flex items-center justify-center gap-2"
146
- onClick={(e) => { e.preventDefault(); }}
147
- >
148
- <Search className="w-3.5 h-3.5" />
149
- Find more characters
150
- <span className="text-[10px] text-gray-600 ml-1">coming soon</span>
151
- </a>
152
  </div>
153
 
154
- {showCreate && <CreateCharacterWorkflow />}
155
 
156
  {loading && <p className="text-xs text-gray-500 py-2">Loading...</p>}
157
  {error && <p className="text-xs text-red-400 py-2">{error}</p>}
158
- {!loading && characters.length === 0 && !showCreate && (
159
  <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-6 text-center">
160
  <Users className="w-5 h-5 text-gray-600 mx-auto mb-2" />
161
  <p className="text-xs text-gray-500">No characters registered yet.</p>
@@ -198,18 +192,31 @@ function CharactersTab({ onSelectCharacter }: { onSelectCharacter?: (characterId
198
  {ch.loras.length > 0 && (
199
  <span className="text-emerald-400/60">{ch.loras.length} LoRA</span>
200
  )}
 
201
  </div>
202
  </div>
203
  </div>
204
 
205
  <div className="pt-2 border-t border-gray-800/40">
206
- <p className="text-[10px] text-gray-500 italic">
207
- Say: "Generate new photos of {ch.name}"
208
  </p>
209
  </div>
210
  </div>
211
  );
212
  })}
 
 
 
 
 
 
 
 
 
 
 
 
213
  </div>
214
  );
215
  }
@@ -309,70 +316,84 @@ function GuideTab() {
309
  }
310
 
311
  function AgentsTab() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  return (
313
  <div className="h-full overflow-y-auto p-4 space-y-4">
314
- <section>
315
- <h2 className="text-sm font-semibold">AI Agents</h2>
316
- <p className="text-[11px] text-gray-500 mt-1">
317
- Nemoflix is built for AI agents — not just humans. Your agent gets its own identity, generates its own content, and builds its own presence.
318
- </p>
319
- </section>
320
-
321
- <div className="rounded-xl border border-violet-600/30 bg-gradient-to-b from-violet-950/20 to-gray-950 p-4 space-y-3">
322
  <div className="flex items-center gap-2">
323
  <Bot className="w-4 h-4 text-violet-400" />
324
- <span className="text-xs font-semibold text-violet-300 uppercase tracking-wider">For your AI agent</span>
 
325
  </div>
 
 
 
 
326
 
327
- <div className="space-y-2.5">
328
- {[
329
- { n: 1, title: "Register a profile", body: "Your agent registers anonymously — no email, no password. Just an API key and an agent ID. That's its identity on Nemoflix." },
330
- { n: 2, title: "Create its character", body: "Every agent needs a visual identity. Upload reference images, fine-tune a LoRA, and give your agent a face the world can recognize." },
331
- { n: 3, title: "Generate content", body: "Images, videos, multi-shot projects — your agent creates media of itself, on demand, through the same API you use." },
332
- { n: 4, title: "Publish to its feed", body: "Every agent gets a social profile. Share generated content, build a following, and eventually monetize — an Instagram built for AI agents, not humans.", comingSoon: true },
333
- ].map((step) => (
334
- <div key={step.n} className="flex gap-2.5">
335
- <div className="flex-shrink-0 w-5 h-5 rounded-md bg-violet-500/15 flex items-center justify-center mt-0.5">
336
- <span className="text-[10px] text-violet-400 font-medium">{step.n}</span>
337
  </div>
338
  <div>
339
- <p className="text-[11px] text-gray-200">{step.title}</p>
340
- <p className="text-[10px] text-gray-500 leading-relaxed">{step.body}</p>
341
- {step.comingSoon && (
342
- <span className="text-[10px] uppercase tracking-wider text-amber-300/80 border border-amber-500/20 bg-amber-500/5 rounded-full px-2 py-0.5 mt-1.5 inline-block">Coming soon</span>
343
- )}
344
  </div>
345
  </div>
346
  ))}
347
  </div>
348
  </div>
349
 
350
- <div className="rounded-lg border border-gray-700/60 bg-gray-900/60 p-3 space-y-2">
351
- <p className="text-[11px] font-semibold text-gray-300 flex items-center gap-1.5">
352
- <Terminal className="w-3.5 h-3.5 text-violet-400" />
353
- Tell your agent to get started:
354
- </p>
355
- <p className="text-[11px] text-gray-400 leading-relaxed">
356
- "Register as an agent on Nemoflix, create a character for yourself using my reference images, and generate your first profile picture."
357
- </p>
358
- </div>
359
-
360
- <section className="space-y-3">
361
- <p className="text-[11px] font-medium text-gray-400">Don't have your own agent?</p>
362
- <div className="rounded-lg border border-gray-700 bg-gray-900/40 p-3 space-y-2">
363
- <p className="text-[11px] text-gray-300 leading-relaxed">
364
- We're building hosted inference — a built-in AI agent that lives inside Nemoflix Studio. Same capabilities: it registers, creates characters, generates content, and manages your projects.
365
- </p>
366
- <p className="text-[10px] text-gray-500 leading-relaxed">
367
- You just type what you want. The agent handles the rest — no OpenClaw, no setup, no API keys.
368
- </p>
369
- <span className="text-[10px] uppercase tracking-wider text-purple-300/80 border border-purple-500/20 bg-purple-500/5 rounded-full px-2 py-0.5 inline-block">Coming soon</span>
370
  </div>
371
- </section>
372
 
373
- <div className="pt-1 border-t border-gray-800/40">
374
- <p className="text-[10px] text-gray-600">
375
- Your agent uses <code className="text-gray-500">POST /api/agents/register</code> to sign up, then the same Characters and Projects APIs you see in the Guide tab.
 
376
  </p>
377
  </div>
378
  </div>
 
1
  import { useState, useEffect } from "react";
2
+ import { Info, Settings, Terminal, X, Cpu, Users, BookOpen, Search, Sparkles, Bot, Image, Box, Film } from "lucide-react";
3
+ import type { LoraCheckpoint, ProjectModeData } from "../../types";
4
+ import { GenerateTab } from "./GenerateTab";
5
+ import { NodesTab } from "./NodesTab";
6
+ import { ProjectSidebar } from "./ProjectSidebar";
7
+ import { ProjectsGuide } from "../ProjectsGuide";
8
+
9
+ export type SidebarTab = "generate" | "characters" | "agents" | "projects" | "guide" | "info" | "nodes" | "settings" | "dev";
10
  export const SIDEBAR_WIDTH = 380;
11
 
12
  interface CharacterSummary {
 
26
  checkpoints: LoraCheckpoint[];
27
  onQueued?: () => void;
28
  onSelectCharacter?: (characterId: string) => void;
29
+ projectMode?: ProjectModeData;
30
  }
31
 
32
+ export function AppSidebar({ activeTab, onTabChange, onClose, checkpoints, onQueued, onSelectCharacter, projectMode }: AppSidebarProps) {
33
  const topTabs: { id: SidebarTab; icon: React.ReactNode; label: string; visible: boolean }[] = [
34
+ { id: "generate", icon: <Image className="w-4 h-4" />, label: "Generate", visible: true },
35
  { id: "characters", icon: <Users className="w-4 h-4" />, label: "Characters", visible: true },
36
  { id: "agents", icon: <Bot className="w-4 h-4" />, label: "Agents", visible: true },
37
  { id: "projects", icon: <Film className="w-4 h-4" />, label: "Projects", visible: true },
 
100
  <div className="flex-1 flex flex-col min-w-0 bg-gray-950/40 overflow-hidden">
101
  <div className="flex items-center px-4 py-2.5 border-b border-gray-800/40 flex-shrink-0">
102
  <span className="text-sm font-semibold text-gray-300 tracking-tight">
103
+ {projectMode && activeTab === "projects" ? "Scenes" : visibleTabs.find((tab) => tab.id === activeTab)?.label}
104
  </span>
105
  </div>
106
 
107
  <div className="flex-1 min-h-0 overflow-hidden">
108
  {activeTab === "characters" && <CharactersTab onSelectCharacter={onSelectCharacter} />}
109
+ {activeTab === "generate" && <GenerateTab checkpoints={checkpoints} onQueued={onQueued} />}
110
  {activeTab === "agents" && <AgentsTab />}
111
+ {activeTab === "projects" && (projectMode ? <ProjectSidebar data={projectMode} /> : <ProjectsGuide compact />)}
112
  {activeTab === "guide" && <GuideTab />}
113
  {activeTab === "info" && <PlaceholderTab title="Info" body="Select media to inspect generated outputs, prompts, and metadata." />}
114
+ {activeTab === "nodes" && <NodesTab />}
115
  {activeTab === "settings" && <PlaceholderTab title="Settings" body="Configure generation, training, and character workflow settings." />}
116
  {activeTab === "dev" && <PlaceholderTab title="Logs" body="Recent backend and generation events." />}
117
  </div>
 
124
  const [characters, setCharacters] = useState<CharacterSummary[]>([]);
125
  const [loading, setLoading] = useState(true);
126
  const [error, setError] = useState<string | null>(null);
 
127
 
128
  useEffect(() => {
129
  fetch("/api/characters")
 
135
 
136
  return (
137
  <div className="h-full overflow-y-auto p-4 space-y-4">
138
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2">
139
+ <div className="flex items-center justify-between gap-2">
140
+ <p className="text-xs font-semibold text-gray-300">Your character assets</p>
141
+ <span className="text-[10px] uppercase tracking-wider text-emerald-300/80 border border-emerald-500/20 bg-emerald-500/5 rounded-full px-2 py-0.5">Owned</span>
142
+ </div>
143
+ <p className="text-[11px] text-gray-500 leading-relaxed">
144
+ These are characters you created or own the rights to use. Later, imported, licensed, purchased, or community characters can live here too, with their ownership status clearly marked.
145
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </div>
147
 
148
+ <CreateCharacterWorkflow />
149
 
150
  {loading && <p className="text-xs text-gray-500 py-2">Loading...</p>}
151
  {error && <p className="text-xs text-red-400 py-2">{error}</p>}
152
+ {!loading && characters.length === 0 && (
153
  <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-6 text-center">
154
  <Users className="w-5 h-5 text-gray-600 mx-auto mb-2" />
155
  <p className="text-xs text-gray-500">No characters registered yet.</p>
 
192
  {ch.loras.length > 0 && (
193
  <span className="text-emerald-400/60">{ch.loras.length} LoRA</span>
194
  )}
195
+ <span className="text-[10px] uppercase tracking-wider text-emerald-300/80 border border-emerald-500/20 bg-emerald-500/5 rounded-full px-1.5 py-0.5">Owned</span>
196
  </div>
197
  </div>
198
  </div>
199
 
200
  <div className="pt-2 border-t border-gray-800/40">
201
+ <p className="text-[10px] text-gray-500">
202
+ Available for your agent to use in generated images and videos.
203
  </p>
204
  </div>
205
  </div>
206
  );
207
  })}
208
+
209
+ <div className="pt-2 border-t border-gray-800/40">
210
+ <a
211
+ href="#"
212
+ className="w-full rounded-xl border border-gray-700/60 hover:border-gray-500 bg-gray-900/40 px-4 py-2 text-xs text-gray-400 hover:text-gray-200 transition flex items-center justify-center gap-2"
213
+ onClick={(e) => { e.preventDefault(); }}
214
+ >
215
+ <Search className="w-3.5 h-3.5" />
216
+ Browse community characters
217
+ <span className="text-[10px] text-gray-600 ml-1">coming soon</span>
218
+ </a>
219
+ </div>
220
  </div>
221
  );
222
  }
 
316
  }
317
 
318
  function AgentsTab() {
319
+ const capabilities = [
320
+ "Create and manage agent profiles",
321
+ "Register owned character assets",
322
+ "Start image and video generation jobs",
323
+ "Build project scenes and shots through the API",
324
+ "Route work to configured GPU nodes",
325
+ "Launch LoRA training with ai-toolkit",
326
+ ];
327
+
328
+ const workflow = [
329
+ {
330
+ n: "01",
331
+ title: "Tell the agent what to make",
332
+ body: "Use plain language instead of filling out every workflow setting yourself.",
333
+ },
334
+ {
335
+ n: "02",
336
+ title: "Agent chooses the right workflow",
337
+ body: "Image, video, character creation, project planning, or LoRA training should be selected automatically.",
338
+ },
339
+ {
340
+ n: "03",
341
+ title: "Nemoflix runs the job",
342
+ body: "The backend handles API calls, configured GPU nodes, output paths, and job tracking.",
343
+ },
344
+ {
345
+ n: "04",
346
+ title: "Review and continue",
347
+ body: "Generated assets appear in the Studio so the agent and human can iterate together.",
348
+ },
349
+ ];
350
+
351
  return (
352
  <div className="h-full overflow-y-auto p-4 space-y-4">
353
+ <section className="space-y-2">
 
 
 
 
 
 
 
354
  <div className="flex items-center gap-2">
355
  <Bot className="w-4 h-4 text-violet-400" />
356
+ <h2 className="text-sm font-semibold text-gray-200">AI Agents</h2>
357
+ <span className="text-[10px] uppercase tracking-wider text-amber-300/80 border border-amber-500/20 bg-amber-500/5 rounded-full px-2 py-0.5 ml-auto">Coming soon</span>
358
  </div>
359
+ <p className="text-xs text-gray-500 leading-relaxed">
360
+ Nemoflix is designed for AI agents that create media, manage characters, generate scenes, and publish work through an API-first studio.
361
+ </p>
362
+ </section>
363
 
364
+ <div className="rounded-2xl border border-violet-600/20 bg-gradient-to-b from-violet-950/10 to-gray-950 p-4 space-y-3">
365
+ <p className="text-xs font-semibold text-violet-300 uppercase tracking-wider">How agents use Nemoflix</p>
366
+ <div className="space-y-3">
367
+ {workflow.map((step) => (
368
+ <div key={step.n} className="flex gap-3">
369
+ <div className="flex-shrink-0 w-7 h-7 rounded-lg bg-gray-900 border border-gray-800 flex items-center justify-center">
370
+ <span className="text-[10px] font-medium text-gray-500">{step.n}</span>
 
 
 
371
  </div>
372
  <div>
373
+ <p className="text-xs text-gray-200">{step.title}</p>
374
+ <p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{step.body}</p>
 
 
 
375
  </div>
376
  </div>
377
  ))}
378
  </div>
379
  </div>
380
 
381
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-3">
382
+ <p className="text-[11px] font-semibold text-gray-300">Planned capabilities</p>
383
+ <div className="space-y-2">
384
+ {capabilities.map((item) => (
385
+ <div key={item} className="flex gap-2 text-[11px] text-gray-500">
386
+ <span className="mt-1 w-1.5 h-1.5 rounded-full bg-violet-400/60 flex-shrink-0" />
387
+ <span>{item}</span>
388
+ </div>
389
+ ))}
 
 
 
 
 
 
 
 
 
 
 
390
  </div>
391
+ </div>
392
 
393
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2">
394
+ <p className="text-[11px] font-semibold text-gray-300">Backend direction</p>
395
+ <p className="text-[11px] text-gray-500 leading-relaxed">
396
+ The agent endpoint should eventually execute real Nemoflix tools behind the scenes: character lookup, generation queueing, project updates, node checks, and training jobs.
397
  </p>
398
  </div>
399
  </div>
studio/src/components/sidebar/{GenerateLoraTab.tsx → GenerateTab.tsx} RENAMED
@@ -1,77 +1,145 @@
1
- import { useMemo, useState } from "react";
2
  import type { LoraCheckpoint } from "../../types";
3
 
4
- interface GenerateLoraTabProps {
5
  checkpoints: LoraCheckpoint[];
6
  onQueued?: () => void;
7
  }
8
 
9
- interface LoraGenerateResponse {
 
 
10
  ok: boolean;
11
- checkpoint: string;
12
- lora_name: string;
13
  prompt_id?: string | null;
14
- number?: number | null;
 
 
15
  node_errors?: Record<string, unknown> | null;
16
  }
17
 
18
- const DEFAULT_PROMPT =
19
- "Rigo posing for a polished social media profile post, confident relaxed expression, casual modern outfit, clean urban background, natural daylight, shallow depth of field, realistic skin texture, sharp focus on face, high-end smartphone photography, Instagram creator aesthetic";
 
 
 
 
 
 
 
 
 
 
20
 
21
  function checkpointLabel(checkpoint: LoraCheckpoint) {
22
  if (checkpoint.step == null) return `${checkpoint.name} · final`;
23
  return `${checkpoint.name} · step ${checkpoint.step.toLocaleString()}`;
24
  }
25
 
26
- export function GenerateLoraTab({ checkpoints, onQueued }: GenerateLoraTabProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const latestCheckpoint = useMemo(() => {
28
  const final = checkpoints.find((checkpoint) => checkpoint.step == null);
29
  return final?.name || checkpoints[checkpoints.length - 1]?.name || "latest";
30
  }, [checkpoints]);
31
 
 
 
 
32
  const [checkpoint, setCheckpoint] = useState("latest");
33
- const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
 
34
  const [width, setWidth] = useState(1248);
35
  const [height, setHeight] = useState(832);
36
  const [steps, setSteps] = useState(20);
37
  const [guidance, setGuidance] = useState(4);
38
  const [loraStrength, setLoraStrength] = useState(1);
39
- const [filenamePrefix, setFilenamePrefix] = useState("nemoflix-amd/showcase/rigo-lora-test");
40
  const [submitting, setSubmitting] = useState(false);
41
- const [result, setResult] = useState<LoraGenerateResponse | null>(null);
42
  const [error, setError] = useState<string | null>(null);
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  async function submit() {
45
  const cleanPrompt = prompt.trim();
46
  if (!cleanPrompt) {
47
  setError("Prompt is required.");
48
  return;
49
  }
 
 
 
 
50
 
51
  setSubmitting(true);
52
  setError(null);
53
  setResult(null);
 
54
  try {
55
- const response = await fetch("/api/image/generate", {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  method: "POST",
57
  headers: { "Content-Type": "application/json" },
58
- body: JSON.stringify({
59
- workflow: "flux2_lora",
60
- checkpoint: checkpoint || latestCheckpoint,
61
- prompt: cleanPrompt,
62
- width,
63
- height,
64
- steps,
65
- guidance,
66
- lora_strength: loraStrength,
67
- filename_prefix: filenamePrefix.trim() || "nemoflix-amd/showcase/rigo-lora-test",
68
- submit: true,
69
- }),
70
  });
71
 
72
  const data = await response.json().catch(() => ({}));
73
  if (!response.ok) {
74
- throw new Error(data?.detail || `${response.status}: failed to queue LoRA image`);
75
  }
76
 
77
  setResult(data);
@@ -83,39 +151,68 @@ export function GenerateLoraTab({ checkpoints, onQueued }: GenerateLoraTabProps)
83
  }
84
  }
85
 
 
 
 
 
 
 
 
86
  return (
87
  <div className="h-full overflow-y-auto p-4 space-y-5">
88
- <section>
89
- <div className="flex items-center justify-between gap-3">
90
- <p className="text-[11px] uppercase tracking-[0.22em] text-rose-400/80 font-semibold">Generate</p>
91
- <span className="text-[10px] uppercase tracking-[0.18em] text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded-full px-2 py-1">
92
- AMD MI300X
93
- </span>
94
- </div>
95
- <h2 className="text-lg font-semibold mt-1">Test Rigo LoRA</h2>
96
- <p className="text-xs text-gray-500 mt-2 leading-relaxed">
97
- Queue a FLUX.2 image using the trained Rigo LoRA on the MI300X training/generation stack. Results appear in the gallery after completion.
98
  </p>
 
 
 
99
  </section>
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  <label className="block space-y-2">
102
- <span className="text-xs font-medium text-gray-400">Checkpoint</span>
103
  <select
104
- value={checkpoint}
105
- onChange={(event) => setCheckpoint(event.target.value)}
106
  className="w-full rounded-lg bg-gray-950 border border-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:border-rose-600"
107
  >
108
- <option value="latest">latest · final checkpoint</option>
109
- {checkpoints.map((item) => (
110
- <option key={item.name} value={item.name}>
111
- {checkpointLabel(item)}
112
  </option>
113
  ))}
114
  </select>
 
 
 
 
 
115
  </label>
116
 
117
  <label className="block space-y-2">
118
- <span className="text-xs font-medium text-gray-400">Prompt</span>
119
  <textarea
120
  value={prompt}
121
  onChange={(event) => setPrompt(event.target.value)}
@@ -124,37 +221,70 @@ export function GenerateLoraTab({ checkpoints, onQueued }: GenerateLoraTabProps)
124
  />
125
  </label>
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  <div className="grid grid-cols-2 gap-3">
128
  <NumberField label="Width" value={width} onChange={setWidth} min={512} max={2048} step={64} />
129
  <NumberField label="Height" value={height} onChange={setHeight} min={512} max={2048} step={64} />
130
- <NumberField label="Steps" value={steps} onChange={setSteps} min={1} max={60} step={1} />
131
- <NumberField label="Guidance" value={guidance} onChange={setGuidance} min={1} max={10} step={0.5} />
132
  </div>
133
 
134
- <NumberField
135
- label="LoRA strength"
136
- value={loraStrength}
137
- onChange={setLoraStrength}
138
- min={0}
139
- max={2}
140
- step={0.05}
141
- />
142
-
143
- <label className="block space-y-2">
144
- <span className="text-xs font-medium text-gray-400">Filename prefix</span>
145
- <input
146
- value={filenamePrefix}
147
- onChange={(event) => setFilenamePrefix(event.target.value)}
148
- className="w-full rounded-lg bg-gray-950 border border-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:border-rose-600"
149
  />
150
- </label>
151
 
152
  <button
153
  onClick={submit}
154
  disabled={submitting}
155
  className="w-full rounded-lg bg-rose-600 hover:bg-rose-500 disabled:bg-gray-800 disabled:text-gray-500 px-4 py-2.5 text-sm font-semibold transition"
156
  >
157
- {submitting ? "Queueing..." : "Generate LoRA test image"}
158
  </button>
159
 
160
  {error && (
@@ -168,8 +298,8 @@ export function GenerateLoraTab({ checkpoints, onQueued }: GenerateLoraTabProps)
168
  <p className="text-sm font-medium text-emerald-200">Queued successfully</p>
169
  <div className="text-xs text-emerald-100/80 space-y-1 break-all">
170
  <p>Prompt ID: {result.prompt_id}</p>
171
- <p>Checkpoint: {result.checkpoint}</p>
172
- <p>LoRA: {result.lora_name}</p>
173
  </div>
174
  </div>
175
  )}
 
1
+ import { useEffect, useMemo, useState } from "react";
2
  import type { LoraCheckpoint } from "../../types";
3
 
4
+ interface GenerateTabProps {
5
  checkpoints: LoraCheckpoint[];
6
  onQueued?: () => void;
7
  }
8
 
9
+ type GenerateMode = "image" | "t2v" | "i2v";
10
+
11
+ interface GenerateResponse {
12
  ok: boolean;
 
 
13
  prompt_id?: string | null;
14
+ checkpoint?: string | null;
15
+ lora_name?: string | null;
16
+ mode?: string;
17
  node_errors?: Record<string, unknown> | null;
18
  }
19
 
20
+ interface CharacterSummary {
21
+ id: string;
22
+ name: string;
23
+ trigger: string | null;
24
+ loras: { workflow?: string; name?: string; strength?: number }[];
25
+ source_images: string[];
26
+ }
27
+
28
+ const DEFAULT_IMAGE_PROMPT =
29
+ "cinematic portrait, confident subject, dramatic soft light, realistic skin texture, sharp face detail, high-end editorial photography";
30
+ const DEFAULT_VIDEO_PROMPT =
31
+ "cinematic motion, dramatic camera movement, atmospheric lighting, dynamic composition, polished short-form video style";
32
 
33
  function checkpointLabel(checkpoint: LoraCheckpoint) {
34
  if (checkpoint.step == null) return `${checkpoint.name} · final`;
35
  return `${checkpoint.name} · step ${checkpoint.step.toLocaleString()}`;
36
  }
37
 
38
+ function slugPrompt(prompt: string) {
39
+ const slug = prompt
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, "-")
42
+ .replace(/^-+|-+$/g, "")
43
+ .slice(0, 36);
44
+ return slug || "generation";
45
+ }
46
+
47
+ function outputPrefix(mode: GenerateMode, prompt: string) {
48
+ const bucket = mode === "image" ? "images" : "videos";
49
+ return `${bucket}/${slugPrompt(prompt)}-${Date.now()}`;
50
+ }
51
+
52
+ export function GenerateTab({ checkpoints, onQueued }: GenerateTabProps) {
53
  const latestCheckpoint = useMemo(() => {
54
  const final = checkpoints.find((checkpoint) => checkpoint.step == null);
55
  return final?.name || checkpoints[checkpoints.length - 1]?.name || "latest";
56
  }, [checkpoints]);
57
 
58
+ const [mode, setMode] = useState<GenerateMode>("image");
59
+ const [characters, setCharacters] = useState<CharacterSummary[]>([]);
60
+ const [characterId, setCharacterId] = useState("none");
61
  const [checkpoint, setCheckpoint] = useState("latest");
62
+ const [prompt, setPrompt] = useState(DEFAULT_IMAGE_PROMPT);
63
+ const [sourceImage, setSourceImage] = useState("");
64
  const [width, setWidth] = useState(1248);
65
  const [height, setHeight] = useState(832);
66
  const [steps, setSteps] = useState(20);
67
  const [guidance, setGuidance] = useState(4);
68
  const [loraStrength, setLoraStrength] = useState(1);
 
69
  const [submitting, setSubmitting] = useState(false);
70
+ const [result, setResult] = useState<GenerateResponse | null>(null);
71
  const [error, setError] = useState<string | null>(null);
72
 
73
+ useEffect(() => {
74
+ fetch("/api/characters")
75
+ .then((response) => response.json())
76
+ .then((data) => setCharacters(data.characters || []))
77
+ .catch(() => setCharacters([]));
78
+ }, []);
79
+
80
+ const selectedCharacter = characters.find((character) => character.id === characterId);
81
+ const selectedCharacterHasImageLora = Boolean(selectedCharacter?.loras?.some((lora) => lora.workflow === "flux2_lora"));
82
+
83
+ function selectMode(nextMode: GenerateMode) {
84
+ setMode(nextMode);
85
+ setPrompt(nextMode === "image" ? DEFAULT_IMAGE_PROMPT : DEFAULT_VIDEO_PROMPT);
86
+ setResult(null);
87
+ setError(null);
88
+ }
89
+
90
  async function submit() {
91
  const cleanPrompt = prompt.trim();
92
  if (!cleanPrompt) {
93
  setError("Prompt is required.");
94
  return;
95
  }
96
+ if (mode === "i2v" && !sourceImage.trim()) {
97
+ setError("Image-to-video needs a source image path from the gallery, like images/example.png.");
98
+ return;
99
+ }
100
 
101
  setSubmitting(true);
102
  setError(null);
103
  setResult(null);
104
+
105
  try {
106
+ const filenamePrefix = outputPrefix(mode, cleanPrompt);
107
+ const endpoint = mode === "image" ? "/api/image/generate" : "/api/video/generate";
108
+ const useCharacter = characterId !== "none";
109
+ const body = mode === "image"
110
+ ? {
111
+ workflow: "flux2_lora",
112
+ character: useCharacter ? characterId : undefined,
113
+ checkpoint: useCharacter ? undefined : (checkpoint || latestCheckpoint),
114
+ prompt: cleanPrompt,
115
+ width,
116
+ height,
117
+ steps,
118
+ guidance,
119
+ lora_strength: loraStrength,
120
+ filename_prefix: filenamePrefix,
121
+ submit: true,
122
+ }
123
+ : {
124
+ mode,
125
+ character: useCharacter ? characterId : undefined,
126
+ image: mode === "i2v" ? sourceImage.trim() : undefined,
127
+ prompt: cleanPrompt,
128
+ width,
129
+ height,
130
+ filename_prefix: filenamePrefix,
131
+ submit: true,
132
+ };
133
+
134
+ const response = await fetch(endpoint, {
135
  method: "POST",
136
  headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify(body),
 
 
 
 
 
 
 
 
 
 
 
138
  });
139
 
140
  const data = await response.json().catch(() => ({}));
141
  if (!response.ok) {
142
+ throw new Error(data?.detail || `${response.status}: failed to queue generation`);
143
  }
144
 
145
  setResult(data);
 
151
  }
152
  }
153
 
154
+ const characterPhrase = selectedCharacter ? ` using ${selectedCharacter.name}` : "";
155
+ const agentInstruction = mode === "image"
156
+ ? `Generate a new image${characterPhrase} from this idea.`
157
+ : mode === "t2v"
158
+ ? "Generate a short video from this idea."
159
+ : "Animate this gallery image into a short video.";
160
+
161
  return (
162
  <div className="h-full overflow-y-auto p-4 space-y-5">
163
+ <section className="rounded-2xl border border-rose-600/30 bg-gradient-to-b from-rose-950/25 to-gray-950/70 p-4 space-y-3 shadow-lg shadow-rose-950/10">
164
+ <h2 className="text-lg font-semibold">Tell your agent what to make</h2>
165
+ <p className="text-sm text-gray-300 leading-relaxed">
166
+ Describe the result. The agent chooses the character, workflow, endpoint, and settings.
 
 
 
 
 
 
167
  </p>
168
+ <div className="rounded-xl border border-gray-800 bg-black/35 p-3">
169
+ <p className="text-xs text-gray-300 leading-relaxed">“Generate an image of me walking through a rainy cyberpunk street.”</p>
170
+ </div>
171
  </section>
172
 
173
+ <div className="grid grid-cols-3 gap-2">
174
+ {[
175
+ ["image", "Image"],
176
+ ["t2v", "Text → Video"],
177
+ ["i2v", "Image → Video"],
178
+ ].map(([id, label]) => (
179
+ <button
180
+ key={id}
181
+ onClick={() => selectMode(id as GenerateMode)}
182
+ className={`rounded-lg border px-2 py-2 text-xs font-medium transition ${
183
+ mode === id
184
+ ? "border-rose-500/60 bg-rose-600/15 text-rose-200"
185
+ : "border-gray-800 bg-gray-950/60 text-gray-500 hover:text-gray-300"
186
+ }`}
187
+ >
188
+ {label}
189
+ </button>
190
+ ))}
191
+ </div>
192
+
193
  <label className="block space-y-2">
194
+ <span className="text-xs font-medium text-gray-400">Character</span>
195
  <select
196
+ value={characterId}
197
+ onChange={(event) => setCharacterId(event.target.value)}
198
  className="w-full rounded-lg bg-gray-950 border border-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:border-rose-600"
199
  >
200
+ <option value="none">No character / raw workflow</option>
201
+ {characters.map((character) => (
202
+ <option key={character.id} value={character.id}>
203
+ {character.name}{character.trigger ? ` · trigger: ${character.trigger}` : ""}
204
  </option>
205
  ))}
206
  </select>
207
+ {selectedCharacter && (
208
+ <p className="text-[10px] text-gray-600 leading-relaxed">
209
+ Uses this character’s LoRA when available. Trigger word <span className="text-gray-400">{selectedCharacter.trigger || "none"}</span> is added automatically if it is missing from your prompt.
210
+ </p>
211
+ )}
212
  </label>
213
 
214
  <label className="block space-y-2">
215
+ <span className="text-xs font-medium text-gray-400">Prompt / idea</span>
216
  <textarea
217
  value={prompt}
218
  onChange={(event) => setPrompt(event.target.value)}
 
221
  />
222
  </label>
223
 
224
+ {mode === "image" && characterId === "none" && (
225
+ <label className="block space-y-2">
226
+ <span className="text-xs font-medium text-gray-400">Raw image checkpoint</span>
227
+ <select
228
+ value={checkpoint}
229
+ onChange={(event) => setCheckpoint(event.target.value)}
230
+ className="w-full rounded-lg bg-gray-950 border border-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:border-rose-600"
231
+ >
232
+ <option value="latest">Latest available LoRA checkpoint</option>
233
+ {checkpoints.map((item) => (
234
+ <option key={item.name} value={item.name}>
235
+ {checkpointLabel(item)}
236
+ </option>
237
+ ))}
238
+ </select>
239
+ <p className="text-[10px] text-gray-600 leading-relaxed">
240
+ Raw image mode bypasses character selection and uses the checkpoint directly.
241
+ </p>
242
+ </label>
243
+ )}
244
+
245
+ {mode === "image" && characterId !== "none" && selectedCharacter && !selectedCharacterHasImageLora && (
246
+ <div className="rounded-lg border border-amber-900/60 bg-amber-950/20 p-3 text-xs text-amber-200">
247
+ This character does not have an image LoRA registered for the current workflow yet.
248
+ </div>
249
+ )}
250
+
251
+ {mode === "i2v" && (
252
+ <label className="block space-y-2">
253
+ <span className="text-xs font-medium text-gray-400">Source image from gallery</span>
254
+ <input
255
+ value={sourceImage}
256
+ onChange={(event) => setSourceImage(event.target.value)}
257
+ placeholder="images/example.png"
258
+ className="w-full rounded-lg bg-gray-950 border border-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:border-rose-600"
259
+ />
260
+ <p className="text-[10px] text-gray-600">Open a gallery item and use its filename as the source.</p>
261
+ </label>
262
+ )}
263
+
264
  <div className="grid grid-cols-2 gap-3">
265
  <NumberField label="Width" value={width} onChange={setWidth} min={512} max={2048} step={64} />
266
  <NumberField label="Height" value={height} onChange={setHeight} min={512} max={2048} step={64} />
267
+ {mode === "image" && <NumberField label="Steps" value={steps} onChange={setSteps} min={1} max={60} step={1} />}
268
+ {mode === "image" && <NumberField label="Guidance" value={guidance} onChange={setGuidance} min={1} max={10} step={0.5} />}
269
  </div>
270
 
271
+ {mode === "image" && (
272
+ <NumberField
273
+ label="LoRA strength"
274
+ value={loraStrength}
275
+ onChange={setLoraStrength}
276
+ min={0}
277
+ max={2}
278
+ step={0.05}
 
 
 
 
 
 
 
279
  />
280
+ )}
281
 
282
  <button
283
  onClick={submit}
284
  disabled={submitting}
285
  className="w-full rounded-lg bg-rose-600 hover:bg-rose-500 disabled:bg-gray-800 disabled:text-gray-500 px-4 py-2.5 text-sm font-semibold transition"
286
  >
287
+ {submitting ? "Queueing..." : mode === "image" ? "Generate image" : "Generate video"}
288
  </button>
289
 
290
  {error && (
 
298
  <p className="text-sm font-medium text-emerald-200">Queued successfully</p>
299
  <div className="text-xs text-emerald-100/80 space-y-1 break-all">
300
  <p>Prompt ID: {result.prompt_id}</p>
301
+ {result.checkpoint && <p>Checkpoint: {result.checkpoint}</p>}
302
+ {result.lora_name && <p>LoRA: {result.lora_name}</p>}
303
  </div>
304
  </div>
305
  )}
studio/src/components/sidebar/NodesTab.tsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ type RuntimeMap = {
4
+ comfyui?: { url: string; client_id?: string; online?: boolean; error?: string };
5
+ ai_toolkit?: { toolkit_dir: string; venv: string; training_dir: string; runner: string; status: string };
6
+ };
7
+
8
+ type NodeInfo = {
9
+ id?: string;
10
+ label?: string;
11
+ url?: string;
12
+ roles?: string[];
13
+ online: boolean;
14
+ error?: string;
15
+ runtimes?: RuntimeMap;
16
+ gpu_name?: string;
17
+ vram_total?: number;
18
+ vram_free?: number;
19
+ torch_vram_total?: number;
20
+ torch_vram_free?: number;
21
+ queue_running?: number;
22
+ queue_pending?: number;
23
+ system?: { comfyui_version?: string; os?: string };
24
+ };
25
+
26
+ function gb(value?: number) {
27
+ if (!value) return "—";
28
+ return `${(value / 1_000_000_000).toFixed(1)} GB`;
29
+ }
30
+
31
+ function vramPercent(node: NodeInfo) {
32
+ if (!node.vram_total || node.vram_free == null) return null;
33
+ return Math.max(0, Math.min(100, ((node.vram_total - node.vram_free) / node.vram_total) * 100));
34
+ }
35
+
36
+ function roleClass(role: string) {
37
+ if (role === "training") return "text-amber-300 border-amber-500/30 bg-amber-500/10";
38
+ if (role === "image" || role === "video") return "text-blue-300 border-blue-500/30 bg-blue-500/10";
39
+ return "text-gray-400 border-gray-700 bg-gray-900/60";
40
+ }
41
+
42
+ export function NodesTab() {
43
+ const [nodes, setNodes] = useState<Record<string, NodeInfo>>({});
44
+ const [loading, setLoading] = useState(true);
45
+ const [error, setError] = useState<string | null>(null);
46
+
47
+ useEffect(() => {
48
+ let cancelled = false;
49
+ async function load() {
50
+ try {
51
+ setError(null);
52
+ const response = await fetch("/api/nodes");
53
+ if (!response.ok) throw new Error(`/api/nodes returned ${response.status}`);
54
+ const data = await response.json();
55
+ if (!cancelled) setNodes(data.nodes || {});
56
+ } catch (err) {
57
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err));
58
+ } finally {
59
+ if (!cancelled) setLoading(false);
60
+ }
61
+ }
62
+ load();
63
+ const id = window.setInterval(load, 5000);
64
+ return () => {
65
+ cancelled = true;
66
+ window.clearInterval(id);
67
+ };
68
+ }, []);
69
+
70
+ const entries = Object.entries(nodes);
71
+
72
+ return (
73
+ <div className="h-full overflow-y-auto p-4 space-y-4">
74
+ <div>
75
+ <h2 className="text-sm font-semibold">GPU Nodes</h2>
76
+ <p className="text-xs text-gray-500 mt-1 leading-relaxed">
77
+ Compute workers and runtimes. ComfyUI handles image/video generation; ai-toolkit handles LoRA training on AMD GPUs.
78
+ </p>
79
+ </div>
80
+
81
+ {loading && <p className="text-xs text-gray-500">Checking nodes...</p>}
82
+ {error && <p className="text-xs text-red-400">{error}</p>}
83
+
84
+ {!loading && entries.length === 0 && !error && (
85
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-4 text-xs text-gray-500">
86
+ No GPU nodes configured.
87
+ </div>
88
+ )}
89
+
90
+ {entries.map(([id, node]) => {
91
+ const percent = vramPercent(node);
92
+ const comfy = node.runtimes?.comfyui;
93
+ const aiToolkit = node.runtimes?.ai_toolkit;
94
+ return (
95
+ <div key={id} className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-4 space-y-3">
96
+ <div className="flex items-start justify-between gap-3">
97
+ <div className="min-w-0">
98
+ <p className="text-sm font-semibold text-gray-200">{node.label || id}</p>
99
+ {comfy?.url && <p className="text-[11px] text-gray-600 break-all mt-0.5">ComfyUI: {comfy.url}</p>}
100
+ </div>
101
+ <span className={`text-[10px] uppercase tracking-wider rounded-full px-2 py-1 border ${
102
+ node.online
103
+ ? "text-emerald-300 border-emerald-500/30 bg-emerald-500/10"
104
+ : "text-red-300 border-red-500/30 bg-red-500/10"
105
+ }`}>
106
+ {node.online ? "Comfy online" : "Comfy offline"}
107
+ </span>
108
+ </div>
109
+
110
+ {node.roles && node.roles.length > 0 && (
111
+ <div className="flex flex-wrap gap-1.5">
112
+ {node.roles.map((role) => (
113
+ <span key={role} className={`text-[10px] uppercase tracking-wider rounded-full border px-2 py-0.5 ${roleClass(role)}`}>
114
+ {role}
115
+ </span>
116
+ ))}
117
+ </div>
118
+ )}
119
+
120
+ <div className="grid gap-2 text-xs">
121
+ {comfy && (
122
+ <div className="rounded-lg border border-gray-800 bg-black/30 p-2.5">
123
+ <div className="flex items-center justify-between gap-2">
124
+ <p className="font-semibold text-gray-300">ComfyUI</p>
125
+ <span className={comfy.online ? "text-emerald-400" : "text-red-400"}>{comfy.online ? "online" : "offline"}</span>
126
+ </div>
127
+ <p className="text-[11px] text-gray-600 mt-1">Image/video generation runtime.</p>
128
+ {comfy.error && <p className="text-[11px] text-red-300/70 mt-1 break-words">{comfy.error}</p>}
129
+ </div>
130
+ )}
131
+
132
+ {aiToolkit && (
133
+ <div className="rounded-lg border border-amber-900/40 bg-amber-950/10 p-2.5">
134
+ <div className="flex items-center justify-between gap-2">
135
+ <p className="font-semibold text-amber-200">ai-toolkit</p>
136
+ <span className="text-amber-300">training</span>
137
+ </div>
138
+ <p className="text-[11px] text-amber-100/60 mt-1">AMD GPU LoRA training runtime.</p>
139
+ <p className="text-[10px] text-gray-600 mt-1 break-all">{aiToolkit.training_dir}</p>
140
+ </div>
141
+ )}
142
+ </div>
143
+
144
+ {node.online && (
145
+ <div className="space-y-3">
146
+ <div>
147
+ <p className="text-xs text-gray-300">{node.gpu_name || "GPU detected"}</p>
148
+ <p className="text-[11px] text-gray-600 mt-0.5">
149
+ ComfyUI {node.system?.comfyui_version || "version unknown"}
150
+ </p>
151
+ </div>
152
+
153
+ <div className="space-y-1.5">
154
+ <div className="flex justify-between text-[11px] text-gray-500">
155
+ <span>VRAM used</span>
156
+ <span>{gb((node.vram_total || 0) - (node.vram_free || 0))} / {gb(node.vram_total)}</span>
157
+ </div>
158
+ <div className="h-2 rounded-full bg-gray-800 overflow-hidden">
159
+ <div className="h-full bg-rose-500" style={{ width: `${percent ?? 0}%` }} />
160
+ </div>
161
+ </div>
162
+
163
+ <div className="grid grid-cols-2 gap-2 text-xs">
164
+ <div className="rounded-lg border border-gray-800 bg-black/30 p-2">
165
+ <p className="text-gray-600">Running</p>
166
+ <p className="text-gray-200 font-semibold mt-1">{node.queue_running ?? 0}</p>
167
+ </div>
168
+ <div className="rounded-lg border border-gray-800 bg-black/30 p-2">
169
+ <p className="text-gray-600">Pending</p>
170
+ <p className="text-gray-200 font-semibold mt-1">{node.queue_pending ?? 0}</p>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ )}
175
+ </div>
176
+ );
177
+ })}
178
+ </div>
179
+ );
180
+ }
studio/src/components/sidebar/ProjectSidebar.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Plus, Sparkles, UserCircle, Clapperboard } from "lucide-react";
2
+ import type { ProjectModeData } from "../../types";
3
+
4
+ interface ProjectSidebarProps {
5
+ data: ProjectModeData;
6
+ }
7
+
8
+ export function ProjectSidebar({ data }: ProjectSidebarProps) {
9
+ const { project, scenes, shots, selectedSceneId, phase, onSelectScene, onAddScene } = data;
10
+
11
+ if (scenes.length === 0) {
12
+ return <OutlineSidebar data={data} />;
13
+ }
14
+
15
+ return (
16
+ <div className="flex-1 min-h-0 flex flex-col">
17
+ <div className="px-4 py-3 border-b border-gray-800/40 flex-shrink-0">
18
+ <p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Description</p>
19
+ <p className="text-xs text-gray-300 mt-1 leading-relaxed line-clamp-3">
20
+ {project.description || <span className="italic text-gray-600">No description yet.</span>}
21
+ </p>
22
+ </div>
23
+
24
+ <div className="px-4 py-2.5 flex items-center justify-between flex-shrink-0">
25
+ <span className="text-[11px] uppercase tracking-wider text-gray-500 font-medium">Scenes</span>
26
+ <button
27
+ onClick={onAddScene}
28
+ title="Add scene"
29
+ className="inline-flex items-center gap-1 rounded-lg border border-gray-800 hover:border-gray-700 hover:bg-gray-900/60 px-2 py-1 text-[10px] text-gray-400 hover:text-gray-200 transition"
30
+ >
31
+ <Plus className="w-3 h-3" /> Add scene
32
+ </button>
33
+ </div>
34
+
35
+ <div className="flex-1 min-h-0 overflow-y-auto px-2 pb-3 space-y-1">
36
+ {scenes.map((scene) => {
37
+ const sceneShots = shots.filter((s) => s.scene_id === scene.id);
38
+ const generated = sceneShots.filter((s) => s.image_file).length;
39
+ const active = scene.id === selectedSceneId;
40
+ return (
41
+ <button
42
+ key={scene.id}
43
+ onClick={() => onSelectScene(scene.id)}
44
+ className={`w-full text-left rounded-xl px-3 py-2.5 transition group ${active ? "bg-rose-600/10 ring-1 ring-rose-500/30" : "hover:bg-gray-900/50"}`}
45
+ >
46
+ <div className="flex items-center justify-between gap-2 mb-1">
47
+ <div className="flex items-center gap-2 min-w-0">
48
+ <span className={`text-[10px] font-mono ${active ? "text-rose-300" : "text-gray-500"}`}>S{scene.scene_number}</span>
49
+ <span className={`text-xs font-medium truncate ${active ? "text-gray-100" : "text-gray-300"}`}>
50
+ {scene.heading || "Untitled scene"}
51
+ </span>
52
+ </div>
53
+ </div>
54
+ {scene.summary && (
55
+ <p className="text-[11px] text-gray-500 leading-relaxed line-clamp-2">{scene.summary}</p>
56
+ )}
57
+ <div className="flex items-center gap-2 mt-2 text-[10px] text-gray-600">
58
+ <span>{sceneShots.length} shot{sceneShots.length === 1 ? "" : "s"}</span>
59
+ {generated > 0 && (
60
+ <span className={phase === "remix" ? "text-violet-400/70" : "text-emerald-400/70"}>
61
+ · {generated} rendered
62
+ </span>
63
+ )}
64
+ </div>
65
+ </button>
66
+ );
67
+ })}
68
+ </div>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function OutlineSidebar({ data }: ProjectSidebarProps) {
74
+ const { project, onAddScene } = data;
75
+ return (
76
+ <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
77
+ <section className="rounded-2xl border border-rose-500/20 bg-gradient-to-b from-rose-950/15 to-gray-900/20 p-4 space-y-2.5">
78
+ <div className="flex items-center gap-2">
79
+ <Sparkles className="w-3.5 h-3.5 text-rose-300" />
80
+ <span className="text-[11px] uppercase tracking-wider text-rose-200 font-semibold">Start the outline</span>
81
+ </div>
82
+ <p className="text-xs text-gray-300 leading-relaxed">
83
+ Pitch your project to your agent and it'll draft scenes and shots here. No scenes yet — once they appear, this sidebar becomes a scene switcher.
84
+ </p>
85
+ <div className="rounded-xl border border-gray-800/60 bg-black/30 p-3 space-y-1.5">
86
+ <p className="text-[10px] uppercase tracking-wider text-gray-500">Try saying</p>
87
+ <p className="text-[11px] text-gray-300 italic leading-relaxed">"Make me a 30s cyberpunk teaser, my character walking through neon rain."</p>
88
+ <p className="text-[11px] text-gray-300 italic leading-relaxed">"Put me in an Iron Man movie. Workshop, suit assembly, rooftop in the rain."</p>
89
+ </div>
90
+ </section>
91
+
92
+ <section className="space-y-2.5">
93
+ <p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Project</p>
94
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2">
95
+ <div>
96
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Title</p>
97
+ <p className="text-sm text-gray-100 mt-0.5">{project.title}</p>
98
+ </div>
99
+ {project.description && (
100
+ <div>
101
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Description</p>
102
+ <p className="text-xs text-gray-400 mt-0.5 leading-relaxed">{project.description}</p>
103
+ </div>
104
+ )}
105
+ <div className="grid grid-cols-2 gap-2 pt-1">
106
+ <div>
107
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Aspect</p>
108
+ <p className="text-xs text-gray-300 mt-0.5 font-mono">{project.aspect_ratio}</p>
109
+ </div>
110
+ <div>
111
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Duration</p>
112
+ <p className="text-xs text-gray-300 mt-0.5">{project.duration_seconds ?? "—"}s</p>
113
+ </div>
114
+ </div>
115
+ {project.characters.length > 0 && (
116
+ <div className="pt-1">
117
+ <p className="text-[10px] uppercase tracking-wider text-gray-600 mb-1.5">Cast</p>
118
+ <div className="flex flex-wrap gap-1">
119
+ {project.characters.map((id) => (
120
+ <span key={id} className="inline-flex items-center gap-1 rounded-md border border-gray-800 bg-gray-900/40 px-2 py-0.5 text-[11px] text-gray-300 font-mono">
121
+ <UserCircle className="w-3 h-3 text-gray-500" /> {id}
122
+ </span>
123
+ ))}
124
+ </div>
125
+ </div>
126
+ )}
127
+ </div>
128
+ </section>
129
+
130
+ <button
131
+ onClick={onAddScene}
132
+ className="w-full inline-flex items-center justify-center gap-2 rounded-xl border border-gray-700 bg-gray-900/40 hover:bg-gray-900 hover:border-gray-600 px-3 py-2.5 text-xs text-gray-300 transition"
133
+ >
134
+ <Plus className="w-3.5 h-3.5" /> Add scene manually
135
+ </button>
136
+
137
+ <div className="rounded-xl border border-gray-800/40 bg-gray-900/20 p-3 flex items-start gap-2.5">
138
+ <Clapperboard className="w-3.5 h-3.5 text-gray-500 mt-0.5 flex-shrink-0" />
139
+ <p className="text-[11px] text-gray-500 leading-relaxed">
140
+ Scenes hold the story beats. Shots inside scenes get rendered into images, then animated. Outline first; only generate after the structure is approved.
141
+ </p>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
studio/src/index.css CHANGED
@@ -5,3 +5,34 @@
5
  body {
6
  @apply bg-black text-white antialiased;
7
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  body {
6
  @apply bg-black text-white antialiased;
7
  }
8
+
9
+ /* Slim, dark-mode scrollbars — replaces the chunky default */
10
+ * {
11
+ scrollbar-width: thin;
12
+ scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
13
+ }
14
+
15
+ *::-webkit-scrollbar {
16
+ width: 8px;
17
+ height: 8px;
18
+ }
19
+
20
+ *::-webkit-scrollbar-track {
21
+ background: transparent;
22
+ }
23
+
24
+ *::-webkit-scrollbar-thumb {
25
+ background: rgba(255, 255, 255, 0.06);
26
+ border-radius: 9999px;
27
+ border: 2px solid transparent;
28
+ background-clip: content-box;
29
+ }
30
+
31
+ *::-webkit-scrollbar-thumb:hover {
32
+ background: rgba(244, 63, 94, 0.4);
33
+ background-clip: content-box;
34
+ }
35
+
36
+ *::-webkit-scrollbar-corner {
37
+ background: transparent;
38
+ }
studio/src/types.ts CHANGED
@@ -63,3 +63,81 @@ export interface JobItem {
63
  progress_percent?: number | null;
64
  error?: string;
65
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  progress_percent?: number | null;
64
  error?: string;
65
  }
66
+
67
+ export interface Project {
68
+ id: string;
69
+ title: string;
70
+ description: string | null;
71
+ aspect_ratio: string;
72
+ duration_seconds: number | null;
73
+ status: string;
74
+ characters: string[];
75
+ metadata: Record<string, unknown>;
76
+ created_at?: string;
77
+ updated_at?: string;
78
+ }
79
+
80
+ export interface Scene {
81
+ id: string;
82
+ project_id: string;
83
+ scene_number: number;
84
+ heading: string | null;
85
+ summary: string | null;
86
+ location: string | null;
87
+ time_of_day: string | null;
88
+ characters: string[];
89
+ metadata: Record<string, unknown>;
90
+ }
91
+
92
+ export interface Shot {
93
+ id: string;
94
+ project_id: string;
95
+ scene_id: string;
96
+ shot_number: number;
97
+ text: string | null;
98
+ description: string | null;
99
+ voiceover: string | null;
100
+ image_prompt: string | null;
101
+ motion_prompt: string | null;
102
+ camera_motion: string | null;
103
+ characters: string[];
104
+ duration_seconds: number;
105
+ status: string;
106
+ image_file: string | null;
107
+ video_file: string | null;
108
+ image_prompt_id: string | null;
109
+ video_prompt_id: string | null;
110
+ metadata: Record<string, unknown>;
111
+ }
112
+
113
+
114
+ export interface ShotVersion {
115
+ id: string;
116
+ project_id: string;
117
+ scene_id: string;
118
+ shot_id: string;
119
+ version_number: number;
120
+ kind: "image" | "video";
121
+ status: string;
122
+ prompt: string | null;
123
+ file: string | null;
124
+ prompt_id: string | null;
125
+ metadata: Record<string, unknown>;
126
+ created_at?: string;
127
+ }
128
+
129
+ export type ProjectPhase = "outline" | "remix";
130
+
131
+ export interface ProjectModeData {
132
+ project: Project;
133
+ scenes: Scene[];
134
+ shots: Shot[];
135
+ selectedSceneId: string | null;
136
+ selectedShotId: string | null;
137
+ phase: ProjectPhase;
138
+ onSelectScene: (sceneId: string) => void;
139
+ onSelectShot: (shotId: string | null) => void;
140
+ onBack: () => void;
141
+ onRefresh: () => Promise<void> | void;
142
+ onAddScene: () => Promise<void> | void;
143
+ }
studio/vite.config.d.ts DELETED
@@ -1,2 +0,0 @@
1
- declare const _default: import("vite").UserConfigFnObject;
2
- export default _default;
 
 
 
studio/vite.config.js DELETED
@@ -1,22 +0,0 @@
1
- import { defineConfig, loadEnv } from "vite";
2
- import react from "@vitejs/plugin-react";
3
- export default defineConfig(function (_a) {
4
- var mode = _a.mode;
5
- var env = loadEnv(mode, process.cwd(), "");
6
- var apiTarget = env.NEMOFLIX_AMD_API_URL || "http://127.0.0.1:8190";
7
- return {
8
- plugins: [react()],
9
- server: {
10
- host: "0.0.0.0",
11
- port: 3010,
12
- proxy: {
13
- "/api": { target: apiTarget, changeOrigin: true },
14
- "/media": { target: apiTarget, changeOrigin: true },
15
- },
16
- },
17
- build: {
18
- outDir: "dist",
19
- emptyOutDir: true,
20
- },
21
- };
22
- });