feat: add Nemoflix Studio UI, Docker server, and Space config
Browse files- Dockerfile +18 -0
- README.md +37 -6
- package.json +13 -0
- server.js +64 -0
- studio/components.json +25 -0
- studio/index.html +13 -0
- studio/package-lock.json +0 -0
- studio/package.json +34 -0
- studio/postcss.config.js +6 -0
- studio/src/App.tsx +527 -0
- studio/src/LandingPage.tsx +560 -0
- studio/src/api.ts +37 -0
- studio/src/components/CharacterProfileView.tsx +264 -0
- studio/src/components/GalleryView.tsx +156 -0
- studio/src/components/JobCard.tsx +80 -0
- studio/src/components/LoraTrainingPage.tsx +603 -0
- studio/src/components/MediaTile.tsx +165 -0
- studio/src/components/ProjectDetailView.tsx +1023 -0
- studio/src/components/ProjectFilmsView.tsx +176 -0
- studio/src/components/ProjectsGuide.tsx +106 -0
- studio/src/components/ProjectsView.tsx +203 -0
- studio/src/components/sidebar/AppSidebar.tsx +422 -0
- studio/src/components/sidebar/GenerateTab.tsx +352 -0
- studio/src/components/sidebar/NodesTab.tsx +180 -0
- studio/src/components/sidebar/ProjectSidebar.tsx +153 -0
- studio/src/components/ui/badge.tsx +52 -0
- studio/src/components/ui/button.tsx +58 -0
- studio/src/components/ui/card.tsx +103 -0
- studio/src/components/ui/input.tsx +20 -0
- studio/src/components/ui/progress.tsx +83 -0
- studio/src/components/ui/table.tsx +114 -0
- studio/src/components/ui/tabs.tsx +80 -0
- studio/src/index.css +123 -0
- studio/src/lib/utils.ts +6 -0
- studio/src/main.tsx +10 -0
- studio/src/types.ts +147 -0
- studio/tailwind.config.js +29 -0
- studio/tsconfig.json +25 -0
- studio/tsconfig.node.json +10 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
short_description: Train a LoRA, generate images, animate into AI films.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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/<id>/</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"><prj_id></code> / <code className="text-gray-400"><scn_id></code> / <code className="text-gray-400"><sht_id></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 |
+
});
|