ortegarod commited on
Commit
dea9ad9
·
1 Parent(s): 64c4916

feat: add Nemoflix Studio UI, Docker server, and Space config

Browse files
Files changed (40) hide show
  1. Dockerfile +18 -0
  2. README.md +37 -6
  3. package.json +13 -0
  4. server.js +64 -0
  5. studio/components.json +25 -0
  6. studio/index.html +13 -0
  7. studio/package-lock.json +0 -0
  8. studio/package.json +34 -0
  9. studio/postcss.config.js +6 -0
  10. studio/src/App.tsx +527 -0
  11. studio/src/LandingPage.tsx +560 -0
  12. studio/src/api.ts +37 -0
  13. studio/src/components/CharacterProfileView.tsx +264 -0
  14. studio/src/components/GalleryView.tsx +156 -0
  15. studio/src/components/JobCard.tsx +80 -0
  16. studio/src/components/LoraTrainingPage.tsx +603 -0
  17. studio/src/components/MediaTile.tsx +165 -0
  18. studio/src/components/ProjectDetailView.tsx +1023 -0
  19. studio/src/components/ProjectFilmsView.tsx +176 -0
  20. studio/src/components/ProjectsGuide.tsx +106 -0
  21. studio/src/components/ProjectsView.tsx +203 -0
  22. studio/src/components/sidebar/AppSidebar.tsx +422 -0
  23. studio/src/components/sidebar/GenerateTab.tsx +352 -0
  24. studio/src/components/sidebar/NodesTab.tsx +180 -0
  25. studio/src/components/sidebar/ProjectSidebar.tsx +153 -0
  26. studio/src/components/ui/badge.tsx +52 -0
  27. studio/src/components/ui/button.tsx +58 -0
  28. studio/src/components/ui/card.tsx +103 -0
  29. studio/src/components/ui/input.tsx +20 -0
  30. studio/src/components/ui/progress.tsx +83 -0
  31. studio/src/components/ui/table.tsx +114 -0
  32. studio/src/components/ui/tabs.tsx +80 -0
  33. studio/src/index.css +123 -0
  34. studio/src/lib/utils.ts +6 -0
  35. studio/src/main.tsx +10 -0
  36. studio/src/types.ts +147 -0
  37. studio/tailwind.config.js +29 -0
  38. studio/tsconfig.json +25 -0
  39. studio/tsconfig.node.json +10 -0
  40. studio/vite.config.ts +27 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json ./
6
+ COPY server.js ./
7
+ COPY studio ./studio
8
+
9
+ WORKDIR /app/studio
10
+ RUN npm ci
11
+ RUN npm run build
12
+
13
+ WORKDIR /app
14
+ RUN npm install --omit=dev
15
+
16
+ ENV PORT=7860
17
+ EXPOSE 7860
18
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,12 +1,43 @@
1
  ---
2
  title: NemoFlix
3
- emoji: 👀
4
- colorFrom: pink
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
  short_description: Train a LoRA, generate images, animate into AI films.
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: NemoFlix
3
+ emoji: 🎬
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
  short_description: Train a LoRA, generate images, animate into AI films.
10
+ tags:
11
+ - amd
12
+ - amd-hackathon-2026
13
+ - comfyui
14
+ - video-generation
15
+ - lora
16
+ - ai-video
17
+ - image-generation
18
+ - flux
19
+ - wan
20
+ - agent-native
21
+ - self-hosted
22
+
23
  ---
24
 
25
+ # Nemoflix
26
+
27
+ **Nemoflix** is an open, self-hostable, API-first media studio for AI agents.
28
+
29
+ Upload a few photos, train a Flux.2 LoRA of yourself or any character (~90 min on AMD MI300X), then generate photorealistic images and animate them into short films with Wan 2.2 I2V. One consistent identity across every frame.
30
+
31
+ ## What it does
32
+
33
+ - **Train** — Fine-tune a Flux.2 LoRA from 15–25 reference photos (ROCm, no CUDA required)
34
+ - **Generate** — Photorealistic images with your trained identity in any scene
35
+ - **Animate** — Image-to-video with Wan 2.2 I2V (14B, fp8 scaled, LightX2V 2-step)
36
+ - **Direct** — Assemble scenes, shots, and final cuts in a project timeline
37
+
38
+
39
+ ## Required Space secrets
40
+
41
+ | Secret | Description |
42
+ | --- | --- |
43
+ | `NEMOFLIX_API_URL` | URL of your Nemoflix backend API (e.g. `http://your-vps:8190`) |
package.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nemoflix-amd-space",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "start": "node server.js"
7
+ },
8
+ "dependencies": {
9
+ "@fastify/static": "latest",
10
+ "fastify": "latest",
11
+ "http-proxy-middleware": "latest"
12
+ }
13
+ }
server.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Fastify from "fastify";
2
+ import fastifyStatic from "@fastify/static";
3
+ import { createProxyMiddleware } from "http-proxy-middleware";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const port = Number(process.env.PORT || 7860);
10
+
11
+ function normalizeApiUrl(value) {
12
+ let url = String(value || "").trim().replace(/\/+$/, "");
13
+ if (!url) return "";
14
+ if (!/^https?:\/\//i.test(url)) url = `http://${url}`;
15
+ const parsed = new URL(url);
16
+ if (!parsed.port) parsed.port = "8190";
17
+ return parsed.toString().replace(/\/+$/, "");
18
+ }
19
+
20
+ const apiTarget = normalizeApiUrl(process.env.NEMOFLIX_API_URL || process.env.NEMOFLIX_AMD_API_URL || "");
21
+ const app = Fastify({ logger: true });
22
+ const distDir = path.join(__dirname, "studio", "dist");
23
+
24
+ const backendProxy = apiTarget
25
+ ? createProxyMiddleware({
26
+ target: apiTarget,
27
+ changeOrigin: true,
28
+ ws: true,
29
+ logLevel: "warn",
30
+ timeout: 30_000,
31
+ proxyTimeout: 30_000,
32
+ on: {
33
+ error: (err, _req, res) => {
34
+ app.log.error({ err, apiTarget }, "backend proxy error");
35
+ if (!res.headersSent) {
36
+ res.writeHead(502, { "Content-Type": "application/json" });
37
+ }
38
+ res.end(JSON.stringify({ detail: `Backend unavailable: ${err.message}` }));
39
+ },
40
+ },
41
+ })
42
+ : null;
43
+
44
+ app.addHook("onRequest", async (request, reply) => {
45
+ if (request.url.startsWith("/api/") || request.url === "/api" || request.url.startsWith("/media/") || request.url === "/media") {
46
+ if (!backendProxy) {
47
+ return reply.code(503).send({ detail: "NEMOFLIX_API_URL is not configured" });
48
+ }
49
+ await new Promise((resolve, reject) => backendProxy(request.raw, reply.raw, (err) => err ? reject(err) : resolve()));
50
+ reply.hijack();
51
+ }
52
+ });
53
+
54
+ app.get("/space-health", async () => ({ ok: true, apiConfigured: Boolean(apiTarget), apiTarget: apiTarget || null }));
55
+
56
+ await app.register(fastifyStatic, { root: distDir, prefix: "/" });
57
+
58
+ app.setNotFoundHandler((request, reply) => {
59
+ const index = path.join(distDir, "index.html");
60
+ if (fs.existsSync(index)) return reply.type("text/html").send(fs.createReadStream(index));
61
+ return reply.code(404).send("Studio build not found");
62
+ });
63
+
64
+ app.listen({ host: "0.0.0.0", port });
studio/components.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.js",
8
+ "css": "src/index.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "menuColor": "default",
23
+ "menuAccent": "subtle",
24
+ "registries": {}
25
+ }
studio/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Nemoflix</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%23000'/%3E%3Ctext x='50%25' y='55%25' dominant-baseline='middle' text-anchor='middle' font-size='60'%3E%F0%9F%8E%AC%3C/text%3E%3C/svg%3E" />
8
+ </head>
9
+ <body class="bg-black text-white">
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
studio/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
studio/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nemoflix-studio",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@base-ui/react": "^1.4.1",
13
+ "@fontsource-variable/geist": "^5.2.8",
14
+ "class-variance-authority": "^0.7.1",
15
+ "clsx": "^2.1.1",
16
+ "lucide-react": "^1.14.0",
17
+ "react": "^19.2.4",
18
+ "react-dom": "^19.2.4",
19
+ "react-router-dom": "^7.15.0",
20
+ "shadcn": "^4.7.0",
21
+ "tailwind-merge": "^3.5.0",
22
+ "tw-animate-css": "^1.4.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.2.14",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^6.0.1",
28
+ "autoprefixer": "^10.4.21",
29
+ "postcss": "^8.5.3",
30
+ "tailwindcss": "^3.4.1",
31
+ "typescript": "~5.9.3",
32
+ "vite": "^8.0.1"
33
+ }
34
+ }
studio/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
studio/src/App.tsx ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react";
2
+ import { BrowserRouter, Routes, Route, Outlet, useMatch, useNavigate, useParams, Link } from "react-router-dom";
3
+ import { Menu, Sparkles, UserCircle } from "lucide-react";
4
+ import { StudioView } from "./components/GalleryView";
5
+ import { CharacterProfileView } from "./components/CharacterProfileView";
6
+ import { ProjectsView } from "./components/ProjectsView";
7
+ import { LoraTrainingPage } from "./components/LoraTrainingPage";
8
+ import { ProjectDetailView } from "./components/ProjectDetailView";
9
+ import { ProjectFilmsView } from "./components/ProjectFilmsView";
10
+ import { AppSidebar } from "./components/sidebar/AppSidebar";
11
+ import LandingPage from "./LandingPage";
12
+ import type { SidebarTab } from "./components/sidebar/AppSidebar";
13
+ import type { JobItem, LoraCheckpoint, LoraTrainingStatus, MediaItem, Project, Scene, Shot, ProjectPhase, ProjectModeData } from "./types";
14
+
15
+ async function fetchJson<T>(url: string, timeoutMs = 5000): Promise<T> {
16
+ const controller = new AbortController();
17
+ const id = window.setTimeout(() => controller.abort(), timeoutMs);
18
+ try {
19
+ const response = await fetch(url, { signal: controller.signal });
20
+ if (!response.ok) throw new Error(`${url} returned ${response.status}`);
21
+ return await response.json();
22
+ } finally {
23
+ window.clearTimeout(id);
24
+ }
25
+ }
26
+
27
+ /* ── App Context ── */
28
+ interface AppContextType {
29
+ items: MediaItem[];
30
+ jobs: JobItem[];
31
+ loading: boolean;
32
+ hasLoadedOnce: boolean;
33
+ error: string | null;
34
+ selected: string | null;
35
+ setSelected: (url: string | null) => void;
36
+ sidebarOpen: boolean;
37
+ setSidebarOpen: (v: boolean) => void;
38
+ activeSidebarTab: SidebarTab;
39
+ setActiveSidebarTab: (tab: SidebarTab) => void;
40
+ training: LoraTrainingStatus | null;
41
+ checkpoints: LoraCheckpoint[];
42
+ trainingJobs: any[];
43
+ deleteItem: (item: MediaItem) => Promise<void>;
44
+ load: () => Promise<void>;
45
+ projectData: { project: Project; scenes: Scene[]; shots: Shot[] } | null;
46
+ selectedSceneId: string | null;
47
+ selectedShotId: string | null;
48
+ setSelectedSceneId: (id: string | null) => void;
49
+ setSelectedShotId: (id: string | null) => void;
50
+ loadProject: (id: string) => Promise<void>;
51
+ addScene: () => Promise<void>;
52
+ deleteScene: (sceneId: string) => Promise<void>;
53
+ deleteShot: (shotId: string) => Promise<void>;
54
+ }
55
+
56
+ const AppContext = createContext<AppContextType>(null!);
57
+ export function useApp() {
58
+ return useContext(AppContext);
59
+ }
60
+
61
+ /* ── Layout Shell: header + sidebar + <Outlet /> ── */
62
+ function Shell() {
63
+ const ctx = useApp();
64
+ const navigate = useNavigate();
65
+ const projectMatch = useMatch("/studio/projects/:projectId");
66
+ const loraMatch = useMatch("/studio/lora-training");
67
+
68
+ // Load project when entering a project-detail route
69
+ const lastProjectId = useRef<string | undefined>(undefined);
70
+ useEffect(() => {
71
+ const pid = projectMatch?.params.projectId;
72
+ if (pid && pid !== lastProjectId.current) {
73
+ lastProjectId.current = pid;
74
+ ctx.loadProject(pid);
75
+ ctx.setActiveSidebarTab("projects");
76
+ }
77
+ }, [projectMatch?.params.projectId]);
78
+
79
+ // Keep "Characters & LoRA Training" sidebar tab highlighted when on /lora-training
80
+ useEffect(() => {
81
+ if (loraMatch) ctx.setActiveSidebarTab("characters");
82
+ }, [loraMatch]);
83
+
84
+ const videoCount = ctx.items.filter(
85
+ (item) => item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm")
86
+ ).length;
87
+ const imageCount = ctx.items.length - videoCount;
88
+
89
+ const projectMode: ProjectModeData | undefined =
90
+ projectMatch && ctx.projectData
91
+ ? {
92
+ project: ctx.projectData.project,
93
+ scenes: ctx.projectData.scenes,
94
+ shots: ctx.projectData.shots,
95
+ selectedSceneId: ctx.selectedSceneId,
96
+ selectedShotId: ctx.selectedShotId,
97
+ phase: "outline",
98
+ onSelectScene: (id) => {
99
+ ctx.setSelectedSceneId(id);
100
+ ctx.setSelectedShotId(null);
101
+ },
102
+ onSelectShot: ctx.setSelectedShotId,
103
+ onBack: () => {
104
+ ctx.setSelectedShotId(null);
105
+ navigate("/studio/projects");
106
+ },
107
+ onRefresh: () => {
108
+ const pid = projectMatch?.params.projectId;
109
+ return pid ? ctx.loadProject(pid) : Promise.resolve();
110
+ },
111
+ onAddScene: ctx.addScene,
112
+ onDeleteScene: ctx.deleteScene,
113
+ onDeleteShot: ctx.deleteShot,
114
+ }
115
+ : undefined;
116
+
117
+ return (
118
+ <div className="min-h-screen bg-black text-white flex flex-col">
119
+ <header className="h-14 border-b border-gray-800/60 bg-black/90 backdrop-blur-xl flex items-center justify-between px-5 sticky top-0 z-40">
120
+ <div className="flex items-center gap-3 min-w-0">
121
+ {!ctx.sidebarOpen && (
122
+ <button
123
+ onClick={() => ctx.setSidebarOpen(true)}
124
+ className="w-9 h-9 rounded-xl border border-gray-800 text-gray-500 hover:text-gray-200 hover:border-gray-600 transition"
125
+ title="Open tools"
126
+ >
127
+ <Menu className="w-4 h-4 mx-auto" />
128
+ </button>
129
+ )}
130
+ <button
131
+ onClick={() => {
132
+ navigate("/studio");
133
+ ctx.setActiveSidebarTab("generate");
134
+ }}
135
+ className="flex items-center gap-3 min-w-0 hover:opacity-80 transition"
136
+ title="Studio home"
137
+ >
138
+ <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-rose-500 via-fuchsia-500 to-amber-400 flex items-center justify-center shadow-lg shadow-rose-500/20 ring-1 ring-white/10">
139
+ <Sparkles className="w-4 h-4 text-white" />
140
+ </div>
141
+ <div className="min-w-0 hidden sm:block">
142
+ <h1 className="text-sm font-bold tracking-tight">Nemoflix Studio</h1>
143
+ <p className="text-[10px] text-rose-400/60 tracking-wide">AMD MI300X</p>
144
+ </div>
145
+ </button>
146
+ <Link to="/" className="hidden sm:inline-flex items-center text-[11px] text-gray-600 hover:text-gray-400 transition ml-1" title="Back to home">
147
+ ← Home
148
+ </Link>
149
+ </div>
150
+
151
+ <div className="flex items-center gap-2 text-[11px]">
152
+ <div className="hidden md:flex items-center gap-1.5">
153
+ {ctx.jobs.length > 0 && (
154
+ <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">
155
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
156
+ {ctx.jobs.length} generating
157
+ </span>
158
+ )}
159
+ <span className="rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
160
+ {ctx.items.length} media
161
+ </span>
162
+ <span className="hidden lg:inline rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
163
+ {imageCount} images
164
+ </span>
165
+ <span className="hidden lg:inline rounded-full border border-gray-800 bg-gray-900/50 px-2.5 py-1 text-gray-500">
166
+ {videoCount} videos
167
+ </span>
168
+ </div>
169
+
170
+ <div className="group relative">
171
+ <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">
172
+ <UserCircle className="w-4 h-4" />
173
+ <span className="hidden sm:inline font-medium">Demo Account</span>
174
+ <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">
175
+ Hackathon
176
+ </span>
177
+ </button>
178
+ <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">
179
+ <p className="text-xs font-semibold text-gray-200">Demo workspace</p>
180
+ <p className="text-[11px] text-gray-500 mt-1 leading-relaxed">
181
+ This hackathon build uses a sample owner dataset to demonstrate character LoRA training,
182
+ generation, and media management. Authentication is intentionally mocked for the demo.
183
+ </p>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ </header>
188
+
189
+ <div className="flex flex-1 min-h-0">
190
+ {ctx.sidebarOpen && (
191
+ <AppSidebar
192
+ activeTab={ctx.activeSidebarTab}
193
+ onTabChange={(tab) => {
194
+ ctx.setActiveSidebarTab(tab);
195
+ if (tab === "generate") navigate("/studio");
196
+ if (tab === "projects") navigate("/studio/projects");
197
+ if (tab === "characters") navigate("/studio/lora-training");
198
+ }}
199
+ onClose={() => ctx.setSidebarOpen(false)}
200
+ checkpoints={ctx.checkpoints}
201
+ onQueued={ctx.load}
202
+ onSelectCharacter={(id) => {
203
+ ctx.setActiveSidebarTab("characters");
204
+ navigate(`/studio/characters/${id}`);
205
+ }}
206
+ projectMode={projectMode}
207
+ />
208
+ )}
209
+
210
+ <main className="flex-1 min-w-0 overflow-y-auto bg-gradient-to-b from-transparent via-transparent to-gray-950/30">
211
+ <Outlet />
212
+ </main>
213
+ </div>
214
+
215
+ {/* Lightbox */}
216
+ {ctx.selected && (
217
+ <div
218
+ className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center p-4"
219
+ onClick={() => ctx.setSelected(null)}
220
+ >
221
+ <div className="max-w-full max-h-full" onClick={(e) => e.stopPropagation()}>
222
+ {ctx.selected.endsWith(".mp4") || ctx.selected.endsWith(".webm") ? (
223
+ <video src={ctx.selected} controls autoPlay className="max-w-full max-h-[90vh] rounded" />
224
+ ) : (
225
+ <img src={ctx.selected} alt="" className="max-w-full max-h-[90vh] rounded" />
226
+ )}
227
+ </div>
228
+ </div>
229
+ )}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ /* ── Route wrappers that connect URL params to existing components ── */
235
+ function StudioRoute() {
236
+ const ctx = useApp();
237
+ return (
238
+ <StudioView
239
+ items={ctx.items}
240
+ jobs={ctx.jobs}
241
+ loading={ctx.loading && !ctx.hasLoadedOnce && !(ctx.jobs.length > 0 || ctx.items.length > 0)}
242
+ error={ctx.error}
243
+ onOpen={ctx.setSelected}
244
+ onDelete={ctx.deleteItem}
245
+ onOpenProjects={() => window.location.href = "/studio/projects"}
246
+ />
247
+ );
248
+ }
249
+
250
+ function ProjectsRoute() {
251
+ const navigate = useNavigate();
252
+ return <ProjectsView onOpenProject={(id) => navigate(`/studio/projects/${id}`)} />;
253
+ }
254
+
255
+ function ProjectRoute() {
256
+ const { projectId } = useParams<{ projectId: string }>();
257
+ const ctx = useApp();
258
+
259
+ useEffect(() => {
260
+ if (projectId) ctx.loadProject(projectId);
261
+ }, [projectId]);
262
+
263
+ const navigate = useNavigate();
264
+
265
+ if (!ctx.projectData) {
266
+ return <div className="p-8 text-center text-gray-500">Loading project…</div>;
267
+ }
268
+
269
+ return (
270
+ <ProjectDetailView
271
+ project={ctx.projectData.project}
272
+ scenes={ctx.projectData.scenes}
273
+ shots={ctx.projectData.shots}
274
+ jobs={ctx.jobs}
275
+ selectedSceneId={ctx.selectedSceneId}
276
+ selectedShotId={ctx.selectedShotId}
277
+ onSelectScene={(id) => {
278
+ ctx.setSelectedSceneId(id);
279
+ ctx.setSelectedShotId(null);
280
+ }}
281
+ onSelectShot={ctx.setSelectedShotId}
282
+ onRefresh={() => (projectId ? ctx.loadProject(projectId) : Promise.resolve())}
283
+ onBack={() => {
284
+ ctx.setSelectedShotId(null);
285
+ navigate("/studio/projects");
286
+ }}
287
+ onDeleteScene={ctx.deleteScene}
288
+ onDeleteShot={ctx.deleteShot}
289
+ />
290
+ );
291
+ }
292
+
293
+ function ProjectFilmsRoute() {
294
+ const { projectId } = useParams<{ projectId: string }>();
295
+ const navigate = useNavigate();
296
+ if (!projectId) return null;
297
+ return (
298
+ <ProjectFilmsView
299
+ projectId={projectId}
300
+ onBack={() => navigate(`/studio/projects/${projectId}`)}
301
+ />
302
+ );
303
+ }
304
+
305
+ function CharacterRoute() {
306
+ const { characterId } = useParams<{ characterId: string }>();
307
+ const { items, setSelected, deleteItem, setActiveSidebarTab, setSidebarOpen } = useApp();
308
+ const navigate = useNavigate();
309
+ if (!characterId) return null;
310
+ return (
311
+ <CharacterProfileView
312
+ characterId={characterId}
313
+ items={items}
314
+ onOpen={setSelected}
315
+ onDelete={deleteItem}
316
+ onGenerate={() => {
317
+ setSidebarOpen(true);
318
+ setActiveSidebarTab("generate");
319
+ navigate(`/studio?character=${characterId}`);
320
+ }}
321
+ />
322
+ );
323
+ }
324
+
325
+ /* ── App Root ── */
326
+ export default function App() {
327
+ const [items, setItems] = useState<MediaItem[]>([]);
328
+ const [jobs, setJobs] = useState<JobItem[]>([]);
329
+ const [training, setTraining] = useState<LoraTrainingStatus | null>(null);
330
+ const [checkpoints, setCheckpoints] = useState<LoraCheckpoint[]>([]);
331
+ const [trainingJobs, setTrainingJobs] = useState<any[]>([]);
332
+ const [loading, setLoading] = useState(true);
333
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
334
+ const [error, setError] = useState<string | null>(null);
335
+ const [selected, setSelected] = useState<string | null>(null);
336
+ const [sidebarOpen, setSidebarOpen] = useState(true);
337
+ const [activeSidebarTab, setActiveSidebarTab] = useState<SidebarTab>("generate");
338
+ const [projectData, setProjectData] = useState<{
339
+ project: Project;
340
+ scenes: Scene[];
341
+ shots: Shot[];
342
+ } | null>(null);
343
+ const [selectedSceneId, setSelectedSceneId] = useState<string | null>(null);
344
+ const [selectedShotId, setSelectedShotId] = useState<string | null>(null);
345
+ const [projectId, setProjectId] = useState<string | null>(null);
346
+
347
+ const load = useCallback(async () => {
348
+ try {
349
+ setError(null);
350
+ const listing = await fetchJson<{ images?: MediaItem[] }>("/api/listing", 8000);
351
+ setItems(listing.images || []);
352
+ if (!hasLoadedOnce) setHasLoadedOnce(true);
353
+ } catch (e) {
354
+ console.error("Failed to load gallery", e);
355
+ setError(e instanceof Error ? e.message : "Failed to load gallery");
356
+ } finally {
357
+ setLoading(false);
358
+ }
359
+
360
+ const [jobsResult, trainingResult, checkpointsResult, trainingJobsResult] = await Promise.allSettled([
361
+ fetchJson<{ jobs?: JobItem[] }>("/api/jobs", 3500),
362
+ fetchJson<LoraTrainingStatus & { ok?: boolean }>("/api/lora-training/status", 3500),
363
+ fetchJson<{ checkpoints?: LoraCheckpoint[] }>("/api/lora-training/checkpoints", 3500),
364
+ fetchJson<{ jobs?: any[] }>("/api/lora-training/jobs", 3500),
365
+ ]);
366
+
367
+ if (jobsResult.status === "fulfilled") setJobs(jobsResult.value.jobs || []);
368
+ if (trainingResult.status === "fulfilled")
369
+ setTraining(trainingResult.value.ok ? trainingResult.value : null);
370
+ if (checkpointsResult.status === "fulfilled")
371
+ setCheckpoints(checkpointsResult.value.checkpoints || []);
372
+ if (trainingJobsResult.status === "fulfilled")
373
+ setTrainingJobs(trainingJobsResult.value.jobs || []);
374
+ }, []);
375
+
376
+ // Stable polling — uses refs to avoid dependency churn
377
+ const loadRef = useRef(load);
378
+ loadRef.current = load;
379
+
380
+ useEffect(() => {
381
+ loadRef.current();
382
+ const id = window.setInterval(() => loadRef.current(), 5000);
383
+
384
+ const es = new EventSource("/api/events");
385
+ es.addEventListener("job_update", () => loadRef.current());
386
+ es.onerror = () => {};
387
+
388
+ return () => {
389
+ window.clearInterval(id);
390
+ es.close();
391
+ };
392
+ }, []);
393
+
394
+ const loadProject = useCallback(async (id: string) => {
395
+ setProjectId(id);
396
+ try {
397
+ const response = await fetch(`/api/projects/${id}`);
398
+ if (!response.ok) throw new Error(`Project fetch failed: ${response.status}`);
399
+ const data = await response.json();
400
+ const scenes: Scene[] = (data.scenes || []).slice().sort(
401
+ (a: Scene, b: Scene) => a.scene_number - b.scene_number
402
+ );
403
+ const shots: Shot[] = (data.shots || []).slice().sort(
404
+ (a: Shot, b: Shot) => a.shot_number - b.shot_number
405
+ );
406
+ setProjectData({ project: data.project, scenes, shots });
407
+ setSelectedSceneId((current) => {
408
+ if (current && scenes.some((scene) => scene.id === current)) return current;
409
+ return scenes.length > 0 ? scenes[0].id : null;
410
+ });
411
+ } catch (e) {
412
+ console.error("Failed to load project", e);
413
+ }
414
+ }, []);
415
+
416
+ const addScene = useCallback(async () => {
417
+ if (!projectId || !projectData) return;
418
+ const next =
419
+ projectData.scenes.length > 0
420
+ ? Math.max(...projectData.scenes.map((scene) => scene.scene_number)) + 1
421
+ : 1;
422
+ try {
423
+ const response = await fetch(`/api/projects/${projectId}/scenes`, {
424
+ method: "POST",
425
+ headers: { "Content-Type": "application/json" },
426
+ body: JSON.stringify({ scene_number: next, heading: `SCENE ${next}` }),
427
+ });
428
+ if (!response.ok) throw new Error(`Add scene failed: ${response.status}`);
429
+ const created = await response.json();
430
+ await loadProject(projectId);
431
+ setSelectedSceneId(created.id);
432
+ setSelectedShotId(null);
433
+ } catch (e) {
434
+ console.error("Failed to add scene", e);
435
+ }
436
+ }, [projectId, projectData, loadProject]);
437
+
438
+ const deleteScene = useCallback(async (sceneId: string) => {
439
+ if (!projectId) return;
440
+ if (!confirm("Delete this scene and all its shots?")) return;
441
+ await fetch(`/api/projects/${projectId}/scenes/${sceneId}`, { method: "DELETE" });
442
+ if (selectedSceneId === sceneId) {
443
+ setSelectedSceneId("");
444
+ setSelectedShotId(null);
445
+ }
446
+ loadProject(projectId);
447
+ }, [projectId, selectedSceneId, loadProject]);
448
+
449
+ const deleteShot = useCallback(async (shotId: string) => {
450
+ if (!projectId || !projectData) return;
451
+ if (!confirm("Delete this shot?")) return;
452
+ const shot = projectData.shots.find((s) => s.id === shotId);
453
+ if (!shot) return;
454
+ await fetch(`/api/projects/${projectId}/scenes/${shot.scene_id}/shots/${shotId}`, { method: "DELETE" });
455
+ if (selectedShotId === shotId) setSelectedShotId(null);
456
+ loadProject(projectId);
457
+ }, [projectId, projectData, selectedShotId, loadProject]);
458
+
459
+ const deleteItem = useCallback(
460
+ async (item: MediaItem) => {
461
+ const filename = item.filename || item.url.replace(/^\/media\//, "");
462
+ const response = await fetch("/api/delete", {
463
+ method: "POST",
464
+ headers: { "Content-Type": "application/json" },
465
+ body: JSON.stringify({ files: [filename] }),
466
+ });
467
+ if (!response.ok) {
468
+ const data = await response.json().catch(() => ({}));
469
+ throw new Error(data?.detail || `Failed to delete ${filename}`);
470
+ }
471
+ setItems((current) =>
472
+ current.filter(
473
+ (candidate) =>
474
+ (candidate.filename || candidate.url) !== (item.filename || item.url)
475
+ )
476
+ );
477
+ if (selected === item.url) setSelected(null);
478
+ },
479
+ [selected]
480
+ );
481
+
482
+ const ctxValue: AppContextType = {
483
+ items,
484
+ jobs,
485
+ loading,
486
+ hasLoadedOnce,
487
+ error,
488
+ selected,
489
+ setSelected,
490
+ sidebarOpen,
491
+ setSidebarOpen,
492
+ activeSidebarTab,
493
+ setActiveSidebarTab,
494
+ training,
495
+ checkpoints,
496
+ trainingJobs,
497
+ deleteItem,
498
+ load,
499
+ projectData,
500
+ selectedSceneId,
501
+ selectedShotId,
502
+ setSelectedSceneId,
503
+ setSelectedShotId,
504
+ loadProject,
505
+ addScene,
506
+ deleteScene,
507
+ deleteShot,
508
+ };
509
+
510
+ return (
511
+ <BrowserRouter>
512
+ <AppContext.Provider value={ctxValue}>
513
+ <Routes>
514
+ <Route path="/" element={<LandingPage />} />
515
+ <Route path="/studio" element={<Shell />}>
516
+ <Route index element={<StudioRoute />} />
517
+ <Route path="projects" element={<ProjectsRoute />} />
518
+ <Route path="projects/:projectId" element={<ProjectRoute />} />
519
+ <Route path="projects/:projectId/films" element={<ProjectFilmsRoute />} />
520
+ <Route path="characters/:characterId" element={<CharacterRoute />} />
521
+ <Route path="lora-training" element={<LoraTrainingPage />} />
522
+ </Route>
523
+ </Routes>
524
+ </AppContext.Provider>
525
+ </BrowserRouter>
526
+ );
527
+ }
studio/src/LandingPage.tsx ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "react-router-dom";
2
+ import {
3
+ ArrowRight,
4
+ Bot,
5
+ Cpu,
6
+ Film,
7
+ Lock,
8
+ Play,
9
+ Server,
10
+ Sparkles,
11
+ Users,
12
+ Zap,
13
+ } from "lucide-react";
14
+ import { Badge } from "@/components/ui/badge";
15
+ import {
16
+ Card,
17
+ CardContent,
18
+ CardDescription,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from "@/components/ui/card";
22
+
23
+ const btnPrimary = "inline-flex items-center gap-2 rounded-xl bg-rose-600 hover:bg-rose-500 px-6 py-2.5 text-sm font-semibold text-white transition shadow-lg shadow-rose-500/20";
24
+ const btnOutline = "inline-flex items-center gap-2 rounded-xl border border-gray-700 hover:border-gray-500 px-6 py-2.5 text-sm font-semibold text-gray-300 hover:text-white transition";
25
+
26
+ function GitHubIcon({ className }: { className?: string }) {
27
+ return (
28
+ <svg viewBox="0 0 24 24" fill="currentColor" className={className}>
29
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
30
+ </svg>
31
+ );
32
+ }
33
+
34
+ /* ── Navbar ── */
35
+ function Navbar() {
36
+ return (
37
+ <nav className="fixed top-0 inset-x-0 z-50 h-14 border-b border-gray-800/60 bg-black/90 backdrop-blur-xl flex items-center px-6">
38
+ <div className="max-w-6xl mx-auto w-full flex items-center justify-between">
39
+ <div className="flex items-center gap-2.5">
40
+ <div className="w-7 h-7 rounded-lg bg-gradient-to-br from-rose-500 via-fuchsia-500 to-amber-400 flex items-center justify-center">
41
+ <Sparkles className="w-3.5 h-3.5 text-white" />
42
+ </div>
43
+ <span className="font-bold text-sm tracking-tight text-white">
44
+ <span className="text-rose-400">Nemo</span>flix
45
+ </span>
46
+ </div>
47
+
48
+ <div className="flex items-center gap-2">
49
+ <a
50
+ href="https://github.com/ortegarod/nemoflix"
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-gray-500 hover:text-gray-200 transition"
54
+ >
55
+ <GitHubIcon className="w-3.5 h-3.5" />
56
+ <span className="hidden sm:inline">GitHub</span>
57
+ </a>
58
+ <Link
59
+ to="/studio"
60
+ className="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 hover:bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition"
61
+ >
62
+ Launch Studio <ArrowRight className="w-3.5 h-3.5" />
63
+ </Link>
64
+ </div>
65
+ </div>
66
+ </nav>
67
+ );
68
+ }
69
+
70
+ /* ── Hero ── */
71
+ function Hero() {
72
+ return (
73
+ <section className="min-h-screen flex items-center justify-center pt-14 px-6">
74
+ <div className="max-w-4xl text-center">
75
+ <Badge
76
+ className="mb-8 gap-1.5 border-amber-500/30 bg-amber-500/5 text-amber-300 h-7 px-3"
77
+ variant="outline"
78
+ >
79
+ <Cpu className="w-3.5 h-3.5" />
80
+ AMD MI300X · 192 GB VRAM · Open Source
81
+ </Badge>
82
+
83
+ <h1 className="text-5xl md:text-7xl font-bold leading-tight tracking-tight mb-6 text-white">
84
+ Put Yourself
85
+ <br />
86
+ <span className="bg-gradient-to-r from-rose-400 via-fuchsia-400 to-amber-400 bg-clip-text text-transparent">
87
+ In the Film.
88
+ </span>
89
+ </h1>
90
+
91
+ {/* Hero video — phone-sized preview right under headline */}
92
+ <div className="mx-auto max-w-xs sm:max-w-sm md:max-w-md mb-10">
93
+ <div className="rounded-[1.5rem] overflow-hidden border border-gray-800/60 bg-gray-950 shadow-2xl shadow-rose-500/10 ring-1 ring-white/5 aspect-square">
94
+ <video
95
+ src="/demos/iron-man-flight.mp4"
96
+ controls
97
+ poster=""
98
+ className="w-full h-full object-cover"
99
+ preload="metadata"
100
+ autoPlay
101
+ muted
102
+ loop
103
+ playsInline
104
+ />
105
+ </div>
106
+ <div className="flex items-center justify-center gap-2 mt-2">
107
+ <span className="text-[10px] text-gray-600 uppercase tracking-wider">Wan 2.2 I2V · AMD MI300X</span>
108
+ </div>
109
+ </div>
110
+
111
+ <p className="text-xl text-gray-400 max-w-2xl mx-auto mb-10 leading-relaxed">
112
+ Upload a few photos. Build a model of yourself. Generate photorealistic
113
+ images in any scene you can imagine. Animate them into short films.
114
+ <span className="text-white font-medium"> Make the impossible, possible.</span>
115
+ </p>
116
+
117
+ <div className="flex flex-col items-center gap-3">
118
+ <Link to="/studio" className={btnPrimary}>
119
+ <Sparkles className="w-4 h-4" />
120
+ Launch Studio
121
+ </Link>
122
+ <a
123
+ href="https://github.com/ortegarod/nemoflix"
124
+ target="_blank"
125
+ rel="noopener noreferrer"
126
+ className="text-xs text-gray-500 hover:text-gray-300 transition"
127
+ >
128
+ View on GitHub →
129
+ </a>
130
+ </div>
131
+
132
+ <p className="mt-8 text-xs text-gray-700">
133
+ Self-hosted · Agent-native · MIT License
134
+ </p>
135
+ </div>
136
+ </section>
137
+ );
138
+ }
139
+
140
+ /* ── Three pillars ── */
141
+ function Pillars() {
142
+ const items = [
143
+ {
144
+ icon: Cpu,
145
+ color: "text-amber-400",
146
+ border: "border-amber-500/20",
147
+ bg: "from-amber-950/20",
148
+ label: "AMD MI300X",
149
+ body: "Fine-tune Flux.2 LoRAs on 192 GB VRAM via ROCm. ~90 minutes to a consistent character. No CUDA.",
150
+ },
151
+ {
152
+ icon: Bot,
153
+ color: "text-rose-400",
154
+ border: "border-rose-500/20",
155
+ bg: "from-rose-950/20",
156
+ label: "Agent Skill File",
157
+ body: "Agent reading this? Pull the repo and read SKILL.md. Every endpoint, every workflow, every parameter. One file. No SDK.",
158
+ },
159
+ {
160
+ icon: Film,
161
+ color: "text-fuchsia-400",
162
+ border: "border-fuchsia-500/20",
163
+ bg: "from-fuchsia-950/20",
164
+ label: "Image + Video",
165
+ body: "Photorealistic Flux.2 images and Wan 2.2 I2V animation on the same pipeline. One character, every format.",
166
+ },
167
+ ];
168
+
169
+ return (
170
+ <section className="py-20 px-6 border-y border-gray-800/40">
171
+ <div className="max-w-6xl mx-auto">
172
+ <div className="text-center mb-12">
173
+ <h2 className="text-3xl font-bold text-white mb-3">The Stack</h2>
174
+ <p className="text-gray-500">GPU, pipeline, and agent integration in one system.</p>
175
+ </div>
176
+ <div className="grid md:grid-cols-3 gap-5">
177
+ {items.map((item) => (
178
+ <Card
179
+ key={item.label}
180
+ className={`border ${item.border} bg-gradient-to-b ${item.bg} to-gray-950/60 ring-0`}
181
+ >
182
+ <CardHeader>
183
+ <item.icon className={`w-5 h-5 ${item.color} mb-1`} />
184
+ <CardTitle className="text-white text-sm">{item.label}</CardTitle>
185
+ </CardHeader>
186
+ <CardContent>
187
+ <CardDescription className="text-gray-400 leading-relaxed">
188
+ {item.body}
189
+ </CardDescription>
190
+ </CardContent>
191
+ </Card>
192
+ ))}
193
+ </div>
194
+ </div>
195
+ </section>
196
+ );
197
+ }
198
+
199
+ /* ── Feature Film Showcase ── */
200
+ function FeatureFilmShowcase() {
201
+ return (
202
+ <section className="py-16 px-6 border-y border-gray-800/40 bg-gradient-to-b from-black via-gray-950/30 to-black">
203
+ <div className="max-w-6xl mx-auto">
204
+ {/* Section headline */}
205
+ <div className="text-center mb-12">
206
+ <Badge className="mb-4 gap-1.5 border-fuchsia-500/20 bg-fuchsia-500/5 text-fuchsia-300 h-7 px-3" variant="outline">
207
+ <Film className="w-3.5 h-3.5" />
208
+ Your Model. Your Film. Minutes.
209
+ </Badge>
210
+ <h2 className="text-4xl md:text-5xl font-bold text-white mb-5">
211
+ You and Your Agent.
212
+ <br />
213
+ <span className="bg-gradient-to-r from-rose-400 via-fuchsia-400 to-amber-400 bg-clip-text text-transparent">
214
+ Co-Directing a Film.
215
+ </span>
216
+ </h2>
217
+ <p className="text-lg text-gray-500 max-w-2xl mx-auto leading-relaxed">
218
+ This is what your film looks like. Once your model is trained from your photos,
219
+ you and your agent can assemble scenes, shots, and final cuts in minutes —
220
+ not the hours or days it used to take. You bring the vision. Your agent handles the rest.
221
+ </p>
222
+ </div>
223
+
224
+ {/* Film title card */}
225
+ <div className="max-w-2xl mx-auto mb-6 text-center">
226
+ <div className="flex items-center justify-center gap-2 mb-3">
227
+ <Badge className="gap-1 border-fuchsia-500/20 bg-fuchsia-500/5 text-fuchsia-300 text-[10px]" variant="outline">
228
+ <Film className="w-3 h-3" /> Full Project Render
229
+ </Badge>
230
+ <span className="text-xs text-gray-500">Multi-shot · assembled · AMD MI300X</span>
231
+ </div>
232
+ <p className="text-[10px] uppercase tracking-[0.2em] text-amber-400 font-medium mb-1">Official Selection — AMD Developer Hackathon 2026</p>
233
+ <h3 className="text-xl font-bold text-white">Nemoflix: A Debut Feature</h3>
234
+ <p className="text-xs text-gray-500 mt-0.5">LabLab.ai · World Premiere</p>
235
+ </div>
236
+
237
+ {/* The Film */}
238
+ <div className="max-w-2xl mx-auto">
239
+ <div className="rounded-2xl overflow-hidden border border-gray-800/60 bg-gray-950 shadow-2xl shadow-fuchsia-500/5 aspect-square">
240
+ <video
241
+ src="/demos/feature-film.mp4"
242
+ controls
243
+ poster=""
244
+ className="w-full h-full object-cover"
245
+ preload="metadata"
246
+ />
247
+ </div>
248
+
249
+ <div className="mt-6 flex justify-center">
250
+ <Link
251
+ to="/studio/projects"
252
+ className="inline-flex items-center gap-1.5 rounded-lg border border-gray-700 hover:border-rose-500/50 px-4 py-2 text-xs font-medium text-gray-300 hover:text-white transition"
253
+ >
254
+ Try It Yourself <ArrowRight className="w-3.5 h-3.5" />
255
+ </Link>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </section>
260
+ );
261
+ }
262
+
263
+ /* ── How It Works ── */
264
+ function HowItWorks() {
265
+ const steps = [
266
+ {
267
+ n: "01",
268
+ color: "text-amber-400",
269
+ ring: "ring-amber-500/30",
270
+ title: "Train Your Character LoRA",
271
+ body: "Upload 15–25 reference photos. Nemoflix fine-tunes a Flux.2 LoRA on AMD MI300X — 192 GB VRAM, ROCm, no CUDA. ~90 minutes to a character that looks consistent in every single frame.",
272
+ aside: (
273
+ <Card className="border-amber-500/20 bg-gradient-to-b from-amber-950/20 to-gray-950/60 ring-0">
274
+ <CardHeader>
275
+ <Badge className="w-fit gap-1.5 border-amber-500/20 bg-amber-500/5 text-amber-400" variant="outline">
276
+ <Cpu className="w-3 h-3" /> AMD MI300X
277
+ </Badge>
278
+ <CardTitle className="text-white text-sm mt-2">Training Config</CardTitle>
279
+ </CardHeader>
280
+ <CardContent>
281
+ <div className="space-y-1.5 font-mono text-xs text-gray-400">
282
+ {[
283
+ ["model", "flux.2-dev"],
284
+ ["vram", "192 GB"],
285
+ ["runtime", "~90 min"],
286
+ ["steps", "1000"],
287
+ ["framework", "ROCm 7.2"],
288
+ ].map(([k, v]) => (
289
+ <p key={k}>
290
+ <span className="text-gray-600 inline-block w-20">{k}</span>
291
+ {v}
292
+ </p>
293
+ ))}
294
+ </div>
295
+ </CardContent>
296
+ </Card>
297
+ ),
298
+ },
299
+ {
300
+ n: "02",
301
+ color: "text-rose-400",
302
+ ring: "ring-rose-500/30",
303
+ title: "Generate Images via API",
304
+ body: "Your AI agent calls the API with a prompt and character ID. Nemoflix builds the ComfyUI workflow, routes to the right GPU node, queues the job, and returns a prompt ID. Photorealistic results in seconds.",
305
+ aside: (
306
+ <Card className="border-gray-800 bg-gray-950 ring-0">
307
+ <CardHeader>
308
+ <CardTitle className="text-xs text-gray-500 font-mono font-normal">API call</CardTitle>
309
+ </CardHeader>
310
+ <CardContent className="space-y-2 font-mono text-xs">
311
+ <p className="text-gray-500">
312
+ POST <span className="text-rose-400">/api/image/generate</span>
313
+ </p>
314
+ <div className="rounded-lg bg-black/60 p-3 space-y-1 text-gray-400">
315
+ <p className="text-gray-600">{"{"}</p>
316
+ <p className="pl-3">
317
+ <span className="text-amber-300">"character"</span>:{" "}
318
+ <span className="text-emerald-300">"rigo"</span>,
319
+ </p>
320
+ <p className="pl-3">
321
+ <span className="text-amber-300">"prompt"</span>:{" "}
322
+ <span className="text-emerald-300">"walking through a rainy street"</span>
323
+ </p>
324
+ <p className="text-gray-600">{"}"}</p>
325
+ </div>
326
+ <p className="text-emerald-400/70 pt-1">← prompt_id: a3f9c1d2</p>
327
+ </CardContent>
328
+ </Card>
329
+ ),
330
+ },
331
+ {
332
+ n: "03",
333
+ color: "text-fuchsia-400",
334
+ ring: "ring-fuchsia-500/30",
335
+ title: "Animate to Video",
336
+ body: "One more API call and Wan 2.2 I2V animates the image into a short video clip. The same character, moving. String clips together into a full scene. Your agent builds the whole sequence autonomously.",
337
+ aside: (
338
+ <Card className="border-fuchsia-500/20 bg-gradient-to-b from-fuchsia-950/20 to-gray-950/60 ring-0">
339
+ <CardHeader>
340
+ <Badge className="w-fit gap-1.5 border-fuchsia-500/20 bg-fuchsia-500/5 text-fuchsia-400" variant="outline">
341
+ Wan 2.2 I2V
342
+ </Badge>
343
+ <CardTitle className="text-white text-sm mt-2">Video Config</CardTitle>
344
+ </CardHeader>
345
+ <CardContent>
346
+ <div className="space-y-1.5 font-mono text-xs text-gray-400">
347
+ {[
348
+ ["model", "wan2.2-i2v-14b"],
349
+ ["input", "still image"],
350
+ ["output", "5s video clip"],
351
+ ["cfg", "3.5 · steps 20"],
352
+ ].map(([k, v]) => (
353
+ <p key={k}>
354
+ <span className="text-gray-600 inline-block w-20">{k}</span>
355
+ {v}
356
+ </p>
357
+ ))}
358
+ </div>
359
+ <p className="text-[11px] text-fuchsia-300/50 mt-4">
360
+ Same character. Real motion. Not a filter.
361
+ </p>
362
+ </CardContent>
363
+ </Card>
364
+ ),
365
+ },
366
+ {
367
+ n: "04",
368
+ color: "text-emerald-400",
369
+ ring: "ring-emerald-500/30",
370
+ title: "Build Films Scene by Scene",
371
+ body: "Projects hold Scenes, Scenes hold Shots — each with its own generated image and video. When every shot is animated, hit render and Nemoflix stitches the whole thing together into one finished video. Your agent wrote, directed, and rendered a short film, start to finish.",
372
+ aside: (
373
+ <Card className="border-emerald-500/20 bg-gradient-to-b from-emerald-950/20 to-gray-950/60 ring-0">
374
+ <CardHeader>
375
+ <Badge className="w-fit gap-1.5 border-emerald-500/20 bg-emerald-500/5 text-emerald-400" variant="outline">
376
+ <Film className="w-3 h-3" /> Projects
377
+ </Badge>
378
+ <CardTitle className="text-white text-sm mt-2">Film Structure</CardTitle>
379
+ </CardHeader>
380
+ <CardContent>
381
+ <div className="font-mono text-xs space-y-2">
382
+ <div className="text-emerald-300/80">
383
+ Project <span className="text-emerald-500">"Neon Nights"</span>
384
+ </div>
385
+ <div className="pl-4 border-l border-gray-800 space-y-1.5">
386
+ <div className="text-gray-400">
387
+ Scene 1 <span className="text-gray-600">"Alley Chase"</span>
388
+ </div>
389
+ <div className="pl-4 text-gray-600 space-y-0.5">
390
+ <div>Shot 1A → image + video</div>
391
+ <div>Shot 1B → image + video</div>
392
+ </div>
393
+ <div className="text-gray-400 pt-1">
394
+ Scene 2 <span className="text-gray-600">"Rooftop"</span>
395
+ </div>
396
+ </div>
397
+ </div>
398
+ <p className="text-[11px] text-emerald-300/50 mt-4">
399
+ One API. Train → generate → animate → render.
400
+ </p>
401
+ </CardContent>
402
+ </Card>
403
+ ),
404
+ },
405
+ ];
406
+
407
+ return (
408
+ <section className="py-28 px-6">
409
+ <div className="max-w-6xl mx-auto">
410
+ <div className="text-center mb-20">
411
+ <h2 className="text-4xl font-bold text-white mb-4">How It Works</h2>
412
+ <p className="text-gray-500 text-lg">
413
+ From reference photos to a finished film in four steps.
414
+ </p>
415
+ </div>
416
+
417
+ <div className="space-y-24">
418
+ {steps.map((step, i) => (
419
+ <div key={step.n} className="grid md:grid-cols-2 gap-12 items-center">
420
+ <div className={i % 2 === 1 ? "md:order-2" : ""}>
421
+ <div
422
+ className={`w-10 h-10 rounded-full ring-1 ${step.ring} bg-gray-950 flex items-center justify-center mb-4`}
423
+ >
424
+ <span className={`text-xs font-bold font-mono ${step.color}`}>
425
+ {step.n}
426
+ </span>
427
+ </div>
428
+ <h3 className="text-3xl font-bold text-white mb-4">{step.title}</h3>
429
+ <p className="text-gray-400 text-lg leading-relaxed">{step.body}</p>
430
+ </div>
431
+ <div className={i % 2 === 1 ? "md:order-1" : ""}>{step.aside}</div>
432
+ </div>
433
+ ))}
434
+ </div>
435
+ </div>
436
+ </section>
437
+ );
438
+ }
439
+
440
+ /* ── Features grid ── */
441
+ function Features() {
442
+ const features = [
443
+ { icon: Cpu, color: "text-amber-400", title: "LoRA Training on AMD", body: "Fine-tune Flux.2 on AMD MI300X via ROCm. No CUDA required. Monitor job progress live in the Studio." },
444
+ { icon: Server, color: "text-rose-400", title: "Self-Hosted", body: "Your hardware, your models, your data. No cloud dependency, no rate limits, no data leaving your machine." },
445
+ { icon: Bot, color: "text-violet-400", title: "Agent API", body: "REST API any agent can call. Characters, images, video, training jobs — all simple HTTP endpoints." },
446
+ { icon: Users, color: "text-fuchsia-400", title: "Character Registry", body: "Register characters with trigger words and LoRA weights. Every generation references them consistently." },
447
+ { icon: Zap, color: "text-emerald-400", title: "Multi-GPU Routing", body: "Images and video automatically routed to the right node. Add more GPUs without changing any code." },
448
+ { icon: Lock, color: "text-blue-400", title: "Open Source", body: "MIT license. Audit the code, fork it, extend it. No black boxes." },
449
+ ];
450
+
451
+ return (
452
+ <section className="py-28 px-6 border-t border-gray-800/40">
453
+ <div className="max-w-6xl mx-auto">
454
+ <div className="text-center mb-16">
455
+ <h2 className="text-4xl font-bold text-white mb-4">What's Inside</h2>
456
+ <p className="text-gray-500 text-lg">
457
+ Everything you need to run a visual AI studio.
458
+ </p>
459
+ </div>
460
+
461
+ <div className="grid md:grid-cols-3 gap-5">
462
+ {features.map((f) => (
463
+ <Card
464
+ key={f.title}
465
+ className="border-gray-800/60 bg-gray-950/50 ring-0 hover:border-gray-700 transition-colors"
466
+ >
467
+ <CardHeader>
468
+ <f.icon className={`w-5 h-5 ${f.color}`} />
469
+ <CardTitle className="text-white text-sm mt-3">{f.title}</CardTitle>
470
+ </CardHeader>
471
+ <CardContent>
472
+ <CardDescription className="text-gray-500 leading-relaxed">
473
+ {f.body}
474
+ </CardDescription>
475
+ </CardContent>
476
+ </Card>
477
+ ))}
478
+ </div>
479
+ </div>
480
+ </section>
481
+ );
482
+ }
483
+
484
+ /* ── CTA ── */
485
+ function CTA() {
486
+ return (
487
+ <section className="py-32 px-6">
488
+ <div className="max-w-2xl mx-auto text-center">
489
+ <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-rose-500 via-fuchsia-500 to-amber-400 flex items-center justify-center mx-auto mb-8 shadow-2xl shadow-rose-500/20">
490
+ <Sparkles className="w-7 h-7 text-white" />
491
+ </div>
492
+ <h2 className="text-4xl md:text-5xl font-bold text-white mb-6">
493
+ Ready to Run It?
494
+ </h2>
495
+ <p className="text-xl text-gray-400 mb-10 leading-relaxed">
496
+ Clone the repo, point it at your GPU nodes, and your agent is
497
+ generating in minutes.
498
+ </p>
499
+ <div className="flex items-center justify-center gap-3 flex-wrap">
500
+ <Link to="/studio" className={btnPrimary}>
501
+ <Sparkles className="w-4 h-4" />
502
+ Launch Studio
503
+ </Link>
504
+ <a
505
+ href="https://github.com/ortegarod/nemoflix"
506
+ target="_blank"
507
+ rel="noopener noreferrer"
508
+ className={btnOutline}
509
+ >
510
+ <GitHubIcon className="w-4 h-4" />
511
+ View on GitHub
512
+ </a>
513
+ </div>
514
+ </div>
515
+ </section>
516
+ );
517
+ }
518
+
519
+ /* ── Footer ── */
520
+ function Footer() {
521
+ return (
522
+ <footer className="border-t border-gray-800/40 py-10 px-6">
523
+ <div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
524
+ <span className="text-sm font-bold">
525
+ <span className="text-rose-400">Nemo</span>
526
+ <span className="text-gray-500">flix</span>
527
+ </span>
528
+ <div className="flex items-center gap-6 text-xs text-gray-600">
529
+ <a
530
+ href="https://github.com/ortegarod/nemoflix"
531
+ target="_blank"
532
+ rel="noreferrer"
533
+ className="hover:text-gray-400 transition"
534
+ >
535
+ GitHub
536
+ </a>
537
+ <Link to="/studio" className="hover:text-gray-400 transition">
538
+ Studio
539
+ </Link>
540
+ <span>© {new Date().getFullYear()} Nemoflix · MIT License</span>
541
+ </div>
542
+ </div>
543
+ </footer>
544
+ );
545
+ }
546
+
547
+ export default function LandingPage() {
548
+ return (
549
+ <div className="min-h-screen bg-black text-white">
550
+ <Navbar />
551
+ <Hero />
552
+ <Pillars />
553
+ <FeatureFilmShowcase />
554
+ <HowItWorks />
555
+ <Features />
556
+ <CTA />
557
+ <Footer />
558
+ </div>
559
+ );
560
+ }
studio/src/api.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = "";
2
+
3
+ async function apiFetch(path: string, init?: RequestInit) {
4
+ const res = await fetch(`${API_BASE}${path}`, init);
5
+ if (!res.ok) {
6
+ const text = await res.text().catch(() => "");
7
+ throw new Error(`${res.status}: ${text}`);
8
+ }
9
+ return res.json();
10
+ }
11
+
12
+ export async function getListing(): Promise<{ images: any[]; total: number }> {
13
+ return apiFetch("/api/listing");
14
+ }
15
+
16
+ export async function generateVideo(params: {
17
+ image: string;
18
+ prompt?: string;
19
+ width?: number;
20
+ height?: number;
21
+ length?: number;
22
+ fps?: number;
23
+ }): Promise<{ ok: boolean; prompt_id: string; mode: string }> {
24
+ return apiFetch("/api/video/generate", {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({
28
+ mode: "i2v",
29
+ image: params.image,
30
+ prompt: params.prompt || "",
31
+ width: params.width || 640,
32
+ height: params.height || 640,
33
+ length: params.length || 81,
34
+ fps: params.fps || 16,
35
+ }),
36
+ });
37
+ }
studio/src/components/CharacterProfileView.tsx ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Cpu, Edit3, Film, Image, Sparkles, UserRound } from "lucide-react";
3
+ import { MediaTile } from "./MediaTile";
4
+ import type { MediaItem } from "../types";
5
+
6
+ interface LoraEntry {
7
+ name: string;
8
+ strength: number;
9
+ workflow?: string;
10
+ base_model?: string;
11
+ }
12
+
13
+ interface Checkpoint {
14
+ name: string;
15
+ step: number | null;
16
+ size_bytes: number;
17
+ modified_at: string;
18
+ }
19
+
20
+ interface CheckpointsResponse {
21
+ job_name: string;
22
+ checkpoints: Checkpoint[];
23
+ count: number;
24
+ updated_at: string;
25
+ }
26
+
27
+ interface CharacterRecord {
28
+ id: string;
29
+ name: string;
30
+ kind: string | null;
31
+ trigger: string | null;
32
+ description: string | null;
33
+ source_images: string[];
34
+ loras: LoraEntry[];
35
+ defaults: Record<string, unknown>;
36
+ }
37
+
38
+ interface CharacterProfileViewProps {
39
+ characterId: string;
40
+ items: MediaItem[];
41
+ onOpen: (url: string) => void;
42
+ onDelete: (item: MediaItem) => Promise<void> | void;
43
+ onGenerate: () => void;
44
+ }
45
+
46
+ function resolveImageUrl(img: string): string {
47
+ return img.startsWith("/") ? img : `/media/${img}`;
48
+ }
49
+
50
+ function isVideo(item: MediaItem) {
51
+ return item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm");
52
+ }
53
+
54
+ async function fetchJson<T>(url: string): Promise<T> {
55
+ const response = await fetch(url);
56
+ if (!response.ok) throw new Error(`${url} returned ${response.status}`);
57
+ return await response.json();
58
+ }
59
+
60
+ export function CharacterProfileView({ characterId, items, onOpen, onDelete, onGenerate }: CharacterProfileViewProps) {
61
+ const [character, setCharacter] = useState<CharacterRecord | null>(null);
62
+ const [loading, setLoading] = useState(true);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [tab, setTab] = useState<"images" | "videos">("images");
65
+ const [checkpoints, setCheckpoints] = useState<CheckpointsResponse | null>(null);
66
+
67
+ const load = useCallback(async () => {
68
+ try {
69
+ setError(null);
70
+ const data = await fetchJson<{ character: CharacterRecord }>(`/api/characters/${characterId}`);
71
+ const char: CharacterRecord = data.character || (data as unknown as CharacterRecord);
72
+ setCharacter(char);
73
+ if (char.loras.length > 0) {
74
+ try {
75
+ const ckpts = await fetchJson<CheckpointsResponse>("/api/lora-training/checkpoints");
76
+ setCheckpoints(ckpts);
77
+ } catch {
78
+ // checkpoints optional — don't block character render
79
+ }
80
+ }
81
+ } catch (err) {
82
+ setError(err instanceof Error ? err.message : "Failed to load character");
83
+ } finally {
84
+ setLoading(false);
85
+ }
86
+ }, [characterId]);
87
+
88
+ useEffect(() => { load(); }, [load]);
89
+
90
+ const related = useMemo(() => {
91
+ if (!character) return [];
92
+ const terms = [character.id, character.name, character.trigger].filter(Boolean).map((value) => String(value).toLowerCase());
93
+ return items.filter((item) => {
94
+ const haystack = [item.name, item.filename, item.prompt].join(" ").toLowerCase();
95
+ return terms.some((term) => haystack.includes(term));
96
+ });
97
+ }, [character, items]);
98
+
99
+ const fallbackItems = related.length > 0 ? related : items;
100
+ const images = fallbackItems.filter((item) => !isVideo(item));
101
+ const videos = fallbackItems.filter(isVideo);
102
+ const shown = tab === "images" ? images : videos;
103
+
104
+ if (loading) return <div className="p-6 text-gray-500">Loading character...</div>;
105
+ if (error) return <div className="p-6 text-red-400">{error}</div>;
106
+ if (!character) return null;
107
+
108
+ const avatarUrl = character.source_images[0] ? resolveImageUrl(character.source_images[0]) : null;
109
+ const loraCount = character.loras.length;
110
+
111
+ return (
112
+ <div className="p-5 lg:p-7 space-y-6">
113
+ <section className="rounded-3xl border border-gray-800/60 bg-gradient-to-b from-gray-900/70 to-gray-950/40 overflow-hidden">
114
+ <div className="relative h-44 bg-gradient-to-br from-rose-950/50 via-fuchsia-950/20 to-amber-950/20">
115
+ {avatarUrl && <img src={avatarUrl} alt="" className="absolute inset-0 w-full h-full object-cover opacity-35 blur-sm scale-105" />}
116
+ <div className="absolute inset-0 bg-gradient-to-t from-black via-black/40 to-transparent" />
117
+ </div>
118
+
119
+ <div className="px-5 lg:px-7 pb-6 -mt-16 relative">
120
+ <div className="flex flex-col lg:flex-row lg:items-end gap-5">
121
+ <div className="w-32 h-32 rounded-3xl overflow-hidden ring-4 ring-black bg-gray-900 shadow-2xl flex-shrink-0">
122
+ {avatarUrl ? (
123
+ <img src={avatarUrl} alt={character.name} className="w-full h-full object-cover" />
124
+ ) : (
125
+ <div className="w-full h-full bg-gradient-to-br from-rose-500 to-amber-400 flex items-center justify-center">
126
+ <UserRound className="w-12 h-12 text-white" />
127
+ </div>
128
+ )}
129
+ </div>
130
+
131
+ <div className="flex-1 min-w-0">
132
+ <div className="flex flex-wrap items-center gap-2 mb-2">
133
+ {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>}
134
+ {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>}
135
+ </div>
136
+ <h1 className="text-3xl font-bold tracking-tight">{character.name}</h1>
137
+ <p className="text-sm text-gray-500 mt-2 max-w-3xl">
138
+ {character.description || "Reusable character identity for agent-generated images and videos."}
139
+ </p>
140
+ </div>
141
+
142
+ <div className="flex gap-2">
143
+ <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">
144
+ <Sparkles className="w-4 h-4" /> Generate
145
+ </button>
146
+ <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">
147
+ <Edit3 className="w-4 h-4" /> Edit
148
+ </button>
149
+ </div>
150
+ </div>
151
+
152
+ <div className="grid grid-cols-3 gap-3 mt-6 max-w-xl">
153
+ <div className="rounded-2xl border border-gray-800 bg-black/30 p-3">
154
+ <p className="text-xs text-gray-600">Images</p>
155
+ <p className="text-xl font-bold text-gray-100">{images.length}</p>
156
+ </div>
157
+ <div className="rounded-2xl border border-gray-800 bg-black/30 p-3">
158
+ <p className="text-xs text-gray-600">Videos</p>
159
+ <p className="text-xl font-bold text-gray-100">{videos.length}</p>
160
+ </div>
161
+ <div className="rounded-2xl border border-gray-800 bg-black/30 p-3">
162
+ <p className="text-xs text-gray-600">LoRAs</p>
163
+ <p className="text-xl font-bold text-gray-100">{loraCount}</p>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ </section>
168
+
169
+ {character.loras.length > 0 && (
170
+ <section className="rounded-3xl border border-gray-800/60 bg-gray-950/40 p-5 space-y-4">
171
+ <div className="flex items-center gap-2">
172
+ <Cpu className="w-4 h-4 text-violet-400" />
173
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-gray-300">Model</h2>
174
+ </div>
175
+
176
+ <div className="space-y-3">
177
+ {character.loras.map((lora, i) => {
178
+ const shortName = lora.name.split("/").pop() ?? lora.name;
179
+ return (
180
+ <div key={i} className="rounded-2xl border border-gray-800/60 bg-black/30 p-4 space-y-3">
181
+ <p className="text-xs font-mono text-violet-300 break-all">{shortName}</p>
182
+ <div className="grid grid-cols-3 gap-3">
183
+ {lora.base_model && (
184
+ <div>
185
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Base</p>
186
+ <p className="text-xs text-gray-300 mt-0.5 font-mono">{lora.base_model}</p>
187
+ </div>
188
+ )}
189
+ {lora.workflow && (
190
+ <div>
191
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Workflow</p>
192
+ <p className="text-xs text-gray-300 mt-0.5 font-mono">{lora.workflow}</p>
193
+ </div>
194
+ )}
195
+ <div>
196
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Strength</p>
197
+ <p className="text-xs text-gray-300 mt-0.5 font-mono">{lora.strength}</p>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+
205
+ {checkpoints && (
206
+ <div className="space-y-2">
207
+ <div className="flex items-center justify-between">
208
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Training Checkpoints — <span className="font-mono">{checkpoints.job_name}</span></p>
209
+ <p className="text-[10px] text-gray-700">
210
+ checked {new Date(checkpoints.updated_at).toLocaleString()}
211
+ </p>
212
+ </div>
213
+ <div className="rounded-xl border border-gray-800/60 overflow-hidden">
214
+ <table className="w-full text-xs">
215
+ <thead>
216
+ <tr className="border-b border-gray-800/60 bg-black/20">
217
+ <th className="text-left px-3 py-2 text-[10px] uppercase tracking-wider text-gray-600 font-medium">Step</th>
218
+ <th className="text-left px-3 py-2 text-[10px] uppercase tracking-wider text-gray-600 font-medium">File</th>
219
+ <th className="text-right px-3 py-2 text-[10px] uppercase tracking-wider text-gray-600 font-medium">Size</th>
220
+ <th className="text-right px-3 py-2 text-[10px] uppercase tracking-wider text-gray-600 font-medium">Date</th>
221
+ </tr>
222
+ </thead>
223
+ <tbody>
224
+ {checkpoints.checkpoints.map((ck, i) => (
225
+ <tr key={i} className="border-b border-gray-800/40 last:border-0 hover:bg-gray-900/30">
226
+ <td className="px-3 py-2 font-mono text-violet-300">{ck.step ?? "final"}</td>
227
+ <td className="px-3 py-2 text-gray-400 font-mono text-[11px] break-all">{ck.name}</td>
228
+ <td className="px-3 py-2 text-gray-400 text-right font-mono">{(ck.size_bytes / 1024 / 1024).toFixed(0)} MB</td>
229
+ <td className="px-3 py-2 text-gray-500 text-right whitespace-nowrap">{new Date(ck.modified_at).toLocaleDateString()}</td>
230
+ </tr>
231
+ ))}
232
+ </tbody>
233
+ </table>
234
+ </div>
235
+ </div>
236
+ )}
237
+ </section>
238
+ )}
239
+
240
+ <section className="space-y-4">
241
+ <div className="flex items-center gap-2 border-b border-gray-800/60">
242
+ <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"}`}>
243
+ <Image className="w-4 h-4" /> Images
244
+ </button>
245
+ <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"}`}>
246
+ <Film className="w-4 h-4" /> Videos
247
+ </button>
248
+ </div>
249
+
250
+ {shown.length === 0 ? (
251
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/20 p-10 text-center text-sm text-gray-500">
252
+ No {tab} found for this character yet.
253
+ </div>
254
+ ) : (
255
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
256
+ {shown.map((item) => (
257
+ <MediaTile key={item.filename || item.url} item={item} onOpen={() => onOpen(item.url)} onDelete={onDelete} />
258
+ ))}
259
+ </div>
260
+ )}
261
+ </section>
262
+ </div>
263
+ );
264
+ }
studio/src/components/GalleryView.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState, useCallback } 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 { generateVideo } from "../api";
6
+ import type { JobItem, MediaItem } from "../types";
7
+
8
+ type Filter = "all" | "images" | "videos";
9
+
10
+ interface StudioViewProps {
11
+ items: MediaItem[];
12
+ jobs: JobItem[];
13
+ loading: boolean;
14
+ error: string | null;
15
+ onOpen: (url: string) => void;
16
+ onDelete: (item: MediaItem) => Promise<void> | void;
17
+ onOpenProjects?: () => void;
18
+ }
19
+
20
+ function isVideo(item: MediaItem) {
21
+ return item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm");
22
+ }
23
+
24
+ export function StudioView({ items, jobs, loading, error, onOpen, onDelete, onOpenProjects }: StudioViewProps) {
25
+ const [filter, setFilter] = useState<Filter>("all");
26
+ const [query, setQuery] = useState("");
27
+ const [generatingVideo, setGeneratingVideo] = useState<Set<string>>(new Set());
28
+
29
+ const handleGenerateVideo = useCallback(async (item: MediaItem, motionPrompt: string) => {
30
+ const key = item.filename || item.url;
31
+ setGeneratingVideo((prev) => new Set(prev).add(key));
32
+ try {
33
+ await generateVideo({
34
+ image: item.filename || item.url.split("/").pop() || "",
35
+ prompt: motionPrompt || undefined,
36
+ });
37
+ } catch (err) {
38
+ console.error("I2V failed:", err);
39
+ } finally {
40
+ setGeneratingVideo((prev) => {
41
+ const next = new Set(prev);
42
+ next.delete(key);
43
+ return next;
44
+ });
45
+ }
46
+ }, []);
47
+
48
+ const imageCount = items.filter((item) => !isVideo(item)).length;
49
+ const videoCount = items.length - imageCount;
50
+
51
+ const visibleItems = useMemo(() => {
52
+ const q = query.trim().toLowerCase();
53
+ return items.filter((item) => {
54
+ if (filter === "images" && isVideo(item)) return false;
55
+ if (filter === "videos" && !isVideo(item)) return false;
56
+ if (!q) return true;
57
+ return [item.name, item.filename, item.prompt].some((value) => String(value || "").toLowerCase().includes(q));
58
+ });
59
+ }, [filter, items, query]);
60
+
61
+ return (
62
+ <div className="p-5 lg:p-7 space-y-6">
63
+ <section className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
64
+ <div>
65
+ <p className="text-xs uppercase tracking-[0.22em] text-rose-400/70">Workspace</p>
66
+ <h1 className="text-2xl font-bold tracking-tight mt-1">Studio</h1>
67
+ <p className="text-sm text-gray-500 mt-2">Your gallery for quick image and video generation — freeform, no structure. Generate, browse, iterate.</p>
68
+ </div>
69
+ <div className="grid grid-cols-3 gap-2 text-xs min-w-[260px]">
70
+ <div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
71
+ <p className="text-gray-600">Total</p>
72
+ <p className="text-lg font-semibold text-gray-200">{items.length}</p>
73
+ </div>
74
+ <div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
75
+ <p className="text-gray-600">Images</p>
76
+ <p className="text-lg font-semibold text-gray-200">{imageCount}</p>
77
+ </div>
78
+ <div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
79
+ <p className="text-gray-600">Videos</p>
80
+ <p className="text-lg font-semibold text-gray-200">{videoCount}</p>
81
+ </div>
82
+ </div>
83
+ </section>
84
+
85
+ <button
86
+ onClick={onOpenProjects}
87
+ 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"
88
+ >
89
+ <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">
90
+ <Film className="w-5 h-5 text-white" />
91
+ </div>
92
+ <div className="flex-1 min-w-0">
93
+ <p className="text-sm font-semibold text-violet-100">Got a specific idea? Turn it into a Project.</p>
94
+ <p className="text-xs text-gray-400 mt-1 leading-relaxed">
95
+ 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.
96
+ </p>
97
+ </div>
98
+ <ArrowRight className="w-5 h-5 text-violet-400 group-hover:translate-x-1 transition flex-shrink-0" />
99
+ </button>
100
+
101
+ <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">
102
+ <div className="flex gap-2">
103
+ {[
104
+ ["all", "All", SlidersHorizontal],
105
+ ["images", "Images", Image],
106
+ ["videos", "Videos", Video],
107
+ ].map(([id, label, Icon]) => (
108
+ <button
109
+ key={id as string}
110
+ onClick={() => setFilter(id as Filter)}
111
+ className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-xs font-medium transition ${
112
+ filter === id ? "bg-rose-600 text-white" : "bg-gray-900 text-gray-500 hover:text-gray-200"
113
+ }`}
114
+ >
115
+ <Icon className="w-3.5 h-3.5" />
116
+ {label as string}
117
+ </button>
118
+ ))}
119
+ </div>
120
+ <label className="relative w-full lg:w-80">
121
+ <Search className="w-4 h-4 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" />
122
+ <input
123
+ value={query}
124
+ onChange={(event) => setQuery(event.target.value)}
125
+ placeholder="Search studio"
126
+ 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"
127
+ />
128
+ </label>
129
+ </section>
130
+
131
+ {loading && items.length === 0 && jobs.length === 0 ? (
132
+ <p className="text-gray-500">Loading...</p>
133
+ ) : error ? (
134
+ <div className="rounded-lg border border-red-900/60 bg-red-950/20 p-4 text-sm text-red-300">{error}</div>
135
+ ) : jobs.length === 0 && visibleItems.length === 0 ? (
136
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/20 p-10 text-center">
137
+ <Image className="w-8 h-8 text-gray-700 mx-auto mb-3" />
138
+ <p className="text-sm text-gray-500">No media found.</p>
139
+ </div>
140
+ ) : (
141
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
142
+ {jobs.map((job) => <JobCard key={job.prompt_id} job={job} />)}
143
+ {visibleItems.map((item) => (
144
+ <MediaTile
145
+ key={item.filename || item.url}
146
+ item={item}
147
+ onOpen={() => onOpen(item.url)}
148
+ onDelete={onDelete}
149
+ onGenerateVideo={handleGenerateVideo}
150
+ />
151
+ ))}
152
+ </div>
153
+ )}
154
+ </div>
155
+ );
156
+ }
studio/src/components/JobCard.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { JobItem } from "../types";
2
+
3
+ interface JobCardProps {
4
+ job: JobItem;
5
+ }
6
+
7
+ function getProgress(job: JobItem): number | null {
8
+ if (typeof job.progress_percent === "number") return job.progress_percent;
9
+ if (job.step_max && job.step_max > 0) {
10
+ return Math.round(((job.step_value || 0) / job.step_max) * 100);
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function statusLabel(status: string) {
16
+ if (status === "pending") return "Queued";
17
+ if (status === "running") return "Generating";
18
+ if (status === "failed") return "Failed";
19
+ return status;
20
+ }
21
+
22
+ export function JobCard({ job }: JobCardProps) {
23
+ const progress = getProgress(job);
24
+ const isRunning = job.status === "running";
25
+ const isFailed = job.status === "failed";
26
+
27
+ return (
28
+ <div className={`
29
+ rounded-xl overflow-hidden border aspect-video relative p-4 flex flex-col justify-between
30
+ transition-all duration-300
31
+ ${isFailed
32
+ ? "border-red-800/40 bg-red-950/10"
33
+ : "border-amber-800/30 bg-gray-950/80 hover:border-amber-700/60 hover:shadow-lg hover:shadow-amber-900/10"
34
+ }
35
+ `}>
36
+ {/* Ambient gradient */}
37
+ <div className={`absolute inset-0 bg-gradient-to-br from-amber-500/5 via-transparent to-rose-600/5 transition-opacity ${isFailed ? "opacity-20" : "opacity-100"}`} />
38
+
39
+ {/* Status header */}
40
+ <div className={`relative flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide ${isFailed ? "text-red-400" : "text-amber-400"}`}>
41
+ <span className="flex items-center gap-2">
42
+ {isRunning && <span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
43
+ {statusLabel(job.status)}
44
+ {job.queue_position ? ` · Queue ${job.queue_position}` : ""}
45
+ </span>
46
+ {progress !== null && !isFailed && <span className="tabular-nums">{progress}%</span>}
47
+ </div>
48
+
49
+ {/* Content */}
50
+ <div className="relative space-y-2.5">
51
+ <p className="text-sm font-medium line-clamp-2 text-gray-200 leading-relaxed">
52
+ {job.prompt || "Generation job"}
53
+ </p>
54
+
55
+ <div className="space-y-1.5">
56
+ <div className={`h-1.5 rounded-full overflow-hidden ${isFailed ? "bg-red-950" : "bg-gray-800"}`}>
57
+ <div
58
+ className={`h-full rounded-full transition-all duration-700 ${isFailed ? "bg-red-500/60" : "bg-gradient-to-r from-amber-400 to-amber-300"}`}
59
+ style={{ width: `${isFailed ? 100 : Math.max(3, progress ?? 3)}%` }}
60
+ />
61
+ </div>
62
+
63
+ <p className="text-[11px] text-gray-500 truncate">
64
+ {job.current_node
65
+ ? `Node: ${job.current_node}`
66
+ : isRunning
67
+ ? "Starting..."
68
+ : job.status === "pending"
69
+ ? "Waiting for GPU..."
70
+ : ""}
71
+ {job.step_max ? ` · step ${job.step_value || 0}/${job.step_max}` : ""}
72
+ </p>
73
+ </div>
74
+ </div>
75
+
76
+ {/* ID */}
77
+ <p className="relative text-[10px] text-gray-600 font-mono truncate">{job.prompt_id}</p>
78
+ </div>
79
+ );
80
+ }
studio/src/components/LoraTrainingPage.tsx ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useState } from "react";
2
+ import { ChevronDown, Database, FolderPlus, X } from "lucide-react";
3
+ import { useApp } from "../App";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Progress } from "@/components/ui/progress";
8
+ import {
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from "@/components/ui/table";
16
+
17
+ interface Dataset {
18
+ id: string;
19
+ name: string;
20
+ description: string | null;
21
+ image_count: number | null;
22
+ created_at: string;
23
+ }
24
+
25
+ interface Sample {
26
+ name: string;
27
+ step: number | null;
28
+ }
29
+
30
+ interface Checkpoint {
31
+ name: string;
32
+ step: number | null;
33
+ path: string;
34
+ size_bytes: number;
35
+ modified_at: string;
36
+ }
37
+
38
+ function statusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
39
+ if (status === "training" || status === "running") return "default";
40
+ if (status === "completed") return "secondary";
41
+ if (status === "failed") return "destructive";
42
+ return "outline";
43
+ }
44
+
45
+ function formatDate(iso: string | undefined | null): string {
46
+ if (!iso) return "--";
47
+ try {
48
+ return new Date(iso).toLocaleString();
49
+ } catch {
50
+ return iso;
51
+ }
52
+ }
53
+
54
+ export function LoraTrainingPage() {
55
+ const ctx = useApp();
56
+ const jobs = ctx.trainingJobs ?? [];
57
+ const live = ctx.training;
58
+
59
+ // Datasets
60
+ const [datasets, setDatasets] = useState<Dataset[]>([]);
61
+ const [datasetsLoading, setDatasetsLoading] = useState(true);
62
+ const [showAddDataset, setShowAddDataset] = useState(false);
63
+ const [addDatasetId, setAddDatasetId] = useState("");
64
+ const [addDatasetName, setAddDatasetName] = useState("");
65
+ const [addDatasetDesc, setAddDatasetDesc] = useState("");
66
+ const [addDatasetCount, setAddDatasetCount] = useState("");
67
+ const [addDatasetSubmitting, setAddDatasetSubmitting] = useState(false);
68
+ const [addDatasetError, setAddDatasetError] = useState<string | null>(null);
69
+
70
+ const loadDatasets = useCallback(async () => {
71
+ try {
72
+ const res = await fetch("/api/lora-training/datasets");
73
+ const data = await res.json();
74
+ setDatasets(data.datasets || []);
75
+ } catch {
76
+ // ignore — non-critical
77
+ } finally {
78
+ setDatasetsLoading(false);
79
+ }
80
+ }, []);
81
+
82
+ useEffect(() => { loadDatasets(); }, [loadDatasets]);
83
+
84
+ const submitAddDataset = async () => {
85
+ setAddDatasetError(null);
86
+ setAddDatasetSubmitting(true);
87
+ try {
88
+ const res = await fetch("/api/lora-training/datasets", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({
92
+ id: addDatasetId,
93
+ name: addDatasetName || addDatasetId,
94
+ description: addDatasetDesc || undefined,
95
+ image_count: addDatasetCount ? parseInt(addDatasetCount, 10) : undefined,
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ const d = await res.json();
100
+ throw new Error(d.detail || "Failed to register dataset");
101
+ }
102
+ setAddDatasetId("");
103
+ setAddDatasetName("");
104
+ setAddDatasetDesc("");
105
+ setAddDatasetCount("");
106
+ setShowAddDataset(false);
107
+ loadDatasets();
108
+ } catch (e: any) {
109
+ setAddDatasetError(e.message);
110
+ } finally {
111
+ setAddDatasetSubmitting(false);
112
+ }
113
+ };
114
+
115
+ const mergedJobs = jobs.map((job: any) => {
116
+ // Merge live ai-toolkit data into the matching job row so we get real
117
+ // current_step / total_steps / loss / info. ai-toolkit uses "running"
118
+ // while the DB-backed job list may say "training".
119
+ if (live && live.job_name === job.job_name && (live.status === "running" || live.status === "training")) {
120
+ return { ...job, ...live, _live: true };
121
+ }
122
+ // Job says running/training in the DB but ai-toolkit has no matching
123
+ // live process. The job died or was abandoned — treat as failed.
124
+ if (!live || live.job_name !== job.job_name) {
125
+ if (job.status === "running" || job.status === "training") {
126
+ return { ...job, status: "failed", _dead: true };
127
+ }
128
+ }
129
+ return job;
130
+ });
131
+
132
+ const [showForm] = useState(true);
133
+ const [formJobName, setFormJobName] = useState("");
134
+ const [formTrigger, setFormTrigger] = useState("");
135
+ const [formDataset, setFormDataset] = useState("rigo_flux2_lora_v1_dop");
136
+ const [submitting, setSubmitting] = useState(false);
137
+ const [submitError, setSubmitError] = useState<string | null>(null);
138
+
139
+ const submitTraining = async () => {
140
+ setSubmitError(null);
141
+ setSubmitting(true);
142
+ try {
143
+ const res = await fetch("/api/lora-training/start", {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({ job_name: formJobName, trigger_word: formTrigger, dataset: formDataset }),
147
+ });
148
+ const data = await res.json();
149
+ if (!res.ok) throw new Error(data.detail || "Failed to start training");
150
+ setFormJobName("");
151
+ setFormTrigger("");
152
+ ctx.load();
153
+ } catch (e: any) {
154
+ setSubmitError(e.message);
155
+ } finally {
156
+ setSubmitting(false);
157
+ }
158
+ };
159
+
160
+ const [expandedJob, setExpandedJob] = useState<string | null>(null);
161
+ // Cache samples + checkpoints per job so expanding one doesn't overwrite another.
162
+ const [expandedData, setExpandedData] = useState<Map<string, { samples: Sample[]; checkpoints: Checkpoint[] }>>(new Map());
163
+ const [loadingExpanded, setLoadingExpanded] = useState(false);
164
+
165
+ const loadExpanded = useCallback(async (jobName: string) => {
166
+ if (expandedData.has(jobName)) return; // already cached
167
+ setLoadingExpanded(true);
168
+ try {
169
+ const [sRes, cRes] = await Promise.all([
170
+ fetch(`/api/lora-training/samples?job_name=${jobName}`),
171
+ fetch(`/api/lora-training/checkpoints?job_name=${jobName}`),
172
+ ]);
173
+ const sData = await sRes.json();
174
+ const cData = await cRes.json();
175
+ // ai-toolkit returns samples as flat file paths. Parse step number
176
+ // from the filename pattern: ...__000000250_0.jpg
177
+ const rawSamples: string[] = sData.samples || [];
178
+ const parsedSamples: Sample[] = rawSamples.map((path) => {
179
+ const basename = path.split("/").pop() ?? path;
180
+ const match = basename.match(/__([0-9]+)_/);
181
+ return { name: path, step: match ? Number(match[1]) : null };
182
+ });
183
+ setExpandedData(prev => {
184
+ const next = new Map(prev);
185
+ next.set(jobName, { samples: parsedSamples, checkpoints: cData.checkpoints || [] });
186
+ return next;
187
+ });
188
+ } catch {
189
+ setExpandedData(prev => {
190
+ const next = new Map(prev);
191
+ next.set(jobName, { samples: [], checkpoints: [] });
192
+ return next;
193
+ });
194
+ } finally {
195
+ setLoadingExpanded(false);
196
+ }
197
+ }, [expandedData]);
198
+
199
+ const toggleJob = (jobName: string) => {
200
+ if (expandedJob === jobName) {
201
+ setExpandedJob(null);
202
+ } else {
203
+ setExpandedJob(jobName);
204
+ loadExpanded(jobName);
205
+ }
206
+ };
207
+
208
+ const completed = jobs.filter((j: any) => j.status === "completed").length;
209
+ const running = jobs.filter((j: any) => j.status === "training" || j.status === "running").length;
210
+ const failed = jobs.filter((j: any) => j.status === "failed").length;
211
+
212
+ return (
213
+ <div className="h-full overflow-y-auto p-6 space-y-6">
214
+ {/* Header */}
215
+ <div className="flex items-center justify-between">
216
+ <div>
217
+ <h1 className="text-xl font-bold text-white">Characters &amp; LoRA Training</h1>
218
+ <p className="text-sm text-gray-500 mt-1">
219
+ Train fine-tuned character LoRAs on AMD MI300X. Track all jobs, checkpoints, and stats.
220
+ </p>
221
+ </div>
222
+ </div>
223
+
224
+ {/* New training form */}
225
+ {showForm && (
226
+ <div className="rounded-xl border border-fuchsia-500/30 bg-gray-950 p-5 space-y-4">
227
+ <h2 className="text-sm font-semibold text-fuchsia-300 uppercase tracking-wide">Start Training Job</h2>
228
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
229
+ <div className="space-y-1.5">
230
+ <label className="text-xs text-gray-400 uppercase tracking-wide">Job Name</label>
231
+ <Input
232
+ placeholder="e.g. rigo_v6_full"
233
+ value={formJobName}
234
+ onChange={e => setFormJobName(e.target.value)}
235
+ className="bg-black/40 border-gray-700 text-white placeholder:text-gray-600"
236
+ />
237
+ </div>
238
+ <div className="space-y-1.5">
239
+ <label className="text-xs text-gray-400 uppercase tracking-wide">Trigger Word</label>
240
+ <Input
241
+ placeholder="e.g. Rigo"
242
+ value={formTrigger}
243
+ onChange={e => setFormTrigger(e.target.value)}
244
+ className="bg-black/40 border-gray-700 text-white placeholder:text-gray-600"
245
+ />
246
+ </div>
247
+ <div className="space-y-1.5">
248
+ <label className="text-xs text-gray-400 uppercase tracking-wide">Dataset</label>
249
+ {datasets.length > 0 ? (
250
+ <select
251
+ value={formDataset}
252
+ onChange={e => setFormDataset(e.target.value)}
253
+ className="w-full rounded-md border border-gray-700 bg-black/40 px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-fuchsia-500"
254
+ >
255
+ {datasets.map(ds => (
256
+ <option key={ds.id} value={ds.id}>{ds.name}</option>
257
+ ))}
258
+ </select>
259
+ ) : (
260
+ <Input
261
+ value={formDataset}
262
+ onChange={e => setFormDataset(e.target.value)}
263
+ className="bg-black/40 border-gray-700 text-white"
264
+ />
265
+ )}
266
+ </div>
267
+ </div>
268
+ {submitError && <p className="text-sm text-rose-400">{submitError}</p>}
269
+ <Button
270
+ onClick={submitTraining}
271
+ disabled={submitting || !formJobName || !formTrigger || !formDataset}
272
+ className="bg-fuchsia-600 hover:bg-fuchsia-500 text-white"
273
+ >
274
+ {submitting ? "Starting…" : "Start Training"}
275
+ </Button>
276
+ </div>
277
+ )}
278
+
279
+ {/* Datasets */}
280
+ <section className="rounded-xl border border-gray-800/60 bg-gray-950 overflow-hidden">
281
+ <div className="px-5 py-4 border-b border-gray-800/60 flex items-center justify-between">
282
+ <div className="flex items-center gap-2">
283
+ <Database className="w-4 h-4 text-fuchsia-400" />
284
+ <h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">
285
+ Training Datasets
286
+ <span className="ml-2 text-xs font-mono text-gray-500">{datasets.length}</span>
287
+ </h2>
288
+ </div>
289
+ <button
290
+ onClick={() => setShowAddDataset(!showAddDataset)}
291
+ className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-900/60 px-3 py-1.5 text-xs text-gray-300 hover:text-white hover:border-fuchsia-500/50 transition"
292
+ >
293
+ {showAddDataset ? <X className="w-3.5 h-3.5" /> : <FolderPlus className="w-3.5 h-3.5" />}
294
+ {showAddDataset ? "Cancel" : "Add Dataset"}
295
+ </button>
296
+ </div>
297
+
298
+ {showAddDataset && (
299
+ <div className="px-5 py-4 border-b border-gray-800/40 bg-gray-900/20 space-y-3">
300
+ <p className="text-xs text-gray-500">
301
+ Register a dataset folder. Place your images at{" "}
302
+ <code className="text-gray-400 bg-black/40 px-1 rounded">/root/nemoflix-training/datasets/&lt;id&gt;/</code>{" "}
303
+ on the AMD node before starting a training job.
304
+ </p>
305
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
306
+ <div className="space-y-1">
307
+ <label className="text-[10px] text-gray-500 uppercase tracking-wide">Folder ID *</label>
308
+ <Input
309
+ placeholder="e.g. rigo_v2_photos"
310
+ value={addDatasetId}
311
+ onChange={e => setAddDatasetId(e.target.value)}
312
+ className="bg-black/40 border-gray-700 text-white text-sm placeholder:text-gray-600"
313
+ />
314
+ </div>
315
+ <div className="space-y-1">
316
+ <label className="text-[10px] text-gray-500 uppercase tracking-wide">Display Name</label>
317
+ <Input
318
+ placeholder="Optional display name"
319
+ value={addDatasetName}
320
+ onChange={e => setAddDatasetName(e.target.value)}
321
+ className="bg-black/40 border-gray-700 text-white text-sm placeholder:text-gray-600"
322
+ />
323
+ </div>
324
+ <div className="space-y-1">
325
+ <label className="text-[10px] text-gray-500 uppercase tracking-wide">Description</label>
326
+ <Input
327
+ placeholder="e.g. Rigo reference photos v2"
328
+ value={addDatasetDesc}
329
+ onChange={e => setAddDatasetDesc(e.target.value)}
330
+ className="bg-black/40 border-gray-700 text-white text-sm placeholder:text-gray-600"
331
+ />
332
+ </div>
333
+ <div className="space-y-1">
334
+ <label className="text-[10px] text-gray-500 uppercase tracking-wide">Image Count</label>
335
+ <Input
336
+ type="number"
337
+ placeholder="e.g. 25"
338
+ value={addDatasetCount}
339
+ onChange={e => setAddDatasetCount(e.target.value)}
340
+ className="bg-black/40 border-gray-700 text-white text-sm placeholder:text-gray-600"
341
+ />
342
+ </div>
343
+ </div>
344
+ {addDatasetError && <p className="text-sm text-rose-400">{addDatasetError}</p>}
345
+ <Button
346
+ onClick={submitAddDataset}
347
+ disabled={addDatasetSubmitting || !addDatasetId}
348
+ className="bg-fuchsia-600 hover:bg-fuchsia-500 text-white text-sm"
349
+ >
350
+ {addDatasetSubmitting ? "Registering…" : "Register Dataset"}
351
+ </Button>
352
+ </div>
353
+ )}
354
+
355
+ {datasetsLoading ? (
356
+ <p className="text-xs text-gray-500 px-5 py-6">Loading…</p>
357
+ ) : datasets.length === 0 ? (
358
+ <p className="text-sm text-gray-500 py-8 text-center">
359
+ No datasets registered. Add one above, then reference it when starting a training job.
360
+ </p>
361
+ ) : (
362
+ <div className="divide-y divide-gray-800/40">
363
+ {datasets.map(ds => (
364
+ <div
365
+ key={ds.id}
366
+ className="px-5 py-3 flex items-center gap-4 hover:bg-gray-900/30 transition cursor-pointer"
367
+ onClick={() => setFormDataset(ds.id)}
368
+ title="Use in training form"
369
+ >
370
+ <div className="w-8 h-8 rounded-lg bg-fuchsia-900/30 border border-fuchsia-500/20 flex items-center justify-center flex-shrink-0">
371
+ <Database className="w-4 h-4 text-fuchsia-400" />
372
+ </div>
373
+ <div className="flex-1 min-w-0">
374
+ <p className="text-sm font-semibold text-white truncate">{ds.name}</p>
375
+ <p className="text-[11px] text-gray-500 font-mono">{ds.id}{ds.description ? ` · ${ds.description}` : ""}</p>
376
+ </div>
377
+ {ds.image_count != null && (
378
+ <span className="text-[11px] text-gray-500 flex-shrink-0">{ds.image_count} images</span>
379
+ )}
380
+ <span className="text-[10px] text-gray-600 flex-shrink-0">{new Date(ds.created_at).toLocaleDateString()}</span>
381
+ </div>
382
+ ))}
383
+ </div>
384
+ )}
385
+ </section>
386
+
387
+ {/* Stats */}
388
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
389
+ <StatBox label="Total Jobs" value={jobs.length} />
390
+ <StatBox label="Running" value={running} color="text-fuchsia-400" />
391
+ <StatBox label="Completed" value={completed} color="text-emerald-400" />
392
+ <StatBox label="Failed" value={failed} color="text-rose-400" />
393
+ </div>
394
+
395
+ {/* Jobs table */}
396
+ <section className="rounded-xl border border-gray-800/60 bg-gray-950 overflow-hidden">
397
+ <div className="px-5 py-4 border-b border-gray-800/60">
398
+ <h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">
399
+ Training Jobs
400
+ <span className="ml-2 text-xs font-mono text-gray-500">{jobs.length}</span>
401
+ </h2>
402
+ </div>
403
+
404
+ {jobs.length === 0 ? (
405
+ <p className="text-sm text-gray-500 py-8 text-center">No training jobs yet.</p>
406
+ ) : (
407
+ <Table>
408
+ <TableHeader>
409
+ <TableRow className="border-gray-800/60 hover:bg-transparent">
410
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider w-10" />
411
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">Job</TableHead>
412
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">Status</TableHead>
413
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">Progress</TableHead>
414
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">Loss</TableHead>
415
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">Model</TableHead>
416
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider text-right">Created</TableHead>
417
+ </TableRow>
418
+ </TableHeader>
419
+ <TableBody>
420
+ {mergedJobs.map((job: any) => {
421
+ // ai-toolkit returns status="running" when actually training. The info
422
+ // field is "Training" when steps are executing and "Initializing" when
423
+ // models/latents are loading. Trust the live data, not hardcoded logic.
424
+ const hasLiveProgress = job.current_step > 0 && job.total_steps > 0;
425
+ const isTraining = (job.status === "training" || job.status === "running") && hasLiveProgress;
426
+ const isInitializing = (job.status === "running" || job.status === "training") && !hasLiveProgress && job._live;
427
+ const progress = hasLiveProgress
428
+ ? Math.round((job.current_step / job.total_steps) * 100)
429
+ : 0;
430
+ const isExpanded = expandedJob === job.job_name;
431
+ const isLoading = isExpanded && loadingExpanded && !expandedData.has(job.job_name);
432
+ const jobData = expandedData.get(job.job_name);
433
+ const samples = jobData?.samples ?? [];
434
+ const checkpoints = jobData?.checkpoints ?? [];
435
+
436
+ return (
437
+ <React.Fragment key={job.job_name}>
438
+ {/* Main row — clickable */}
439
+ <TableRow
440
+ key={job.job_name}
441
+ className={`border-gray-800/40 cursor-pointer transition-colors ${
442
+ isExpanded ? "bg-gray-900/60" : "hover:bg-gray-900/30"
443
+ }`}
444
+ onClick={() => toggleJob(job.job_name)}
445
+ >
446
+ <TableCell>
447
+ <ChevronDown
448
+ className={`w-4 h-4 text-gray-500 transition-transform ${
449
+ isExpanded ? "rotate-180" : ""
450
+ }`}
451
+ />
452
+ </TableCell>
453
+ <TableCell>
454
+ <div>
455
+ <p className="text-sm font-mono text-white truncate max-w-[200px]">{job.job_name}</p>
456
+ <p className="text-[11px] text-gray-500 mt-0.5">
457
+ {[job.trigger_word && `trigger: ${job.trigger_word}`, job.dataset].filter(Boolean).join(" · ")}
458
+ </p>
459
+ </div>
460
+ </TableCell>
461
+ <TableCell>
462
+ <Badge variant={statusVariant(job.status)}>{job.status}</Badge>
463
+ </TableCell>
464
+ <TableCell className="min-w-[220px]">
465
+ {isTraining ? (
466
+ <div className="space-y-1.5">
467
+ <div className="flex items-center gap-2">
468
+ <Progress value={progress} className="h-1.5 flex-1 bg-gray-800 [&>div]:bg-fuchsia-500" />
469
+ <span className="text-[11px] font-mono text-fuchsia-400 tabular-nums">{progress}%</span>
470
+ </div>
471
+ <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-gray-500 font-mono">
472
+ <span>Step {job.current_step}/{job.total_steps}</span>
473
+ {job.seconds_per_step ? <span>{job.seconds_per_step.toFixed(1)}s/step</span> : null}
474
+ {job.lr != null ? <span>lr {Number(job.lr).toExponential(1)}</span> : null}
475
+ {job.eta ? <span>{job.eta} left</span> : null}
476
+ </div>
477
+ {job.info && <div className="text-[10px] text-gray-500">ai-toolkit: {job.info}</div>}
478
+ </div>
479
+ ) : isInitializing ? (
480
+ <div className="flex items-center gap-2 text-sm text-amber-400">
481
+ <span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
482
+ Initializing — loading models, caching latents…
483
+ </div>
484
+ ) : job.status === "completed" ? (
485
+ <div className="text-sm text-gray-400 font-mono space-y-0.5">
486
+ <p>{job.total_steps || 0} steps{job.elapsed ? ` · ${job.elapsed}` : ""}</p>
487
+ {job.loss != null ? <p className="text-xs text-gray-500">final loss {job.loss.toFixed(4)}</p> : null}
488
+ </div>
489
+ ) : job.status === "failed" ? (
490
+ <span className="text-xs text-gray-500">
491
+ {job._dead ? "Process died or was abandoned" : job.error || "Job failed"}
492
+ </span>
493
+ ) : (
494
+ <span className="text-sm text-gray-600">—</span>
495
+ )}
496
+ </TableCell>
497
+ <TableCell>
498
+ <span className="text-sm font-mono text-white">
499
+ {job.loss != null ? job.loss.toFixed(4) : "—"}
500
+ </span>
501
+ </TableCell>
502
+ <TableCell>
503
+ <span className="text-sm text-gray-400">{job.model || "—"}</span>
504
+ </TableCell>
505
+ <TableCell className="text-right">
506
+ <span className="text-sm text-gray-500 font-mono">{formatDate(job.created_at)}</span>
507
+ </TableCell>
508
+ </TableRow>
509
+
510
+ {/* Expanded sub-row — inline, directly under the clicked row */}
511
+ {isExpanded && (
512
+ <TableRow key={`${job.job_name}-expanded`} className="border-gray-800/40 bg-gray-900/40">
513
+ <TableCell colSpan={7} className="p-0">
514
+ {isLoading ? (
515
+ <div className="px-6 py-6 text-sm text-gray-500">Loading samples and checkpoints…</div>
516
+ ) : (
517
+ <div className="px-6 py-5 space-y-5">
518
+ {/* Samples */}
519
+ {samples.length > 0 && (
520
+ <div>
521
+ <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
522
+ Training Samples ({samples.length})
523
+ </h4>
524
+ <div className="flex gap-3 overflow-x-auto pb-2">
525
+ {samples.map((s) => (
526
+ <div
527
+ key={s.name}
528
+ className="flex-shrink-0 w-24 rounded-lg border border-gray-800 bg-black/40 overflow-hidden"
529
+ >
530
+ <img
531
+ src={`/api/lora-training/sample-image?path=${encodeURIComponent(s.name)}`}
532
+ alt={`Sample step ${s.step}`}
533
+ className="w-full aspect-square object-cover"
534
+ />
535
+ <div className="px-1.5 py-1 text-[10px] text-gray-500 font-mono text-center">
536
+ Step {s.step ?? "?"}
537
+ </div>
538
+ </div>
539
+ ))}
540
+ </div>
541
+ </div>
542
+ )}
543
+
544
+ {/* Checkpoints */}
545
+ {checkpoints.length > 0 && (
546
+ <div>
547
+ <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
548
+ Checkpoints ({checkpoints.length})
549
+ </h4>
550
+ <Table>
551
+ <TableHeader>
552
+ <TableRow className="border-gray-800/60 hover:bg-transparent">
553
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">Step</TableHead>
554
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider">File</TableHead>
555
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider text-right">Size</TableHead>
556
+ <TableHead className="text-gray-500 text-xs uppercase tracking-wider text-right">Date</TableHead>
557
+ </TableRow>
558
+ </TableHeader>
559
+ <TableBody>
560
+ {checkpoints.map((ck) => (
561
+ <TableRow key={ck.name} className="border-gray-800/40 hover:bg-gray-900/30">
562
+ <TableCell className="font-mono text-sm text-violet-300">{ck.step ?? "final"}</TableCell>
563
+ <TableCell className="font-mono text-[11px] text-gray-400 max-w-[300px] truncate">{ck.name}</TableCell>
564
+ <TableCell className="text-right font-mono text-sm text-gray-400">
565
+ {(ck.size_bytes / 1024 / 1024).toFixed(0)} MB
566
+ </TableCell>
567
+ <TableCell className="text-right text-sm text-gray-500">
568
+ {new Date(ck.modified_at).toLocaleDateString()}
569
+ </TableCell>
570
+ </TableRow>
571
+ ))}
572
+ </TableBody>
573
+ </Table>
574
+ </div>
575
+ )}
576
+
577
+ {samples.length === 0 && checkpoints.length === 0 && (
578
+ <p className="text-sm text-gray-600 py-2">No samples or checkpoints for this job.</p>
579
+ )}
580
+ </div>
581
+ )}
582
+ </TableCell>
583
+ </TableRow>
584
+ )}
585
+ </React.Fragment>
586
+ );
587
+ })}
588
+ </TableBody>
589
+ </Table>
590
+ )}
591
+ </section>
592
+ </div>
593
+ );
594
+ }
595
+
596
+ function StatBox({ label, value, color }: { label: string; value: number; color?: string }) {
597
+ return (
598
+ <div className="rounded-lg border border-gray-800 bg-black/40 p-3 text-center">
599
+ <p className={`text-lg font-bold ${color || "text-white"}`}>{value}</p>
600
+ <p className="text-[10px] uppercase tracking-wide text-gray-500">{label}</p>
601
+ </div>
602
+ );
603
+ }
studio/src/components/MediaTile.tsx ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Check, Trash2, X, Play, Wand2, ArrowRight } from "lucide-react";
3
+ import type { MediaItem } from "../types";
4
+
5
+ interface MediaTileProps {
6
+ item: MediaItem;
7
+ onOpen: () => void;
8
+ onDelete: (item: MediaItem) => Promise<void> | void;
9
+ onGenerateVideo?: (item: MediaItem, motionPrompt: string) => void;
10
+ }
11
+
12
+ export function MediaTile({ item, onOpen, onDelete, onGenerateVideo }: MediaTileProps) {
13
+ const [confirmDelete, setConfirmDelete] = useState(false);
14
+ const [deleting, setDeleting] = useState(false);
15
+ const [showI2VInput, setShowI2VInput] = useState(false);
16
+ const [motionPrompt, setMotionPrompt] = useState("");
17
+
18
+ async function confirm(event: React.MouseEvent) {
19
+ event.stopPropagation();
20
+ if (deleting) return;
21
+ setDeleting(true);
22
+ try {
23
+ await onDelete(item);
24
+ } finally {
25
+ setDeleting(false);
26
+ setConfirmDelete(false);
27
+ }
28
+ }
29
+
30
+ return (
31
+ <div
32
+ onClick={onOpen}
33
+ className="cursor-pointer rounded-xl overflow-hidden border border-gray-800/60 hover:border-gray-600 aspect-[3/4] bg-gray-900/50 relative group transition-all duration-200 hover:shadow-xl hover:shadow-black/30 hover:-translate-y-0.5"
34
+ >
35
+ {/* Media */}
36
+ {item.type === "video" ? (
37
+ <video src={item.thumb || item.url} className="w-full h-full object-cover" preload="metadata" muted />
38
+ ) : (
39
+ <img src={item.thumb || item.url} alt={item.name || ""} className="w-full h-full object-cover group-hover:scale-105 transition duration-500" loading="lazy" />
40
+ )}
41
+
42
+ {/* Delete button */}
43
+ {!confirmDelete && (
44
+ <button
45
+ onClick={(event) => {
46
+ event.stopPropagation();
47
+ setConfirmDelete(true);
48
+ }}
49
+ className="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-black/60 text-white/80 backdrop-blur-sm flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 hover:text-white transition-all"
50
+ title="Delete"
51
+ >
52
+ <Trash2 className="w-4 h-4" />
53
+ </button>
54
+ )}
55
+
56
+ {/* I2V button / prompt — only for images */}
57
+ {!confirmDelete && item.type === "image" && onGenerateVideo && (
58
+ <>
59
+ {!showI2VInput ? (
60
+ <button
61
+ onClick={(event) => {
62
+ event.stopPropagation();
63
+ setShowI2VInput(true);
64
+ }}
65
+ className="absolute top-2 left-2 z-10 flex items-center gap-1.5 rounded-lg bg-violet-600/80 text-white text-[10px] font-medium px-2 py-1.5 backdrop-blur-sm opacity-0 group-hover:opacity-100 hover:bg-violet-500 transition-all"
66
+ title="Generate Video"
67
+ >
68
+ <Wand2 className="w-3 h-3" />
69
+ I2V
70
+ </button>
71
+ ) : (
72
+ <div
73
+ className="absolute inset-0 z-20 bg-black/90 backdrop-blur-sm flex flex-col items-center justify-center gap-2 px-4"
74
+ onClick={(event) => event.stopPropagation()}
75
+ >
76
+ <p className="text-[10px] uppercase tracking-wider text-white/60">Motion Prompt</p>
77
+ <input
78
+ autoFocus
79
+ value={motionPrompt}
80
+ onChange={(e) => setMotionPrompt(e.target.value)}
81
+ onKeyDown={(e) => {
82
+ if (e.key === "Enter") {
83
+ onGenerateVideo(item, motionPrompt);
84
+ setShowI2VInput(false);
85
+ setMotionPrompt("");
86
+ }
87
+ if (e.key === "Escape") {
88
+ setShowI2VInput(false);
89
+ setMotionPrompt("");
90
+ }
91
+ }}
92
+ placeholder="camera push in, rain falling..."
93
+ className="w-full rounded-lg bg-gray-800 border border-gray-700 px-3 py-2 text-xs text-white focus:outline-none focus:border-violet-500 placeholder:text-gray-600"
94
+ />
95
+ <div className="flex gap-2">
96
+ <button
97
+ onClick={() => {
98
+ setShowI2VInput(false);
99
+ setMotionPrompt("");
100
+ }}
101
+ className="text-[10px] text-gray-500 hover:text-white transition"
102
+ >
103
+ Cancel
104
+ </button>
105
+ <button
106
+ onClick={() => {
107
+ onGenerateVideo(item, motionPrompt);
108
+ setShowI2VInput(false);
109
+ setMotionPrompt("");
110
+ }}
111
+ className="flex items-center gap-1 text-[10px] text-violet-400 hover:text-violet-200 transition font-medium"
112
+ >
113
+ Generate <ArrowRight className="w-3 h-3" />
114
+ </button>
115
+ </div>
116
+ </div>
117
+ )}
118
+ </>
119
+ )}
120
+
121
+ {/* Bottom info bar */}
122
+ <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 pt-6 opacity-0 group-hover:opacity-100 transition pointer-events-none">
123
+ <p className="text-xs font-medium truncate text-white/90">{item.name || "Untitled"}</p>
124
+ </div>
125
+
126
+ {/* Video badge */}
127
+ {item.type === "video" && (
128
+ <div className="absolute top-2 left-2 bg-black/60 backdrop-blur-sm text-white text-[10px] font-medium px-2 py-1 rounded-md flex items-center gap-1">
129
+ <Play className="w-2.5 h-2.5 fill-white" />
130
+ VIDEO
131
+ </div>
132
+ )}
133
+
134
+ {/* Delete confirmation overlay */}
135
+ {confirmDelete && (
136
+ <div
137
+ className="absolute inset-0 z-20 bg-black/80 backdrop-blur-sm flex flex-col items-center justify-center gap-3"
138
+ onClick={(event) => {
139
+ event.stopPropagation();
140
+ setConfirmDelete(false);
141
+ }}
142
+ >
143
+ <span className="text-xs font-semibold uppercase tracking-wider text-white/80">Delete this?</span>
144
+ <div className="flex gap-3" onClick={(event) => event.stopPropagation()}>
145
+ <button
146
+ onClick={() => setConfirmDelete(false)}
147
+ className="w-10 h-10 rounded-full bg-gray-800/90 text-gray-400 hover:text-white hover:bg-gray-700 flex items-center justify-center transition"
148
+ title="Cancel"
149
+ >
150
+ <X className="w-5 h-5" />
151
+ </button>
152
+ <button
153
+ onClick={confirm}
154
+ disabled={deleting}
155
+ className="w-10 h-10 rounded-full bg-red-600/90 text-white hover:bg-red-500 disabled:bg-gray-800 disabled:text-gray-600 flex items-center justify-center transition"
156
+ title="Confirm delete"
157
+ >
158
+ <Check className="w-5 h-5" />
159
+ </button>
160
+ </div>
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
studio/src/components/ProjectDetailView.tsx ADDED
@@ -0,0 +1,1023 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { Film, Image as ImageIcon, Video, Wand2, Plus, Save, Layers, Sparkles, Edit3, Play, ArrowLeft, Trash2, Clapperboard, Loader2, CheckCircle2, AlertTriangle, X, Download } from "lucide-react";
4
+ import type { JobItem, Project, Scene, Shot, ShotVersion, ProjectPhase } from "../types";
5
+
6
+ interface ProjectDetailViewProps {
7
+ project: Project;
8
+ scenes: Scene[];
9
+ shots: Shot[];
10
+ jobs: JobItem[];
11
+ selectedSceneId: string | null;
12
+ selectedShotId: string | null;
13
+ onSelectScene: (id: string) => void;
14
+ onSelectShot: (id: string | null) => void;
15
+ onRefresh: () => Promise<void> | void;
16
+ onBack: () => void;
17
+ onDeleteScene: (sceneId: string) => Promise<void> | void;
18
+ onDeleteShot: (shotId: string) => Promise<void> | void;
19
+ }
20
+
21
+ function mediaUrl(file: string | null | undefined): string | null {
22
+ if (!file) return null;
23
+ if (file.startsWith("/") || file.startsWith("http")) return file;
24
+ return `/media/${file}`;
25
+ }
26
+
27
+ export function ProjectDetailView({
28
+ project, scenes, shots, jobs,
29
+ selectedSceneId, selectedShotId,
30
+ onSelectScene, onSelectShot, onRefresh, onBack,
31
+ onDeleteScene, onDeleteShot,
32
+ }: ProjectDetailViewProps) {
33
+ // Derive real phase from shot data, not prop
34
+ const phase = useMemo<ProjectPhase>(() => {
35
+ if (shots.length === 0) return "outline";
36
+ const anyImage = shots.some((s) => s.image_file);
37
+ const anyVideo = shots.some((s) => s.video_file);
38
+ if (anyVideo) return "animate";
39
+ if (anyImage) return "generate";
40
+ return "outline";
41
+ }, [shots]);
42
+ const [activeRenderId, setActiveRenderId] = useState<string | null>(null);
43
+ const [versions, setVersions] = useState<ShotVersion[]>([]);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [renderStatus, setRenderStatus] = useState<string>(() => String(project.metadata?.render_status ?? "none"));
46
+ const [renders, setRenders] = useState<Array<{ id: string; render_number: number; final_video_url: string | null; created_at: string; status: string }>>([]);
47
+ const [finalVideoUrl, setFinalVideoUrl] = useState<string | null>(() => {
48
+ const v = project.metadata?.final_video;
49
+ return typeof v === "string" ? `/media/${v}` : null;
50
+ });
51
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
52
+
53
+ useEffect(() => {
54
+ setRenderStatus(String(project.metadata?.render_status ?? "none"));
55
+ const v = project.metadata?.final_video;
56
+ setFinalVideoUrl(typeof v === "string" ? `/media/${v}` : null);
57
+ }, [project.metadata]);
58
+
59
+ useEffect(() => {
60
+ if (renderStatus !== "rendering") {
61
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
62
+ return;
63
+ }
64
+ pollRef.current = setInterval(async () => {
65
+ try {
66
+ const res = await fetch(`/api/projects/${project.id}/render`);
67
+ if (!res.ok) return;
68
+ const data = await res.json();
69
+ setRenderStatus(data.status ?? "none");
70
+ if (data.final_video_url) setFinalVideoUrl(data.final_video_url);
71
+ if (data.renders) {
72
+ setRenders(data.renders);
73
+ if (!activeRenderId && data.renders.length > 0) {
74
+ setActiveRenderId(data.renders[0].id);
75
+ }
76
+ }
77
+ if (data.status !== "rendering") {
78
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
79
+ if (data.status === "failed") setError(`Render failed: ${data.render_error ?? "unknown error"}`);
80
+ }
81
+ } catch { /* ignore */ }
82
+ }, 3000);
83
+ return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
84
+ }, [project.id, renderStatus]);
85
+
86
+ async function handleRender() {
87
+ if (renderStatus === "rendering") return;
88
+ setError(null);
89
+ setRenderStatus("rendering");
90
+ try {
91
+ const res = await fetch(`/api/projects/${project.id}/render`, { method: "POST" });
92
+ const data = await res.json();
93
+ if (!res.ok) throw new Error(data.detail ?? `${res.status}`);
94
+ } catch (e) {
95
+ setRenderStatus("failed");
96
+ setError(e instanceof Error ? e.message : "Render failed");
97
+ }
98
+ }
99
+
100
+ const selectedScene = useMemo(() => scenes.find((s) => s.id === selectedSceneId) || null, [scenes, selectedSceneId]);
101
+ const selectedShot = useMemo(() => shots.find((s) => s.id === selectedShotId) || null, [shots, selectedShotId]);
102
+ const sceneShots = useMemo(() => shots.filter((s) => s.scene_id === selectedSceneId), [shots, selectedSceneId]);
103
+
104
+ // Load versions for the focused shot
105
+ useEffect(() => {
106
+ if (!selectedShotId || !selectedSceneId) { setVersions([]); return; }
107
+ let cancelled = false;
108
+ fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots/${selectedShotId}/versions`)
109
+ .then((r) => r.ok ? r.json() : { versions: [] })
110
+ .then((data) => { if (!cancelled) setVersions(data.versions || []); })
111
+ .catch(() => { if (!cancelled) setVersions([]); });
112
+ return () => { cancelled = true; };
113
+ }, [project.id, selectedSceneId, selectedShotId, shots]);
114
+
115
+ const [saving, setSaving] = useState(false);
116
+ const [showRenderConfirm, setShowRenderConfirm] = useState(false);
117
+
118
+ // Look up the active job for a shot by matching prompt IDs
119
+ function shotJob(shot: Shot): JobItem | undefined {
120
+ return jobs.find((j) =>
121
+ (shot.image_prompt_id && j.prompt_id === shot.image_prompt_id) ||
122
+ (shot.video_prompt_id && j.prompt_id === shot.video_prompt_id)
123
+ );
124
+ }
125
+
126
+ function isRendering(shot: Shot): boolean {
127
+ const job = shotJob(shot);
128
+ return (shot.status === 'rendering_image' || shot.status === 'animating') ||
129
+ (!!job && (job.status === 'pending' || job.status === 'running'));
130
+ }
131
+
132
+ async function patchShot(shotId: string, patch: Partial<Shot>) {
133
+ const shot = shots.find((s) => s.id === shotId);
134
+ if (!shot) return;
135
+ setSaving(true);
136
+ try {
137
+ const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shotId}`, {
138
+ method: "PATCH",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify(patch),
141
+ });
142
+ if (!response.ok) throw new Error(`Patch failed: ${response.status}`);
143
+ await onRefresh();
144
+ } catch (e) {
145
+ setError(e instanceof Error ? e.message : "Failed to save shot");
146
+ } finally {
147
+ setSaving(false);
148
+ }
149
+ }
150
+
151
+ async function addShot() {
152
+ if (!selectedSceneId) return;
153
+ setSaving(true);
154
+ const next = sceneShots.length > 0 ? Math.max(...sceneShots.map((s) => s.shot_number)) + 1 : 1;
155
+ try {
156
+ const response = await fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots`, {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json" },
159
+ body: JSON.stringify({ shot_number: next, description: "" }),
160
+ });
161
+ if (!response.ok) throw new Error(`Add shot failed: ${response.status}`);
162
+ const created = await response.json();
163
+ await onRefresh();
164
+ onSelectShot(created.id);
165
+ } catch (e) {
166
+ setError(e instanceof Error ? e.message : "Failed to add shot");
167
+ } finally {
168
+ setSaving(false);
169
+ }
170
+ }
171
+
172
+ async function generateImage(shot: Shot) {
173
+ if (isRendering(shot)) return;
174
+ setSaving(true);
175
+ try {
176
+ const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/generate-image`, { method: "POST" });
177
+ if (!response.ok) throw new Error(`Generate failed: ${response.status}`);
178
+ await onRefresh();
179
+ } catch (e) {
180
+ setError(e instanceof Error ? e.message : "Failed to generate image");
181
+ } finally {
182
+ setSaving(false);
183
+ }
184
+ }
185
+
186
+ async function animateShot(shot: Shot) {
187
+ if (isRendering(shot)) return;
188
+ setSaving(true);
189
+ try {
190
+ const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/animate`, { method: "POST" });
191
+ if (!response.ok) throw new Error(`Animate failed: ${response.status}`);
192
+ await onRefresh();
193
+ } catch (e) {
194
+ setError(e instanceof Error ? e.message : "Failed to animate");
195
+ } finally {
196
+ setSaving(false);
197
+ }
198
+ }
199
+
200
+ async function selectVersion(version: ShotVersion) {
201
+ if (!selectedShot) return;
202
+ try {
203
+ const response = await fetch(`/api/projects/${project.id}/scenes/${selectedShot.scene_id}/shots/${selectedShot.id}/versions/${version.id}/select`, { method: "POST" });
204
+ if (!response.ok) throw new Error(`Select version failed: ${response.status}`);
205
+ await onRefresh();
206
+ } catch (e) {
207
+ setError(e instanceof Error ? e.message : "Failed to select version");
208
+ }
209
+ }
210
+
211
+ return (
212
+ <div className="h-full flex flex-col bg-black">
213
+ {/* Top bar */}
214
+ <div className="relative 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 z-30">
215
+ <div className="flex items-center gap-3 min-w-0 flex-1">
216
+ <button
217
+ onClick={onBack}
218
+ 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"
219
+ >
220
+ <ArrowLeft className="w-3.5 h-3.5" /> All projects
221
+ </button>
222
+ <div className="min-w-0">
223
+ <p className="text-[10px] uppercase tracking-[0.22em] text-rose-400/70">Project</p>
224
+ <h1 className="text-base font-semibold tracking-tight text-gray-100 truncate">{project.title}</h1>
225
+ </div>
226
+ </div>
227
+ <div className="flex items-center gap-1.5 text-[11px] flex-shrink-0">
228
+ <PhaseChip phase={phase} />
229
+ <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>
230
+ {project.duration_seconds !== null && (
231
+ <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>
232
+ )}
233
+ <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>
234
+ <Link
235
+ to={`/studio/projects/${project.id}/films`}
236
+ className="inline-flex items-center gap-1.5 rounded-xl border border-emerald-500/40 bg-emerald-600/15 hover:bg-emerald-600/25 px-3 py-1.5 text-xs font-medium text-emerald-100 transition"
237
+ >
238
+ <Film className="w-3.5 h-3.5" /> Films ({renders.length})
239
+ </Link>
240
+ {phase !== "outline" && (
241
+ <button
242
+ onClick={() => renderStatus !== "rendering" && setShowRenderConfirm(true)}
243
+ disabled={renderStatus === "rendering"}
244
+ className="inline-flex items-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-not-allowed px-3 py-1.5 text-xs font-medium text-violet-100 transition"
245
+ >
246
+ {renderStatus === "rendering"
247
+ ? <><Loader2 className="w-3.5 h-3.5 animate-spin" /> Rendering…</>
248
+ : <><Clapperboard className="w-3.5 h-3.5" /> Render final video</>
249
+ }
250
+ </button>
251
+ )}
252
+ </div>
253
+ </div>
254
+
255
+ {/* Main split: center | right context editor */}
256
+ <div className="flex-1 min-h-0 flex">
257
+ {/* Center: shots in current scene */}
258
+ <main className="flex-1 min-w-0 flex flex-col bg-gradient-to-b from-transparent via-transparent to-gray-950/30">
259
+ <div className="flex-1 overflow-y-auto">
260
+ <div className="max-w-5xl mx-auto p-6 space-y-4">
261
+ {selectedScene ? (
262
+ <>
263
+ <div className="flex items-start justify-between gap-4">
264
+ <div>
265
+ <p className="text-[11px] uppercase tracking-[0.2em] text-rose-400/70">Scene {selectedScene.scene_number}</p>
266
+ <h2 className="text-2xl font-bold tracking-tight mt-1">{selectedScene.title || "Untitled scene"}</h2>
267
+ {selectedScene.summary && (
268
+ <p className="text-sm text-gray-400 mt-2 max-w-2xl leading-relaxed">{selectedScene.summary}</p>
269
+ )}
270
+ </div>
271
+ <button
272
+ onClick={addShot}
273
+ disabled={saving}
274
+ 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 disabled:opacity-50 px-3 py-1.5 text-xs font-medium text-rose-100 transition flex-shrink-0"
275
+ >
276
+ <Plus className="w-3.5 h-3.5" /> {saving ? "Adding…" : "Add shot"}
277
+ </button>
278
+ </div>
279
+
280
+ {sceneShots.length === 0 ? (
281
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/30 p-12 text-center">
282
+ <Film className="w-7 h-7 text-gray-600 mx-auto mb-2" />
283
+ <p className="text-sm text-gray-400">No shots in this scene yet.</p>
284
+ <p className="text-xs text-gray-600 mt-1.5">Add shots and write image prompts before generating.</p>
285
+ </div>
286
+ ) : (
287
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
288
+ {sceneShots.map((shot) => (
289
+ <ShotCard
290
+ key={shot.id}
291
+ shot={shot}
292
+ phase={phase}
293
+ selected={shot.id === selectedShotId}
294
+ saving={saving}
295
+ onSelect={() => onSelectShot(shot.id)}
296
+ onGenerateImage={() => generateImage(shot)}
297
+ onAnimate={() => animateShot(shot)}
298
+ onDeleteShot={() => onDeleteShot(shot.id)}
299
+ />
300
+ ))}
301
+ </div>
302
+ )}
303
+ </>
304
+ ) : (
305
+ <OutlineCenter project={project} />
306
+ )}
307
+ </div>
308
+ </div>
309
+
310
+ {/* Bottom: shot version strip */}
311
+ <div className="border-t border-gray-800/60 bg-gray-950/60 flex-shrink-0">
312
+ <div className="px-4 py-2 flex items-center gap-3 overflow-x-auto">
313
+ <span className="text-[10px] uppercase tracking-wider text-gray-500 font-medium flex-shrink-0">
314
+ {selectedShot ? `S${selectedScene?.scene_number}·shot ${selectedShot.shot_number} versions` : "Versions"}
315
+ </span>
316
+ {!selectedShot && (
317
+ <span className="text-[11px] text-gray-600">Select a shot to see its generated versions.</span>
318
+ )}
319
+ {selectedShot && versions.length === 0 && (
320
+ <span className="text-[11px] text-gray-600">No versions yet. Generate to create one.</span>
321
+ )}
322
+ {versions.map((version) => {
323
+ const url = mediaUrl(version.file);
324
+ const isCurrent = selectedShot?.image_file === version.file || selectedShot?.video_file === version.file;
325
+ return (
326
+ <button
327
+ key={version.id}
328
+ onClick={() => selectVersion(version)}
329
+ title={`v${version.version_number} · ${version.kind} · ${version.status}`}
330
+ 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"}`}
331
+ >
332
+ {url ? (
333
+ version.kind === "video" ? (
334
+ <video src={url} className="h-14 w-24 object-cover bg-black" muted />
335
+ ) : (
336
+ <img src={url} alt="" className="h-14 w-24 object-cover bg-black" />
337
+ )
338
+ ) : (
339
+ <div className="h-14 w-24 flex items-center justify-center bg-gray-900 text-[10px] text-gray-600">{version.status}</div>
340
+ )}
341
+ <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>
342
+ </button>
343
+ );
344
+ })}
345
+ {selectedShot && (
346
+ <button
347
+ disabled
348
+ title="Coming soon"
349
+ className="flex-shrink-0 h-14 px-3 rounded-lg border border-dashed border-gray-800 bg-gray-900/30 flex items-center gap-1.5 text-[10px] font-medium text-gray-600 cursor-not-allowed"
350
+ >
351
+ <Layers className="w-3 h-3" />
352
+ Import from gallery
353
+ <span className="text-[8px] uppercase tracking-wider text-gray-500">Soon</span>
354
+ </button>
355
+ )}
356
+ </div>
357
+ </div>
358
+ </main>
359
+
360
+ {/* Right: context-aware editor — styled to match AppSidebar */}
361
+ <aside className="w-[340px] flex-shrink-0 border-l border-gray-800/60 bg-gray-950/40 flex flex-col">
362
+ <div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-800/40 flex-shrink-0">
363
+ <span className="text-sm font-semibold text-gray-300 tracking-tight">
364
+ {selectedShot ? `Shot ${selectedShot.shot_number}` : selectedScene ? `Scene ${selectedScene.scene_number}` : "Project"}
365
+ </span>
366
+ <span className="text-[10px] uppercase tracking-wider text-gray-600">{phase}</span>
367
+ </div>
368
+
369
+ <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
370
+ {selectedShot ? (
371
+ <ShotEditor
372
+ shot={selectedShot}
373
+ phase={phase}
374
+ saving={saving}
375
+ onPatch={(patch) => patchShot(selectedShot.id, patch)}
376
+ onGenerate={() => generateImage(selectedShot)}
377
+ onAnimate={() => animateShot(selectedShot)}
378
+ />
379
+ ) : selectedScene ? (
380
+ <SceneSummary scene={selectedScene} />
381
+ ) : (
382
+ <ProjectSummary project={project} />
383
+ )}
384
+ </div>
385
+ </aside>
386
+ </div>
387
+
388
+ {error && (
389
+ <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)}>
390
+ {error}
391
+ </div>
392
+ )}
393
+
394
+ {showRenderConfirm && (
395
+ <RenderConfirmModal
396
+ project={project}
397
+ scenes={scenes}
398
+ shots={shots}
399
+ onConfirm={() => { setShowRenderConfirm(false); handleRender(); }}
400
+ onCancel={() => setShowRenderConfirm(false)}
401
+ />
402
+ )}
403
+ </div>
404
+ );
405
+ }
406
+
407
+ function PhaseChip({ phase }: { phase: ProjectPhase }) {
408
+ if (phase === "outline") {
409
+ return (
410
+ <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">
411
+ <Edit3 className="w-3 h-3" /> Outline
412
+ </span>
413
+ );
414
+ }
415
+ if (phase === "generate") {
416
+ return (
417
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-rose-500/40 bg-rose-600/15 px-2.5 py-1 text-rose-200">
418
+ <Wand2 className="w-3 h-3" /> Generate
419
+ </span>
420
+ );
421
+ }
422
+ return (
423
+ <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">
424
+ <Play className="w-3 h-3" /> Animate
425
+ </span>
426
+ );
427
+ }
428
+
429
+ function OutlineCenter({ project }: { project: Project }) {
430
+ return (
431
+ <div className="rounded-2xl border border-gray-800/60 bg-gray-900/30 p-12 text-center max-w-2xl mx-auto">
432
+ <Layers className="w-9 h-9 text-gray-600 mx-auto mb-3" />
433
+ <p className="text-base text-gray-300 font-medium">Outline phase</p>
434
+ <p className="text-sm text-gray-500 mt-2 leading-relaxed max-w-md mx-auto">
435
+ 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.
436
+ </p>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ function RemixCenter({ project, shots }: { project: Project; shots: Shot[] }) {
442
+ const imageCount = shots.filter((s) => s.image_file).length;
443
+ const videoCount = shots.filter((s) => s.video_file).length;
444
+ const totalShots = shots.length;
445
+
446
+ return (
447
+ <div className="rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-950/10 to-gray-950 p-8 text-center max-w-2xl mx-auto space-y-5">
448
+ <div className="flex items-center justify-center gap-2">
449
+ <Sparkles className="w-6 h-6 text-violet-400" />
450
+ <p className="text-lg font-semibold text-violet-200">Remix Phase</p>
451
+ </div>
452
+
453
+ <div className="grid grid-cols-3 gap-3 text-[11px]">
454
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-3 py-2">
455
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Images</p>
456
+ <p className="text-violet-300 mt-0.5 font-mono text-sm">{imageCount}/{totalShots}</p>
457
+ </div>
458
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-3 py-2">
459
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Videos</p>
460
+ <p className="text-violet-300 mt-0.5 font-mono text-sm">{videoCount}/{totalShots}</p>
461
+ </div>
462
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-3 py-2">
463
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Status</p>
464
+ <p className="text-violet-300 mt-0.5 font-mono text-sm">{project.status}</p>
465
+ </div>
466
+ </div>
467
+
468
+ <div className="space-y-3 text-left">
469
+ <p className="text-xs text-gray-300 leading-relaxed">
470
+ <strong className="text-violet-200">Remixing is editing.</strong> Change any prompt, hit regenerate, and iterate until it lands. Your agent can also edit prompts for you.
471
+ </p>
472
+
473
+ <div className="rounded-xl border border-gray-700/40 bg-gray-900/40 p-3 space-y-2">
474
+ <p className="text-[11px] font-semibold text-gray-300">How Remix works:</p>
475
+ {[
476
+ { n: 1, title: "Pick a shot", body: "Click any shot card to select it. The right panel shows its prompts." },
477
+ { n: 2, title: "Edit the prompt", body: "Change the image prompt or description in the right panel. Save your changes." },
478
+ { n: 3, title: "Regenerate", body: "Click Generate or Re-image. The API runs the new prompt on AMD MI300X and returns a fresh image." },
479
+ { n: 4, title: "Iterate", body: "Not quite right? Edit the prompt again and regenerate. Each run creates a new version you can compare." },
480
+ ].map((step) => (
481
+ <div key={step.n} className="flex gap-2.5">
482
+ <div className="flex-shrink-0 w-5 h-5 rounded-md bg-violet-500/20 flex items-center justify-center mt-0.5">
483
+ <span className="text-[10px] font-medium text-violet-400">{step.n}</span>
484
+ </div>
485
+ <div>
486
+ <p className="text-[11px] text-gray-200">{step.title}</p>
487
+ <p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">{step.body}</p>
488
+ </div>
489
+ </div>
490
+ ))}
491
+ </div>
492
+
493
+ <p className="text-[11px] text-gray-500 leading-relaxed">
494
+ <strong className="text-gray-400">API:</strong> <code className="text-gray-500">POST /api/projects/{'{projectId}'}/scenes/{'{sceneId}'}/shots/{'{shotId}'}/generate-image</code> — regenerates with the current prompt. Or ask your agent: "Remix shot 2 with a darker mood."
495
+ </p>
496
+ </div>
497
+ </div>
498
+ );
499
+ }
500
+
501
+ function AnimateCenter({ project, shots }: { project: Project; shots: Shot[] }) {
502
+ const imageCount = shots.filter((s) => s.image_file).length;
503
+ const videoCount = shots.filter((s) => s.video_file).length;
504
+ const totalShots = shots.length;
505
+
506
+ return (
507
+ <div className="rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-950/10 to-gray-950 p-8 text-center max-w-2xl mx-auto space-y-5">
508
+ <div className="flex items-center justify-center gap-2">
509
+ <Play className="w-6 h-6 text-violet-400" />
510
+ <p className="text-lg font-semibold text-violet-200">Animate Phase</p>
511
+ </div>
512
+
513
+ <div className="grid grid-cols-3 gap-3 text-[11px]">
514
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-3 py-2">
515
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Images</p>
516
+ <p className="text-violet-300 mt-0.5 font-mono text-sm">{imageCount}/{totalShots}</p>
517
+ </div>
518
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-3 py-2">
519
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Videos</p>
520
+ <p className="text-violet-300 mt-0.5 font-mono text-sm">{videoCount}/{totalShots}</p>
521
+ </div>
522
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-3 py-2">
523
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Status</p>
524
+ <p className="text-violet-300 mt-0.5 font-mono text-sm">{project.status}</p>
525
+ </div>
526
+ </div>
527
+
528
+ <div className="space-y-3 text-left">
529
+ <p className="text-xs text-gray-300 leading-relaxed">
530
+ <strong className="text-violet-200">All images are generated.</strong> Now you can animate any shot into a video clip. Pick a shot and click Animate.
531
+ </p>
532
+
533
+ <div className="rounded-xl border border-gray-700/40 bg-gray-900/40 p-3 space-y-2">
534
+ <p className="text-[11px] font-semibold text-gray-300">How Animate works:</p>
535
+ {[
536
+ { n: 1, title: "Pick a shot", body: "Click any shot card that has an image." },
537
+ { n: 2, title: "Click Animate", body: "The API sends the image to Wan 2.2 I2V on AMD MI300X and returns a video clip." },
538
+ { n: 3, title: "Iterate", body: "Don't like the video? Animate again — each run creates a new version." },
539
+ { n: 4, title: "Select versions", body: "The bottom strip shows all versions. Click one to make it active." },
540
+ ].map((step) => (
541
+ <div key={step.n} className="flex gap-2.5">
542
+ <div className="flex-shrink-0 w-5 h-5 rounded-md bg-violet-500/20 flex items-center justify-center mt-0.5">
543
+ <span className="text-[10px] font-medium text-violet-400">{step.n}</span>
544
+ </div>
545
+ <div>
546
+ <p className="text-[11px] text-gray-200">{step.title}</p>
547
+ <p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">{step.body}</p>
548
+ </div>
549
+ </div>
550
+ ))}
551
+ </div>
552
+
553
+ <p className="text-[11px] text-gray-500 leading-relaxed">
554
+ <strong className="text-gray-400">API:</strong> <code className="text-gray-500">POST /api/projects/{'{projectId}'}/scenes/{'{sceneId}'}/shots/{'{shotId}'}/animate</code> — returns <code className="text-gray-500">prompt_id</code> for tracking. Backend uses Wan 2.2 I2V on AMD MI300X.
555
+ </p>
556
+ </div>
557
+ </div>
558
+ );
559
+ }
560
+
561
+ interface ShotCardProps {
562
+ shot: Shot;
563
+ phase: ProjectPhase;
564
+ selected: boolean;
565
+ saving: boolean;
566
+ onSelect: () => void;
567
+ onGenerateImage: () => void;
568
+ onAnimate: () => void;
569
+ onDeleteShot: () => void;
570
+ }
571
+
572
+ function ShotCard({ shot, phase, selected, saving, onSelect, onGenerateImage, onAnimate, onDeleteShot }: ShotCardProps) {
573
+ const imageUrl = mediaUrl(shot.image_file);
574
+ const videoUrl = mediaUrl(shot.video_file);
575
+ const showAnimate = !!imageUrl;
576
+ // Only show video if there's no newer image version (re-image invalidates old video)
577
+ const showVideo = !!videoUrl && shot.status !== 'image_ready';
578
+ const rendering = shot.status === 'rendering_image' || shot.status === 'animating' || saving;
579
+ return (
580
+ <div
581
+ onClick={onSelect}
582
+ 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"}`}
583
+ >
584
+ <div className="aspect-video bg-black flex items-center justify-center relative">
585
+ {videoUrl ? (
586
+ <video src={videoUrl} className="w-full h-full object-cover" muted loop autoPlay />
587
+ ) : imageUrl ? (
588
+ <img src={imageUrl} alt="" className="w-full h-full object-cover" />
589
+ ) : (
590
+ <div className="text-center text-gray-600">
591
+ <ImageIcon className="w-6 h-6 mx-auto mb-1 opacity-50" />
592
+ <p className="text-[10px] uppercase tracking-wider">No image</p>
593
+ </div>
594
+ )}
595
+ <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">
596
+ shot {shot.shot_number}
597
+ </span>
598
+ {videoUrl && (
599
+ <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">
600
+ <Video className="w-2.5 h-2.5" /> video
601
+ </span>
602
+ )}
603
+ </div>
604
+ <div className="p-2.5 space-y-1.5">
605
+ <p className="text-[11px] text-gray-300 leading-relaxed line-clamp-2 min-h-[2.4em]">
606
+ {shot.description || <span className="italic text-gray-600">no description</span>}
607
+ </p>
608
+ <div className="flex items-center gap-1.5 pt-1 border-t border-gray-800/40">
609
+ {rendering ? (
610
+ <div className="flex-1 rounded-lg border border-amber-800/30 bg-amber-950/20 px-2 py-1 text-[11px] text-amber-400 text-center">
611
+ <span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-1.5 align-middle" />
612
+ {shot.status === 'animating' ? 'Animating…' : 'Generating…'}
613
+ </div>
614
+ ) : phase === "outline" || !imageUrl ? (
615
+ <>
616
+ <button
617
+ onClick={(e) => { e.stopPropagation(); onGenerateImage(); }}
618
+ 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 px-2 py-1 text-[11px] text-rose-100 transition"
619
+ >
620
+ <Wand2 className="w-3 h-3" /> {imageUrl ? "Regenerate" : "Generate"}
621
+ </button>
622
+ <button
623
+ onClick={(e) => { e.stopPropagation(); onDeleteShot(); }}
624
+ className="rounded-lg border border-gray-800 hover:bg-red-900/40 hover:border-red-800/50 px-1.5 py-1 text-[11px] text-gray-600 hover:text-red-400 transition"
625
+ title="Delete shot"
626
+ >
627
+ <Trash2 className="w-3 h-3" />
628
+ </button>
629
+ </>
630
+ ) : (
631
+ <>
632
+ <button
633
+ onClick={(e) => { e.stopPropagation(); onGenerateImage(); }}
634
+ 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 px-2 py-1 text-[11px] text-gray-300 transition"
635
+ >
636
+ <Wand2 className="w-3 h-3" /> Re-image
637
+ </button>
638
+ <button
639
+ onClick={(e) => { e.stopPropagation(); onAnimate(); }}
640
+ 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 px-2 py-1 text-[11px] text-violet-100 transition"
641
+ >
642
+ <Play className="w-3 h-3" /> Animate
643
+ </button>
644
+ <button
645
+ onClick={(e) => { e.stopPropagation(); onDeleteShot(); }}
646
+ className="rounded-lg border border-gray-800 hover:bg-red-900/40 hover:border-red-800/50 px-1.5 py-1 text-[11px] text-gray-600 hover:text-red-400 transition"
647
+ title="Delete shot"
648
+ >
649
+ <Trash2 className="w-3 h-3" />
650
+ </button>
651
+ </>
652
+ )}
653
+ </div>
654
+ </div>
655
+ </div>
656
+ );
657
+ }
658
+
659
+ interface ShotEditorProps {
660
+ shot: Shot;
661
+ phase: ProjectPhase;
662
+ saving: boolean;
663
+ onPatch: (patch: Partial<Shot>) => void;
664
+ onGenerate: () => void;
665
+ onAnimate: () => void;
666
+ }
667
+
668
+ function ShotEditor({ shot, phase, saving, onPatch, onGenerate, onAnimate }: ShotEditorProps) {
669
+ const [draft, setDraft] = useState({
670
+ subtitle: shot.subtitle || "",
671
+ description: shot.description || "",
672
+ image_prompt: shot.image_prompt || "",
673
+ motion_prompt: shot.motion_prompt || "",
674
+ });
675
+
676
+ useEffect(() => {
677
+ setDraft({
678
+ subtitle: shot.subtitle || "",
679
+ description: shot.description || "",
680
+ image_prompt: shot.image_prompt || "",
681
+ motion_prompt: shot.motion_prompt || "",
682
+ });
683
+ }, [shot.id]); // eslint-disable-line react-hooks/exhaustive-deps
684
+
685
+ const dirty =
686
+ draft.subtitle !== (shot.subtitle || "") ||
687
+ draft.description !== (shot.description || "") ||
688
+ draft.image_prompt !== (shot.image_prompt || "") ||
689
+ draft.motion_prompt !== (shot.motion_prompt || "");
690
+
691
+ return (
692
+ <div className="space-y-4">
693
+ <Field label="Subtitle" hint="Viewer-facing narration. Burned onto the final video.">
694
+ <textarea
695
+ value={draft.subtitle}
696
+ onChange={(e) => setDraft((d) => ({ ...d, subtitle: e.target.value }))}
697
+ rows={2}
698
+ 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"
699
+ placeholder="The screen flickers to life at precisely 8:00 AM."
700
+ />
701
+ </Field>
702
+
703
+ <Field label="Description" hint="What's in the frame, plain English.">
704
+ <textarea
705
+ value={draft.description}
706
+ onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
707
+ rows={3}
708
+ 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"
709
+ placeholder="Wide shot of the workshop, neon glow on the floor."
710
+ />
711
+ </Field>
712
+
713
+ <Field label="Image prompt" hint="Sent to image generation. Trigger words, lighting, lens, mood.">
714
+ <textarea
715
+ value={draft.image_prompt}
716
+ onChange={(e) => setDraft((d) => ({ ...d, image_prompt: e.target.value }))}
717
+ rows={4}
718
+ 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"
719
+ placeholder="rigo, workshop interior, neon underglow, cinematic anamorphic lens, moody lighting"
720
+ />
721
+ </Field>
722
+
723
+ <Field label="Video prompt" hint="Camera move + motion for the animate step.">
724
+ <textarea
725
+ value={draft.motion_prompt}
726
+ onChange={(e) => setDraft((d) => ({ ...d, motion_prompt: e.target.value }))}
727
+ rows={3}
728
+ 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"
729
+ placeholder="Slow push in, suit plates locking into place."
730
+ />
731
+ </Field>
732
+
733
+ <div className="grid grid-cols-2 gap-2 text-[11px] text-gray-500">
734
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
735
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Status</p>
736
+ <p className="text-gray-300 mt-0.5">{shot.status}</p>
737
+ </div>
738
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
739
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Duration</p>
740
+ <p className="text-gray-300 mt-0.5">{shot.duration_seconds}s</p>
741
+ </div>
742
+ </div>
743
+
744
+ <div className="space-y-2 pt-2 border-t border-gray-800/40">
745
+ <button
746
+ onClick={() => onPatch(draft)}
747
+ disabled={!dirty || saving}
748
+ 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"
749
+ >
750
+ <Save className="w-3.5 h-3.5" /> {saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
751
+ </button>
752
+ {(shot.status === 'rendering_image' || shot.status === 'animating') ? (
753
+ <div className="rounded-xl border border-amber-800/30 bg-amber-950/20 px-3 py-2 text-xs text-amber-400 text-center font-medium">
754
+ <span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-1.5 align-middle" />
755
+ {shot.status === 'animating' ? 'Animating…' : 'Generating…'}
756
+ </div>
757
+ ) : phase === "outline" || !shot.image_file ? (
758
+ <button
759
+ onClick={() => { if (!saving) onGenerate(); }}
760
+ disabled={saving}
761
+ 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 px-3 py-2 text-xs font-medium text-rose-100 transition"
762
+ >
763
+ <Wand2 className="w-3.5 h-3.5" /> {shot.image_file ? "Regenerate image" : "Generate image"}
764
+ </button>
765
+ ) : (
766
+ <div className="grid grid-cols-2 gap-2">
767
+ <button
768
+ onClick={() => { if (!saving) onGenerate(); }}
769
+ disabled={saving}
770
+ 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 px-3 py-2 text-xs font-medium text-gray-200 transition"
771
+ >
772
+ <Wand2 className="w-3.5 h-3.5" /> Re-image
773
+ </button>
774
+ <button
775
+ onClick={() => { if (!saving) onAnimate(); }}
776
+ disabled={saving}
777
+ 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 px-3 py-2 text-xs font-medium text-violet-100 transition"
778
+ >
779
+ <Play className="w-3.5 h-3.5" /> Animate
780
+ </button>
781
+ </div>
782
+ )}
783
+ </div>
784
+ </div>
785
+ );
786
+ }
787
+
788
+ function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
789
+ return (
790
+ <div className="space-y-1.5">
791
+ <div>
792
+ <p className="text-[11px] font-medium text-gray-300">{label}</p>
793
+ {hint && <p className="text-[10px] text-gray-600 leading-relaxed">{hint}</p>}
794
+ </div>
795
+ {children}
796
+ </div>
797
+ );
798
+ }
799
+
800
+ function ProjectSummary({ project }: { project: Project }) {
801
+ return (
802
+ <div className="space-y-3">
803
+ <div>
804
+ <p className="text-[11px] font-medium text-gray-300">Title</p>
805
+ <p className="text-sm text-gray-100 mt-0.5">{project.title}</p>
806
+ </div>
807
+ <div>
808
+ <p className="text-[11px] font-medium text-gray-300">Description</p>
809
+ <p className="text-xs text-gray-400 mt-0.5 leading-relaxed">{project.description || <span className="italic text-gray-600">no description</span>}</p>
810
+ </div>
811
+ <div className="grid grid-cols-2 gap-2 text-[11px]">
812
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
813
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Aspect</p>
814
+ <p className="text-gray-300 mt-0.5 font-mono">{project.aspect_ratio}</p>
815
+ </div>
816
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
817
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Duration</p>
818
+ <p className="text-gray-300 mt-0.5">{project.duration_seconds ?? "—"}s</p>
819
+ </div>
820
+ </div>
821
+ {project.characters.length > 0 && (
822
+ <div>
823
+ <p className="text-[11px] font-medium text-gray-300 mb-1">Cast</p>
824
+ <div className="flex flex-wrap gap-1">
825
+ {project.characters.map((id) => (
826
+ <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>
827
+ ))}
828
+ </div>
829
+ </div>
830
+ )}
831
+ <div className="pt-2 border-t border-gray-800/40">
832
+ <p className="text-[11px] font-medium text-gray-300 mb-2">Share (coming soon)</p>
833
+ <div className="grid grid-cols-1 gap-1.5">
834
+ <button disabled className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5 text-[11px] text-gray-500 text-left flex items-center gap-2 opacity-50 cursor-not-allowed">
835
+ <span className="text-rose-400">♪</span> TikTok
836
+ </button>
837
+ <button disabled className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5 text-[11px] text-gray-500 text-left flex items-center gap-2 opacity-50 cursor-not-allowed">
838
+ <span className="text-red-400">▶</span> YouTube Shorts
839
+ </button>
840
+ <button disabled className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5 text-[11px] text-gray-500 text-left flex items-center gap-2 opacity-50 cursor-not-allowed">
841
+ <span className="text-pink-400">▣</span> Instagram Reels
842
+ </button>
843
+ <button disabled className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5 text-[11px] text-gray-500 text-left flex items-center gap-2 opacity-50 cursor-not-allowed">
844
+ <span className="text-purple-400">◉</span> X / Twitter
845
+ </button>
846
+ </div>
847
+ </div>
848
+ <p className="text-[11px] text-gray-600 leading-relaxed pt-2 border-t border-gray-800/40">
849
+ Pick a scene from the Projects tab to start editing shots, or ask your agent to draft an outline.
850
+ </p>
851
+ </div>
852
+ );
853
+ }
854
+
855
+ function RenderConfirmModal({
856
+ project, scenes, shots, onConfirm, onCancel,
857
+ }: {
858
+ project: Project;
859
+ scenes: Scene[];
860
+ shots: Shot[];
861
+ onConfirm: () => void;
862
+ onCancel: () => void;
863
+ }) {
864
+ const animated = shots.filter((s) => s.video_file);
865
+ const imageOnly = shots.filter((s) => s.image_file && !s.video_file);
866
+ const noMedia = shots.filter((s) => !s.image_file && !s.video_file);
867
+
868
+ return (
869
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onCancel}>
870
+ <div
871
+ className="w-full max-w-md mx-4 rounded-2xl border border-gray-700/60 bg-gray-950 shadow-2xl flex flex-col max-h-[80vh]"
872
+ onClick={(e) => e.stopPropagation()}
873
+ >
874
+ {/* Header */}
875
+ <div className="flex items-center justify-between px-5 py-4 border-b border-gray-800/60 flex-shrink-0">
876
+ <div className="flex items-center gap-2">
877
+ <Clapperboard className="w-4 h-4 text-violet-400" />
878
+ <h2 className="text-sm font-semibold text-gray-100">Render final video</h2>
879
+ </div>
880
+ <button onClick={onCancel} className="text-gray-600 hover:text-gray-300 transition">
881
+ <X className="w-4 h-4" />
882
+ </button>
883
+ </div>
884
+
885
+ {/* Project meta */}
886
+ <div className="px-5 py-2.5 border-b border-gray-800/40 flex items-center gap-2 text-[11px] text-gray-400 flex-shrink-0">
887
+ <span className="font-medium text-gray-200">{project.title}</span>
888
+ <span className="text-gray-700">·</span>
889
+ <span>{project.aspect_ratio}</span>
890
+ {project.duration_seconds != null && (
891
+ <><span className="text-gray-700">·</span><span>{project.duration_seconds}s</span></>
892
+ )}
893
+ </div>
894
+
895
+ {/* Shot list */}
896
+ <div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
897
+ {scenes.map((scene) => {
898
+ const sceneShots = shots
899
+ .filter((s) => s.scene_id === scene.id)
900
+ .sort((a, b) => a.shot_number - b.shot_number);
901
+ if (sceneShots.length === 0) return null;
902
+ return (
903
+ <div key={scene.id}>
904
+ <p className="text-[10px] uppercase tracking-wider text-gray-500 mb-1.5 font-medium">
905
+ {scene.title || `Scene ${scene.scene_number}`}
906
+ </p>
907
+ <div className="space-y-1">
908
+ {sceneShots.map((shot) => {
909
+ const hasVideo = !!shot.video_file;
910
+ const hasImage = !!shot.image_file;
911
+ return (
912
+ <div key={shot.id} className="flex items-start gap-2.5 rounded-lg bg-gray-900/50 px-2.5 py-2">
913
+ <span className="flex-shrink-0 mt-0.5">
914
+ {hasVideo
915
+ ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
916
+ : hasImage
917
+ ? <AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
918
+ : <X className="w-3.5 h-3.5 text-red-500/60" />}
919
+ </span>
920
+ <div className="min-w-0 flex-1">
921
+ <div className="flex items-center gap-1.5 mb-0.5">
922
+ <span className="text-[10px] font-mono text-gray-500">shot {shot.shot_number}</span>
923
+ <span className={`text-[9px] uppercase tracking-wider font-medium ${
924
+ hasVideo ? "text-emerald-400" : hasImage ? "text-amber-400" : "text-red-400/60"
925
+ }`}>
926
+ {hasVideo ? "animated" : hasImage ? "image only" : "no media"}
927
+ </span>
928
+ </div>
929
+ {shot.subtitle ? (
930
+ <p className="text-[11px] text-gray-400 leading-relaxed line-clamp-2 italic">"{shot.subtitle}"</p>
931
+ ) : (
932
+ <p className="text-[11px] text-gray-600 italic">no subtitle</p>
933
+ )}
934
+ </div>
935
+ </div>
936
+ );
937
+ })}
938
+ </div>
939
+ </div>
940
+ );
941
+ })}
942
+ </div>
943
+
944
+ {/* Warnings */}
945
+ {(imageOnly.length > 0 || noMedia.length > 0) && (
946
+ <div className="px-5 py-3 border-t border-gray-800/40 space-y-1.5 flex-shrink-0">
947
+ {imageOnly.length > 0 && (
948
+ <p className="text-[11px] text-amber-300/80 leading-relaxed">
949
+ <AlertTriangle className="w-3 h-3 inline mr-1 mb-0.5" />
950
+ {imageOnly.length === 1 ? "1 shot" : `${imageOnly.length} shots`} without animation — will render as a still image.
951
+ </p>
952
+ )}
953
+ {noMedia.length > 0 && (
954
+ <p className="text-[11px] text-red-400/70 leading-relaxed">
955
+ <X className="w-3 h-3 inline mr-1 mb-0.5" />
956
+ {noMedia.length} shot{noMedia.length > 1 ? "s" : ""} with no media — will be skipped.
957
+ </p>
958
+ )}
959
+ </div>
960
+ )}
961
+
962
+ {/* Stats row + actions */}
963
+ <div className="flex items-center justify-between gap-3 px-5 py-4 border-t border-gray-800/60 flex-shrink-0">
964
+ <div className="flex items-center gap-3 text-[11px]">
965
+ {animated.length > 0 && <span className="text-emerald-400 font-medium">{animated.length} animated</span>}
966
+ {imageOnly.length > 0 && <span className="text-amber-400 font-medium">{imageOnly.length} image-only</span>}
967
+ {noMedia.length > 0 && <span className="text-red-400/70 font-medium">{noMedia.length} empty</span>}
968
+ </div>
969
+ <div className="flex items-center gap-2">
970
+ <button
971
+ onClick={onCancel}
972
+ className="px-4 py-1.5 rounded-xl border border-gray-700 bg-gray-900/50 hover:bg-gray-900 text-xs text-gray-300 hover:text-gray-100 transition"
973
+ >
974
+ Cancel
975
+ </button>
976
+ <button
977
+ onClick={onConfirm}
978
+ className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-xl border border-violet-500/40 bg-violet-600/20 hover:bg-violet-600/30 text-xs font-medium text-violet-100 transition"
979
+ >
980
+ <Clapperboard className="w-3.5 h-3.5" /> Render
981
+ </button>
982
+ </div>
983
+ </div>
984
+ </div>
985
+ </div>
986
+ );
987
+ }
988
+
989
+ function SceneSummary({ scene }: { scene: Scene }) {
990
+ return (
991
+ <div className="space-y-3">
992
+ <div>
993
+ <p className="text-[11px] font-medium text-gray-300">Title</p>
994
+ <p className="text-sm text-gray-100 mt-0.5">{scene.title || "Untitled scene"}</p>
995
+ </div>
996
+ <div>
997
+ <p className="text-[11px] font-medium text-gray-300">Summary</p>
998
+ <p className="text-xs text-gray-400 mt-0.5 leading-relaxed">{scene.summary || <span className="italic text-gray-600">no summary</span>}</p>
999
+ </div>
1000
+ <div className="grid grid-cols-2 gap-2 text-[11px]">
1001
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
1002
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Setting</p>
1003
+ <p className="text-gray-300 mt-0.5 capitalize">{scene.setting}</p>
1004
+ </div>
1005
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
1006
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Weather</p>
1007
+ <p className="text-gray-300 mt-0.5 capitalize">{scene.weather}</p>
1008
+ </div>
1009
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
1010
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Location</p>
1011
+ <p className="text-gray-300 mt-0.5">{scene.location || "—"}</p>
1012
+ </div>
1013
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 px-2.5 py-1.5">
1014
+ <p className="text-gray-600 text-[10px] uppercase tracking-wider">Time</p>
1015
+ <p className="text-gray-300 mt-0.5">{scene.time_of_day || "—"}</p>
1016
+ </div>
1017
+ </div>
1018
+ <p className="text-[11px] text-gray-600 leading-relaxed pt-2 border-t border-gray-800/40">
1019
+ Pick a shot in the center to start editing prompts.
1020
+ </p>
1021
+ </div>
1022
+ );
1023
+ }
studio/src/components/ProjectFilmsView.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { ArrowLeft, Film, Play, Download, Trash2 } from "lucide-react";
3
+
4
+ interface FilmItem {
5
+ id: string;
6
+ render_number: number;
7
+ final_video_url: string | null;
8
+ created_at: string;
9
+ status: string;
10
+ }
11
+
12
+ interface Project {
13
+ id: string;
14
+ title: string;
15
+ aspect_ratio: string;
16
+ }
17
+
18
+ interface ProjectFilmsViewProps {
19
+ projectId: string;
20
+ onBack: () => void;
21
+ }
22
+
23
+ export function ProjectFilmsView({ projectId, onBack }: ProjectFilmsViewProps) {
24
+ const [project, setProject] = useState<Project | null>(null);
25
+ const [films, setFilms] = useState<FilmItem[]>([]);
26
+ const [loading, setLoading] = useState(true);
27
+ const [error, setError] = useState<string | null>(null);
28
+
29
+ async function load() {
30
+ setLoading(true);
31
+ setError(null);
32
+ try {
33
+ const [projectRes, filmRes] = await Promise.all([
34
+ fetch(`/api/projects/${projectId}`),
35
+ fetch(`/api/projects/${projectId}/render`),
36
+ ]);
37
+ if (!projectRes.ok) throw new Error(`Project ${projectRes.status}`);
38
+ if (!filmRes.ok) throw new Error(`Films ${filmRes.status}`);
39
+ const projectData = await projectRes.json();
40
+ const filmData = await filmRes.json();
41
+ setProject(projectData.project);
42
+ setFilms(filmData.renders || []);
43
+ } catch (e) {
44
+ setError(e instanceof Error ? e.message : "Failed to load");
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ }
49
+
50
+ useEffect(() => {
51
+ load();
52
+ }, [projectId]);
53
+
54
+ async function deleteFilm(filmId: string) {
55
+ try {
56
+ const res = await fetch(`/api/projects/${projectId}/renders/${filmId}`, { method: "DELETE" });
57
+ if (!res.ok) throw new Error("Delete failed");
58
+ setFilms((prev) => prev.filter((f) => f.id !== filmId));
59
+ } catch (e) {
60
+ setError(e instanceof Error ? e.message : "Delete failed");
61
+ }
62
+ }
63
+
64
+ if (loading) {
65
+ return (
66
+ <div className="h-full flex items-center justify-center bg-black">
67
+ <p className="text-sm text-gray-500">Loading films…</p>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ if (error) {
73
+ return (
74
+ <div className="h-full flex items-center justify-center bg-black">
75
+ <p className="text-sm text-red-400">{error}</p>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ const ar = project?.aspect_ratio ?? "9:16";
81
+ const aspectClass = ar === "16:9" ? "aspect-[16/9]" : ar === "1:1" ? "aspect-square" : "aspect-[9/16]";
82
+ const gridClass = ar === "16:9"
83
+ ? "grid gap-6 sm:grid-cols-2 max-w-4xl mx-auto"
84
+ : ar === "1:1"
85
+ ? "grid gap-4 sm:grid-cols-2 lg:grid-cols-3 max-w-3xl mx-auto"
86
+ : "grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 max-w-3xl mx-auto";
87
+
88
+ return (
89
+ <div className="h-full flex flex-col bg-black">
90
+ {/* Top bar */}
91
+ <div className="flex items-center gap-3 px-5 py-2.5 border-b border-gray-800/60 bg-gray-950/60 flex-shrink-0">
92
+ <button
93
+ onClick={onBack}
94
+ 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"
95
+ >
96
+ <ArrowLeft className="w-3.5 h-3.5" /> Back to project
97
+ </button>
98
+ <div>
99
+ <p className="text-[10px] uppercase tracking-[0.22em] text-rose-400/70">Films</p>
100
+ <h1 className="text-base font-semibold tracking-tight text-gray-100">{project?.title}</h1>
101
+ </div>
102
+ <span className="ml-auto text-[11px] text-gray-500 font-mono">{films.length} film{films.length !== 1 ? "s" : ""}</span>
103
+ </div>
104
+
105
+ {/* Main */}
106
+ <div className="flex-1 overflow-y-auto p-6">
107
+ {films.length === 0 ? (
108
+ <div className="text-center py-20">
109
+ <Film className="w-8 h-8 text-gray-700 mx-auto mb-3" />
110
+ <p className="text-sm text-gray-500">No films yet.</p>
111
+ <p className="text-xs text-gray-600 mt-1">Go back and hit Re-render to create one.</p>
112
+ </div>
113
+ ) : (
114
+ <div className={gridClass}>
115
+ {films.map((f) => (
116
+ <div
117
+ key={f.id}
118
+ className="rounded-2xl border border-gray-800 bg-gray-900/40 overflow-hidden hover:border-gray-700 transition"
119
+ >
120
+ <div className={`${aspectClass} bg-black relative`}>
121
+ {f.final_video_url ? (
122
+ <video
123
+ src={f.final_video_url}
124
+ className="w-full h-full object-contain"
125
+ controls
126
+ preload="metadata"
127
+ />
128
+ ) : (
129
+ <div className="w-full h-full flex items-center justify-center text-gray-700">
130
+ <Film className="w-8 h-8" />
131
+ </div>
132
+ )}
133
+ <span className="absolute top-2 left-2 rounded-md bg-black/70 px-1.5 py-0.5 text-[10px] font-mono text-gray-200">
134
+ #{f.render_number}
135
+ </span>
136
+ </div>
137
+ <div className="p-3 space-y-2">
138
+ <div className="flex items-center justify-between">
139
+ <span className="text-[11px] text-gray-400">{new Date(f.created_at).toLocaleString()}</span>
140
+ <span className={`text-[10px] uppercase px-1.5 py-0.5 rounded ${f.status === "completed" ? "bg-emerald-900/40 text-emerald-400" : "bg-amber-900/40 text-amber-400"}`}>
141
+ {f.status}
142
+ </span>
143
+ </div>
144
+ <div className="flex gap-2">
145
+ <a
146
+ href={f.final_video_url || "#"}
147
+ target="_blank"
148
+ rel="noopener noreferrer"
149
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-emerald-500/30 bg-emerald-600/10 hover:bg-emerald-600/20 px-2 py-1.5 text-[11px] text-emerald-300 transition"
150
+ >
151
+ <Play className="w-3 h-3" /> Watch
152
+ </a>
153
+ <a
154
+ href={f.final_video_url || "#"}
155
+ download
156
+ 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 px-2 py-1.5 text-[11px] text-gray-300 transition"
157
+ >
158
+ <Download className="w-3 h-3" /> Download
159
+ </a>
160
+ <button
161
+ onClick={() => deleteFilm(f.id)}
162
+ className="inline-flex items-center justify-center gap-1 rounded-lg border border-gray-800 hover:bg-red-900/30 hover:border-red-800/50 px-2 py-1.5 text-[11px] text-gray-600 hover:text-red-400 transition"
163
+ title="Delete film"
164
+ >
165
+ <Trash2 className="w-3 h-3" />
166
+ </button>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ ))}
171
+ </div>
172
+ )}
173
+ </div>
174
+ </div>
175
+ );
176
+ }
studio/src/components/ProjectsGuide.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
22
+ </div>
23
+ <p className="text-sm text-gray-300 leading-relaxed mb-4">
24
+ You talk to your AI agent like you'd talk to a person. Tell it what you want — the agent plans the scenes, picks the shots, generates the images, animates them, and stitches everything together. You just review and say yes or no.
25
+ </p>
26
+ <p className="text-xs text-gray-500">That's it. No settings. No config. Just talk.</p>
27
+ </div>
28
+
29
+ {/* Example prompts — simple natural language */}
30
+ <div className="space-y-4">
31
+ <h3 className="text-sm font-semibold text-gray-300 flex items-center gap-2">
32
+ <Sparkles className="w-4 h-4 text-amber-400" />
33
+ What to say to your agent
34
+ </h3>
35
+
36
+ {[
37
+ {
38
+ type: "Story video",
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
+ {
46
+ type: "Product demo",
47
+ say: "I need a 45-second product walkthrough. Show my character using the app on their phone, then switching to a laptop, then reacting to the results on screen. Modern office setting, natural light.",
48
+ },
49
+ {
50
+ type: "Social media reel",
51
+ say: "Make me an Instagram reel — vertical format, quick cuts, high energy. My character doing everyday things in a cinematic way. Coffee, walking, looking at the skyline. 15 seconds.",
52
+ },
53
+ {
54
+ type: "Anime short",
55
+ say: "An anime-style short about a lone wanderer entering a haunted forest. Studio Ghibli vibes. About 45 seconds. Slow, atmospheric, beautiful.",
56
+ },
57
+ {
58
+ type: "Kids' storybook",
59
+ say: "A children's storybook style video. My character as the hero of a fairy tale — castle, forest, friendly dragon. Gentle narration vibe. One minute.",
60
+ },
61
+ ].map((ex, i) => (
62
+ <div key={i} className="rounded-xl border border-gray-800/40 bg-gray-900/20 p-4 hover:border-gray-600 transition">
63
+ <div className="flex items-center gap-2 mb-2">
64
+ <span className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-800/60 rounded-md px-2 py-0.5">{ex.type}</span>
65
+ </div>
66
+ <p className="text-sm text-gray-300 leading-relaxed italic">"{ex.say}"</p>
67
+ </div>
68
+ ))}
69
+ </div>
70
+
71
+ {/* Iterate */}
72
+ <div className="rounded-2xl border border-gray-800/40 bg-gray-900/20 p-5">
73
+ <h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
74
+ <Terminal className="w-4 h-4 text-rose-400" />
75
+ After you see it, change anything
76
+ </h3>
77
+ <div className="space-y-1.5 text-sm text-gray-400">
78
+ <p>"I don't like shot 3 — try a wider angle"</p>
79
+ <p>"Make scene 2 darker, more moody"</p>
80
+ <p>"Swap my outfit to a hoodie in all shots"</p>
81
+ <p>"Add a slow-motion moment at the end"</p>
82
+ <p>"Cut the whole thing down to 30 seconds"</p>
83
+ </div>
84
+ </div>
85
+
86
+ {/* API peek — collapsed */}
87
+ <details className="rounded-2xl border border-gray-800/30 bg-gray-900/10 overflow-hidden">
88
+ <summary className="px-4 py-2.5 cursor-pointer hover:bg-gray-800/20 transition flex items-center gap-2 list-none">
89
+ <ChevronDown className="w-3 h-3 text-gray-600" />
90
+ <span className="text-[11px] text-gray-600">API endpoints (for developers)</span>
91
+ </summary>
92
+ <div className="px-4 pb-4">
93
+ <pre className="text-[10px] text-gray-600 font-mono leading-relaxed">
94
+ {`POST /api/projects create a project
95
+ POST /api/projects/:id/scenes add a scene
96
+ POST /api/projects/:id/scenes/:sid/shots add a shot
97
+ POST .../shots/:sid/generate-image generate image
98
+ POST .../shots/:sid/animate animate shot
99
+ GET /api/projects/:id view full project
100
+ POST /api/projects/:id/export export MP4`}
101
+ </pre>
102
+ </div>
103
+ </details>
104
+ </div>
105
+ );
106
+ }
studio/src/components/ProjectsView.tsx ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ {(project.metadata?.final_video || (project as any).render_count > 0) && (
172
+ <span className="flex items-center gap-1 text-emerald-400">
173
+ <Film className="w-3 h-3" />
174
+ {(project as any).render_count || 1} render{(project as any).render_count > 1 ? "s" : ""}
175
+ </span>
176
+ )}
177
+ {(project.metadata?.final_video as string | undefined) && (
178
+ <a
179
+ href={`/media/${project.metadata.final_video}`}
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ onClick={(e) => e.stopPropagation()}
183
+ className="text-emerald-400 hover:text-emerald-300 underline underline-offset-2"
184
+ >
185
+ Watch
186
+ </a>
187
+ )}
188
+ <span className="ml-auto">{formatRelative(project.updated_at)}</span>
189
+ </div>
190
+ </article>
191
+ );
192
+ })}
193
+
194
+ {loading && projects.length === 0 && !error && (
195
+ <div className="text-center py-8">
196
+ <RefreshCw className="w-5 h-5 text-gray-600 mx-auto animate-spin" />
197
+ <p className="text-xs text-gray-600 mt-2">Loading projects…</p>
198
+ </div>
199
+ )}
200
+ </section>
201
+ </div>
202
+ );
203
+ }
studio/src/components/sidebar/AppSidebar.tsx ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {
13
+ id: string;
14
+ name: string;
15
+ trigger: string | null;
16
+ kind: string | null;
17
+ loras: Record<string, unknown>[];
18
+ source_images: string[];
19
+ defaults: Record<string, unknown>;
20
+ }
21
+
22
+ interface AppSidebarProps {
23
+ activeTab: SidebarTab;
24
+ onTabChange: (tab: SidebarTab) => void;
25
+ onClose: () => void;
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 & LoRA Training", 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 },
38
+ { id: "guide", icon: <BookOpen className="w-4 h-4" />, label: "Guide", visible: true },
39
+ { id: "info", icon: <Info className="w-4 h-4" />, label: "Info", visible: true },
40
+ { id: "nodes", icon: <Cpu className="w-4 h-4" />, label: "Nodes", visible: true },
41
+ ];
42
+
43
+ const bottomTabs: { id: SidebarTab; icon: React.ReactNode; label: string; visible: boolean }[] = [
44
+ { id: "dev", icon: <Terminal className="w-4 h-4" />, label: "Logs", visible: true },
45
+ { id: "settings", icon: <Settings className="w-4 h-4" />, label: "Settings", visible: true },
46
+ ];
47
+
48
+ const visibleTopTabs = topTabs.filter((tab) => tab.visible);
49
+ const visibleBottomTabs = bottomTabs.filter((tab) => tab.visible);
50
+ const visibleTabs = [...visibleTopTabs, ...visibleBottomTabs];
51
+
52
+ return (
53
+ <div className="h-screen flex flex-shrink-0 border-r border-gray-800/60" style={{ width: `${SIDEBAR_WIDTH}px` }}>
54
+ {/* Icon rail */}
55
+ <div className="w-12 flex-shrink-0 bg-gray-950/60 border-r border-gray-800/40 flex flex-col items-center py-3 gap-1">
56
+ {visibleTopTabs.map((tab) => (
57
+ <button
58
+ key={tab.id}
59
+ onClick={() => onTabChange(tab.id)}
60
+ title={tab.label}
61
+ className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all relative group ${
62
+ activeTab === tab.id
63
+ ? "bg-rose-600/10 text-rose-400 ring-1 ring-rose-500/20"
64
+ : "text-gray-600 hover:text-gray-300 hover:bg-gray-900/60"
65
+ }`}
66
+ >
67
+ {tab.icon}
68
+ {activeTab === tab.id && (
69
+ <span className="absolute -left-1 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-rose-500 rounded-full" />
70
+ )}
71
+ </button>
72
+ ))}
73
+
74
+ <div className="mt-auto flex flex-col items-center gap-1">
75
+ {visibleBottomTabs.map((tab) => (
76
+ <button
77
+ key={tab.id}
78
+ onClick={() => onTabChange(tab.id)}
79
+ title={tab.label}
80
+ className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all relative ${
81
+ activeTab === tab.id
82
+ ? "bg-rose-600/10 text-rose-400 ring-1 ring-rose-500/20"
83
+ : "text-gray-600 hover:text-gray-300 hover:bg-gray-900/60"
84
+ }`}
85
+ >
86
+ {tab.icon}
87
+ </button>
88
+ ))}
89
+ <button
90
+ onClick={onClose}
91
+ title="Close sidebar"
92
+ className="w-9 h-9 rounded-xl flex items-center justify-center text-gray-700 hover:text-gray-400 hover:bg-gray-900/60 transition mt-1"
93
+ >
94
+ <X className="w-4 h-4" />
95
+ </button>
96
+ </div>
97
+ </div>
98
+
99
+ {/* Content panel */}
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} onDeleteScene={(id) => projectMode.onDeleteScene(id)} /> : <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>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function CharactersTab({ onSelectCharacter }: { onSelectCharacter?: (characterId: string) => void }) {
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")
130
+ .then((r) => r.json())
131
+ .then((d) => setCharacters(d.characters || []))
132
+ .catch((e) => setError(e.message))
133
+ .finally(() => setLoading(false));
134
+ }, []);
135
+
136
+ return (
137
+ <div className="h-full overflow-y-auto p-4 space-y-4">
138
+ {/* Character cards first — your owned assets */}
139
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2">
140
+ <div className="flex items-center justify-between gap-2">
141
+ <p className="text-xs font-semibold text-gray-300">Your character assets</p>
142
+ <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>
143
+ </div>
144
+ <p className="text-[11px] text-gray-500 leading-relaxed">
145
+ These are characters you created or own the rights to use. Each one is backed by a fine-tuned LoRA trained on AMD MI300X.
146
+ </p>
147
+ </div>
148
+
149
+ {loading && <p className="text-xs text-gray-500 py-2">Loading...</p>}
150
+ {error && <p className="text-xs text-red-400 py-2">{error}</p>}
151
+ {!loading && characters.length === 0 && (
152
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-6 text-center">
153
+ <Users className="w-5 h-5 text-gray-600 mx-auto mb-2" />
154
+ <p className="text-xs text-gray-500">No characters registered yet.</p>
155
+ </div>
156
+ )}
157
+
158
+ {characters.map((ch) => {
159
+ const avatarUrl = ch.source_images.length > 0
160
+ ? (ch.source_images[0].startsWith("/") ? ch.source_images[0] : `/media/${ch.source_images[0]}`)
161
+ : null;
162
+
163
+ return (
164
+ <div
165
+ key={ch.id}
166
+ className="rounded-xl border border-gray-800/60 bg-gray-900/30 hover:bg-gray-900/50 hover:border-gray-600 p-3.5 cursor-pointer transition-all group"
167
+ onClick={() => onSelectCharacter?.(ch.id)}
168
+ >
169
+ <div className="flex items-center gap-3 mb-2.5">
170
+ <div className="w-10 h-10 rounded-xl overflow-hidden flex-shrink-0 ring-1 ring-white/10">
171
+ {avatarUrl ? (
172
+ <img src={avatarUrl} alt={ch.name} className="w-full h-full object-cover" />
173
+ ) : (
174
+ <div className="w-full h-full bg-gradient-to-br from-rose-500 to-amber-400 flex items-center justify-center">
175
+ <span className="text-xs font-bold text-white">{ch.name.charAt(0).toUpperCase()}</span>
176
+ </div>
177
+ )}
178
+ </div>
179
+ <div className="min-w-0">
180
+ <div className="flex items-center gap-1.5">
181
+ <span className="text-sm font-semibold text-gray-200 truncate">{ch.name}</span>
182
+ {ch.kind === "human" && (
183
+ <span className="text-[9px] uppercase tracking-wider text-blue-300/80 border border-blue-500/30 bg-blue-500/10 rounded-full px-1.5 py-0.5 flex-shrink-0">Human</span>
184
+ )}
185
+ {ch.kind === "agent" && (
186
+ <span className="text-[9px] uppercase tracking-wider text-violet-300/80 border border-violet-500/30 bg-violet-500/10 rounded-full px-1.5 py-0.5 flex-shrink-0">Agent</span>
187
+ )}
188
+ </div>
189
+ <div className="flex items-center gap-2 text-[11px] mt-0.5">
190
+ <span className="text-gray-600 font-mono">{ch.id}</span>
191
+ {ch.loras.length > 0 && (
192
+ <span className="text-emerald-400/60">{ch.loras.length} LoRA</span>
193
+ )}
194
+ <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>
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <div className="pt-2 border-t border-gray-800/40">
200
+ <p className="text-[10px] text-gray-500">
201
+ Available for your agent to use in generated images and videos.
202
+ </p>
203
+ </div>
204
+ </div>
205
+ );
206
+ })}
207
+
208
+ {/* Training workflow below the character card */}
209
+ <CreateCharacterWorkflow />
210
+
211
+ <div className="pt-2 border-t border-gray-800/40">
212
+ <a
213
+ href="#"
214
+ 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"
215
+ onClick={(e) => { e.preventDefault(); }}
216
+ >
217
+ <Search className="w-3.5 h-3.5" />
218
+ Browse community characters
219
+ <span className="text-[10px] text-gray-600 ml-1">coming soon</span>
220
+ </a>
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ function CreateCharacterWorkflow() {
227
+ return (
228
+ <div className="rounded-2xl border border-rose-600/20 bg-gradient-to-b from-rose-950/10 to-gray-950 p-4 space-y-4">
229
+ <div className="flex items-center gap-2">
230
+ <Box className="w-4 h-4 text-rose-400" />
231
+ <span className="text-xs font-semibold text-rose-300 uppercase tracking-wider">LoRA Fine-tuning</span>
232
+ <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">AMD MI300X</span>
233
+ </div>
234
+
235
+ <p className="text-xs text-gray-300 leading-relaxed">
236
+ <strong className="text-rose-200">Train your own character LoRA in ~90 minutes on AMD MI300X.</strong> Once fine-tuned, your AI agent can generate consistent images and videos with your face — every single time.
237
+ </p>
238
+
239
+ <div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
240
+ <p className="text-[11px] font-semibold text-amber-300 flex items-center gap-1.5 mb-1">
241
+ <Cpu className="w-3.5 h-3.5" />
242
+ AMD MI300X — 192 GB VRAM
243
+ </p>
244
+ <p className="text-[11px] text-amber-200/70 leading-relaxed">
245
+ Fine-tuning runs on AMD's flagship GPU via ROCm. No CUDA required. Train a Flux2 LoRA in ~90 minutes, then generate images and videos immediately.
246
+ </p>
247
+ </div>
248
+
249
+ <div className="rounded-xl border border-gray-700/40 bg-gray-900/40 p-3">
250
+ <p className="text-[11px] font-semibold text-gray-300 flex items-center gap-1.5 mb-2">
251
+ <Terminal className="w-3.5 h-3.5 text-rose-400" />
252
+ Tell your AI agent:
253
+ </p>
254
+ <p className="text-[11px] text-gray-400 leading-relaxed">
255
+ "Create a character for me. Upload my reference images, register the character, start a LoRA fine-tune on AMD MI300X, and let me know when it's ready to use."
256
+ </p>
257
+ </div>
258
+
259
+ <div className="space-y-2.5">
260
+ <p className="text-[11px] font-medium text-gray-400">How it works:</p>
261
+ {[
262
+ { n: 1, title: "Upload your images", body: "5-20 reference photos. Different angles and lighting work best. Your agent can even generate variations to build a dataset." },
263
+ { n: 2, title: "Register your character", body: "A character record with a unique trigger word — this is how the agent references your identity in every generation." },
264
+ { n: 3, title: "Fine-tune on AMD MI300X", body: "Training a Flux2 LoRA on 192 GB MI300X VRAM via ROCm. Takes about 90 minutes. Your agent monitors progress and notifies you when done.", highlight: true },
265
+ { n: 4, title: "Generate consistently", body: 'Your character appears in the registry. From then on, just say "generate a shot with [character name] doing X" — your agent handles the rest.' },
266
+ ].map((step) => (
267
+ <div key={step.n} className="flex gap-2.5">
268
+ <div className={`flex-shrink-0 w-5 h-5 rounded-md flex items-center justify-center mt-0.5 ${step.highlight ? "bg-amber-500/20" : "bg-gray-800"}`}>
269
+ <span className={`text-[10px] font-medium ${step.highlight ? "text-amber-400" : "text-gray-500"}`}>{step.n}</span>
270
+ </div>
271
+ <div>
272
+ <p className={`text-[11px] ${step.highlight ? "text-amber-300 font-medium" : "text-gray-200"}`}>{step.title}</p>
273
+ <p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">{step.body}</p>
274
+ </div>
275
+ </div>
276
+ ))}
277
+ </div>
278
+
279
+ <div className="pt-1 border-t border-gray-800/40">
280
+ <p className="text-[10px] text-gray-600">
281
+ Your agent knows the API. It uses <code className="text-gray-500">/api/characters</code> to register, <code className="text-gray-500">/api/lora-training</code> to fine-tune, and the Guide tab has all the curl commands.
282
+ </p>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function GuideTab() {
289
+ const apiRoot = typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:8190";
290
+ const examples = [
291
+ { label: "List characters", cmd: `curl ${apiRoot}/api/characters` },
292
+ { label: "Create character", cmd: `curl -X POST ${apiRoot}/api/characters \\
293
+ -H "Content-Type: application/json" \\
294
+ -d '{"id":"my-char","name":"My Character","trigger":"MyTrigger"}'` },
295
+ { label: "Create project", cmd: `curl -X POST ${apiRoot}/api/projects \\
296
+ -H "Content-Type: application/json" \\
297
+ -d '{"title":"My First Short","content":"A brief story...","characters":["rigo"]}'` },
298
+ { label: "Add scene to project", cmd: `curl -X POST ${apiRoot}/api/projects/<prj_id>/scenes \\
299
+ -H "Content-Type: application/json" \\
300
+ -d '{"scene_number":1,"heading":"INT.-DAY","summary":"Scene summary"}'` },
301
+ { label: "Add shot to scene", cmd: `curl -X POST ${apiRoot}/api/projects/<prj_id>/scenes/<scn_id>/shots \\
302
+ -H "Content-Type: application/json" \\
303
+ -d '{"shot_number":1,"description":"Visual shot description","motion_prompt":"Camera push in","duration_seconds":4}'` },
304
+ { label: "Generate shot image", cmd: `curl -X POST ${apiRoot}/api/projects/<prj_id>/scenes/<scn_id>/shots/<sht_id>/generate-image` },
305
+ { label: "Animate shot", cmd: `curl -X POST ${apiRoot}/api/projects/<prj_id>/scenes/<scn_id>/shots/<sht_id>/animate` },
306
+ { label: "View project", cmd: `curl ${apiRoot}/api/projects/<prj_id>` },
307
+ { label: "Raw image generation", cmd: `curl -X POST ${apiRoot}/api/image/generate \\
308
+ -H "Content-Type: application/json" \\
309
+ -d '{"character":"rigo","prompt":"studio portrait"}'` },
310
+ { label: "Raw video generation", cmd: `curl -X POST ${apiRoot}/api/video/generate \\
311
+ -H "Content-Type: application/json" \\
312
+ -d '{"character":"rigo","prompt":"walks forward","width":1024,"height":1024,"length":41}'` },
313
+ ];
314
+
315
+ return (
316
+ <div className="h-full overflow-y-auto p-4 space-y-3">
317
+ <p className="text-xs text-gray-500 leading-relaxed">
318
+ Paste these into a terminal or send them to your agent. Replace <code className="text-gray-400">&lt;prj_id&gt;</code> / <code className="text-gray-400">&lt;scn_id&gt;</code> / <code className="text-gray-400">&lt;sht_id&gt;</code> with actual IDs.
319
+ </p>
320
+ {examples.map((ex) => (
321
+ <div key={ex.label} className="space-y-1">
322
+ <p className="text-[11px] font-medium text-gray-400">{ex.label}</p>
323
+ <pre className="text-[11px] bg-black/40 text-gray-300 rounded-xl p-2.5 overflow-x-auto leading-relaxed whitespace-pre-wrap break-all border border-gray-800/60 font-mono">{ex.cmd}</pre>
324
+ </div>
325
+ ))}
326
+ </div>
327
+ );
328
+ }
329
+
330
+ function AgentsTab() {
331
+ const capabilities = [
332
+ "Create and manage agent profiles",
333
+ "Register owned character assets",
334
+ "Start image and video generation jobs",
335
+ "Build project scenes and shots through the API",
336
+ "Route work to configured GPU nodes",
337
+ "Launch LoRA training with ai-toolkit",
338
+ ];
339
+
340
+ const workflow = [
341
+ {
342
+ n: "01",
343
+ title: "Tell the agent what to make",
344
+ body: "Use plain language instead of filling out every workflow setting yourself.",
345
+ },
346
+ {
347
+ n: "02",
348
+ title: "Agent chooses the right workflow",
349
+ body: "Image, video, character creation, project planning, or LoRA training should be selected automatically.",
350
+ },
351
+ {
352
+ n: "03",
353
+ title: "Nemoflix runs the job",
354
+ body: "The backend handles API calls, configured GPU nodes, output paths, and job tracking.",
355
+ },
356
+ {
357
+ n: "04",
358
+ title: "Review and continue",
359
+ body: "Generated assets appear in the Studio so the agent and human can iterate together.",
360
+ },
361
+ ];
362
+
363
+ return (
364
+ <div className="h-full overflow-y-auto p-4 space-y-4">
365
+ <section className="space-y-2">
366
+ <div className="flex items-center gap-2">
367
+ <Bot className="w-4 h-4 text-violet-400" />
368
+ <h2 className="text-sm font-semibold text-gray-200">AI Agents</h2>
369
+ <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>
370
+ </div>
371
+ <p className="text-xs text-gray-500 leading-relaxed">
372
+ Nemoflix is designed for AI agents that create media, manage characters, generate scenes, and publish work through an API-first studio.
373
+ </p>
374
+ </section>
375
+
376
+ <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">
377
+ <p className="text-xs font-semibold text-violet-300 uppercase tracking-wider">How agents use Nemoflix</p>
378
+ <div className="space-y-3">
379
+ {workflow.map((step) => (
380
+ <div key={step.n} className="flex gap-3">
381
+ <div className="flex-shrink-0 w-7 h-7 rounded-lg bg-gray-900 border border-gray-800 flex items-center justify-center">
382
+ <span className="text-[10px] font-medium text-gray-500">{step.n}</span>
383
+ </div>
384
+ <div>
385
+ <p className="text-xs text-gray-200">{step.title}</p>
386
+ <p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{step.body}</p>
387
+ </div>
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-3">
394
+ <p className="text-[11px] font-semibold text-gray-300">Planned capabilities</p>
395
+ <div className="space-y-2">
396
+ {capabilities.map((item) => (
397
+ <div key={item} className="flex gap-2 text-[11px] text-gray-500">
398
+ <span className="mt-1 w-1.5 h-1.5 rounded-full bg-violet-400/60 flex-shrink-0" />
399
+ <span>{item}</span>
400
+ </div>
401
+ ))}
402
+ </div>
403
+ </div>
404
+
405
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2">
406
+ <p className="text-[11px] font-semibold text-gray-300">Backend direction</p>
407
+ <p className="text-[11px] text-gray-500 leading-relaxed">
408
+ The agent endpoint should eventually execute real Nemoflix tools behind the scenes: character lookup, generation queueing, project updates, node checks, and training jobs.
409
+ </p>
410
+ </div>
411
+ </div>
412
+ );
413
+ }
414
+
415
+ function PlaceholderTab({ title, body }: { title: string; body: string }) {
416
+ return (
417
+ <div className="p-4 space-y-2 overflow-y-auto h-full">
418
+ <h3 className="text-xs font-bold uppercase tracking-widest text-gray-600">{title}</h3>
419
+ <p className="text-sm text-gray-500 leading-relaxed">{body}</p>
420
+ </div>
421
+ );
422
+ }
studio/src/components/sidebar/GenerateTab.tsx ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import type { LoraCheckpoint } from "../../types";
4
+
5
+ interface GenerateTabProps {
6
+ checkpoints: LoraCheckpoint[];
7
+ onQueued?: () => void;
8
+ }
9
+
10
+ type GenerateMode = "image" | "t2v" | "i2v";
11
+
12
+ interface GenerateResponse {
13
+ ok: boolean;
14
+ prompt_id?: string | null;
15
+ checkpoint?: string | null;
16
+ lora_name?: string | null;
17
+ mode?: string;
18
+ node_errors?: Record<string, unknown> | null;
19
+ }
20
+
21
+ interface CharacterSummary {
22
+ id: string;
23
+ name: string;
24
+ trigger: string | null;
25
+ loras: { workflow?: string; name?: string; strength?: number }[];
26
+ source_images: string[];
27
+ }
28
+
29
+ const DEFAULT_IMAGE_PROMPT =
30
+ "Rigo, portrait in a sleek AI media studio, leaning one hand on a workstation beside glowing video timeline screens, calm confident expression, modern editorial photography, warm cinematic key light, 85mm lens, shallow depth of field, realistic skin texture and sharp facial detail.";
31
+ const DEFAULT_VIDEO_PROMPT =
32
+ "cinematic motion, dramatic camera movement, atmospheric lighting, dynamic composition, polished short-form video style";
33
+
34
+ function checkpointLabel(checkpoint: LoraCheckpoint) {
35
+ if (checkpoint.step == null) return `${checkpoint.name} · final`;
36
+ return `${checkpoint.name} · step ${checkpoint.step.toLocaleString()}`;
37
+ }
38
+
39
+ function slugPrompt(prompt: string) {
40
+ const slug = prompt
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, "-")
43
+ .replace(/^-+|-+$/g, "")
44
+ .slice(0, 36);
45
+ return slug || "generation";
46
+ }
47
+
48
+ function outputPrefix(mode: GenerateMode, prompt: string) {
49
+ const bucket = mode === "image" ? "images" : "videos";
50
+ return `${bucket}/${slugPrompt(prompt)}-${Date.now()}`;
51
+ }
52
+
53
+ export function GenerateTab({ checkpoints, onQueued }: GenerateTabProps) {
54
+ const latestCheckpoint = useMemo(() => {
55
+ const final = checkpoints.find((checkpoint) => checkpoint.step == null);
56
+ return final?.name || checkpoints[checkpoints.length - 1]?.name || "latest";
57
+ }, [checkpoints]);
58
+
59
+ const [searchParams, setSearchParams] = useSearchParams();
60
+ const [mode, setMode] = useState<GenerateMode>("image");
61
+ const [characters, setCharacters] = useState<CharacterSummary[]>([]);
62
+ const [characterId, setCharacterId] = useState("none");
63
+ const [checkpoint, setCheckpoint] = useState("base");
64
+ const [prompt, setPrompt] = useState(DEFAULT_IMAGE_PROMPT);
65
+ const [sourceImage, setSourceImage] = useState("");
66
+ const [width, setWidth] = useState(1248);
67
+ const [height, setHeight] = useState(832);
68
+ const [steps, setSteps] = useState(20);
69
+ const [guidance, setGuidance] = useState(4);
70
+ const [loraStrength, setLoraStrength] = useState(1);
71
+ const [submitting, setSubmitting] = useState(false);
72
+ const [result, setResult] = useState<GenerateResponse | null>(null);
73
+ const [error, setError] = useState<string | null>(null);
74
+
75
+ useEffect(() => {
76
+ fetch("/api/characters")
77
+ .then((response) => response.json())
78
+ .then((data) => setCharacters(data.characters || []))
79
+ .catch(() => setCharacters([]));
80
+ }, []);
81
+
82
+ // When ?character= appears in the URL, pre-select it and clear the param
83
+ useEffect(() => {
84
+ const preselect = searchParams.get("character");
85
+ if (preselect) {
86
+ setCharacterId(preselect);
87
+ setSearchParams({}, { replace: true });
88
+ }
89
+ }, [searchParams.get("character")]);
90
+
91
+ const selectedCharacter = characters.find((character) => character.id === characterId);
92
+ const selectedCharacterHasImageLora = Boolean(selectedCharacter?.loras?.some((lora) => lora.workflow === "flux2_lora"));
93
+
94
+ function selectMode(nextMode: GenerateMode) {
95
+ setMode(nextMode);
96
+ setPrompt(nextMode === "image" ? DEFAULT_IMAGE_PROMPT : DEFAULT_VIDEO_PROMPT);
97
+ setResult(null);
98
+ setError(null);
99
+ }
100
+
101
+ async function submit() {
102
+ const cleanPrompt = prompt.trim();
103
+ if (!cleanPrompt) {
104
+ setError("Prompt is required.");
105
+ return;
106
+ }
107
+ if (mode === "i2v" && !sourceImage.trim()) {
108
+ setError("Image-to-video needs a source image path from the gallery, like images/example.png.");
109
+ return;
110
+ }
111
+
112
+ setSubmitting(true);
113
+ setError(null);
114
+ setResult(null);
115
+
116
+ try {
117
+ const filenamePrefix = outputPrefix(mode, cleanPrompt);
118
+ const endpoint = mode === "image" ? "/api/image/generate" : "/api/video/generate";
119
+ const useCharacter = characterId !== "none";
120
+ const useCheckpoint = !useCharacter && checkpoint !== "base";
121
+ const body = mode === "image"
122
+ ? {
123
+ workflow: "flux2_lora",
124
+ character: useCharacter ? characterId : undefined,
125
+ checkpoint: useCheckpoint ? (checkpoint || latestCheckpoint) : undefined,
126
+ prompt: cleanPrompt,
127
+ width,
128
+ height,
129
+ steps,
130
+ guidance,
131
+ lora_strength: loraStrength,
132
+ filename_prefix: filenamePrefix,
133
+ submit: true,
134
+ }
135
+ : {
136
+ mode,
137
+ character: useCharacter ? characterId : undefined,
138
+ image: mode === "i2v" ? sourceImage.trim() : undefined,
139
+ prompt: cleanPrompt,
140
+ width,
141
+ height,
142
+ filename_prefix: filenamePrefix,
143
+ submit: true,
144
+ };
145
+
146
+ const response = await fetch(endpoint, {
147
+ method: "POST",
148
+ headers: { "Content-Type": "application/json" },
149
+ body: JSON.stringify(body),
150
+ });
151
+
152
+ const data = await response.json().catch(() => ({}));
153
+ if (!response.ok) {
154
+ throw new Error(data?.detail || `${response.status}: failed to queue generation`);
155
+ }
156
+
157
+ setResult(data);
158
+ onQueued?.();
159
+ } catch (err) {
160
+ setError(err instanceof Error ? err.message : String(err));
161
+ } finally {
162
+ setSubmitting(false);
163
+ }
164
+ }
165
+
166
+ const characterPhrase = selectedCharacter ? ` using ${selectedCharacter.name}` : "";
167
+ const agentInstruction = mode === "image"
168
+ ? `Generate a new image${characterPhrase} from this idea.`
169
+ : mode === "t2v"
170
+ ? "Generate a short video from this idea."
171
+ : "Animate this gallery image into a short video.";
172
+
173
+ return (
174
+ <div className="h-full overflow-y-auto p-4 space-y-5">
175
+ <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">
176
+ <h2 className="text-lg font-semibold">Tell your agent what to make</h2>
177
+ <p className="text-sm text-gray-300 leading-relaxed">
178
+ Describe the result. The agent chooses the character, workflow, endpoint, and settings.
179
+ </p>
180
+ <div className="rounded-xl border border-gray-800 bg-black/35 p-3">
181
+ <p className="text-xs text-gray-300 leading-relaxed">“Generate an image of me walking through a rainy cyberpunk street.”</p>
182
+ </div>
183
+ </section>
184
+
185
+ <div className="grid grid-cols-3 gap-2">
186
+ {[
187
+ ["image", "Image"],
188
+ ["t2v", "Text → Video"],
189
+ ["i2v", "Image → Video"],
190
+ ].map(([id, label]) => (
191
+ <button
192
+ key={id}
193
+ onClick={() => selectMode(id as GenerateMode)}
194
+ className={`rounded-lg border px-2 py-2 text-xs font-medium transition ${
195
+ mode === id
196
+ ? "border-rose-500/60 bg-rose-600/15 text-rose-200"
197
+ : "border-gray-800 bg-gray-950/60 text-gray-500 hover:text-gray-300"
198
+ }`}
199
+ >
200
+ {label}
201
+ </button>
202
+ ))}
203
+ </div>
204
+
205
+ <label className="block space-y-2">
206
+ <span className="text-xs font-medium text-gray-400">Character</span>
207
+ <select
208
+ value={characterId}
209
+ onChange={(event) => setCharacterId(event.target.value)}
210
+ 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"
211
+ >
212
+ <option value="none">No character / raw workflow</option>
213
+ {characters.map((character) => (
214
+ <option key={character.id} value={character.id}>
215
+ {character.name}{character.trigger ? ` · trigger: ${character.trigger}` : ""}
216
+ </option>
217
+ ))}
218
+ </select>
219
+ {selectedCharacter && (
220
+ <p className="text-[10px] text-gray-600 leading-relaxed">
221
+ 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.
222
+ </p>
223
+ )}
224
+ </label>
225
+
226
+ <label className="block space-y-2">
227
+ <span className="text-xs font-medium text-gray-400">Prompt / idea</span>
228
+ <textarea
229
+ value={prompt}
230
+ onChange={(event) => setPrompt(event.target.value)}
231
+ rows={8}
232
+ className="w-full rounded-lg bg-gray-950 border border-gray-800 px-3 py-2 text-sm text-white leading-relaxed resize-y focus:outline-none focus:border-rose-600"
233
+ />
234
+ </label>
235
+
236
+ {mode === "image" && characterId === "none" && (
237
+ <label className="block space-y-2">
238
+ <span className="text-xs font-medium text-gray-400">Raw image checkpoint</span>
239
+ <select
240
+ value={checkpoint}
241
+ onChange={(event) => setCheckpoint(event.target.value)}
242
+ 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"
243
+ >
244
+ <option value="base">Base Flux2 model — no LoRA</option>
245
+ <option value="latest">Latest available LoRA checkpoint</option>
246
+ {checkpoints.map((item) => (
247
+ <option key={item.name} value={item.name}>
248
+ {checkpointLabel(item)}
249
+ </option>
250
+ ))}
251
+ </select>
252
+ <p className="text-[10px] text-gray-600 leading-relaxed">
253
+ Raw image mode bypasses character selection. Use base Flux2 or optionally apply a LoRA checkpoint directly.
254
+ </p>
255
+ </label>
256
+ )}
257
+
258
+ {mode === "image" && characterId !== "none" && selectedCharacter && !selectedCharacterHasImageLora && (
259
+ <div className="rounded-lg border border-amber-900/60 bg-amber-950/20 p-3 text-xs text-amber-200">
260
+ This character does not have an image LoRA registered for the current workflow yet.
261
+ </div>
262
+ )}
263
+
264
+ {mode === "i2v" && (
265
+ <label className="block space-y-2">
266
+ <span className="text-xs font-medium text-gray-400">Source image from gallery</span>
267
+ <input
268
+ value={sourceImage}
269
+ onChange={(event) => setSourceImage(event.target.value)}
270
+ placeholder="images/example.png"
271
+ 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"
272
+ />
273
+ <p className="text-[10px] text-gray-600">Open a gallery item and use its filename as the source.</p>
274
+ </label>
275
+ )}
276
+
277
+ <div className="grid grid-cols-2 gap-3">
278
+ <NumberField label="Width" value={width} onChange={setWidth} min={512} max={2048} step={64} />
279
+ <NumberField label="Height" value={height} onChange={setHeight} min={512} max={2048} step={64} />
280
+ {mode === "image" && <NumberField label="Steps" value={steps} onChange={setSteps} min={1} max={60} step={1} />}
281
+ {mode === "image" && <NumberField label="Guidance" value={guidance} onChange={setGuidance} min={1} max={10} step={0.5} />}
282
+ </div>
283
+
284
+ {mode === "image" && (
285
+ <NumberField
286
+ label="LoRA strength"
287
+ value={loraStrength}
288
+ onChange={setLoraStrength}
289
+ min={0}
290
+ max={2}
291
+ step={0.05}
292
+ />
293
+ )}
294
+
295
+ <button
296
+ onClick={submit}
297
+ disabled={submitting}
298
+ 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"
299
+ >
300
+ {submitting ? "Queueing..." : mode === "image" ? "Generate image" : "Generate video"}
301
+ </button>
302
+
303
+ {error && (
304
+ <div className="rounded-lg border border-red-900/60 bg-red-950/40 p-3 text-sm text-red-200">
305
+ {error}
306
+ </div>
307
+ )}
308
+
309
+ {result && (
310
+ <div className="rounded-lg border border-emerald-900/60 bg-emerald-950/30 p-3 space-y-2">
311
+ <p className="text-sm font-medium text-emerald-200">Queued successfully</p>
312
+ <div className="text-xs text-emerald-100/80 space-y-1 break-all">
313
+ <p>Prompt ID: {result.prompt_id}</p>
314
+ {result.checkpoint && <p>Checkpoint: {result.checkpoint}</p>}
315
+ {result.lora_name && <p>LoRA: {result.lora_name}</p>}
316
+ </div>
317
+ </div>
318
+ )}
319
+ </div>
320
+ );
321
+ }
322
+
323
+ function NumberField({
324
+ label,
325
+ value,
326
+ onChange,
327
+ min,
328
+ max,
329
+ step,
330
+ }: {
331
+ label: string;
332
+ value: number;
333
+ onChange: (value: number) => void;
334
+ min?: number;
335
+ max?: number;
336
+ step?: number;
337
+ }) {
338
+ return (
339
+ <label className="block space-y-2">
340
+ <span className="text-xs font-medium text-gray-400">{label}</span>
341
+ <input
342
+ type="number"
343
+ value={value}
344
+ min={min}
345
+ max={max}
346
+ step={step}
347
+ onChange={(event) => onChange(Number(event.target.value))}
348
+ 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"
349
+ />
350
+ </label>
351
+ );
352
+ }
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,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Plus, Sparkles, UserCircle, Clapperboard, Trash2 } from "lucide-react";
2
+ import type { ProjectModeData } from "../../types";
3
+
4
+ interface ProjectSidebarProps {
5
+ data: ProjectModeData;
6
+ onDeleteScene: (sceneId: string) => void;
7
+ }
8
+
9
+ export function ProjectSidebar({ data, onDeleteScene }: ProjectSidebarProps) {
10
+ const { project, scenes, shots, selectedSceneId, phase, onSelectScene, onAddScene } = data;
11
+
12
+ if (scenes.length === 0) {
13
+ return <OutlineSidebar data={data} onDeleteScene={onDeleteScene} />;
14
+ }
15
+
16
+ return (
17
+ <div className="flex-1 min-h-0 flex flex-col">
18
+ <div className="px-4 py-3 border-b border-gray-800/40 flex-shrink-0">
19
+ <p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Description</p>
20
+ <p className="text-xs text-gray-300 mt-1 leading-relaxed line-clamp-3">
21
+ {project.description || <span className="italic text-gray-600">No description yet.</span>}
22
+ </p>
23
+ </div>
24
+
25
+ <div className="px-4 py-2.5 flex items-center justify-between flex-shrink-0">
26
+ <span className="text-[11px] uppercase tracking-wider text-gray-500 font-medium">Scenes</span>
27
+ <button
28
+ onClick={onAddScene}
29
+ title="Add scene"
30
+ 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"
31
+ >
32
+ <Plus className="w-3 h-3" /> Add scene
33
+ </button>
34
+ </div>
35
+
36
+ <div className="flex-1 min-h-0 overflow-y-auto px-2 pb-3 space-y-1">
37
+ {scenes.map((scene) => {
38
+ const sceneShots = shots.filter((s) => s.scene_id === scene.id);
39
+ const generated = sceneShots.filter((s) => s.image_file).length;
40
+ const active = scene.id === selectedSceneId;
41
+ return (
42
+ <div
43
+ key={scene.id}
44
+ onClick={() => onSelectScene(scene.id)}
45
+ className={`w-full text-left rounded-xl px-3 py-2.5 transition cursor-pointer group ${active ? "bg-rose-600/10 ring-1 ring-rose-500/30" : "hover:bg-gray-900/50"}`}
46
+ >
47
+ <div className="flex items-center justify-between gap-2 mb-1">
48
+ <div className="flex items-center gap-2 min-w-0">
49
+ <span className={`text-[10px] font-mono ${active ? "text-rose-300" : "text-gray-500"}`}>S{scene.scene_number}</span>
50
+ <span className={`text-xs font-medium truncate ${active ? "text-gray-100" : "text-gray-300"}`}>
51
+ {scene.title || "Untitled scene"}
52
+ </span>
53
+ </div>
54
+ <button
55
+ onClick={(e) => { e.stopPropagation(); onDeleteScene(scene.id); }}
56
+ className="flex-shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-900/40 text-gray-600 hover:text-red-400 transition"
57
+ title="Delete scene"
58
+ >
59
+ <Trash2 className="w-3 h-3" />
60
+ </button>
61
+ </div>
62
+ {scene.summary && (
63
+ <p className="text-[11px] text-gray-500 leading-relaxed line-clamp-2">{scene.summary}</p>
64
+ )}
65
+ <div className="flex items-center gap-2 mt-2 text-[10px] text-gray-600">
66
+ <span>{sceneShots.length} shot{sceneShots.length === 1 ? "" : "s"}</span>
67
+ {generated > 0 && (
68
+ <span className={phase === "animate" ? "text-violet-400/70" : "text-emerald-400/70"}>
69
+ · {generated} rendered
70
+ </span>
71
+ )}
72
+ </div>
73
+ </div>
74
+ );
75
+ })}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ function OutlineSidebar({ data }: ProjectSidebarProps) {
82
+ const { project, onAddScene } = data;
83
+ return (
84
+ <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
85
+ <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">
86
+ <div className="flex items-center gap-2">
87
+ <Sparkles className="w-3.5 h-3.5 text-rose-300" />
88
+ <span className="text-[11px] uppercase tracking-wider text-rose-200 font-semibold">Start the outline</span>
89
+ </div>
90
+ <p className="text-xs text-gray-300 leading-relaxed">
91
+ 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.
92
+ </p>
93
+ <div className="rounded-xl border border-gray-800/60 bg-black/30 p-3 space-y-1.5">
94
+ <p className="text-[10px] uppercase tracking-wider text-gray-500">Try saying</p>
95
+ <p className="text-[11px] text-gray-300 italic leading-relaxed">"Make me a 30s cyberpunk teaser, my character walking through neon rain."</p>
96
+ <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>
97
+ </div>
98
+ </section>
99
+
100
+ <section className="space-y-2.5">
101
+ <p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Project</p>
102
+ <div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2">
103
+ <div>
104
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Title</p>
105
+ <p className="text-sm text-gray-100 mt-0.5">{project.title}</p>
106
+ </div>
107
+ {project.description && (
108
+ <div>
109
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Description</p>
110
+ <p className="text-xs text-gray-400 mt-0.5 leading-relaxed">{project.description}</p>
111
+ </div>
112
+ )}
113
+ <div className="grid grid-cols-2 gap-2 pt-1">
114
+ <div>
115
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Aspect</p>
116
+ <p className="text-xs text-gray-300 mt-0.5 font-mono">{project.aspect_ratio}</p>
117
+ </div>
118
+ <div>
119
+ <p className="text-[10px] uppercase tracking-wider text-gray-600">Duration</p>
120
+ <p className="text-xs text-gray-300 mt-0.5">{project.duration_seconds ?? "—"}s</p>
121
+ </div>
122
+ </div>
123
+ {project.characters.length > 0 && (
124
+ <div className="pt-1">
125
+ <p className="text-[10px] uppercase tracking-wider text-gray-600 mb-1.5">Cast</p>
126
+ <div className="flex flex-wrap gap-1">
127
+ {project.characters.map((id) => (
128
+ <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">
129
+ <UserCircle className="w-3 h-3 text-gray-500" /> {id}
130
+ </span>
131
+ ))}
132
+ </div>
133
+ </div>
134
+ )}
135
+ </div>
136
+ </section>
137
+
138
+ <button
139
+ onClick={onAddScene}
140
+ 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"
141
+ >
142
+ <Plus className="w-3.5 h-3.5" /> Add scene manually
143
+ </button>
144
+
145
+ <div className="rounded-xl border border-gray-800/40 bg-gray-900/20 p-3 flex items-start gap-2.5">
146
+ <Clapperboard className="w-3.5 h-3.5 text-gray-500 mt-0.5 flex-shrink-0" />
147
+ <p className="text-[11px] text-gray-500 leading-relaxed">
148
+ Scenes hold the story beats. Shots inside scenes get rendered into images, then animated. Outline first; only generate after the structure is approved.
149
+ </p>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
studio/src/components/ui/badge.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mergeProps } from "@base-ui/react/merge-props"
2
+ import { useRender } from "@base-ui/react/use-render"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const badgeVariants = cva(
8
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
13
+ secondary:
14
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
15
+ destructive:
16
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
17
+ outline:
18
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
19
+ ghost:
20
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ variant: "default",
26
+ },
27
+ }
28
+ )
29
+
30
+ function Badge({
31
+ className,
32
+ variant = "default",
33
+ render,
34
+ ...props
35
+ }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
36
+ return useRender({
37
+ defaultTagName: "span",
38
+ props: mergeProps<"span">(
39
+ {
40
+ className: cn(badgeVariants({ variant }), className),
41
+ },
42
+ props
43
+ ),
44
+ render,
45
+ state: {
46
+ slot: "badge",
47
+ variant,
48
+ },
49
+ })
50
+ }
51
+
52
+ export { Badge, badgeVariants }
studio/src/components/ui/button.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
12
+ outline:
13
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
14
+ secondary:
15
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
16
+ ghost:
17
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
18
+ destructive:
19
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default:
24
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
25
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
28
+ icon: "size-8",
29
+ "icon-xs":
30
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
31
+ "icon-sm":
32
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
33
+ "icon-lg": "size-9",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ size: "default",
39
+ },
40
+ }
41
+ )
42
+
43
+ function Button({
44
+ className,
45
+ variant = "default",
46
+ size = "default",
47
+ ...props
48
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
49
+ return (
50
+ <ButtonPrimitive
51
+ data-slot="button"
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
studio/src/components/ui/card.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Card({
6
+ className,
7
+ size = "default",
8
+ ...props
9
+ }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
10
+ return (
11
+ <div
12
+ data-slot="card"
13
+ data-size={size}
14
+ className={cn(
15
+ "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
24
+ return (
25
+ <div
26
+ data-slot="card-header"
27
+ className={cn(
28
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
29
+ className
30
+ )}
31
+ {...props}
32
+ />
33
+ )
34
+ }
35
+
36
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
37
+ return (
38
+ <div
39
+ data-slot="card-title"
40
+ className={cn(
41
+ "font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
42
+ className
43
+ )}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+
49
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
50
+ return (
51
+ <div
52
+ data-slot="card-description"
53
+ className={cn("text-sm text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
60
+ return (
61
+ <div
62
+ data-slot="card-action"
63
+ className={cn(
64
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ )
70
+ }
71
+
72
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
73
+ return (
74
+ <div
75
+ data-slot="card-content"
76
+ className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
83
+ return (
84
+ <div
85
+ data-slot="card-footer"
86
+ className={cn(
87
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ )
93
+ }
94
+
95
+ export {
96
+ Card,
97
+ CardHeader,
98
+ CardFooter,
99
+ CardTitle,
100
+ CardAction,
101
+ CardDescription,
102
+ CardContent,
103
+ }
studio/src/components/ui/input.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Input as InputPrimitive } from "@base-ui/react/input"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
7
+ return (
8
+ <InputPrimitive
9
+ type={type}
10
+ data-slot="input"
11
+ className={cn(
12
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Input }
studio/src/components/ui/progress.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Progress({
8
+ className,
9
+ children,
10
+ value,
11
+ ...props
12
+ }: ProgressPrimitive.Root.Props) {
13
+ return (
14
+ <ProgressPrimitive.Root
15
+ value={value}
16
+ data-slot="progress"
17
+ className={cn("flex flex-wrap gap-3", className)}
18
+ {...props}
19
+ >
20
+ {children}
21
+ <ProgressTrack>
22
+ <ProgressIndicator />
23
+ </ProgressTrack>
24
+ </ProgressPrimitive.Root>
25
+ )
26
+ }
27
+
28
+ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
29
+ return (
30
+ <ProgressPrimitive.Track
31
+ className={cn(
32
+ "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
33
+ className
34
+ )}
35
+ data-slot="progress-track"
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function ProgressIndicator({
42
+ className,
43
+ ...props
44
+ }: ProgressPrimitive.Indicator.Props) {
45
+ return (
46
+ <ProgressPrimitive.Indicator
47
+ data-slot="progress-indicator"
48
+ className={cn("h-full bg-primary transition-all", className)}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
55
+ return (
56
+ <ProgressPrimitive.Label
57
+ className={cn("text-sm font-medium", className)}
58
+ data-slot="progress-label"
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
65
+ return (
66
+ <ProgressPrimitive.Value
67
+ className={cn(
68
+ "ml-auto text-sm text-muted-foreground tabular-nums",
69
+ className
70
+ )}
71
+ data-slot="progress-value"
72
+ {...props}
73
+ />
74
+ )
75
+ }
76
+
77
+ export {
78
+ Progress,
79
+ ProgressTrack,
80
+ ProgressIndicator,
81
+ ProgressLabel,
82
+ ProgressValue,
83
+ }
studio/src/components/ui/table.tsx ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
6
+ return (
7
+ <div
8
+ data-slot="table-container"
9
+ className="relative w-full overflow-x-auto"
10
+ >
11
+ <table
12
+ data-slot="table"
13
+ className={cn("w-full caption-bottom text-sm", className)}
14
+ {...props}
15
+ />
16
+ </div>
17
+ )
18
+ }
19
+
20
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
21
+ return (
22
+ <thead
23
+ data-slot="table-header"
24
+ className={cn("[&_tr]:border-b", className)}
25
+ {...props}
26
+ />
27
+ )
28
+ }
29
+
30
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
31
+ return (
32
+ <tbody
33
+ data-slot="table-body"
34
+ className={cn("[&_tr:last-child]:border-0", className)}
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
41
+ return (
42
+ <tfoot
43
+ data-slot="table-footer"
44
+ className={cn(
45
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ )
51
+ }
52
+
53
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
54
+ return (
55
+ <tr
56
+ data-slot="table-row"
57
+ className={cn(
58
+ "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
67
+ return (
68
+ <th
69
+ data-slot="table-head"
70
+ className={cn(
71
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
72
+ className
73
+ )}
74
+ {...props}
75
+ />
76
+ )
77
+ }
78
+
79
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
80
+ return (
81
+ <td
82
+ data-slot="table-cell"
83
+ className={cn(
84
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
85
+ className
86
+ )}
87
+ {...props}
88
+ />
89
+ )
90
+ }
91
+
92
+ function TableCaption({
93
+ className,
94
+ ...props
95
+ }: React.ComponentProps<"caption">) {
96
+ return (
97
+ <caption
98
+ data-slot="table-caption"
99
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
100
+ {...props}
101
+ />
102
+ )
103
+ }
104
+
105
+ export {
106
+ Table,
107
+ TableHeader,
108
+ TableBody,
109
+ TableFooter,
110
+ TableHead,
111
+ TableRow,
112
+ TableCell,
113
+ TableCaption,
114
+ }
studio/src/components/ui/tabs.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Tabs({
7
+ className,
8
+ orientation = "horizontal",
9
+ ...props
10
+ }: TabsPrimitive.Root.Props) {
11
+ return (
12
+ <TabsPrimitive.Root
13
+ data-slot="tabs"
14
+ data-orientation={orientation}
15
+ className={cn(
16
+ "group/tabs flex gap-2 data-horizontal:flex-col",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ const tabsListVariants = cva(
25
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
26
+ {
27
+ variants: {
28
+ variant: {
29
+ default: "bg-muted",
30
+ line: "gap-1 bg-transparent",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ variant: "default",
35
+ },
36
+ }
37
+ )
38
+
39
+ function TabsList({
40
+ className,
41
+ variant = "default",
42
+ ...props
43
+ }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
44
+ return (
45
+ <TabsPrimitive.List
46
+ data-slot="tabs-list"
47
+ data-variant={variant}
48
+ className={cn(tabsListVariants({ variant }), className)}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
55
+ return (
56
+ <TabsPrimitive.Tab
57
+ data-slot="tabs-trigger"
58
+ className={cn(
59
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
60
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
61
+ "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
62
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
63
+ className
64
+ )}
65
+ {...props}
66
+ />
67
+ )
68
+ }
69
+
70
+ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
71
+ return (
72
+ <TabsPrimitive.Panel
73
+ data-slot="tabs-content"
74
+ className={cn("flex-1 text-sm outline-none", className)}
75
+ {...props}
76
+ />
77
+ )
78
+ }
79
+
80
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
studio/src/index.css ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "@fontsource-variable/geist";
2
+ @tailwind base;
3
+ @tailwind components;
4
+ @tailwind utilities;
5
+
6
+ body {
7
+ @apply bg-black text-white antialiased;
8
+ }
9
+
10
+ /* Slim, dark-mode scrollbars — replaces the chunky default */
11
+ * {
12
+ scrollbar-width: thin;
13
+ scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
14
+ }
15
+
16
+ *::-webkit-scrollbar {
17
+ width: 8px;
18
+ height: 8px;
19
+ }
20
+
21
+ *::-webkit-scrollbar-track {
22
+ background: transparent;
23
+ }
24
+
25
+ *::-webkit-scrollbar-thumb {
26
+ background: rgba(255, 255, 255, 0.06);
27
+ border-radius: 9999px;
28
+ border: 2px solid transparent;
29
+ background-clip: content-box;
30
+ }
31
+
32
+ *::-webkit-scrollbar-thumb:hover {
33
+ background: rgba(244, 63, 94, 0.4);
34
+ background-clip: content-box;
35
+ }
36
+
37
+ *::-webkit-scrollbar-corner {
38
+ background: transparent;
39
+ }
40
+
41
+ @layer base {
42
+ .theme {
43
+ --font-heading: var(--font-sans);
44
+ --font-sans: 'Geist Variable', sans-serif;
45
+ }
46
+ :root {
47
+ --background: oklch(1 0 0);
48
+ --foreground: oklch(0.145 0 0);
49
+ --card: oklch(1 0 0);
50
+ --card-foreground: oklch(0.145 0 0);
51
+ --popover: oklch(1 0 0);
52
+ --popover-foreground: oklch(0.145 0 0);
53
+ --primary: oklch(0.205 0 0);
54
+ --primary-foreground: oklch(0.985 0 0);
55
+ --secondary: oklch(0.97 0 0);
56
+ --secondary-foreground: oklch(0.205 0 0);
57
+ --muted: oklch(0.97 0 0);
58
+ --muted-foreground: oklch(0.556 0 0);
59
+ --accent: oklch(0.97 0 0);
60
+ --accent-foreground: oklch(0.205 0 0);
61
+ --destructive: oklch(0.577 0.245 27.325);
62
+ --border: oklch(0.922 0 0);
63
+ --input: oklch(0.922 0 0);
64
+ --ring: oklch(0.708 0 0);
65
+ --chart-1: oklch(0.87 0 0);
66
+ --chart-2: oklch(0.556 0 0);
67
+ --chart-3: oklch(0.439 0 0);
68
+ --chart-4: oklch(0.371 0 0);
69
+ --chart-5: oklch(0.269 0 0);
70
+ --radius: 0.625rem;
71
+ --sidebar: oklch(0.985 0 0);
72
+ --sidebar-foreground: oklch(0.145 0 0);
73
+ --sidebar-primary: oklch(0.205 0 0);
74
+ --sidebar-primary-foreground: oklch(0.985 0 0);
75
+ --sidebar-accent: oklch(0.97 0 0);
76
+ --sidebar-accent-foreground: oklch(0.205 0 0);
77
+ --sidebar-border: oklch(0.922 0 0);
78
+ --sidebar-ring: oklch(0.708 0 0);
79
+ }
80
+ .dark {
81
+ --background: oklch(0.145 0 0);
82
+ --foreground: oklch(0.985 0 0);
83
+ --card: oklch(0.205 0 0);
84
+ --card-foreground: oklch(0.985 0 0);
85
+ --popover: oklch(0.205 0 0);
86
+ --popover-foreground: oklch(0.985 0 0);
87
+ --primary: oklch(0.922 0 0);
88
+ --primary-foreground: oklch(0.205 0 0);
89
+ --secondary: oklch(0.269 0 0);
90
+ --secondary-foreground: oklch(0.985 0 0);
91
+ --muted: oklch(0.269 0 0);
92
+ --muted-foreground: oklch(0.708 0 0);
93
+ --accent: oklch(0.269 0 0);
94
+ --accent-foreground: oklch(0.985 0 0);
95
+ --destructive: oklch(0.704 0.191 22.216);
96
+ --border: oklch(1 0 0 / 10%);
97
+ --input: oklch(1 0 0 / 15%);
98
+ --ring: oklch(0.556 0 0);
99
+ --chart-1: oklch(0.87 0 0);
100
+ --chart-2: oklch(0.556 0 0);
101
+ --chart-3: oklch(0.439 0 0);
102
+ --chart-4: oklch(0.371 0 0);
103
+ --chart-5: oklch(0.269 0 0);
104
+ --sidebar: oklch(0.205 0 0);
105
+ --sidebar-foreground: oklch(0.985 0 0);
106
+ --sidebar-primary: oklch(0.488 0.243 264.376);
107
+ --sidebar-primary-foreground: oklch(0.985 0 0);
108
+ --sidebar-accent: oklch(0.269 0 0);
109
+ --sidebar-accent-foreground: oklch(0.985 0 0);
110
+ --sidebar-border: oklch(1 0 0 / 10%);
111
+ --sidebar-ring: oklch(0.556 0 0);
112
+ }
113
+ * {
114
+ border-color: hsl(var(--border));
115
+ }
116
+ body {
117
+ background-color: hsl(var(--background));
118
+ color: hsl(var(--foreground));
119
+ }
120
+ html {
121
+ font-family: var(--font-sans);
122
+ }
123
+ }
studio/src/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
studio/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App";
4
+ import "./index.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );
studio/src/types.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface MediaItem {
2
+ name: string;
3
+ type: "image" | "video";
4
+ width: number;
5
+ height: number;
6
+ mtime: number;
7
+ url: string;
8
+ filename?: string;
9
+ thumb?: string;
10
+ prompt?: string | null;
11
+ prompt_id?: string | null;
12
+ }
13
+
14
+ export interface LoraTrainingStatus {
15
+ ok: boolean;
16
+ status: string;
17
+ running?: boolean;
18
+ job_name?: string | null;
19
+ current_step: number;
20
+ total_steps: number;
21
+ progress_percent?: number | null;
22
+ loss?: number | null;
23
+ lr?: number | null;
24
+ elapsed?: string | null;
25
+ eta?: string | null;
26
+ seconds_per_step?: number | null;
27
+ gpu_util?: number | null;
28
+ vram_percent?: number | null;
29
+ updated_at: string;
30
+ error?: string | null;
31
+ }
32
+
33
+ export interface LoraCheckpoint {
34
+ name: string;
35
+ filename?: string;
36
+ step?: number | null;
37
+ path: string;
38
+ size_bytes: number;
39
+ modified_at: string;
40
+ created_at?: string;
41
+ }
42
+
43
+ export interface LoraCheckpointsResponse {
44
+ ok: boolean;
45
+ job_name: string;
46
+ checkpoints: LoraCheckpoint[];
47
+ count: number;
48
+ updated_at: string;
49
+ }
50
+
51
+ export interface JobItem {
52
+ prompt_id: string;
53
+ status: "pending" | "running" | "completed" | "failed" | string;
54
+ mode?: string;
55
+ prompt?: string;
56
+ created_at?: string;
57
+ queue_position?: number | null;
58
+ current_node?: string | null;
59
+ step_value?: number;
60
+ step_max?: number;
61
+ nodes_finished?: number;
62
+ nodes_total?: number;
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
+ title: string | null;
85
+ setting: string;
86
+ weather: string;
87
+ summary: string | null;
88
+ location: string | null;
89
+ time_of_day: string | null;
90
+ characters: string[];
91
+ metadata: Record<string, unknown>;
92
+ }
93
+
94
+ export interface Shot {
95
+ id: string;
96
+ project_id: string;
97
+ scene_id: string;
98
+ shot_number: number;
99
+ text: string | null;
100
+ description: string | null;
101
+ subtitle: string | null;
102
+ voiceover: string | null;
103
+ image_prompt: string | null;
104
+ motion_prompt: string | null;
105
+ characters: string[];
106
+ duration_seconds: number;
107
+ status: string;
108
+ image_file: string | null;
109
+ video_file: string | null;
110
+ image_prompt_id: string | null;
111
+ video_prompt_id: string | null;
112
+ metadata: Record<string, unknown>;
113
+ }
114
+
115
+
116
+ export interface ShotVersion {
117
+ id: string;
118
+ project_id: string;
119
+ scene_id: string;
120
+ shot_id: string;
121
+ version_number: number;
122
+ kind: "image" | "video";
123
+ status: string;
124
+ prompt: string | null;
125
+ file: string | null;
126
+ prompt_id: string | null;
127
+ metadata: Record<string, unknown>;
128
+ created_at?: string;
129
+ }
130
+
131
+ export type ProjectPhase = "outline" | "generate" | "animate";
132
+
133
+ export interface ProjectModeData {
134
+ project: Project;
135
+ scenes: Scene[];
136
+ shots: Shot[];
137
+ selectedSceneId: string | null;
138
+ selectedShotId: string | null;
139
+ phase: ProjectPhase;
140
+ onSelectScene: (sceneId: string) => void;
141
+ onSelectShot: (shotId: string | null) => void;
142
+ onBack: () => void;
143
+ onRefresh: () => Promise<void> | void;
144
+ onAddScene: () => Promise<void> | void;
145
+ onDeleteScene: (sceneId: string) => Promise<void> | void;
146
+ onDeleteShot: (shotId: string) => Promise<void> | void;
147
+ }
studio/tailwind.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: ["class"],
4
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5
+ theme: {
6
+ extend: {
7
+ colors: {
8
+ background: "var(--background)",
9
+ foreground: "var(--foreground)",
10
+ card: { DEFAULT: "var(--card)", foreground: "var(--card-foreground)" },
11
+ popover: { DEFAULT: "var(--popover)", foreground: "var(--popover-foreground)" },
12
+ primary: { DEFAULT: "var(--primary)", foreground: "var(--primary-foreground)" },
13
+ secondary: { DEFAULT: "var(--secondary)", foreground: "var(--secondary-foreground)" },
14
+ muted: { DEFAULT: "var(--muted)", foreground: "var(--muted-foreground)" },
15
+ accent: { DEFAULT: "var(--accent)", foreground: "var(--accent-foreground)" },
16
+ destructive: { DEFAULT: "var(--destructive)" },
17
+ border: "var(--border)",
18
+ input: "var(--input)",
19
+ ring: "var(--ring)",
20
+ },
21
+ borderRadius: {
22
+ lg: "var(--radius)",
23
+ md: "calc(var(--radius) - 2px)",
24
+ sm: "calc(var(--radius) - 4px)",
25
+ },
26
+ },
27
+ },
28
+ plugins: [],
29
+ };
studio/tsconfig.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ },
23
+ "include": ["src"],
24
+ "references": [{ "path": "./tsconfig.node.json" }]
25
+ }
studio/tsconfig.node.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
studio/vite.config.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export default defineConfig({
9
+ plugins: [react()],
10
+ resolve: {
11
+ alias: {
12
+ "@": path.resolve(__dirname, "./src"),
13
+ },
14
+ },
15
+ server: {
16
+ host: "0.0.0.0",
17
+ port: 3010,
18
+ proxy: {
19
+ "/api": { target: process.env.NEMOFLIX_API_URL, changeOrigin: true },
20
+ "/media": { target: process.env.NEMOFLIX_API_URL, changeOrigin: true },
21
+ },
22
+ },
23
+ build: {
24
+ outDir: "dist",
25
+ emptyOutDir: true,
26
+ },
27
+ });