mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
commit
87f18dd088
27 changed files with 26226 additions and 3447 deletions
83
README.md
83
README.md
|
|
@ -14,25 +14,26 @@ One-click installers — no Node.js or terminal required.
|
|||
|
||||
All releases: [github.com/Anil-matcha/Open-Higgsfield-AI/releases](https://github.com/Anil-matcha/Open-Higgsfield-AI/releases)
|
||||
|
||||
### macOS Installation — "damaged and can't be opened" fix
|
||||
### macOS Installation Guide
|
||||
|
||||
Because the app is not code-signed, macOS Gatekeeper will block it with a **"damaged and can't be opened"** message. This is expected — the file is fine. Fix it with one Terminal command:
|
||||
Because the app is not notarized by Apple, macOS Gatekeeper will block it on first launch. Follow these steps:
|
||||
|
||||
**Step 1** — Open the DMG (it will mount even if the warning appears)
|
||||
**Step 1** — Mount the DMG and drag the app to `/Applications`
|
||||
|
||||
**Step 2** — Open Terminal and run:
|
||||
```bash
|
||||
xattr -cr "/Volumes/Open Higgsfield AI/Open Higgsfield AI.app"
|
||||
```
|
||||
|
||||
**Step 3** — Drag the app to `/Applications`, then open it normally.
|
||||
|
||||
If you already copied it to Applications before running the command:
|
||||
```bash
|
||||
xattr -cr "/Applications/Open Higgsfield AI.app"
|
||||
```
|
||||
|
||||
> Alternatively: after attempting to open, go to **System Settings → Privacy & Security** → scroll down → click **"Open Anyway"**.
|
||||
**Step 3** — Right-click the app in `/Applications` → click **Open** → click **Open** again on the dialog
|
||||
|
||||
> You only need to do this once. After that, the app opens normally.
|
||||
|
||||
**Alternative (no Terminal):**
|
||||
1. Try to open the app — macOS will block it
|
||||
2. Go to **System Settings → Privacy & Security**
|
||||
3. Scroll down to find _"Open Higgsfield AI was blocked"_
|
||||
4. Click **Open Anyway** → **Open**
|
||||
|
||||
### Windows Installation — SmartScreen warning fix
|
||||
|
||||
|
|
@ -211,20 +212,20 @@ Every image you upload is saved locally (URL + thumbnail) so you never upload th
|
|||
git clone https://github.com/Anil-matcha/Open-Higgsfield-AI.git
|
||||
cd Open-Higgsfield-AI
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (installs root + packages/studio workspace)
|
||||
npm install
|
||||
|
||||
# Start the development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` in your browser. You'll be prompted to enter your Muapi API key on first use.
|
||||
Open `http://localhost:3000` in your browser. You'll be prompted to enter your Muapi API key on first use.
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Desktop App Build
|
||||
|
|
@ -246,31 +247,36 @@ Installers are output to the `release/` folder. Pre-built binaries are also avai
|
|||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The app is a **Next.js monorepo** with a shared `packages/studio` component library.
|
||||
|
||||
```
|
||||
src/
|
||||
Open-Higgsfield-AI/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── layout.js # Root layout (Tailwind, fonts)
|
||||
│ ├── page.js # Redirects → /studio
|
||||
│ └── studio/
|
||||
│ └── page.js # Studio page — renders StandaloneShell
|
||||
├── components/
|
||||
│ ├── ImageStudio.js # Dual-mode t2i/i2i studio with dynamic model switching & multi-image support
|
||||
│ ├── VideoStudio.js # Dual-mode t2v/i2v studio with dynamic model switching
|
||||
│ ├── LipSyncStudio.js # Lip sync studio: portrait image/video + audio → talking video (9 models)
|
||||
│ ├── CinemaStudio.js # Pro studio with camera controls & infinite canvas flow
|
||||
│ ├── UploadPicker.js # Upload button + history panel; single & multi-image select modes
|
||||
│ ├── CameraControls.js # Scrollable picker for camera/lens/focal/aperture
|
||||
│ ├── Header.js # App header with navigation (Image, Video, Lip Sync, Cinema Studio…)
|
||||
│ ├── AuthModal.js # API key input modal
|
||||
│ ├── SettingsModal.js # Settings panel for API key management
|
||||
│ └── Sidebar.js # Navigation sidebar
|
||||
├── lib/
|
||||
│ ├── muapi.js # API client: generateImage, generateVideo, generateI2I, generateI2V, processV2V, processLipSync, uploadFile
|
||||
│ ├── models.js # 200+ model definitions: t2i, i2i, t2v, i2v, v2v, lipsync arrays with endpoints & input schemas
|
||||
│ └── uploadHistory.js # localStorage CRUD + canvas thumbnail generation for upload history
|
||||
├── styles/
|
||||
│ ├── global.css # Global styles and animations
|
||||
│ ├── studio.css # Studio-specific styles
|
||||
│ └── variables.css # CSS custom properties
|
||||
├── main.js # App entry point & router (image / video / lipsync / cinema)
|
||||
└── style.css # Tailwind imports
|
||||
│ ├── StandaloneShell.js # Tab nav + BYOK (API key from localStorage)
|
||||
│ └── ApiKeyModal.js # API key entry modal
|
||||
├── packages/
|
||||
│ └── studio/ # Shared React component library
|
||||
│ └── src/
|
||||
│ ├── index.js # Exports: ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio
|
||||
│ ├── models.js # 200+ model definitions (single source of truth)
|
||||
│ ├── muapi.js # API client (named exports, apiKey as first param)
|
||||
│ └── components/
|
||||
│ ├── ImageStudio.jsx # Dual-mode t2i/i2i studio
|
||||
│ ├── VideoStudio.jsx # Dual-mode t2v/i2v studio
|
||||
│ ├── LipSyncStudio.jsx # Portrait/video + audio → talking video
|
||||
│ └── CinemaStudio.jsx # Pro studio with camera controls
|
||||
├── next.config.mjs # transpilePackages: ['studio']
|
||||
├── tailwind.config.js
|
||||
└── package.json # workspaces: ["packages/studio"]
|
||||
```
|
||||
|
||||
The `packages/studio` library is also consumed by the hosted version on [muapi.ai](https://muapi.ai) — model updates made in `packages/studio/src/models.js` apply to both the self-hosted app and the hosted version automatically.
|
||||
|
||||
## 🔌 API Integration
|
||||
|
||||
The app communicates with [Muapi.ai](https://muapi.ai) using a two-step pattern:
|
||||
|
|
@ -296,9 +302,10 @@ Lip sync jobs use the same two-step pattern: a dedicated `processLipSync()` meth
|
|||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Vite** — Build tool & dev server
|
||||
- **Tailwind CSS v4** — Utility-first styling
|
||||
- **Vanilla JS** — No framework, pure DOM manipulation
|
||||
- **Next.js 14** — App Router, server components, fast dev server
|
||||
- **React 18** — Studio UI components
|
||||
- **Tailwind CSS v3** — Utility-first styling
|
||||
- **npm workspaces** — Monorepo with shared `packages/studio` library
|
||||
- **Muapi.ai** — AI model API gateway
|
||||
|
||||
## 🤔 How is this different from Higgsfield AI?
|
||||
|
|
|
|||
30
app/globals.css
Normal file
30
app/globals.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #050505;
|
||||
color: white;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: #d9ff00;
|
||||
--bg-app: #050505;
|
||||
--bg-panel: #0a0a0a;
|
||||
--bg-card: #111111;
|
||||
--border-color: rgba(255,255,255,0.08);
|
||||
--border-radius-xl: 1rem;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up { animation: fade-in-up 0.4s ease forwards; }
|
||||
14
app/layout.js
Normal file
14
app/layout.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import './globals.css';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Open Higgsfield AI — Free AI Image & Video Studio',
|
||||
description: 'Generate AI images and videos using 200+ models — Flux, Midjourney, Kling, Veo, Seedance and more. Free open-source alternative to Higgsfield AI.',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/page.js
Normal file
5
app/page.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/studio');
|
||||
}
|
||||
9
app/studio/page.js
Normal file
9
app/studio/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import StandaloneShell from '@/components/StandaloneShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Studio — Open Higgsfield AI',
|
||||
};
|
||||
|
||||
export default function StudioPage() {
|
||||
return <StandaloneShell />;
|
||||
}
|
||||
65
components/ApiKeyModal.js
Normal file
65
components/ApiKeyModal.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ApiKeyModal({ onSave }) {
|
||||
const [key, setKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) { setError('Please enter your API key'); return; }
|
||||
onSave(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md bg-[#0a0a0a] border border-white/10 rounded-3xl p-8">
|
||||
<div className="flex flex-col items-center text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#d9ff00]/10 rounded-2xl flex items-center justify-center border border-[#d9ff00]/20 mb-6">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L12 17.25l-4.5-4.5L15.5 7.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-white uppercase tracking-wider mb-2">
|
||||
Open Higgsfield AI
|
||||
</h1>
|
||||
<p className="text-white/40 text-sm">
|
||||
Enter your <a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">Muapi.ai</a> API key to start generating
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-white/40 uppercase tracking-widest mb-2">
|
||||
Muapi API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
||||
placeholder="Enter your API key..."
|
||||
className="w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-white placeholder:text-white/20 focus:outline-none focus:border-[#d9ff00]/40 transition-colors"
|
||||
/>
|
||||
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Launch Studio
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-white/30">
|
||||
Don't have a key?{' '}
|
||||
<a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">
|
||||
Get one free at Muapi.ai →
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
components/StandaloneShell.js
Normal file
111
components/StandaloneShell.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio } from 'studio';
|
||||
import ApiKeyModal from './ApiKeyModal';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'image', label: 'Image Studio' },
|
||||
{ id: 'video', label: 'Video Studio' },
|
||||
{ id: 'lipsync', label: 'Lip Sync' },
|
||||
{ id: 'cinema', label: 'Cinema Studio' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'muapi_key';
|
||||
|
||||
export default function StandaloneShell() {
|
||||
const [apiKey, setApiKey] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('image');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) setApiKey(stored);
|
||||
}, []);
|
||||
|
||||
const handleKeySave = useCallback((key) => {
|
||||
localStorage.setItem(STORAGE_KEY, key);
|
||||
setApiKey(key);
|
||||
}, []);
|
||||
|
||||
const handleKeyChange = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setApiKey(null);
|
||||
}, []);
|
||||
|
||||
if (!apiKey) {
|
||||
return <ApiKeyModal onSave={handleKeySave} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-[#050505] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex-shrink-0 flex items-center justify-between px-4 pt-4 pb-0 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white font-black text-lg tracking-wider uppercase">
|
||||
Open Higgsfield AI
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<nav className="flex items-center gap-1">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[#d9ff00] text-black'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="text-white/40 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
⚙ Settings
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Studio Content */}
|
||||
<div className="flex-1">
|
||||
{activeTab === 'image' && <ImageStudio apiKey={apiKey} />}
|
||||
{activeTab === 'video' && <VideoStudio apiKey={apiKey} />}
|
||||
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} />}
|
||||
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettings && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-8 w-full max-w-md">
|
||||
<h2 className="text-white font-bold text-xl mb-6">Settings</h2>
|
||||
<p className="text-white/50 text-sm mb-4">
|
||||
Current API key: <span className="text-white/80 font-mono">{apiKey.slice(0, 8)}••••••••</span>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleKeyChange}
|
||||
className="flex-1 py-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm transition-colors"
|
||||
>
|
||||
Change API Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="flex-1 py-2 rounded-lg bg-white/5 text-white hover:bg-white/10 text-sm transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['studio'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7863
package-lock.json
generated
7863
package-lock.json
generated
File diff suppressed because it is too large
Load diff
81
package.json
81
package.json
|
|
@ -1,17 +1,21 @@
|
|||
{
|
||||
"name": "open-higgsfield-ai",
|
||||
"description": "Open-source alternative to Higgsfield AI — AI image generation and cinema studio with 20+ models",
|
||||
"description": "Open-source alternative to Higgsfield AI — AI image, video, cinema and lip sync studio",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "electron/main.js",
|
||||
"workspaces": [
|
||||
"packages/studio"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite build && electron .",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"build:studio": "cd packages/studio && npm run build",
|
||||
"setup": "npm install && npm run build:studio",
|
||||
"vite:dev": "vite",
|
||||
"vite:build": "vite build",
|
||||
"electron:build": "vite build && electron-builder --mac",
|
||||
"electron:build:dmg": "vite build && electron-builder --mac dmg",
|
||||
"electron:build:win": "vite build && electron-builder --win",
|
||||
"electron:build:all": "vite build && electron-builder --mac --win"
|
||||
},
|
||||
|
|
@ -19,63 +23,38 @@
|
|||
"appId": "ai.higgsfield.open",
|
||||
"productName": "Open Higgsfield AI",
|
||||
"copyright": "Copyright © 2025",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"directories": { "output": "release" },
|
||||
"afterPack": "./afterPack.js",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*"
|
||||
],
|
||||
"files": ["dist/**/*", "electron/**/*"],
|
||||
"mac": {
|
||||
"category": "public.app-category.graphics-design",
|
||||
"icon": "public/banner.png",
|
||||
"gatekeeperAssess": false,
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Open Higgsfield AI",
|
||||
"backgroundColor": "#0d0d0d",
|
||||
"window": {
|
||||
"width": 540,
|
||||
"height": 380
|
||||
}
|
||||
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
|
||||
},
|
||||
"win": {
|
||||
"icon": "public/banner.png",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": true,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": false,
|
||||
"deleteAppDataOnUninstall": false
|
||||
"target": [{ "target": "nsis", "arch": ["x64", "arm64"] }]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"studio": "*",
|
||||
"axios": "^1.7.0",
|
||||
"react-hot-toast": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"electron": "^33.4.11",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"puppeteer": "^24.37.2"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.4.0",
|
||||
"@tailwindcss/vite": "^4.1.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
packages/studio/babel.config.json
Normal file
6
packages/studio/babel.config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
]
|
||||
}
|
||||
3645
packages/studio/package-lock.json
generated
Normal file
3645
packages/studio/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
packages/studio/package.json
Normal file
33
packages/studio/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "studio",
|
||||
"version": "1.0.0",
|
||||
"description": "Open Higgsfield AI studio components for Muapi",
|
||||
"main": "src/index.js",
|
||||
"module": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -i ./src/tailwind.css -o ./dist/tailwind.css --minify",
|
||||
"build": "npm run build:css && babel src --out-dir dist --extensions .js,.jsx"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-hot-toast": "^2.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
"tailwindcss": "^3.3.3"
|
||||
}
|
||||
}
|
||||
6
packages/studio/postcss.config.js
Normal file
6
packages/studio/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
782
packages/studio/src/components/CinemaStudio.jsx
Normal file
782
packages/studio/src/components/CinemaStudio.jsx
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { generateImage } from '../muapi.js';
|
||||
|
||||
// ─── Constants (inlined from promptUtils) ───────────────────────────────────
|
||||
|
||||
const CAMERA_MAP = {
|
||||
"Modular 8K Digital": "modular 8K digital cinema camera",
|
||||
"Full-Frame Cine Digital": "full-frame digital cinema camera",
|
||||
"Grand Format 70mm Film": "grand format 70mm film camera",
|
||||
"Studio Digital S35": "Super 35 studio digital camera",
|
||||
"Classic 16mm Film": "classic 16mm film camera",
|
||||
"Premium Large Format Digital": "premium large-format digital cinema camera"
|
||||
};
|
||||
|
||||
const LENS_MAP = {
|
||||
"Creative Tilt Lens": "creative tilt lens effect",
|
||||
"Compact Anamorphic": "compact anamorphic lens",
|
||||
"Extreme Macro": "extreme macro lens",
|
||||
"70s Cinema Prime": "1970s cinema prime lens",
|
||||
"Classic Anamorphic": "classic anamorphic lens",
|
||||
"Premium Modern Prime": "premium modern prime lens",
|
||||
"Warm Cinema Prime": "warm-toned cinema prime lens",
|
||||
"Swirl Bokeh Portrait": "swirl bokeh portrait lens",
|
||||
"Vintage Prime": "vintage prime lens",
|
||||
"Halation Diffusion": "halation diffusion filter",
|
||||
"Clinical Sharp Prime": "ultra-sharp clinical prime lens"
|
||||
};
|
||||
|
||||
const FOCAL_PERSPECTIVE = {
|
||||
8: "ultra-wide perspective",
|
||||
14: "wide-angle perspective",
|
||||
24: "wide-angle dynamic perspective",
|
||||
35: "natural cinematic perspective",
|
||||
50: "standard portrait perspective",
|
||||
85: "classic portrait perspective"
|
||||
};
|
||||
|
||||
const APERTURE_EFFECT = {
|
||||
"f/1.4": "shallow depth of field, creamy bokeh",
|
||||
"f/4": "balanced depth of field",
|
||||
"f/11": "deep focus clarity, sharp foreground to background"
|
||||
};
|
||||
|
||||
const ASSET_URLS = {
|
||||
"Modular 8K Digital": "/assets/cinema/modular_8k_digital.webp",
|
||||
"Full-Frame Cine Digital": "/assets/cinema/full_frame_cine_digital.webp",
|
||||
"Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
|
||||
"Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
|
||||
"Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
|
||||
"Premium Large Format Digital": "/assets/cinema/premium_large_format_digital.webp",
|
||||
"Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
|
||||
"Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
|
||||
"Extreme Macro": "/assets/cinema/extreme_macro.webp",
|
||||
"70s Cinema Prime": "/assets/cinema/70s_cinema_prime.webp",
|
||||
"Classic Anamorphic": "/assets/cinema/classic_anamorphic.webp",
|
||||
"Premium Modern Prime": "/assets/cinema/premium_modern_prime.webp",
|
||||
"Warm Cinema Prime": "/assets/cinema/warm_cinema_prime.webp",
|
||||
"Swirl Bokeh Portrait": "/assets/cinema/swirl_bokeh_portrait.webp",
|
||||
"Vintage Prime": "/assets/cinema/vintage_prime.webp",
|
||||
"Halation Diffusion": "/assets/cinema/halation_diffusion.webp",
|
||||
"Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
|
||||
"f/1.4": "/assets/cinema/f_1_4.webp",
|
||||
"f/4": "/assets/cinema/f_4.webp",
|
||||
"f/11": "/assets/cinema/f_11.webp"
|
||||
};
|
||||
|
||||
const ASPECT_RATIOS = ['16:9', '21:9', '9:16', '1:1', '4:5'];
|
||||
const RESOLUTIONS = ['1K', '2K', '4K'];
|
||||
const CAMERAS = Object.keys(CAMERA_MAP);
|
||||
const LENSES = Object.keys(LENS_MAP);
|
||||
const FOCAL_LENGTHS = Object.keys(FOCAL_PERSPECTIVE).map(k => parseInt(k));
|
||||
const APERTURES = Object.keys(APERTURE_EFFECT);
|
||||
|
||||
function buildNanoBananaPrompt(basePrompt, camera, lens, focalLength, aperture) {
|
||||
const cameraDesc = CAMERA_MAP[camera] || camera;
|
||||
const lensDesc = LENS_MAP[lens] || lens;
|
||||
const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
|
||||
const depthEffect = APERTURE_EFFECT[aperture] || "";
|
||||
const qualityTags = ["professional photography", "ultra-detailed", "8K resolution"];
|
||||
const parts = [
|
||||
basePrompt,
|
||||
`shot on a ${cameraDesc}`,
|
||||
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ''}`,
|
||||
`aperture ${aperture}`,
|
||||
depthEffect,
|
||||
"cinematic lighting",
|
||||
"natural color science",
|
||||
"high dynamic range",
|
||||
qualityTags.join(", ")
|
||||
];
|
||||
return parts.filter(p => p && p.trim() !== "").join(", ");
|
||||
}
|
||||
|
||||
// ─── Dropdown ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
|
||||
const menuRef = useRef(null);
|
||||
const [position, setPosition] = useState({ bottom: 0, left: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
left: rect.left
|
||||
});
|
||||
}
|
||||
|
||||
const handler = (e) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => document.addEventListener('click', handler), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handler);
|
||||
};
|
||||
}, [triggerRef, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="custom-dropdown fixed bg-[#1a1a1a] border border-white/10 rounded-xl py-1 shadow-2xl z-50 flex flex-col min-w-[100px] animate-fade-in"
|
||||
style={{ bottom: position.bottom, left: position.left }}
|
||||
>
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item}
|
||||
className={`px-3 py-2 text-xs font-bold text-left hover:bg-white/10 transition-colors ${item === selected ? 'text-primary' : 'text-white'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scroll Column (Camera Controls) ─────────────────────────────────────────
|
||||
|
||||
function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||
const listRef = useRef(null);
|
||||
const isDragging = useRef(false);
|
||||
const startY = useRef(0);
|
||||
const scrollTopStart = useRef(0);
|
||||
const isSnapEnabled = useRef(true);
|
||||
|
||||
// Scroll to initial value on mount
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const timer = setTimeout(() => {
|
||||
const target = Array.from(list.children).find(
|
||||
c => c.dataset.value == String(value)
|
||||
);
|
||||
if (target) target.scrollIntoView({ block: 'center' });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const centerY = list.scrollTop + list.clientHeight / 2;
|
||||
let closest = null;
|
||||
let minDist = Infinity;
|
||||
|
||||
const children = Array.from(list.children).filter(c => c.dataset.value);
|
||||
children.forEach(child => {
|
||||
const childCenter = child.offsetTop + child.offsetHeight / 2;
|
||||
const dist = Math.abs(centerY - childCenter);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = child;
|
||||
}
|
||||
});
|
||||
|
||||
children.forEach(child => {
|
||||
const imgBox = child.querySelector('[data-imgbox]');
|
||||
const label = child.querySelector('[data-label]');
|
||||
const focalSpan = imgBox?.querySelector('[data-focal-text]');
|
||||
const isClosest = child === closest;
|
||||
|
||||
if (isClosest) {
|
||||
child.classList.remove('opacity-30', 'scale-75', 'blur-[1px]');
|
||||
child.classList.add('opacity-100', 'scale-100', 'blur-0', 'z-30');
|
||||
if (imgBox) {
|
||||
imgBox.classList.add('border-primary/50', 'shadow-glow-sm', 'scale-110');
|
||||
imgBox.classList.remove('border-white/10', 'bg-white/5');
|
||||
}
|
||||
if (focalSpan) focalSpan.classList.add('text-primary');
|
||||
if (label) label.classList.add('text-primary', 'text-shadow-sm');
|
||||
} else {
|
||||
child.classList.add('opacity-30', 'scale-75', 'blur-[1px]');
|
||||
child.classList.remove('opacity-100', 'scale-100', 'blur-0', 'z-30');
|
||||
if (imgBox) {
|
||||
imgBox.classList.remove('border-primary/50', 'shadow-glow-sm', 'scale-110');
|
||||
imgBox.classList.add('border-white/10', 'bg-white/5');
|
||||
}
|
||||
if (focalSpan) focalSpan.classList.remove('text-primary');
|
||||
if (label) label.classList.remove('text-primary', 'text-shadow-sm');
|
||||
}
|
||||
});
|
||||
|
||||
if (closest) {
|
||||
const newVal = columnKey === 'focal'
|
||||
? parseInt(closest.dataset.value)
|
||||
: closest.dataset.value;
|
||||
if (String(newVal) !== String(value)) {
|
||||
onChange(newVal);
|
||||
}
|
||||
}
|
||||
}, [columnKey, value, onChange]);
|
||||
|
||||
// Attach scroll handler with initial check
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
list.addEventListener('scroll', handleScroll);
|
||||
const timer = setTimeout(handleScroll, 150);
|
||||
return () => {
|
||||
list.removeEventListener('scroll', handleScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
// Mouse drag handlers
|
||||
const onMouseDown = (e) => {
|
||||
isDragging.current = true;
|
||||
isSnapEnabled.current = false;
|
||||
listRef.current.classList.add('cursor-grabbing');
|
||||
listRef.current.classList.remove('snap-y');
|
||||
startY.current = e.pageY - listRef.current.offsetTop;
|
||||
scrollTopStart.current = listRef.current.scrollTop;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
isDragging.current = false;
|
||||
listRef.current.classList.remove('cursor-grabbing');
|
||||
listRef.current.classList.add('snap-y');
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
listRef.current.classList.remove('cursor-grabbing');
|
||||
listRef.current.classList.add('snap-y');
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDragging.current) return;
|
||||
e.preventDefault();
|
||||
const y = e.pageY - listRef.current.offsetTop;
|
||||
const walk = (y - startY.current) * 1.5;
|
||||
listRef.current.scrollTop = scrollTopStart.current - walk;
|
||||
};
|
||||
|
||||
const onItemClick = (item) => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const target = Array.from(list.children).find(
|
||||
c => c.dataset.value == String(item)
|
||||
);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center relative w-[140px] md:w-[160px] shrink-0 snap-center group">
|
||||
<div className="mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center">
|
||||
{title}
|
||||
</div>
|
||||
<div className="relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#1a1a1a]/80 rounded-[2rem] border border-white/5 shadow-2xl backdrop-blur-xl transition-transform duration-300 hover:scale-[1.02] hover:border-white/10">
|
||||
{/* Top mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#1a1a1a] via-[#1a1a1a]/80 to-transparent z-20 pointer-events-none rounded-t-[2rem]" />
|
||||
{/* Bottom mask */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#1a1a1a] via-[#1a1a1a]/80 to-transparent z-20 pointer-events-none rounded-b-[2rem]" />
|
||||
{/* Center glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4/5 h-[80px] bg-primary/5 blur-xl rounded-full pointer-events-none z-0" />
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="h-full overflow-y-auto no-scrollbar snap-y snap-mandatory relative z-10"
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseMove={onMouseMove}
|
||||
>
|
||||
{/* Top spacer */}
|
||||
<div style={{ height: 'calc(50% - 50px)' }} />
|
||||
|
||||
{items.map(item => {
|
||||
const imageUrl = ASSET_URLS[item];
|
||||
return (
|
||||
<div
|
||||
key={item}
|
||||
data-value={item}
|
||||
className="h-[100px] flex flex-col items-center justify-center gap-3 snap-center cursor-pointer transition-all duration-500 ease-out text-white p-2 select-none opacity-30 scale-75 blur-[1px]"
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
<div
|
||||
data-imgbox="true"
|
||||
className="w-14 h-14 rounded-xl border border-white/10 bg-white/5 flex items-center justify-center transition-all duration-500 shadow-inner overflow-hidden relative"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={String(item)}
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
/>
|
||||
) : columnKey === 'focal' ? (
|
||||
<span data-focal-text="true" className="text-lg font-bold text-white/50">
|
||||
{item}
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-3 h-3 bg-white/20 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
data-label="true"
|
||||
className="text-[9px] md:text-[10px] font-bold uppercase text-center leading-tight max-w-full truncate px-1 tracking-wider"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{ height: 'calc(50% - 50px)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Camera Controls Overlay ─────────────────────────────────────────────────
|
||||
|
||||
function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange }) {
|
||||
const backdropRef = useRef(null);
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
};
|
||||
|
||||
const updateSetting = (key) => (val) => {
|
||||
onSettingsChange(prev => ({ ...prev, [key]: val }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className={`fixed inset-0 bg-black/80 backdrop-blur-md z-40 flex items-center justify-center transition-opacity duration-300 ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
className={`w-full max-w-4xl bg-[#141414] border border-white/10 rounded-3xl p-4 md:p-8 shadow-2xl transform transition-transform duration-300 flex flex-col max-h-[90vh] ${isOpen ? 'scale-100' : 'scale-95'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex gap-4">
|
||||
<button className="px-4 py-2 bg-white text-black text-xs font-bold rounded-full">All</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scroll columns */}
|
||||
<div className="w-full flex justify-start md:justify-center gap-3 md:gap-6 py-4 md:py-8 overflow-x-auto no-scrollbar snap-x px-4 md:px-0">
|
||||
<ScrollColumn
|
||||
title="Camera"
|
||||
items={CAMERAS}
|
||||
columnKey="camera"
|
||||
value={settings.camera}
|
||||
onChange={updateSetting('camera')}
|
||||
/>
|
||||
<ScrollColumn
|
||||
title="Lens"
|
||||
items={LENSES}
|
||||
columnKey="lens"
|
||||
value={settings.lens}
|
||||
onChange={updateSetting('lens')}
|
||||
/>
|
||||
<ScrollColumn
|
||||
title="Focal Length"
|
||||
items={FOCAL_LENGTHS}
|
||||
columnKey="focal"
|
||||
value={settings.focal}
|
||||
onChange={updateSetting('focal')}
|
||||
/>
|
||||
<ScrollColumn
|
||||
title="Aperture"
|
||||
items={APERTURES}
|
||||
columnKey="aperture"
|
||||
value={settings.aperture}
|
||||
onChange={updateSetting('aperture')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function CinemaStudio({ apiKey, onGenerationComplete, historyItems }) {
|
||||
// ── Settings state ──
|
||||
const [settings, setSettings] = useState({
|
||||
prompt: '',
|
||||
aspect_ratio: '16:9',
|
||||
camera: CAMERAS[0],
|
||||
lens: LENSES[0],
|
||||
focal: 35,
|
||||
aperture: 'f/1.4'
|
||||
});
|
||||
const [resolution, setResolution] = useState('2K');
|
||||
|
||||
// ── UI state ──
|
||||
const [isOverlayOpen, setIsOverlayOpen] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view
|
||||
const [activeHistoryIndex, setActiveHistoryIndex] = useState(null);
|
||||
|
||||
// ── Internal history state (used when historyItems prop is not provided) ──
|
||||
const [internalHistory, setInternalHistory] = useState([]);
|
||||
|
||||
// ── Dropdown state ──
|
||||
const [openDropdown, setOpenDropdown] = useState(null); // 'ar' | 'res' | null
|
||||
const arBtnRef = useRef(null);
|
||||
const resBtnRef = useRef(null);
|
||||
|
||||
// ── Textarea auto-grow ──
|
||||
const textareaRef = useRef(null);
|
||||
const resultImgRef = useRef(null);
|
||||
|
||||
// Derive effective history (prop wins over internal)
|
||||
const history = historyItems != null ? historyItems : internalHistory;
|
||||
|
||||
const formatSummaryValue = () =>
|
||||
`${settings.lens}, ${settings.focal}mm, ${settings.aperture}`;
|
||||
|
||||
// ── Textarea auto-height ──
|
||||
const handleTextareaInput = (e) => {
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
setSettings(prev => ({ ...prev, prompt: el.value }));
|
||||
};
|
||||
|
||||
// ── Generate ──
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const basePrompt = settings.prompt.trim();
|
||||
if (!basePrompt || isGenerating) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
const finalPrompt = buildNanoBananaPrompt(
|
||||
basePrompt,
|
||||
settings.camera,
|
||||
settings.lens,
|
||||
settings.focal,
|
||||
settings.aperture
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await generateImage(apiKey, {
|
||||
model: 'nano-banana-pro',
|
||||
prompt: finalPrompt,
|
||||
aspect_ratio: settings.aspect_ratio,
|
||||
resolution: resolution.toLowerCase(),
|
||||
negative_prompt: 'blurry, low quality, distortion, bad composition'
|
||||
});
|
||||
|
||||
if (res && res.url) {
|
||||
const entry = {
|
||||
url: res.url,
|
||||
timestamp: Date.now(),
|
||||
settings: {
|
||||
prompt: basePrompt,
|
||||
camera: settings.camera,
|
||||
lens: settings.lens,
|
||||
focal: settings.focal,
|
||||
aperture: settings.aperture,
|
||||
aspect_ratio: settings.aspect_ratio,
|
||||
resolution
|
||||
}
|
||||
};
|
||||
|
||||
// Only update internal history if not using prop-driven history
|
||||
if (historyItems == null) {
|
||||
setInternalHistory(prev => [entry, ...prev].slice(0, 50));
|
||||
}
|
||||
|
||||
setActiveHistoryIndex(0);
|
||||
setCanvasUrl(res.url);
|
||||
|
||||
if (onGenerationComplete) {
|
||||
onGenerationComplete({
|
||||
url: res.url,
|
||||
model: 'nano-banana-pro',
|
||||
prompt: basePrompt,
|
||||
type: 'cinema'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('No data returned');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Generation Failed: ' + e.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [settings, resolution, apiKey, isGenerating, onGenerationComplete, historyItems]);
|
||||
|
||||
// ── Regenerate ──
|
||||
const handleRegenerate = useCallback(() => {
|
||||
setCanvasUrl(null);
|
||||
// Small delay then generate
|
||||
setTimeout(() => handleGenerate(), 300);
|
||||
}, [handleGenerate]);
|
||||
|
||||
// ── Download ──
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!canvasUrl) return;
|
||||
try {
|
||||
const response = await fetch(canvasUrl);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `cinema-shot-${Date.now()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
window.open(canvasUrl, '_blank');
|
||||
}
|
||||
}, [canvasUrl]);
|
||||
|
||||
// ── Load history item ──
|
||||
const loadHistoryItem = (entry, idx) => {
|
||||
if (entry.settings) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
camera: entry.settings.camera ?? prev.camera,
|
||||
lens: entry.settings.lens ?? prev.lens,
|
||||
focal: entry.settings.focal ?? prev.focal,
|
||||
aperture: entry.settings.aperture ?? prev.aperture,
|
||||
aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio,
|
||||
prompt: entry.settings.prompt ?? prev.prompt
|
||||
}));
|
||||
if (entry.settings.resolution) setResolution(entry.settings.resolution);
|
||||
|
||||
// Sync textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.value = entry.settings.prompt || '';
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
setActiveHistoryIndex(idx);
|
||||
setCanvasUrl(entry.url);
|
||||
};
|
||||
|
||||
const resetToPrompt = () => {
|
||||
setCanvasUrl(null);
|
||||
setSettings(prev => ({ ...prev, prompt: '' }));
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.value = '';
|
||||
textareaRef.current.style.height = 'auto';
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}
|
||||
};
|
||||
|
||||
const showCanvas = canvasUrl !== null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-black relative overflow-hidden">
|
||||
|
||||
{/* ── 1. Hero Section (Empty State) ── */}
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center text-center px-4 animate-fade-in-up transition-all duration-700 ${showCanvas ? 'opacity-0 pointer-events-none scale-95' : 'opacity-100 scale-100'}`}
|
||||
>
|
||||
<div className="mb-4 text-xs font-bold text-white/40 tracking-[0.2em] uppercase">
|
||||
Cinema Studio 2.0
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-b from-white to-white/50 tracking-tight leading-tight mb-2">
|
||||
What would you shoot<br />with infinite budget?
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Canvas Area (Result View) ── */}
|
||||
<div
|
||||
className={`absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-30 transition-all duration-1000 bg-black/90 backdrop-blur-3xl ${showCanvas ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-10 scale-95 pointer-events-none'}`}
|
||||
>
|
||||
<div className="relative group max-w-full max-h-[70vh] flex items-center justify-center">
|
||||
{canvasUrl && (
|
||||
<img
|
||||
ref={resultImgRef}
|
||||
src={canvasUrl}
|
||||
alt="Generated cinema shot"
|
||||
className="max-h-[60vh] max-w-[90vw] rounded-2xl shadow-2xl border border-white/10 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Canvas Controls */}
|
||||
<div
|
||||
className={`mt-8 flex gap-3 justify-center transition-opacity duration-500 delay-500 ${showCanvas ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleRegenerate()}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide transition-all border border-white/5 backdrop-blur-lg text-white hover:border-white/20"
|
||||
>
|
||||
↻ Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="bg-[#d9ff00] text-black px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-wide hover:bg-white transition-colors shadow-glow-sm hover:scale-105 active:scale-95"
|
||||
>
|
||||
↓ Download
|
||||
</button>
|
||||
<button
|
||||
onClick={resetToPrompt}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide transition-all border border-white/5 backdrop-blur-lg text-white hover:border-white/20"
|
||||
>
|
||||
+ New Shot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Floating Prompt Bar ── */}
|
||||
<div
|
||||
className={`absolute bottom-8 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-4xl z-30 transition-all duration-700 ${showCanvas ? 'opacity-0 pointer-events-none translate-y-20' : 'opacity-100 translate-y-0'}`}
|
||||
>
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-[2rem] p-4 flex justify-between shadow-3xl items-end relative">
|
||||
|
||||
{/* Left Column */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1 px-1">
|
||||
{/* Input Row */}
|
||||
<div className="flex items-start gap-3 w-full">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Describe your scene - use @ to add characters & props"
|
||||
className="flex-1 bg-transparent border-none text-white text-lg font-medium placeholder:text-white/20 focus:outline-none resize-none h-[28px] leading-relaxed overflow-hidden"
|
||||
rows={1}
|
||||
onInput={handleTextareaInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Toolbar */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Aspect Ratio Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={arBtnRef}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5"
|
||||
onClick={() => setOpenDropdown(d => d === 'ar' ? null : 'ar')}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="10" rx="2" ry="2" />
|
||||
</svg>
|
||||
{settings.aspect_ratio}
|
||||
</button>
|
||||
{openDropdown === 'ar' && (
|
||||
<Dropdown
|
||||
items={ASPECT_RATIOS}
|
||||
selected={settings.aspect_ratio}
|
||||
onSelect={(val) => setSettings(prev => ({ ...prev, aspect_ratio: val }))}
|
||||
triggerRef={arBtnRef}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolution Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={resBtnRef}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5"
|
||||
onClick={() => setOpenDropdown(d => d === 'res' ? null : 'res')}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
</svg>
|
||||
{resolution}
|
||||
</button>
|
||||
{openDropdown === 'res' && (
|
||||
<Dropdown
|
||||
items={RESOLUTIONS}
|
||||
selected={resolution}
|
||||
onSelect={setResolution}
|
||||
triggerRef={resBtnRef}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Group */}
|
||||
<div className="flex items-center gap-2 h-full self-end mb-1">
|
||||
{/* Summary Card (triggers overlay) */}
|
||||
<button
|
||||
className="flex flex-col items-start justify-center px-4 py-2 bg-[#2a2a2a] rounded-xl border border-white/5 hover:border-white/20 transition-colors text-left flex-1 min-w-[100px] md:min-w-[140px] max-w-[240px] h-[56px] relative group overflow-hidden"
|
||||
onClick={() => setIsOverlayOpen(true)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 w-2 h-2 bg-primary rounded-full shadow-glow-sm" />
|
||||
<span className="text-[10px] font-bold text-white uppercase truncate w-full tracking-wide">
|
||||
{settings.camera}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-white/60 truncate w-full">
|
||||
{formatSummaryValue()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
className="h-[56px] px-8 bg-[#d9ff00] text-black rounded-xl font-black text-xs uppercase hover:bg-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isGenerating || !settings.prompt.trim()}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{isGenerating ? 'SHOOTING...' : 'GENERATE ✨'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 4. History Sidebar ── */}
|
||||
<div className="fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500">
|
||||
<div className="text-[9px] font-bold text-white/40 uppercase tracking-widest mb-2">
|
||||
History
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full px-2">
|
||||
{history.map((entry, idx) => (
|
||||
<div
|
||||
key={entry.timestamp ?? idx}
|
||||
className={`relative group/thumb cursor-pointer rounded-lg overflow-hidden border-2 transition-all duration-300 aspect-square ${idx === activeHistoryIndex ? 'border-[#d9ff00] shadow-glow-sm' : 'border-white/10 hover:border-white/30'}`}
|
||||
onClick={() => loadHistoryItem(entry, idx)}
|
||||
>
|
||||
<img
|
||||
src={entry.url}
|
||||
alt={`History item ${idx + 1}`}
|
||||
className="w-full h-full object-cover opacity-80 group-hover/thumb:opacity-100 transition-opacity"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<span className="text-[8px] font-bold text-white uppercase">Load</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 5. Camera Controls Overlay ── */}
|
||||
<CameraControlsOverlay
|
||||
isOpen={isOverlayOpen}
|
||||
onClose={() => setIsOverlayOpen(false)}
|
||||
settings={settings}
|
||||
onSettingsChange={setSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1138
packages/studio/src/components/ImageStudio.jsx
Normal file
1138
packages/studio/src/components/ImageStudio.jsx
Normal file
File diff suppressed because it is too large
Load diff
727
packages/studio/src/components/LipSyncStudio.jsx
Normal file
727
packages/studio/src/components/LipSyncStudio.jsx
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { processLipSync, uploadFile } from '../muapi.js';
|
||||
import {
|
||||
lipsyncModels,
|
||||
imageLipSyncModels,
|
||||
videoLipSyncModels,
|
||||
getLipSyncModelById,
|
||||
getResolutionsForLipSyncModel,
|
||||
} from '../models.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload button states
|
||||
// ---------------------------------------------------------------------------
|
||||
const UPLOAD_STATE = {
|
||||
IDLE: 'idle',
|
||||
UPLOADING: 'uploading',
|
||||
READY: 'ready',
|
||||
};
|
||||
|
||||
function MediaPickerButton({ accept, label, icon, onUpload, onClear, uploadState, fileName, apiKey }) {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (uploadState === UPLOAD_STATE.READY) {
|
||||
onClear();
|
||||
return;
|
||||
}
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleChange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
await onUpload(file);
|
||||
};
|
||||
|
||||
const borderClass =
|
||||
uploadState === UPLOAD_STATE.READY
|
||||
? 'border-primary/60 bg-primary/10'
|
||||
: 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-primary/40';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
uploadState === UPLOAD_STATE.READY
|
||||
? `${fileName} — click to clear`
|
||||
: `Upload ${label.toLowerCase()} file`
|
||||
}
|
||||
onClick={handleClick}
|
||||
className={`flex-shrink-0 w-14 h-14 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden group ${borderClass}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{/* Idle state */}
|
||||
{uploadState === UPLOAD_STATE.IDLE && (
|
||||
<div className="flex flex-col items-center justify-center gap-1 w-full h-full">
|
||||
{icon}
|
||||
<span className="text-[9px] text-muted group-hover:text-primary font-bold transition-colors">
|
||||
{label.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading spinner */}
|
||||
{uploadState === UPLOAD_STATE.UPLOADING && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<span className="animate-spin text-primary text-sm">◌</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ready state */}
|
||||
{uploadState === UPLOAD_STATE.READY && (
|
||||
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10">
|
||||
{icon}
|
||||
<span className="text-[9px] text-primary font-bold">READY</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
|
||||
const dropRef = useRef(null);
|
||||
const [style, setStyle] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !anchorRef?.current || !dropRef.current) return;
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const ddHeight = dropRef.current.offsetHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
|
||||
let top, bottom, maxHeight;
|
||||
if (spaceBelow >= ddHeight || spaceBelow >= spaceAbove) {
|
||||
top = rect.bottom + 8;
|
||||
bottom = 'auto';
|
||||
maxHeight = Math.max(150, spaceBelow - 8);
|
||||
} else {
|
||||
top = 'auto';
|
||||
bottom = window.innerHeight - rect.top + 8;
|
||||
maxHeight = Math.max(150, spaceAbove - 8);
|
||||
}
|
||||
const left = Math.min(rect.left, window.innerWidth - 220);
|
||||
setStyle({ top, bottom, left, maxHeight });
|
||||
}, [isOpen, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e) => {
|
||||
if (!dropRef.current?.contains(e.target) && !anchorRef?.current?.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handler);
|
||||
return () => window.removeEventListener('click', handler);
|
||||
}, [isOpen, onClose, anchorRef]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
style={{ position: 'fixed', zIndex: 100, minWidth: 200, overflowY: 'auto', ...style }}
|
||||
className="bg-[#111] border border-white/10 rounded-2xl shadow-3xl p-2 custom-scrollbar"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => { onSelect(item); onClose(); }}
|
||||
className={`w-full text-left px-4 py-2.5 rounded-xl text-sm transition-all hover:bg-white/10 ${
|
||||
item.id === selectedId ? 'text-primary font-bold bg-primary/5' : 'text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<div>{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{item.description.slice(0, 60)}...
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History sidebar thumbnail
|
||||
// ---------------------------------------------------------------------------
|
||||
function HistoryThumb({ entry, isActive, onSelect, onDownload }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${
|
||||
isActive ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'
|
||||
}`}
|
||||
>
|
||||
<video src={entry.url} preload="metadata" muted className="w-full aspect-square object-cover" />
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(entry); }}
|
||||
className="p-1.5 bg-primary rounded-lg text-black hover:scale-110 transition-transform"
|
||||
title="Download"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG icons
|
||||
// ---------------------------------------------------------------------------
|
||||
const MicIcon = ({ className = 'text-muted group-hover:text-primary transition-colors' }) => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className}>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoIcon = ({ className = 'text-muted group-hover:text-primary transition-colors' }) => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className}>
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function LipSyncStudio({ apiKey, onGenerationComplete, historyItems }) {
|
||||
// ── Mode & model state ──────────────────────────────────────────────────
|
||||
const [inputMode, setInputMode] = useState('image'); // 'image' | 'video'
|
||||
|
||||
const currentModels = inputMode === 'image' ? imageLipSyncModels : videoLipSyncModels;
|
||||
const firstModel = currentModels[0];
|
||||
|
||||
const [selectedModelId, setSelectedModelId] = useState(firstModel?.id ?? '');
|
||||
const [selectedResolution, setSelectedResolution] = useState(
|
||||
firstModel?.inputs?.resolution?.default ?? '480p'
|
||||
);
|
||||
|
||||
// ── Upload state ────────────────────────────────────────────────────────
|
||||
const [imageState, setImageState] = useState(UPLOAD_STATE.IDLE);
|
||||
const [imageName, setImageName] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
|
||||
const [videoState, setVideoState] = useState(UPLOAD_STATE.IDLE);
|
||||
const [videoName, setVideoName] = useState('');
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
|
||||
const [audioState, setAudioState] = useState(UPLOAD_STATE.IDLE);
|
||||
const [audioName, setAudioName] = useState('');
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
|
||||
// ── Prompt ──────────────────────────────────────────────────────────────
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
// ── Generation / UI state ───────────────────────────────────────────────
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generateError, setGenerateError] = useState(null);
|
||||
const [view, setView] = useState('input'); // 'input' | 'result'
|
||||
const [activeResultUrl, setActiveResultUrl] = useState(null);
|
||||
|
||||
// ── History ─────────────────────────────────────────────────────────────
|
||||
// If historyItems prop is provided, use it; otherwise use internal state.
|
||||
const [internalHistory, setInternalHistory] = useState([]);
|
||||
const history = historyItems ?? internalHistory;
|
||||
const [activeHistoryIdx, setActiveHistoryIdx] = useState(0);
|
||||
|
||||
// ── Dropdown state ──────────────────────────────────────────────────────
|
||||
const [openDropdown, setOpenDropdown] = useState(null); // 'model' | 'resolution' | null
|
||||
const modelBtnRef = useRef(null);
|
||||
const resolutionBtnRef = useRef(null);
|
||||
|
||||
// ── Video ref for result ────────────────────────────────────────────────
|
||||
const resultVideoRef = useRef(null);
|
||||
|
||||
// ── Derived model info ──────────────────────────────────────────────────
|
||||
const selectedModel = lipsyncModels.find((m) => m.id === selectedModelId);
|
||||
const resolutionOptions = getResolutionsForLipSyncModel(selectedModelId);
|
||||
const showResolution = resolutionOptions.length > 0;
|
||||
const showPrompt = !!selectedModel?.hasPrompt;
|
||||
|
||||
// ── Sync model when mode changes ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const models = inputMode === 'image' ? imageLipSyncModels : videoLipSyncModels;
|
||||
const first = models[0];
|
||||
if (!first) return;
|
||||
setSelectedModelId(first.id);
|
||||
setSelectedResolution(first.inputs?.resolution?.default ?? '480p');
|
||||
}, [inputMode]);
|
||||
|
||||
// ── Upload handlers ─────────────────────────────────────────────────────
|
||||
const handleImageUpload = useCallback(async (file) => {
|
||||
setImageState(UPLOAD_STATE.UPLOADING);
|
||||
try {
|
||||
const url = await uploadFile(apiKey, file);
|
||||
setImageUrl(url);
|
||||
setImageName(file.name);
|
||||
setImageState(UPLOAD_STATE.READY);
|
||||
} catch (err) {
|
||||
setImageState(UPLOAD_STATE.IDLE);
|
||||
alert(`Image upload failed: ${err.message}`);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
const handleVideoPick = useCallback(async (file) => {
|
||||
setVideoState(UPLOAD_STATE.UPLOADING);
|
||||
try {
|
||||
const url = await uploadFile(apiKey, file);
|
||||
setVideoUrl(url);
|
||||
setVideoName(file.name);
|
||||
setVideoState(UPLOAD_STATE.READY);
|
||||
} catch (err) {
|
||||
setVideoState(UPLOAD_STATE.IDLE);
|
||||
alert(`Video upload failed: ${err.message}`);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
const handleAudioPick = useCallback(async (file) => {
|
||||
setAudioState(UPLOAD_STATE.UPLOADING);
|
||||
try {
|
||||
const url = await uploadFile(apiKey, file);
|
||||
setAudioUrl(url);
|
||||
setAudioName(file.name);
|
||||
setAudioState(UPLOAD_STATE.READY);
|
||||
} catch (err) {
|
||||
setAudioState(UPLOAD_STATE.IDLE);
|
||||
alert(`Audio upload failed: ${err.message}`);
|
||||
}
|
||||
}, [apiKey]);
|
||||
|
||||
// ── Mode toggle ─────────────────────────────────────────────────────────
|
||||
const switchToImage = () => {
|
||||
if (inputMode === 'image') return;
|
||||
setInputMode('image');
|
||||
setVideoUrl(null);
|
||||
setVideoState(UPLOAD_STATE.IDLE);
|
||||
setVideoName('');
|
||||
};
|
||||
|
||||
const switchToVideo = () => {
|
||||
if (inputMode === 'video') return;
|
||||
setInputMode('video');
|
||||
setImageUrl(null);
|
||||
setImageState(UPLOAD_STATE.IDLE);
|
||||
setImageName('');
|
||||
};
|
||||
|
||||
// ── Model selection ─────────────────────────────────────────────────────
|
||||
const handleModelSelect = (model) => {
|
||||
setSelectedModelId(model.id);
|
||||
const resolutions = getResolutionsForLipSyncModel(model.id);
|
||||
if (resolutions.length > 0) {
|
||||
setSelectedResolution(model.inputs?.resolution?.default ?? resolutions[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// ── History helpers ─────────────────────────────────────────────────────
|
||||
const addToInternalHistory = useCallback((entry) => {
|
||||
setInternalHistory((prev) => [entry, ...prev].slice(0, 30));
|
||||
}, []);
|
||||
|
||||
const downloadFile = async (url, filename) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Generation ──────────────────────────────────────────────────────────
|
||||
const handleGenerate = async () => {
|
||||
if (!audioUrl) { alert('Please upload an audio file first.'); return; }
|
||||
if (inputMode === 'image' && !imageUrl) { alert('Please upload a portrait image first.'); return; }
|
||||
if (inputMode === 'video' && !videoUrl) { alert('Please upload a source video first.'); return; }
|
||||
|
||||
setIsGenerating(true);
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const lipsyncParams = {
|
||||
model: selectedModelId,
|
||||
audio_url: audioUrl,
|
||||
};
|
||||
if (inputMode === 'image') lipsyncParams.image_url = imageUrl;
|
||||
else lipsyncParams.video_url = videoUrl;
|
||||
if (prompt && selectedModel?.hasPrompt) lipsyncParams.prompt = prompt;
|
||||
if (showResolution) lipsyncParams.resolution = selectedResolution;
|
||||
if (selectedModel?.hasSeed) lipsyncParams.seed = -1;
|
||||
|
||||
const res = await processLipSync(apiKey, lipsyncParams);
|
||||
|
||||
if (!res?.url) throw new Error('No video URL returned by API');
|
||||
|
||||
const genId = res.id || Date.now().toString();
|
||||
const entry = {
|
||||
id: genId,
|
||||
url: res.url,
|
||||
prompt,
|
||||
model: selectedModelId,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (!historyItems) addToInternalHistory(entry);
|
||||
|
||||
setActiveResultUrl(res.url);
|
||||
setActiveHistoryIdx(0);
|
||||
setView('result');
|
||||
|
||||
if (onGenerationComplete) {
|
||||
onGenerationComplete({ url: res.url, model: selectedModelId, prompt, type: 'lipsync' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LipSyncStudio]', e);
|
||||
setGenerateError(e.message?.slice(0, 80) ?? 'Unknown error');
|
||||
setTimeout(() => setGenerateError(null), 4000);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Reset to input view ─────────────────────────────────────────────────
|
||||
const handleNew = () => {
|
||||
setView('input');
|
||||
setActiveResultUrl(null);
|
||||
setPrompt('');
|
||||
setImageUrl(null); setImageState(UPLOAD_STATE.IDLE); setImageName('');
|
||||
setVideoUrl(null); setVideoState(UPLOAD_STATE.IDLE); setVideoName('');
|
||||
setAudioUrl(null); setAudioState(UPLOAD_STATE.IDLE); setAudioName('');
|
||||
};
|
||||
|
||||
// ── Media status labels ─────────────────────────────────────────────────
|
||||
const mediaStatusText = inputMode === 'image'
|
||||
? (imageState === UPLOAD_STATE.READY ? `✓ ${imageName}` : 'No image')
|
||||
: (videoState === UPLOAD_STATE.READY ? `✓ ${videoName}` : 'No video');
|
||||
const mediaStatusClass = (inputMode === 'image' ? imageState : videoState) === UPLOAD_STATE.READY
|
||||
? 'text-primary' : 'text-muted';
|
||||
|
||||
const audioStatusText = audioState === UPLOAD_STATE.READY ? `✓ ${audioName}` : 'No audio';
|
||||
const audioStatusClass = audioState === UPLOAD_STATE.READY ? 'text-primary' : 'text-muted';
|
||||
|
||||
const hasHistory = history.length > 0;
|
||||
|
||||
// ── Dropdown item lists ─────────────────────────────────────────────────
|
||||
const modelDropdownItems = currentModels;
|
||||
const resolutionDropdownItems = resolutionOptions.map((r) => ({ id: r, name: r }));
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden">
|
||||
|
||||
{/* ── History sidebar ── */}
|
||||
{hasHistory && (
|
||||
<div className="fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500">
|
||||
<div className="text-[9px] font-bold text-muted uppercase tracking-widest mb-2">History</div>
|
||||
<div className="flex flex-col gap-2 w-full px-2">
|
||||
{history.map((entry, idx) => (
|
||||
<HistoryThumb
|
||||
key={entry.id ?? idx}
|
||||
entry={entry}
|
||||
isActive={idx === activeHistoryIdx}
|
||||
onSelect={() => {
|
||||
setActiveResultUrl(entry.url);
|
||||
setActiveHistoryIdx(idx);
|
||||
setView('result');
|
||||
}}
|
||||
onDownload={(e) => downloadFile(e.url, `lipsync-${e.id ?? idx}.mp4`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Input view ── */}
|
||||
{view === 'input' && (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center mb-10 md:mb-20 animate-fade-in-up transition-all duration-700">
|
||||
<div className="mb-10 relative group">
|
||||
<div className="absolute inset-0 bg-primary/20 blur-[100px] rounded-full opacity-40 group-hover:opacity-70 transition-opacity duration-1000" />
|
||||
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-teal-900/40 rounded-3xl flex items-center justify-center border border-white/5 overflow-hidden">
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-primary opacity-20 absolute -right-4 -bottom-4">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</svg>
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center border border-primary/20 shadow-glow relative z-10">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 text-primary animate-pulse">🎙</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">
|
||||
Lip Sync
|
||||
</h1>
|
||||
<p className="text-secondary text-sm font-medium tracking-wide opacity-60">
|
||||
Animate portraits or sync lips to audio with AI
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="w-full max-w-4xl relative z-40 animate-fade-in-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="w-full bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-5 flex flex-col gap-3 md:gap-5 shadow-3xl">
|
||||
|
||||
{/* Mode toggle row */}
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<span className="text-xs text-muted font-bold uppercase tracking-widest mr-2">Input:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToImage}
|
||||
className={`px-4 py-1.5 rounded-xl text-xs font-bold transition-all border ${
|
||||
inputMode === 'image'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-white/10 text-muted hover:border-white/30 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
🖼 Portrait Image
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToVideo}
|
||||
className={`px-4 py-1.5 rounded-xl text-xs font-bold transition-all border ${
|
||||
inputMode === 'video'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-white/10 text-muted hover:border-white/30 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
🎬 Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Uploads row */}
|
||||
<div className="flex items-start gap-3 px-2">
|
||||
{/* Image picker — only in image mode */}
|
||||
{inputMode === 'image' && (
|
||||
<MediaPickerButton
|
||||
accept="image/*"
|
||||
label="Image"
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted group-hover:text-primary transition-colors">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
}
|
||||
onUpload={handleImageUpload}
|
||||
onClear={() => { setImageUrl(null); setImageState(UPLOAD_STATE.IDLE); setImageName(''); }}
|
||||
uploadState={imageState}
|
||||
fileName={imageName}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video picker — only in video mode */}
|
||||
{inputMode === 'video' && (
|
||||
<MediaPickerButton
|
||||
accept="video/*"
|
||||
label="Video"
|
||||
icon={<VideoIcon />}
|
||||
onUpload={handleVideoPick}
|
||||
onClear={() => { setVideoUrl(null); setVideoState(UPLOAD_STATE.IDLE); setVideoName(''); }}
|
||||
uploadState={videoState}
|
||||
fileName={videoName}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Audio picker — always visible */}
|
||||
<MediaPickerButton
|
||||
accept="audio/*"
|
||||
label="Audio"
|
||||
icon={<MicIcon />}
|
||||
onUpload={handleAudioPick}
|
||||
onClear={() => { setAudioUrl(null); setAudioState(UPLOAD_STATE.IDLE); setAudioName(''); }}
|
||||
uploadState={audioState}
|
||||
fileName={audioName}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
|
||||
{/* Prompt textarea */}
|
||||
{showPrompt && (
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Optional: describe the talking style or motion..."
|
||||
className="flex-1 bg-transparent text-white placeholder-muted/50 text-sm resize-none outline-none min-h-[56px] leading-relaxed pt-1"
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status labels */}
|
||||
<div className="flex items-center gap-3 px-2 text-xs text-muted">
|
||||
<span className={mediaStatusClass}>{mediaStatusText}</span>
|
||||
<span>·</span>
|
||||
<span className={audioStatusClass}>{audioStatusText}</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom controls row */}
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap px-2">
|
||||
{/* Model selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={modelBtnRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenDropdown(openDropdown === 'model' ? null : 'model');
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-primary/40 transition-all text-xs font-bold text-white group"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
<span>{selectedModel?.name ?? 'Select model'}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted group-hover:text-white transition-colors">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<Dropdown
|
||||
isOpen={openDropdown === 'model'}
|
||||
items={modelDropdownItems}
|
||||
selectedId={selectedModelId}
|
||||
onSelect={handleModelSelect}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
anchorRef={modelBtnRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resolution selector */}
|
||||
{showResolution && (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={resolutionBtnRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenDropdown(openDropdown === 'resolution' ? null : 'resolution');
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-primary/40 transition-all text-xs font-bold text-white group"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
<span>{selectedResolution}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted group-hover:text-white transition-colors">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<Dropdown
|
||||
isOpen={openDropdown === 'resolution'}
|
||||
items={resolutionDropdownItems}
|
||||
selectedId={selectedResolution}
|
||||
onSelect={(item) => setSelectedResolution(item.id)}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
anchorRef={resolutionBtnRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="ml-auto px-6 py-2.5 bg-primary text-black font-black text-sm rounded-2xl hover:scale-105 active:scale-95 transition-all shadow-glow disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<><span className="animate-spin inline-block mr-2 text-black">◌</span>Generating...</>
|
||||
) : generateError ? (
|
||||
`Error: ${generateError}`
|
||||
) : (
|
||||
'Generate ✨'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Result canvas view ── */}
|
||||
{view === 'result' && activeResultUrl && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-10 transition-all duration-1000">
|
||||
<div className="relative group">
|
||||
<video
|
||||
ref={resultVideoRef}
|
||||
src={activeResultUrl}
|
||||
className="max-h-[60vh] max-w-[80vw] rounded-3xl shadow-3xl border border-white/10 interactive-glow object-contain"
|
||||
controls
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white disabled:opacity-50"
|
||||
>
|
||||
↻ Regenerate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const entry = history.find((e) => e.url === activeResultUrl);
|
||||
downloadFile(activeResultUrl, `lipsync-${entry?.id ?? 'clip'}.mp4`);
|
||||
}}
|
||||
className="bg-primary text-black px-6 py-2.5 rounded-2xl text-xs font-bold transition-all shadow-glow active:scale-95"
|
||||
>
|
||||
↓ Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNew}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
993
packages/studio/src/components/VideoStudio.jsx
Normal file
993
packages/studio/src/components/VideoStudio.jsx
Normal file
|
|
@ -0,0 +1,993 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { generateVideo, generateI2V, uploadFile } from '../muapi.js';
|
||||
import {
|
||||
t2vModels,
|
||||
i2vModels,
|
||||
v2vModels,
|
||||
getAspectRatiosForVideoModel,
|
||||
getDurationsForModel,
|
||||
getResolutionsForVideoModel,
|
||||
getAspectRatiosForI2VModel,
|
||||
getDurationsForI2VModel,
|
||||
getResolutionsForI2VModel,
|
||||
getModesForModel,
|
||||
} from '../models.js';
|
||||
|
||||
// ── tiny helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getQualitiesForModel(modelList, modelId) {
|
||||
const model = modelList.find(m => m.id === modelId);
|
||||
return model?.inputs?.quality?.enum || [];
|
||||
}
|
||||
|
||||
async function downloadFile(url, filename) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// ── SVG icons (kept inline to avoid extra deps) ───────────────────────────────
|
||||
|
||||
const CheckSvg = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoIconSvg = ({ className }) => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className}>
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoReadySvg = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
<polyline points="7 10 10 13 15 8" stroke="#d9ff00" strokeWidth="2.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Dropdown components ───────────────────────────────────────────────────────
|
||||
|
||||
function DropdownItem({ label, selected, onClick }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100 capitalize">{label}</span>
|
||||
{selected && <CheckSvg />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDropdown({ imageMode, selectedModel, onSelect, onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const generationModels = imageMode ? i2vModels : t2vModels;
|
||||
|
||||
const lf = search.toLowerCase();
|
||||
const filteredMain = generationModels.filter(
|
||||
m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf)
|
||||
);
|
||||
const filteredV2V = v2vModels.filter(
|
||||
m => m.name.toLowerCase().includes(lf) || m.id.toLowerCase().includes(lf)
|
||||
);
|
||||
|
||||
const getIconColor = (m, isV2V) => {
|
||||
if (isV2V) return 'bg-orange-500/10 text-orange-400';
|
||||
if (m.id.includes('kling')) return 'bg-blue-500/10 text-blue-400';
|
||||
if (m.id.includes('veo')) return 'bg-purple-500/10 text-purple-400';
|
||||
if (m.id.includes('sora')) return 'bg-rose-500/10 text-rose-400';
|
||||
return 'bg-primary/10 text-primary';
|
||||
};
|
||||
|
||||
const renderItem = (m, isV2V = false) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedModel === m.id ? 'bg-white/5 border-white/5' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(m, isV2V); onClose(); }}
|
||||
>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className={`w-10 h-10 ${getIconColor(m, isV2V)} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase`}>
|
||||
{m.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-bold text-white tracking-tight">{m.name}</span>
|
||||
{isV2V && <span className="text-[9px] text-orange-400/70">Upload a video to use</span>}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel === m.id && <CheckSvg />}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-[70vh]">
|
||||
<div className="px-2 pb-3 mb-2 border-b border-white/5 shrink-0">
|
||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-2.5 border border-white/5 focus-within:border-primary/50 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-muted">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-transparent border-none text-xs text-white focus:ring-0 w-full p-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 shrink-0">
|
||||
Video models
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1 pb-2">
|
||||
{filteredMain.map(m => renderItem(m, false))}
|
||||
{filteredV2V.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] font-bold text-orange-400/70 uppercase tracking-widest px-3 py-2 mt-1 border-t border-white/5">
|
||||
Video Tools
|
||||
</div>
|
||||
{filteredV2V.map(m => renderItem(m, true))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Control button ────────────────────────────────────────────────────────────
|
||||
|
||||
function ControlBtn({ icon, label, onClick, style }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className="flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-xs font-bold text-white group-hover:text-primary transition-colors">{label}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dropdown panel ─────────────────────────────────────────────────────────────
|
||||
// Rendered inside a `relative` wrapper div; floats above the anchor button.
|
||||
|
||||
function DropdownPanel({ type, open, onClose, children }) {
|
||||
const isModel = type === 'model';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={`absolute bottom-[calc(100%+8px)] left-0 z-50 transition-all origin-bottom-left glass rounded-3xl p-3 shadow-4xl border border-white/10 flex flex-col ${isModel ? 'w-[calc(100vw-3rem)] max-w-xs' : 'w-52 max-w-[240px]'} ${open ? 'opacity-100 pointer-events-auto scale-100' : 'opacity-0 pointer-events-none scale-95'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function VideoStudio({ apiKey, onGenerationComplete, historyItems }) {
|
||||
// ── mode state ──
|
||||
const [imageMode, setImageMode] = useState(false); // i2v
|
||||
const [v2vMode, setV2vMode] = useState(false);
|
||||
|
||||
// ── model / params ──
|
||||
const defaultModel = t2vModels[0];
|
||||
const [selectedModel, setSelectedModel] = useState(defaultModel.id);
|
||||
const [selectedModelName, setSelectedModelName] = useState(defaultModel.name);
|
||||
const [selectedAr, setSelectedAr] = useState(defaultModel.inputs?.aspect_ratio?.default || '16:9');
|
||||
const [selectedDuration, setSelectedDuration] = useState(defaultModel.inputs?.duration?.default || 5);
|
||||
const [selectedResolution, setSelectedResolution] = useState(defaultModel.inputs?.resolution?.default || '');
|
||||
const [selectedQuality, setSelectedQuality] = useState(defaultModel.inputs?.quality?.default || '');
|
||||
const [selectedMode, setSelectedMode] = useState('');
|
||||
|
||||
// ── control visibility ──
|
||||
const [showAr, setShowAr] = useState(true);
|
||||
const [showDuration, setShowDuration] = useState(true);
|
||||
const [showResolution, setShowResolution] = useState(false);
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
const [showMode, setShowMode] = useState(false);
|
||||
|
||||
// ── uploads ──
|
||||
const [uploadedImageUrl, setUploadedImageUrl] = useState(null);
|
||||
const [uploadedImagePreview, setUploadedImagePreview] = useState(null);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [uploadedVideoUrl, setUploadedVideoUrl] = useState(null);
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
const [uploadedVideoName, setUploadedVideoName] = useState(null);
|
||||
|
||||
// ── generation / canvas ──
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generateError, setGenerateError] = useState(null);
|
||||
const [canvasUrl, setCanvasUrl] = useState(null);
|
||||
const [canvasModel, setCanvasModel] = useState(null);
|
||||
const [showCanvas, setShowCanvas] = useState(false);
|
||||
const [lastGenerationId, setLastGenerationId] = useState(null);
|
||||
const [lastGenerationModel, setLastGenerationModel] = useState(null);
|
||||
|
||||
// ── history ──
|
||||
const [localHistory, setLocalHistory] = useState([]);
|
||||
const [activeHistoryIdx, setActiveHistoryIdx] = useState(0);
|
||||
|
||||
// ── dropdown ──
|
||||
const [openDropdown, setOpenDropdown] = useState(null); // 'model'|'ar'|'duration'|'resolution'|'quality'|'mode'|null
|
||||
|
||||
// ── prompt ──
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [promptDisabled, setPromptDisabled] = useState(false);
|
||||
|
||||
// ── refs ──
|
||||
const containerRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const modelBtnRef = useRef(null);
|
||||
const arBtnRef = useRef(null);
|
||||
const durationBtnRef = useRef(null);
|
||||
const resolutionBtnRef = useRef(null);
|
||||
const qualityBtnRef = useRef(null);
|
||||
const modeBtnRef = useRef(null);
|
||||
const imageFileInputRef = useRef(null);
|
||||
const videoFileInputRef = useRef(null);
|
||||
const resultVideoRef = useRef(null);
|
||||
|
||||
// ── derived data ──
|
||||
const history = historyItems ?? localHistory;
|
||||
|
||||
const getCurrentModels = useCallback(() => {
|
||||
if (v2vMode) return v2vModels;
|
||||
return imageMode ? i2vModels : t2vModels;
|
||||
}, [imageMode, v2vMode]);
|
||||
|
||||
const getCurrentAspectRatios = useCallback((id) =>
|
||||
imageMode ? getAspectRatiosForI2VModel(id) : getAspectRatiosForVideoModel(id),
|
||||
[imageMode]);
|
||||
|
||||
const getCurrentDurations = useCallback((id) =>
|
||||
imageMode ? getDurationsForI2VModel(id) : getDurationsForModel(id),
|
||||
[imageMode]);
|
||||
|
||||
const getCurrentResolutions = useCallback((id) =>
|
||||
imageMode ? getResolutionsForI2VModel(id) : getResolutionsForVideoModel(id),
|
||||
[imageMode]);
|
||||
|
||||
const getCurrentModel = useCallback(() =>
|
||||
getCurrentModels().find(m => m.id === selectedModel),
|
||||
[getCurrentModels, selectedModel]);
|
||||
|
||||
// ── update controls when model/mode changes ──────────────────────────────
|
||||
const applyControlsForModel = useCallback((modelId, isImageMode, isV2vMode) => {
|
||||
if (isV2vMode) {
|
||||
setShowAr(false); setShowDuration(false); setShowResolution(false);
|
||||
setShowQuality(false); setShowMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const modelList = isImageMode ? i2vModels : t2vModels;
|
||||
const model = modelList.find(m => m.id === modelId);
|
||||
|
||||
const ars = isImageMode ? getAspectRatiosForI2VModel(modelId) : getAspectRatiosForVideoModel(modelId);
|
||||
if (ars.length > 0) { setSelectedAr(ars[0]); setShowAr(true); } else { setShowAr(false); }
|
||||
|
||||
const durations = isImageMode ? getDurationsForI2VModel(modelId) : getDurationsForModel(modelId);
|
||||
if (durations.length > 0) { setSelectedDuration(durations[0]); setShowDuration(true); } else { setShowDuration(false); }
|
||||
|
||||
const resolutions = isImageMode ? getResolutionsForI2VModel(modelId) : getResolutionsForVideoModel(modelId);
|
||||
if (resolutions.length > 0) { setSelectedResolution(resolutions[0]); setShowResolution(true); } else { setShowResolution(false); }
|
||||
|
||||
const qualities = getQualitiesForModel(modelList, modelId);
|
||||
if (qualities.length > 0) {
|
||||
setSelectedQuality(model?.inputs?.quality?.default || qualities[0]);
|
||||
setShowQuality(true);
|
||||
} else { setSelectedQuality(''); setShowQuality(false); }
|
||||
|
||||
const modes = getModesForModel(modelId);
|
||||
if (modes.length > 0) {
|
||||
setSelectedMode(model?.inputs?.mode?.default || modes[0]);
|
||||
setShowMode(true);
|
||||
} else { setSelectedMode(''); setShowMode(false); }
|
||||
}, []);
|
||||
|
||||
// Initialise controls for default model on mount
|
||||
useEffect(() => {
|
||||
applyControlsForModel(defaultModel.id, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── close dropdown on outside click ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handler = () => setOpenDropdown(null);
|
||||
window.addEventListener('click', handler);
|
||||
return () => window.removeEventListener('click', handler);
|
||||
}, []);
|
||||
|
||||
// ── textarea auto-resize ──────────────────────────────────────────────────
|
||||
const handlePromptInput = (e) => {
|
||||
setPrompt(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
const maxH = window.innerWidth < 768 ? 150 : 250;
|
||||
el.style.height = Math.min(el.scrollHeight, maxH) + 'px';
|
||||
};
|
||||
|
||||
// ── image upload ─────────────────────────────────────────────────────────
|
||||
const handleImageFileChange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const url = await uploadFile(apiKey, file);
|
||||
setUploadedImageUrl(url);
|
||||
setUploadedImagePreview(URL.createObjectURL(file));
|
||||
|
||||
// Clear v2v if active
|
||||
setUploadedVideoUrl(null);
|
||||
setUploadedVideoName(null);
|
||||
setV2vMode(false);
|
||||
|
||||
if (!imageMode) {
|
||||
const firstI2V = i2vModels[0];
|
||||
setImageMode(true);
|
||||
setSelectedModel(firstI2V.id);
|
||||
setSelectedModelName(firstI2V.name);
|
||||
applyControlsForModel(firstI2V.id, true, false);
|
||||
}
|
||||
setPromptDisabled(false);
|
||||
} catch (err) {
|
||||
console.error('[VideoStudio] Image upload failed:', err);
|
||||
alert(`Image upload failed: ${err.message}`);
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
if (imageFileInputRef.current) imageFileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const clearImageUpload = () => {
|
||||
setUploadedImageUrl(null);
|
||||
setUploadedImagePreview(null);
|
||||
setImageMode(false);
|
||||
const first = t2vModels[0];
|
||||
setSelectedModel(first.id);
|
||||
setSelectedModelName(first.name);
|
||||
applyControlsForModel(first.id, false, false);
|
||||
setPromptDisabled(false);
|
||||
};
|
||||
|
||||
// ── video upload ─────────────────────────────────────────────────────────
|
||||
const handleVideoFileChange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setVideoUploading(true);
|
||||
try {
|
||||
const url = await uploadFile(apiKey, file);
|
||||
setUploadedVideoUrl(url);
|
||||
setUploadedVideoName(file.name);
|
||||
|
||||
// Clear image mode if active
|
||||
if (imageMode) {
|
||||
setUploadedImageUrl(null);
|
||||
setUploadedImagePreview(null);
|
||||
setImageMode(false);
|
||||
}
|
||||
setV2vMode(true);
|
||||
const firstV2V = v2vModels[0];
|
||||
setSelectedModel(firstV2V.id);
|
||||
setSelectedModelName(firstV2V.name);
|
||||
applyControlsForModel(firstV2V.id, false, true);
|
||||
setPrompt('');
|
||||
setPromptDisabled(true);
|
||||
} catch (err) {
|
||||
console.error('[VideoStudio] Video upload failed:', err);
|
||||
alert(`Video upload failed: ${err.message}`);
|
||||
} finally {
|
||||
setVideoUploading(false);
|
||||
if (videoFileInputRef.current) videoFileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const clearVideoUpload = () => {
|
||||
setUploadedVideoUrl(null);
|
||||
setUploadedVideoName(null);
|
||||
setV2vMode(false);
|
||||
const first = t2vModels[0];
|
||||
setSelectedModel(first.id);
|
||||
setSelectedModelName(first.name);
|
||||
applyControlsForModel(first.id, false, false);
|
||||
setPromptDisabled(false);
|
||||
};
|
||||
|
||||
// ── model selection from dropdown ─────────────────────────────────────────
|
||||
const handleModelSelect = useCallback((m, isV2V) => {
|
||||
if (isV2V) {
|
||||
setV2vMode(true);
|
||||
setImageMode(false);
|
||||
setUploadedImageUrl(null);
|
||||
setUploadedImagePreview(null);
|
||||
setSelectedModel(m.id);
|
||||
setSelectedModelName(m.name);
|
||||
applyControlsForModel(m.id, false, true);
|
||||
setPrompt('');
|
||||
setPromptDisabled(true);
|
||||
} else {
|
||||
if (v2vMode) {
|
||||
setV2vMode(false);
|
||||
setUploadedVideoUrl(null);
|
||||
setUploadedVideoName(null);
|
||||
setPromptDisabled(false);
|
||||
}
|
||||
setSelectedModel(m.id);
|
||||
setSelectedModelName(m.name);
|
||||
applyControlsForModel(m.id, imageMode, false);
|
||||
}
|
||||
}, [v2vMode, imageMode, applyControlsForModel]);
|
||||
|
||||
// ── add to local history ──────────────────────────────────────────────────
|
||||
const addToLocalHistory = useCallback((entry) => {
|
||||
setLocalHistory(prev => [entry, ...prev].slice(0, 30));
|
||||
setActiveHistoryIdx(0);
|
||||
}, []);
|
||||
|
||||
// ── show result in canvas ─────────────────────────────────────────────────
|
||||
const showVideoInCanvas = useCallback((url, model) => {
|
||||
setCanvasUrl(url);
|
||||
setCanvasModel(model);
|
||||
setShowCanvas(true);
|
||||
}, []);
|
||||
|
||||
// ── generate ──────────────────────────────────────────────────────────────
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const currentModel = getCurrentModel();
|
||||
const isExtendMode = currentModel?.requiresRequestId;
|
||||
const trimmedPrompt = prompt.trim();
|
||||
|
||||
if (v2vMode) {
|
||||
if (!uploadedVideoUrl) { alert('Please upload a video first.'); return; }
|
||||
} else if (isExtendMode) {
|
||||
if (!lastGenerationId) { alert('No Seedance 2.0 generation found to extend. Generate a video first.'); return; }
|
||||
} else if (imageMode) {
|
||||
if (!uploadedImageUrl) { alert('Please upload a start frame image first.'); return; }
|
||||
} else {
|
||||
if (!trimmedPrompt) { alert('Please enter a prompt to generate a video.'); return; }
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
setGenerateError(null);
|
||||
|
||||
let hadError = false;
|
||||
|
||||
try {
|
||||
let res;
|
||||
|
||||
if (v2vMode) {
|
||||
// V2V: use generateVideo with video_url (the v2v models use the video endpoint)
|
||||
res = await generateVideo(apiKey, {
|
||||
model: selectedModel,
|
||||
video_url: uploadedVideoUrl,
|
||||
});
|
||||
if (!res?.url) throw new Error('No video URL returned by API');
|
||||
|
||||
const genId = res.id || Date.now().toString();
|
||||
setLastGenerationId(null);
|
||||
setLastGenerationModel(null);
|
||||
const entry = { id: genId, url: res.url, prompt: '', model: selectedModel, timestamp: new Date().toISOString() };
|
||||
addToLocalHistory(entry);
|
||||
showVideoInCanvas(res.url, selectedModel);
|
||||
if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: '', type: 'video' });
|
||||
|
||||
} else if (imageMode) {
|
||||
const i2vParams = { model: selectedModel, image_url: uploadedImageUrl };
|
||||
if (trimmedPrompt) i2vParams.prompt = trimmedPrompt;
|
||||
i2vParams.aspect_ratio = selectedAr;
|
||||
const durations = getDurationsForI2VModel(selectedModel);
|
||||
if (durations.length > 0) i2vParams.duration = selectedDuration;
|
||||
const resolutions = getResolutionsForI2VModel(selectedModel);
|
||||
if (resolutions.length > 0) i2vParams.resolution = selectedResolution;
|
||||
if (selectedQuality) i2vParams.quality = selectedQuality;
|
||||
if (selectedMode) i2vParams.mode = selectedMode;
|
||||
|
||||
res = await generateI2V(apiKey, i2vParams);
|
||||
if (!res?.url) throw new Error('No video URL returned by API');
|
||||
|
||||
const genId = res.id || Date.now().toString();
|
||||
if (selectedModel === 'seedance-v2.0-i2v') {
|
||||
setLastGenerationId(genId);
|
||||
setLastGenerationModel(selectedModel);
|
||||
} else {
|
||||
setLastGenerationId(null);
|
||||
setLastGenerationModel(null);
|
||||
}
|
||||
const entry = { id: genId, url: res.url, prompt: trimmedPrompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration, timestamp: new Date().toISOString() };
|
||||
addToLocalHistory(entry);
|
||||
showVideoInCanvas(res.url, selectedModel);
|
||||
if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: trimmedPrompt, type: 'video' });
|
||||
|
||||
} else {
|
||||
// T2V (including extend mode)
|
||||
const params = { model: selectedModel };
|
||||
if (trimmedPrompt) params.prompt = trimmedPrompt;
|
||||
|
||||
if (isExtendMode) {
|
||||
params.request_id = lastGenerationId;
|
||||
} else {
|
||||
params.aspect_ratio = selectedAr;
|
||||
}
|
||||
|
||||
const durations = getDurationsForModel(selectedModel);
|
||||
if (durations.length > 0) params.duration = selectedDuration;
|
||||
const resolutions = getResolutionsForVideoModel(selectedModel);
|
||||
if (resolutions.length > 0) params.resolution = selectedResolution;
|
||||
if (selectedQuality) params.quality = selectedQuality;
|
||||
if (selectedMode) params.mode = selectedMode;
|
||||
|
||||
res = await generateVideo(apiKey, params);
|
||||
if (!res?.url) throw new Error('No video URL returned by API');
|
||||
|
||||
const genId = res.id || Date.now().toString();
|
||||
if (selectedModel === 'seedance-v2.0-t2v' || selectedModel === 'seedance-v2.0-i2v') {
|
||||
setLastGenerationId(genId);
|
||||
setLastGenerationModel(selectedModel);
|
||||
} else {
|
||||
setLastGenerationId(null);
|
||||
setLastGenerationModel(null);
|
||||
}
|
||||
const entry = { id: genId, url: res.url, prompt: trimmedPrompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration, timestamp: new Date().toISOString() };
|
||||
addToLocalHistory(entry);
|
||||
showVideoInCanvas(res.url, selectedModel);
|
||||
if (onGenerationComplete) onGenerationComplete({ url: res.url, model: selectedModel, prompt: trimmedPrompt, type: 'video' });
|
||||
}
|
||||
} catch (e) {
|
||||
hadError = true;
|
||||
console.error('[VideoStudio]', e);
|
||||
setGenerateError(e.message?.slice(0, 80) || 'Generation failed');
|
||||
setTimeout(() => setGenerateError(null), 4000);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [
|
||||
apiKey, prompt, v2vMode, imageMode, selectedModel, selectedAr, selectedDuration,
|
||||
selectedResolution, selectedQuality, selectedMode, uploadedImageUrl, uploadedVideoUrl,
|
||||
lastGenerationId, getCurrentModel, addToLocalHistory, showVideoInCanvas, onGenerationComplete,
|
||||
]);
|
||||
|
||||
// ── reset to prompt bar ───────────────────────────────────────────────────
|
||||
const resetToPromptBar = useCallback(() => {
|
||||
setShowCanvas(false);
|
||||
}, []);
|
||||
|
||||
const handleNewPrompt = useCallback(() => {
|
||||
resetToPromptBar();
|
||||
setPrompt('');
|
||||
setUploadedImageUrl(null);
|
||||
setUploadedImagePreview(null);
|
||||
setImageMode(false);
|
||||
setUploadedVideoUrl(null);
|
||||
setUploadedVideoName(null);
|
||||
setV2vMode(false);
|
||||
const first = t2vModels[0];
|
||||
setSelectedModel(first.id);
|
||||
setSelectedModelName(first.name);
|
||||
applyControlsForModel(first.id, false, false);
|
||||
setPromptDisabled(false);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [resetToPromptBar, applyControlsForModel]);
|
||||
|
||||
const handleExtend = useCallback(() => {
|
||||
if (!lastGenerationId) return;
|
||||
resetToPromptBar();
|
||||
setPrompt('');
|
||||
setUploadedImageUrl(null);
|
||||
setUploadedImagePreview(null);
|
||||
setImageMode(false);
|
||||
setSelectedModel('seedance-v2.0-extend');
|
||||
setSelectedModelName('Seedance 2.0 Extend');
|
||||
applyControlsForModel('seedance-v2.0-extend', false, false);
|
||||
setPromptDisabled(false);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [lastGenerationId, resetToPromptBar, applyControlsForModel]);
|
||||
|
||||
// ── derived UI values ────────────────────────────────────────────────────
|
||||
const isSeedance2Canvas = canvasModel === 'seedance-v2.0-t2v' || canvasModel === 'seedance-v2.0-i2v';
|
||||
const currentModelObj = getCurrentModel();
|
||||
const isExtendMode = currentModelObj?.requiresRequestId;
|
||||
|
||||
const promptPlaceholder = v2vMode
|
||||
? 'Video ready — click Generate to remove watermark'
|
||||
: imageMode
|
||||
? 'Describe the motion or effect (optional)'
|
||||
: isExtendMode
|
||||
? 'Optional: describe how to continue the video...'
|
||||
: 'Describe the video you want to create';
|
||||
|
||||
const toggleDropdown = (type) => (e) => {
|
||||
e.stopPropagation();
|
||||
setOpenDropdown(prev => prev === type ? null : type);
|
||||
};
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden"
|
||||
>
|
||||
{/* ── History Sidebar ── */}
|
||||
{history.length > 0 && (
|
||||
<div className="fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500">
|
||||
<div className="text-[9px] font-bold text-muted uppercase tracking-widest mb-2">History</div>
|
||||
<div className="flex flex-col gap-2 w-full px-2">
|
||||
{history.map((entry, idx) => (
|
||||
<div
|
||||
key={entry.id || idx}
|
||||
className={`relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${activeHistoryIdx === idx ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`}
|
||||
onClick={(e) => {
|
||||
if (e.target.closest('.hist-download')) {
|
||||
downloadFile(entry.url, `video-${entry.id || idx}.mp4`);
|
||||
return;
|
||||
}
|
||||
setActiveHistoryIdx(idx);
|
||||
if (entry.model === 'seedance-v2.0-t2v' || entry.model === 'seedance-v2.0-i2v') {
|
||||
setLastGenerationId(entry.id);
|
||||
setLastGenerationModel(entry.model);
|
||||
} else {
|
||||
setLastGenerationId(null);
|
||||
setLastGenerationModel(null);
|
||||
}
|
||||
showVideoInCanvas(entry.url, entry.model);
|
||||
}}
|
||||
>
|
||||
<video src={entry.url} preload="metadata" muted className="w-full aspect-square object-cover" />
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
||||
<button className="hist-download p-1.5 bg-primary rounded-lg text-black hover:scale-110 transition-transform" title="Download">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Canvas / Result View ── */}
|
||||
{showCanvas && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-10 transition-all duration-1000">
|
||||
<div className="relative group">
|
||||
<video
|
||||
ref={resultVideoRef}
|
||||
key={canvasUrl}
|
||||
src={canvasUrl}
|
||||
className="max-h-[60vh] max-w-[80vw] rounded-3xl shadow-3xl border border-white/10 interactive-glow object-contain"
|
||||
controls
|
||||
loop
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white"
|
||||
>
|
||||
↻ Regenerate
|
||||
</button>
|
||||
{isSeedance2Canvas && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExtend}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-primary/30 text-primary backdrop-blur-lg"
|
||||
title="Extend this video using Seedance 2.0 Extend"
|
||||
>
|
||||
↗ Extend
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const entry = history.find(e => e.url === canvasUrl);
|
||||
downloadFile(canvasUrl, `video-${entry?.id || 'clip'}.mp4`);
|
||||
}}
|
||||
className="bg-primary text-black px-6 py-2.5 rounded-2xl text-xs font-bold transition-all shadow-glow active:scale-95"
|
||||
>
|
||||
↓ Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewPrompt}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Hero + Prompt Bar (hidden when canvas is showing) ── */}
|
||||
{!showCanvas && (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center mb-10 md:mb-20 animate-fade-in-up transition-all duration-700">
|
||||
<div className="mb-10 relative group">
|
||||
<div className="absolute inset-0 bg-primary/20 blur-[100px] rounded-full opacity-40 group-hover:opacity-70 transition-opacity duration-1000" />
|
||||
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-teal-900/40 rounded-3xl flex items-center justify-center border border-white/5 overflow-hidden">
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-primary opacity-20 absolute -right-4 -bottom-4">
|
||||
<polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center border border-primary/20 shadow-glow relative z-10">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
|
||||
<polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 text-primary animate-pulse">✨</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">
|
||||
Video Studio
|
||||
</h1>
|
||||
<p className="text-secondary text-sm font-medium tracking-wide opacity-60">
|
||||
Animate images into stunning AI videos with motion effects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prompt Bar */}
|
||||
<div className="w-full max-w-4xl relative z-40 animate-fade-in-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="w-full bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-5 flex flex-col gap-3 md:gap-5 shadow-3xl">
|
||||
|
||||
{/* Top row: image picker + video picker + textarea */}
|
||||
<div className="flex items-start gap-5 px-2">
|
||||
|
||||
{/* Image upload button */}
|
||||
<div className="relative mt-1.5">
|
||||
<input
|
||||
ref={imageFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={uploadedImageUrl ? 'Clear image' : 'Upload image for Image-to-Video'}
|
||||
onClick={() => uploadedImageUrl ? clearImageUpload() : imageFileInputRef.current?.click()}
|
||||
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImageUrl ? 'border-primary/60 bg-primary/10' : 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40'} group`}
|
||||
>
|
||||
{imageUploading ? (
|
||||
<span className="animate-spin text-primary text-sm">◌</span>
|
||||
) : uploadedImageUrl ? (
|
||||
<img src={uploadedImagePreview} alt="" className="w-full h-full object-cover rounded-xl" />
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted group-hover:text-primary transition-colors">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video upload button */}
|
||||
<div className="relative mt-1.5">
|
||||
<input
|
||||
ref={videoFileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleVideoFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={uploadedVideoUrl ? `${uploadedVideoName} — click to clear` : 'Upload video to remove watermark'}
|
||||
onClick={() => uploadedVideoUrl ? clearVideoUpload() : videoFileInputRef.current?.click()}
|
||||
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden ${uploadedVideoUrl ? 'border-primary/60 bg-white/5' : 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40'} group`}
|
||||
>
|
||||
{videoUploading ? (
|
||||
<span className="animate-spin text-primary text-sm">◌</span>
|
||||
) : uploadedVideoUrl ? (
|
||||
<VideoReadySvg />
|
||||
) : (
|
||||
<VideoIconSvg className="text-muted group-hover:text-primary transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prompt textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={handlePromptInput}
|
||||
placeholder={promptPlaceholder}
|
||||
disabled={promptDisabled}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent border-none text-white text-base md:text-xl placeholder:text-muted focus:outline-none resize-none pt-2.5 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Extend banner */}
|
||||
{isExtendMode && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 mx-2 mt-2 bg-primary/10 border border-primary/20 rounded-xl text-xs text-primary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>Extending previous Seedance 2.0 generation — add an optional prompt to guide the continuation</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom row: controls + generate */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 px-2 pt-4 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5 md:gap-2.5 relative overflow-x-auto no-scrollbar pb-1 md:pb-0">
|
||||
|
||||
{/* Model btn */}
|
||||
<div ref={modelBtnRef} className="relative">
|
||||
<ControlBtn
|
||||
icon={
|
||||
<div className="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
|
||||
<span className="text-[10px] font-black text-black">V</span>
|
||||
</div>
|
||||
}
|
||||
label={selectedModelName}
|
||||
onClick={toggleDropdown('model')}
|
||||
/>
|
||||
<DropdownPanel type="model" open={openDropdown === 'model'} onClose={() => setOpenDropdown(null)}>
|
||||
<ModelDropdown
|
||||
imageMode={imageMode}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleModelSelect}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
/>
|
||||
</DropdownPanel>
|
||||
</div>
|
||||
|
||||
{/* Aspect ratio btn */}
|
||||
{showAr && (
|
||||
<div ref={arBtnRef} className="relative">
|
||||
<ControlBtn
|
||||
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-60 text-secondary"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /></svg>}
|
||||
label={selectedAr}
|
||||
onClick={toggleDropdown('ar')}
|
||||
/>
|
||||
<DropdownPanel type="ar" open={openDropdown === 'ar'} onClose={() => setOpenDropdown(null)}>
|
||||
<div className="text-[10px] font-bold text-muted uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Aspect Ratio</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{getCurrentAspectRatios(selectedModel).map(r => (
|
||||
<div
|
||||
key={r}
|
||||
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedAr(r); setOpenDropdown(null); }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-6 h-6 border-2 border-white/20 rounded-md shadow-inner flex items-center justify-center group-hover:border-primary/50 transition-colors">
|
||||
<div className="w-3 h-3 bg-white/10 rounded-sm" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100 transition-opacity">{r}</span>
|
||||
</div>
|
||||
{selectedAr === r && <CheckSvg />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration btn */}
|
||||
{showDuration && (
|
||||
<div ref={durationBtnRef} className="relative">
|
||||
<ControlBtn
|
||||
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-60 text-secondary"><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>}
|
||||
label={`${selectedDuration}s`}
|
||||
onClick={toggleDropdown('duration')}
|
||||
/>
|
||||
<DropdownPanel type="duration" open={openDropdown === 'duration'} onClose={() => setOpenDropdown(null)}>
|
||||
<div className="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Duration</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{getCurrentDurations(selectedModel).map(d => (
|
||||
<DropdownItem key={d} label={`${d}s`} selected={selectedDuration === d} onClick={(e) => { e.stopPropagation(); setSelectedDuration(d); setOpenDropdown(null); }} />
|
||||
))}
|
||||
</div>
|
||||
</DropdownPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolution btn */}
|
||||
{showResolution && (
|
||||
<div ref={resolutionBtnRef} className="relative">
|
||||
<ControlBtn
|
||||
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z" /></svg>}
|
||||
label={selectedResolution || '720p'}
|
||||
onClick={toggleDropdown('resolution')}
|
||||
/>
|
||||
<DropdownPanel type="resolution" open={openDropdown === 'resolution'} onClose={() => setOpenDropdown(null)}>
|
||||
<div className="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Resolution</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{getCurrentResolutions(selectedModel).map(r => (
|
||||
<DropdownItem key={r} label={r} selected={selectedResolution === r} onClick={(e) => { e.stopPropagation(); setSelectedResolution(r); setOpenDropdown(null); }} />
|
||||
))}
|
||||
</div>
|
||||
</DropdownPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality btn */}
|
||||
{showQuality && (
|
||||
<div ref={qualityBtnRef} className="relative">
|
||||
<ControlBtn
|
||||
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-60 text-secondary"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /></svg>}
|
||||
label={selectedQuality || 'basic'}
|
||||
onClick={toggleDropdown('quality')}
|
||||
/>
|
||||
<DropdownPanel type="quality" open={openDropdown === 'quality'} onClose={() => setOpenDropdown(null)}>
|
||||
<div className="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Quality</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{getQualitiesForModel(getCurrentModels(), selectedModel).map(q => (
|
||||
<DropdownItem key={q} label={q} selected={selectedQuality === q} onClick={(e) => { e.stopPropagation(); setSelectedQuality(q); setOpenDropdown(null); }} />
|
||||
))}
|
||||
</div>
|
||||
</DropdownPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode btn */}
|
||||
{showMode && (
|
||||
<div ref={modeBtnRef} className="relative">
|
||||
<ControlBtn
|
||||
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-60 text-secondary"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /></svg>}
|
||||
label={selectedMode || 'normal'}
|
||||
onClick={toggleDropdown('mode')}
|
||||
/>
|
||||
<DropdownPanel type="mode" open={openDropdown === 'mode'} onClose={() => setOpenDropdown(null)}>
|
||||
<div className="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">Mode</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{getModesForModel(selectedModel).map(m => (
|
||||
<DropdownItem key={m} label={m} selected={selectedMode === m} onClick={(e) => { e.stopPropagation(); setSelectedMode(m); setOpenDropdown(null); }} />
|
||||
))}
|
||||
</div>
|
||||
</DropdownPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg disabled:opacity-60 disabled:scale-100"
|
||||
>
|
||||
{generating ? (
|
||||
<><span className="animate-spin inline-block text-black">◌</span> Generating...</>
|
||||
) : generateError ? (
|
||||
`Error: ${generateError}`
|
||||
) : (
|
||||
'Generate ✨'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
packages/studio/src/index.js
Normal file
6
packages/studio/src/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
|
||||
export { default as ImageStudio } from './components/ImageStudio';
|
||||
export { default as VideoStudio } from './components/VideoStudio';
|
||||
export { default as LipSyncStudio } from './components/LipSyncStudio';
|
||||
export { default as CinemaStudio } from './components/CinemaStudio';
|
||||
8171
packages/studio/src/models.js
Normal file
8171
packages/studio/src/models.js
Normal file
File diff suppressed because it is too large
Load diff
141
packages/studio/src/muapi.js
Normal file
141
packages/studio/src/muapi.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';
|
||||
|
||||
const BASE_URL = 'https://api.muapi.ai';
|
||||
|
||||
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
|
||||
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
try {
|
||||
const response = await fetch(pollUrl, {
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': key }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
if (response.status >= 500) continue;
|
||||
throw new Error(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const status = data.status?.toLowerCase();
|
||||
if (status === 'completed' || status === 'succeeded' || status === 'success') return data;
|
||||
if (status === 'failed' || status === 'error') throw new Error(`Generation failed: ${data.error || 'Unknown error'}`);
|
||||
} catch (error) {
|
||||
if (attempt === maxAttempts) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error('Generation timed out after polling.');
|
||||
}
|
||||
|
||||
async function submitAndPoll(endpoint, payload, key, onRequestId, maxAttempts = 60) {
|
||||
const url = `${BASE_URL}/api/v1/${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.slice(0, 100)}`);
|
||||
}
|
||||
const submitData = await response.json();
|
||||
const requestId = submitData.request_id || submitData.id;
|
||||
if (!requestId) return submitData;
|
||||
if (onRequestId) onRequestId(requestId);
|
||||
const result = await pollForResult(requestId, key, maxAttempts);
|
||||
const outputUrl = result.outputs?.[0] || result.url || result.output?.url;
|
||||
return { ...result, url: outputUrl };
|
||||
}
|
||||
|
||||
export async function generateImage(apiKey, params) {
|
||||
const modelInfo = getModelById(params.model);
|
||||
const endpoint = modelInfo?.endpoint || params.model;
|
||||
const payload = { prompt: params.prompt };
|
||||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||
if (params.resolution) payload.resolution = params.resolution;
|
||||
if (params.quality) payload.quality = params.quality;
|
||||
if (params.image_url) { payload.image_url = params.image_url; payload.strength = params.strength || 0.6; }
|
||||
else payload.image_url = null;
|
||||
if (params.seed && params.seed !== -1) payload.seed = params.seed;
|
||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
|
||||
}
|
||||
|
||||
export async function generateI2I(apiKey, params) {
|
||||
const modelInfo = getI2IModelById(params.model);
|
||||
const endpoint = modelInfo?.endpoint || params.model;
|
||||
const payload = {};
|
||||
if (params.prompt) payload.prompt = params.prompt;
|
||||
const imageField = modelInfo?.imageField || 'image_url';
|
||||
const imagesList = params.images_list?.length > 0 ? params.images_list : (params.image_url ? [params.image_url] : null);
|
||||
if (imagesList) {
|
||||
if (imageField === 'images_list') payload.images_list = imagesList;
|
||||
else payload[imageField] = imagesList[0];
|
||||
}
|
||||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||
if (params.resolution) payload.resolution = params.resolution;
|
||||
if (params.quality) payload.quality = params.quality;
|
||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
|
||||
}
|
||||
|
||||
export async function generateVideo(apiKey, params) {
|
||||
const modelInfo = getVideoModelById(params.model);
|
||||
const endpoint = modelInfo?.endpoint || params.model;
|
||||
const payload = {};
|
||||
if (params.prompt) payload.prompt = params.prompt;
|
||||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||
if (params.duration) payload.duration = params.duration;
|
||||
if (params.resolution) payload.resolution = params.resolution;
|
||||
if (params.quality) payload.quality = params.quality;
|
||||
if (params.mode) payload.mode = params.mode;
|
||||
if (params.image_url) payload.image_url = params.image_url;
|
||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||
}
|
||||
|
||||
export async function generateI2V(apiKey, params) {
|
||||
const modelInfo = getI2VModelById(params.model);
|
||||
const endpoint = modelInfo?.endpoint || params.model;
|
||||
const payload = {};
|
||||
if (params.prompt) payload.prompt = params.prompt;
|
||||
const imageField = modelInfo?.imageField || 'image_url';
|
||||
if (params.image_url) {
|
||||
if (imageField === 'images_list') payload.images_list = [params.image_url];
|
||||
else payload[imageField] = params.image_url;
|
||||
}
|
||||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||
if (params.duration) payload.duration = params.duration;
|
||||
if (params.resolution) payload.resolution = params.resolution;
|
||||
if (params.quality) payload.quality = params.quality;
|
||||
if (params.mode) payload.mode = params.mode;
|
||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||
}
|
||||
|
||||
export async function processLipSync(apiKey, params) {
|
||||
const modelInfo = getLipSyncModelById(params.model);
|
||||
const endpoint = modelInfo?.endpoint || params.model;
|
||||
const payload = {};
|
||||
if (params.audio_url) payload.audio_url = params.audio_url;
|
||||
if (params.image_url) payload.image_url = params.image_url;
|
||||
if (params.video_url) payload.video_url = params.video_url;
|
||||
if (params.prompt) payload.prompt = params.prompt;
|
||||
if (params.resolution) payload.resolution = params.resolution;
|
||||
if (params.seed !== undefined && params.seed !== -1) payload.seed = params.seed;
|
||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||
}
|
||||
|
||||
export async function uploadFile(apiKey, file) {
|
||||
const url = `${BASE_URL}/api/v1/upload_file`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const fileUrl = data.url || data.file_url || data.data?.url;
|
||||
if (!fileUrl) throw new Error('No URL returned from file upload');
|
||||
return fileUrl;
|
||||
}
|
||||
3
packages/studio/src/tailwind.css
Normal file
3
packages/studio/src/tailwind.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
15
packages/studio/tailwind.config.js
Normal file
15
packages/studio/tailwind.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'app-bg': '#050505',
|
||||
'panel-bg': '#0a0a0a',
|
||||
'card-bg': '#111111',
|
||||
primary: '#d9ff00',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export default {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export class MuapiClient {
|
|||
}
|
||||
|
||||
getKey() {
|
||||
const key = localStorage.getItem('muapi_key');
|
||||
const key = window.__MUAPI_KEY__ || localStorage.getItem('muapi_key');
|
||||
if (!key) throw new Error('API Key missing. Please set it in Settings.');
|
||||
return key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
"./packages/studio/src/**/*.{js,jsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue