Merge: resolve conflict, keep updated styling with /access-keys URLs

This commit is contained in:
Anil Matcha 2026-04-11 21:15:24 +05:30
commit 20bf1bce42
9 changed files with 3930 additions and 2749 deletions

View file

@ -2,21 +2,32 @@
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #050505;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
:root {
--color-primary: #d9ff00;
--bg-app: #050505;
--bg-app: #030303;
--bg-panel: #0a0a0a;
--bg-card: #111111;
--border-color: rgba(255,255,255,0.08);
--border-radius-xl: 1rem;
--border-color: rgba(255,255,255,0.05);
--border-radius-xl: 0.75rem;
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
}
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
@ -24,7 +35,7 @@ body {
.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); }
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up { animation: fade-in-up 0.4s ease forwards; }
.animate-fade-in-up { animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }

View file

@ -14,48 +14,50 @@ export default function ApiKeyModal({ onSave }) {
};
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"/>
<div className="min-h-screen bg-[#030303] flex items-center justify-center px-4 font-inter">
<div className="w-full max-w-sm bg-[#0a0a0a]/40 backdrop-blur-xl border border-white/10 rounded-xl p-10 shadow-2xl">
<div className="flex flex-col items-center text-center mb-10">
<div className="w-14 h-14 bg-[#d9ff00]/5 rounded-2xl flex items-center justify-center border border-[#d9ff00]/10 mb-6 group hover:border-[#d9ff00]/30 transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5" className="group-hover:scale-110 transition-transform">
<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" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 className="text-2xl font-black text-white uppercase tracking-wider mb-2">
<h1 className="text-xl font-bold text-white tracking-tight mb-2">
Open Higgsfield AI
</h1>
<p className="text-white/40 text-sm">
Enter your <a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">Muapi.ai</a> API key to start generating
<p className="text-white/40 text-[13px] leading-relaxed px-4">
Enter your <a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:text-[#e5ff33] transition-colors">Muapi.ai</a> API key to start creating
</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
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="block text-xs font-bold text-white/30 ml-1">
API Access 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"
placeholder="Paste your key here..."
className="w-full bg-white/5 border border-white/[0.03] rounded-md px-5 py-3 text-sm text-white placeholder:text-white/10 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30 focus:bg-white/[0.07] transition-all"
suppressHydrationWarning
/>
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
{error && <p className="mt-2 text-red-500/80 text-[11px] font-medium ml-1">{error}</p>}
</div>
<button
type="submit"
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
className="w-full bg-[#d9ff00] text-black font-medium py-2.5 rounded-md hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all shadow-lg shadow-[#d9ff00]/5"
suppressHydrationWarning
>
Launch Studio
Get Started
</button>
<p className="text-center text-xs text-white/30">
Don&apos;t have a key?{' '}
<a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">
Get one free at Muapi.ai
<p className="text-center text-[12px] text-white/20 pt-2">
Need a key?{' '}
<a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-white/40 hover:text-[#d9ff00] transition-colors font-medium">
Get one free
</a>
</p>
</form>

View file

@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio } from 'studio';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, getUserBalance } from 'studio';
import ApiKeyModal from './ApiKeyModal';
const TABS = [
@ -16,61 +16,107 @@ const STORAGE_KEY = 'muapi_key';
export default function StandaloneShell() {
const [apiKey, setApiKey] = useState(null);
const [activeTab, setActiveTab] = useState('image');
const [balance, setBalance] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
const fetchBalance = useCallback(async (key) => {
try {
const data = await getUserBalance(key);
setBalance(data.balance);
} catch (err) {
console.error('Balance fetch failed:', err);
}
}, []);
useEffect(() => {
setHasMounted(true);
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) setApiKey(stored);
}, []);
if (stored) {
setApiKey(stored);
fetchBalance(stored);
}
}, [fetchBalance]);
const handleKeySave = useCallback((key) => {
localStorage.setItem(STORAGE_KEY, key);
setApiKey(key);
}, []);
fetchBalance(key);
}, [fetchBalance]);
const handleKeyChange = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setApiKey(null);
setBalance(null);
}, []);
// Poll for balance every 30 seconds if key is present
useEffect(() => {
if (!apiKey) return;
const interval = setInterval(() => fetchBalance(apiKey), 30000);
return () => clearInterval(interval);
}, [apiKey, fetchBalance]);
if (!hasMounted) return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
</div>
);
if (!apiKey) {
return <ApiKeyModal onSave={handleKeySave} />;
}
return (
<div className="h-screen bg-[#050505] flex flex-col overflow-hidden">
<div className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white">
{/* 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>
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenHiggsfield</span>
</div>
{/* Tabs */}
<nav className="flex items-center gap-1">
{/* Center: Navigation */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{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 ${
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
activeTab === tab.id
? 'bg-[#d9ff00] text-black'
? 'text-[#d9ff00]'
: 'text-white/50 hover:text-white'
}`}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
)}
</button>
))}
</nav>
{/* Settings */}
<button
{/* Right: Actions */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<div className="flex flex-col">
<span className="text-xs font-bold text-white/90">
${balance !== null ? `${balance}` : '---'}
</span>
</div>
</div>
<div
onClick={() => setShowSettings(true)}
className="text-white/40 hover:text-white text-sm transition-colors"
>
Settings
</button>
className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#d9ff00] to-yellow-200 border border-white/20 cursor-pointer hover:scale-105 transition-transform"
/>
</div>
</header>
{/* Studio Content */}
@ -83,22 +129,34 @@ export default function StandaloneShell() {
{/* 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>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-8 w-full max-w-sm shadow-2xl">
<h2 className="text-white font-bold text-lg mb-2">Settings</h2>
<p className="text-white/40 text-[13px] mb-8">
Manage your AI studio preferences and authentication.
</p>
<div className="space-y-4 mb-8">
<div className="bg-white/5 border border-white/[0.03] rounded-md p-4">
<label className="block text-xs font-bold text-white/30 mb-2">
Active API Key
</label>
<div className="text-[13px] font-mono text-white/80">
{apiKey.slice(0, 8)}
</div>
</div>
</div>
<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"
className="flex-1 h-10 rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 text-xs font-semibold transition-all"
>
Change API Key
Change 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"
className="flex-1 h-10 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold transition-all border border-white/5"
>
Close
</button>

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import { generateImage } from '../muapi.js';
import { useState, useEffect, useRef, useCallback } from "react";
import { generateImage } from "../muapi.js";
// Constants (inlined from promptUtils)
@ -11,7 +11,7 @@ const CAMERA_MAP = {
"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"
"Premium Large Format Digital": "premium large-format digital cinema camera",
};
const LENS_MAP = {
@ -25,7 +25,7 @@ const LENS_MAP = {
"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"
"Clinical Sharp Prime": "ultra-sharp clinical prime lens",
};
const FOCAL_PERSPECTIVE = {
@ -34,13 +34,13 @@ const FOCAL_PERSPECTIVE = {
24: "wide-angle dynamic perspective",
35: "natural cinematic perspective",
50: "standard portrait perspective",
85: "classic 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"
"f/11": "deep focus clarity, sharp foreground to background",
};
const ASSET_URLS = {
@ -49,7 +49,8 @@ const ASSET_URLS = {
"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",
"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",
@ -63,34 +64,44 @@ const ASSET_URLS = {
"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"
"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 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 FOCAL_LENGTHS = Object.keys(FOCAL_PERSPECTIVE).map((k) => parseInt(k));
const APERTURES = Object.keys(APERTURE_EFFECT);
function buildNanoBananaPrompt(basePrompt, camera, lens, focalLength, aperture) {
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 qualityTags = [
"professional photography",
"ultra-detailed",
"8K resolution",
];
const parts = [
basePrompt,
`shot on a ${cameraDesc}`,
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ''}`,
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ""}`,
`aperture ${aperture}`,
depthEffect,
"cinematic lighting",
"natural color science",
"high dynamic range",
qualityTags.join(", ")
qualityTags.join(", "),
];
return parts.filter(p => p && p.trim() !== "").join(", ");
return parts.filter((p) => p && p.trim() !== "").join(", ");
}
// Dropdown
@ -104,7 +115,7 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
bottom: window.innerHeight - rect.top + 8,
left: rect.left
left: rect.left,
});
}
@ -118,10 +129,13 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
onClose();
}
};
const timer = setTimeout(() => document.addEventListener('click', handler), 0);
const timer = setTimeout(
() => document.addEventListener("click", handler),
0,
);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handler);
document.removeEventListener("click", handler);
};
}, [triggerRef, onClose]);
@ -131,10 +145,10 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
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 => (
{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'}`}
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);
@ -163,9 +177,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
if (!list) return;
const timer = setTimeout(() => {
const target = Array.from(list.children).find(
c => c.dataset.value == String(value)
(c) => c.dataset.value == String(value),
);
if (target) target.scrollIntoView({ block: 'center' });
if (target) target.scrollIntoView({ block: "center" });
}, 100);
return () => clearTimeout(timer);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -177,8 +191,8 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
let closest = null;
let minDist = Infinity;
const children = Array.from(list.children).filter(c => c.dataset.value);
children.forEach(child => {
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) {
@ -187,35 +201,44 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
}
});
children.forEach(child => {
const imgBox = child.querySelector('[data-imgbox]');
const label = child.querySelector('[data-label]');
const focalSpan = imgBox?.querySelector('[data-focal-text]');
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');
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');
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');
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');
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');
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 (focalSpan) focalSpan.classList.remove("text-primary");
if (label) label.classList.remove("text-primary", "text-shadow-sm");
}
});
if (closest) {
const newVal = columnKey === 'focal'
const newVal =
columnKey === "focal"
? parseInt(closest.dataset.value)
: closest.dataset.value;
if (String(newVal) !== String(value)) {
@ -228,10 +251,10 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
useEffect(() => {
const list = listRef.current;
if (!list) return;
list.addEventListener('scroll', handleScroll);
list.addEventListener("scroll", handleScroll);
const timer = setTimeout(handleScroll, 150);
return () => {
list.removeEventListener('scroll', handleScroll);
list.removeEventListener("scroll", handleScroll);
clearTimeout(timer);
};
}, [handleScroll]);
@ -240,8 +263,8 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
const onMouseDown = (e) => {
isDragging.current = true;
isSnapEnabled.current = false;
listRef.current.classList.add('cursor-grabbing');
listRef.current.classList.remove('snap-y');
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();
@ -249,14 +272,14 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
const onMouseLeave = () => {
isDragging.current = false;
listRef.current.classList.remove('cursor-grabbing');
listRef.current.classList.add('snap-y');
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');
listRef.current.classList.remove("cursor-grabbing");
listRef.current.classList.add("snap-y");
};
const onMouseMove = (e) => {
@ -271,9 +294,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
const list = listRef.current;
if (!list) return;
const target = Array.from(list.children).find(
c => c.dataset.value == String(item)
(c) => c.dataset.value == String(item),
);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
};
return (
@ -281,13 +304,13 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
<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">
<div className="relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#050505]/60 rounded-2xl border border-white/[0.05] shadow-2xl backdrop-blur-2xl transition-transform duration-500 hover:scale-[1.01] hover:border-white/[0.1]">
{/* 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]" />
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
{/* 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 className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
{/* Center selection indicator */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[85%] h-[80px] bg-primary/[0.03] border border-primary/[0.1] rounded-2xl pointer-events-none z-0" />
<div
ref={listRef}
@ -298,9 +321,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
onMouseMove={onMouseMove}
>
{/* Top spacer */}
<div style={{ height: 'calc(50% - 50px)' }} />
<div style={{ height: "calc(50% - 50px)" }} />
{items.map(item => {
{items.map((item) => {
const imageUrl = ASSET_URLS[item];
return (
<div
@ -319,8 +342,11 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
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">
) : columnKey === "focal" ? (
<span
data-focal-text="true"
className="text-lg font-bold text-white/50"
>
{item}
</span>
) : (
@ -338,16 +364,19 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
})}
{/* Bottom spacer */}
<div style={{ height: 'calc(50% - 50px)' }} />
<div style={{ height: "calc(50% - 50px)" }} />
</div>
</div>
</div>
);
}
// Camera Controls Overlay
function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange }) {
function CameraControlsOverlay({
isOpen,
onClose,
settings,
onSettingsChange,
}) {
const backdropRef = useRef(null);
const handleBackdropClick = (e) => {
@ -355,28 +384,40 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
};
const updateSetting = (key) => (val) => {
onSettingsChange(prev => ({ ...prev, [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'}`}
className={`fixed inset-0 bg-[#0a0a0a]/80 backdrop-blur-2xl z-[100] flex items-center justify-center transition-all duration-500 ${isOpen ? "opacity-100" : "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'}`}
className={`w-full max-w-5xl bg-[#0a0a0a]/60 border border-white/10 rounded-2xl p-6 md:p-10 shadow-3xl transform transition-all duration-500 flex flex-col max-h-[90vh] ${isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-10"}`}
>
{/* 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 className="flex items-center justify-between mb-10">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold text-white tracking-tight">
Camera Configuration
</h2>
<p className="text-[11px] font-medium text-white/20 uppercase tracking-[0.2em]">
Select hardware & optics
</p>
</div>
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors"
className="w-10 h-10 rounded-full bg-white/[0.03] border border-white/[0.05] flex items-center justify-center text-white/40 hover:text-white hover:bg-white/[0.06] transition-all"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<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>
@ -389,28 +430,28 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
items={CAMERAS}
columnKey="camera"
value={settings.camera}
onChange={updateSetting('camera')}
onChange={updateSetting("camera")}
/>
<ScrollColumn
title="Lens"
items={LENSES}
columnKey="lens"
value={settings.lens}
onChange={updateSetting('lens')}
onChange={updateSetting("lens")}
/>
<ScrollColumn
title="Focal Length"
items={FOCAL_LENGTHS}
columnKey="focal"
value={settings.focal}
onChange={updateSetting('focal')}
onChange={updateSetting("focal")}
/>
<ScrollColumn
title="Aperture"
items={APERTURES}
columnKey="aperture"
value={settings.aperture}
onChange={updateSetting('aperture')}
onChange={updateSetting("aperture")}
/>
</div>
</div>
@ -420,23 +461,30 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
// Main Component
export default function CinemaStudio({ apiKey, onGenerationComplete, historyItems }) {
export default function CinemaStudio({
apiKey,
onGenerationComplete,
historyItems,
}) {
const PERSIST_KEY = "hg_cinema_studio_persistent";
// Settings state
const [settings, setSettings] = useState({
prompt: '',
aspect_ratio: '16:9',
prompt: "",
aspect_ratio: "16:9",
camera: CAMERAS[0],
lens: LENSES[0],
focal: 35,
aperture: 'f/1.4'
aperture: "f/1.4",
});
const [resolution, setResolution] = useState('2K');
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);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const [activeHistoryIndex, setactiveHistoryIndex] = useState(null);
// Internal history state (used when historyItems prop is not provided)
const [internalHistory, setInternalHistory] = useState([]);
@ -450,18 +498,55 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
const textareaRef = useRef(null);
const resultImgRef = useRef(null);
// Persistence: Load
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.settings) setSettings(data.settings);
if (data.resolution) setResolution(data.resolution);
if (data.internalHistory) setInternalHistory(data.internalHistory);
}
} catch (err) {
console.warn("Failed to load CinemaStudio persistence:", err);
}
}, []);
// Persistence: Save
useEffect(() => {
const timer = setTimeout(() => {
try {
const state = {
settings,
resolution,
internalHistory,
};
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
} catch (err) {
console.warn("Failed to save CinemaStudio persistence:", err);
}
}, 500); // 500ms debounce
return () => clearTimeout(timer);
}, [settings, resolution, internalHistory]);
// Derive effective history (prop wins over internal)
const history = historyItems != null ? historyItems : internalHistory;
useEffect(() => {
setCanvasUrl(history[0]?.url || null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [historyItems]);
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 }));
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
setSettings((prev) => ({ ...prev, prompt: el.value }));
};
// Generate
@ -476,16 +561,16 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
settings.camera,
settings.lens,
settings.focal,
settings.aperture
settings.aperture,
);
try {
const res = await generateImage(apiKey, {
model: 'nano-banana-pro',
model: "nano-banana-pro",
prompt: finalPrompt,
aspect_ratio: settings.aspect_ratio,
resolution: resolution.toLowerCase(),
negative_prompt: 'blurry, low quality, distortion, bad composition'
negative_prompt: "blurry, low quality, distortion, bad composition",
});
if (res && res.url) {
@ -499,36 +584,42 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
focal: settings.focal,
aperture: settings.aperture,
aspect_ratio: settings.aspect_ratio,
resolution
}
resolution,
},
};
// Only update internal history if not using prop-driven history
if (historyItems == null) {
setInternalHistory(prev => [entry, ...prev].slice(0, 50));
setInternalHistory((prev) => [entry, ...prev].slice(0, 50));
}
setActiveHistoryIndex(0);
setCanvasUrl(res.url);
if (onGenerationComplete) {
onGenerationComplete({
url: res.url,
model: 'nano-banana-pro',
model: "nano-banana-pro",
prompt: basePrompt,
type: 'cinema'
type: "cinema",
});
}
} else {
throw new Error('No data returned');
throw new Error("No data returned");
}
} catch (e) {
console.error(e);
alert('Generation Failed: ' + e.message);
alert("Generation Failed: " + e.message);
} finally {
setIsGenerating(false);
}
}, [settings, resolution, apiKey, isGenerating, onGenerationComplete, historyItems]);
}, [
settings,
resolution,
apiKey,
isGenerating,
onGenerationComplete,
historyItems,
]);
// Regenerate
const handleRegenerate = useCallback(() => {
@ -544,7 +635,7 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
const response = await fetch(canvasUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = blobUrl;
a.download = `cinema-shot-${Date.now()}.jpg`;
document.body.appendChild(a);
@ -552,140 +643,194 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(canvasUrl, '_blank');
window.open(canvasUrl, "_blank");
}
}, [canvasUrl]);
// Load history item
const loadHistoryItem = (entry, idx) => {
if (entry.settings) {
setSettings(prev => ({
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
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';
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: '' }));
setSettings((prev) => ({ ...prev, prompt: "" }));
if (textareaRef.current) {
textareaRef.current.value = '';
textareaRef.current.style.height = 'auto';
textareaRef.current.value = "";
textareaRef.current.style.height = "auto";
setTimeout(() => textareaRef.current?.focus(), 50);
}
};
const showCanvas = canvasUrl !== null;
// Render
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-black relative overflow-hidden">
{/* ── 1. Hero Section (Empty State) ── */}
{/* ── CENTRAL GALLERY AREA ── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{history.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
{history.map((entry, idx) => (
<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'}`}
key={entry.timestamp ?? idx}
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#d9ff00]/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => loadHistoryItem(entry, idx)}
>
<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>
<img
src={entry.url}
alt={`History item ${idx + 1}`}
className="w-full aspect-[4/3] object-cover bg-black/40"
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-[#d9ff00] hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Download"
onClick={async (e) => {
e.stopPropagation();
try {
const response = await fetch(entry.url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `cinema-shot-${entry.id || idx}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(entry.url, "_blank");
}
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-[#d9ff00] hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
</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"
/>
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<p className="text-white/70 text-xs line-clamp-3 leading-relaxed">
{entry.settings?.prompt || "No prompt"}
</p>
<div className="flex items-center justify-between mt-1 flex-wrap gap-1">
<span className="text-[10px] font-bold text-[#d9ff00] px-2 py-0.5 bg-[#d9ff00]/10 rounded border border-[#d9ff00]/20">
{entry.settings?.camera || "Standard"}
</span>
<div className="flex gap-2">
<span className="text-[10px] text-white/40">{entry.settings?.lens || "35mm"}</span>
{entry.settings?.aspect_ratio && (
<span className="text-[10px] text-white/40">{entry.settings.aspect_ratio}</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center px-4 animate-fade-in-up transition-all duration-700 min-h-[50vh]">
<div className="mb-12 relative group">
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary opacity-80">
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</div>
<div className="absolute top-4 right-4 text-[10px] text-primary/40 animate-pulse">REC</div>
</div>
</div>
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
<span className="text-white/40 font-medium">START CREATING WITH</span><br />
<span className="text-white uppercase tracking-wider">Cinema Studio</span>
</h1>
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed">
What would you shoot with infinite budget?
</p>
</div>
)}
</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-0 md:right-0 md:mx-auto 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">
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 left-4 right-4 md:left-0 md:right-0 md:mx-auto md:max-w-[95%] lg:max-w-4xl z-30 transition-all duration-700 animate-fade-in-up">
<div className="bg-[#0a0a0a]/80 backdrop-blur-3xl border border-white/10 rounded-md p-4 flex justify-between shadow-2xl items-end relative gap-2">
{/* Left Column */}
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1 px-1">
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1">
{/* Input Row */}
<div className="flex items-start gap-3 w-full">
<div className="flex items-start gap-4 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"
placeholder="Describe your cinema scene..."
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
rows={1}
onInput={handleTextareaInput}
/>
</div>
{/* Settings Toolbar */}
<div className="flex items-center gap-3">
<div className="flex justify-between gap-2">
<div className="flex flex-wrap 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')}
className="flex items-center gap-1.5 px-3 py-1 bg-white/[0.03] hover:bg-white/10 text-xs font-bold text-white/40 hover:text-white transition-colors rounded-md border border-white/[0.03]"
onClick={() =>
setOpenDropdown((d) => (d === "ar" ? null : "ar"))
}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
<rect x="2" y="7" width="20" height="10" rx="2" ry="2" />
</svg>
{settings.aspect_ratio}
</button>
{openDropdown === 'ar' && (
{openDropdown === "ar" && (
<Dropdown
items={ASPECT_RATIOS}
selected={settings.aspect_ratio}
onSelect={(val) => setSettings(prev => ({ ...prev, aspect_ratio: val }))}
onSelect={(val) =>
setSettings((prev) => ({ ...prev, aspect_ratio: val }))
}
triggerRef={arBtnRef}
onClose={() => setOpenDropdown(null)}
/>
@ -696,15 +841,17 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
<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')}
className="flex items-center gap-1.5 px-3 py-1 bg-white/[0.03] hover:bg-white/10 text-xs font-bold text-white/40 hover:text-white transition-colors rounded-md border border-white/[0.03]"
onClick={() =>
setOpenDropdown((d) => (d === "res" ? null : "res"))
}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
<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' && (
{openDropdown === "res" && (
<Dropdown
items={RESOLUTIONS}
selected={resolution}
@ -715,62 +862,44 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
)}
</div>
</div>
</div>
{/* Right Group */}
<div className="flex items-center gap-2 h-full self-end mb-1">
<div className="flex items-center gap-3 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"
className="flex flex-col items-start justify-center px-4 py-1.5 bg-white/[0.03] rounded-md border border-white/[0.03] hover:border-white/20 transition-all text-left flex-1 min-w-[100px] md:min-w-[160px] max-w-[240px] h-[50px] 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">
<div className="absolute top-3 right-3 w-1.5 h-1.5 bg-[#d9ff00] rounded-full shadow-lg shadow-[#d9ff00]/20" />
<span className="text-[9px] font-bold text-white/30 uppercase truncate w-full tracking-wider group-hover:text-white transition-colors">
{settings.camera}
</span>
<span className="text-[10px] font-medium text-white/60 truncate w-full">
<span className="text-xs font-semibold text-white/70 truncate w-full group-hover:text-[#d9ff00] transition-colors">
{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"
className="h-[50px] px-8 bg-[#d9ff00] text-black rounded-md font-medium text-sm hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 shadow-lg shadow-[#d9ff00]/10 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isGenerating || !settings.prompt.trim()}
onClick={handleGenerate}
>
{isGenerating ? 'SHOOTING...' : 'GENERATE ✨'}
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span> SHOOTING...
</>
) : (
<>
<span>SHOOT</span>
</>
)}
</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 ── */}
{/* ── Camera Controls Overlay ── */}
<CameraControlsOverlay
isOpen={isOverlayOpen}
onClose={() => setIsOverlayOpen(false)}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,3 +4,4 @@ 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';
export * from './muapi';

View file

@ -121,21 +121,65 @@ export async function processLipSync(apiKey, params) {
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
}
export async function uploadFile(apiKey, file) {
export function uploadFile(apiKey, file, onProgress) {
return new Promise((resolve, reject) => {
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
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.setRequestHeader('x-api-key', apiKey);
if (onProgress) {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
onProgress(percentComplete);
}
};
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
const fileUrl = data.url || data.file_url || data.data?.url;
if (!fileUrl) {
reject(new Error('No URL returned from file upload'));
} else {
resolve(fileUrl);
}
} catch (e) {
reject(new Error('Failed to parse upload response'));
}
} else {
let detail = xhr.statusText;
try {
const errObj = JSON.parse(xhr.responseText);
detail = errObj.detail || detail;
} catch (e) {
// fallback to statusText
}
reject(new Error(`File upload failed: ${xhr.status} - ${detail}`));
}
};
xhr.onerror = () => reject(new Error('Network error during file upload'));
xhr.send(formData);
});
}
export async function getUserBalance(apiKey) {
const response = await fetch(`${BASE_URL}/api/v1/account/balance`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
throw new Error(`Failed to fetch balance: ${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;
return await response.json();
}