Update Docker Studio UI for projects workflow
Browse files- README.md +1 -1
- studio/src/App.tsx +139 -63
- studio/src/components/CharacterAssetsPanel.tsx +0 -299
- studio/src/components/CharacterDetail.tsx +0 -300
- studio/src/components/CharacterIdentityPanel.tsx +0 -190
- studio/src/components/CharacterProfileView.tsx +162 -0
- studio/src/components/GalleryView.tsx +134 -0
- studio/src/components/ProjectDetailView.tsx +566 -0
- studio/src/components/ProjectsGuide.tsx +5 -5
- studio/src/components/ProjectsView.tsx +186 -0
- studio/src/components/sidebar/AppSidebar.tsx +102 -81
- studio/src/components/sidebar/{GenerateLoraTab.tsx → GenerateTab.tsx} +195 -65
- studio/src/components/sidebar/NodesTab.tsx +180 -0
- studio/src/components/sidebar/ProjectSidebar.tsx +145 -0
- studio/src/index.css +31 -0
- studio/src/types.ts +78 -0
- studio/vite.config.d.ts +0 -2
- studio/vite.config.js +0 -22
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
|
|
|
|
| 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 {
|
| 4 |
-
import {
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 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>("
|
| 37 |
-
const [selectedCharacterId, setSelectedCharacterId] = useState<string>(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={() => {
|
| 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-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<span className="
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</span>
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
</
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 151 |
-
<
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
{
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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,
|
| 3 |
-
import type { LoraCheckpoint } from "../../types";
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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" && <
|
| 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 |
-
<
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
<
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
|
| 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 &&
|
| 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
|
| 207 |
-
|
| 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 |
-
<
|
|
|
|
| 325 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 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-
|
| 340 |
-
<p className="text-[
|
| 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-
|
| 351 |
-
<p className="text-[11px] font-semibold text-gray-300
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 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 |
-
</
|
| 372 |
|
| 373 |
-
<div className="
|
| 374 |
-
<p className="text-[
|
| 375 |
-
|
|
|
|
| 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
|
| 5 |
checkpoints: LoraCheckpoint[];
|
| 6 |
onQueued?: () => void;
|
| 7 |
}
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
ok: boolean;
|
| 11 |
-
checkpoint: string;
|
| 12 |
-
lora_name: string;
|
| 13 |
prompt_id?: string | null;
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
node_errors?: Record<string, unknown> | null;
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
| 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<
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
<
|
| 90 |
-
|
| 91 |
-
|
| 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">
|
| 103 |
<select
|
| 104 |
-
value={
|
| 105 |
-
onChange={(event) =>
|
| 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="
|
| 109 |
-
{
|
| 110 |
-
<option key={
|
| 111 |
-
{
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 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 |
-
|
| 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..." : "
|
| 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 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|