Merge pull request #49 from Anil-matcha/master

Master
This commit is contained in:
Anil Chandra Naidu Matcha 2026-03-21 10:58:39 +05:30 committed by GitHub
commit 87f18dd088
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 26226 additions and 3447 deletions

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/studio');
}

9
app/studio/page.js Normal file
View 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
View 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&apos;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>
);
}

View 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
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

6
next.config.mjs Normal file
View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['studio'],
};
export default nextConfig;

7863
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}

3645
packages/studio/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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>
);
}

File diff suppressed because it is too large Load diff

View 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>
);
}

View 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>
);
}

View 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';

File diff suppressed because it is too large Load diff

View 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;
}

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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: [],
}

View file

@ -1,5 +1,6 @@
export default {
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -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;
}

View file

@ -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: {