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 components;
@tailwind utilities; @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; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
background: #050505; background: #050505;
color: white; 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 { :root {
--color-primary: #d9ff00; --color-primary: #d9ff00;
--bg-app: #050505; --bg-app: #030303;
--bg-panel: #0a0a0a; --bg-panel: #0a0a0a;
--bg-card: #111111; --bg-card: #111111;
--border-color: rgba(255,255,255,0.08); --border-color: rgba(255,255,255,0.05);
--border-radius-xl: 1rem; --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; } .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; } .custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
@keyframes fade-in-up { @keyframes fade-in-up {
from { opacity: 0; transform: translateY(16px); } from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); } 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 ( return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4"> <div className="min-h-screen bg-[#030303] flex items-center justify-center px-4 font-inter">
<div className="w-full max-w-md bg-[#0a0a0a] border border-white/10 rounded-3xl p-8"> <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-8"> <div className="flex flex-col items-center text-center mb-10">
<div className="w-16 h-16 bg-[#d9ff00]/10 rounded-2xl flex items-center justify-center border border-[#d9ff00]/20 mb-6"> <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="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5"> <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"/> <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> </svg>
</div> </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 Open Higgsfield AI
</h1> </h1>
<p className="text-white/40 text-sm"> <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:underline">Muapi.ai</a> API key to start generating 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> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div className="space-y-2">
<label className="block text-xs font-bold text-white/40 uppercase tracking-widest mb-2"> <label className="block text-xs font-bold text-white/30 ml-1">
Muapi API Key API Access Key
</label> </label>
<input <input
type="password" type="password"
value={key} value={key}
onChange={(e) => { setKey(e.target.value); setError(''); }} onChange={(e) => { setKey(e.target.value); setError(''); }}
placeholder="Enter your API key..." placeholder="Paste your key here..."
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" 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> </div>
<button <button
type="submit" 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> </button>
<p className="text-center text-xs text-white/30"> <p className="text-center text-[12px] text-white/20 pt-2">
Don&apos;t have a key?{' '} Need a key?{' '}
<a href="https://muapi.ai/access-keys" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline"> <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 at Muapi.ai Get one free
</a> </a>
</p> </p>
</form> </form>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; 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'; import ApiKeyModal from './ApiKeyModal';
const TABS = [ const TABS = [
@ -16,61 +16,107 @@ const STORAGE_KEY = 'muapi_key';
export default function StandaloneShell() { export default function StandaloneShell() {
const [apiKey, setApiKey] = useState(null); const [apiKey, setApiKey] = useState(null);
const [activeTab, setActiveTab] = useState('image'); const [activeTab, setActiveTab] = useState('image');
const [balance, setBalance] = useState(null);
const [showSettings, setShowSettings] = useState(false); 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(() => { useEffect(() => {
setHasMounted(true);
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored) setApiKey(stored); if (stored) {
}, []); setApiKey(stored);
fetchBalance(stored);
}
}, [fetchBalance]);
const handleKeySave = useCallback((key) => { const handleKeySave = useCallback((key) => {
localStorage.setItem(STORAGE_KEY, key); localStorage.setItem(STORAGE_KEY, key);
setApiKey(key); setApiKey(key);
}, []); fetchBalance(key);
}, [fetchBalance]);
const handleKeyChange = useCallback(() => { const handleKeyChange = useCallback(() => {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setApiKey(null); 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) { if (!apiKey) {
return <ApiKeyModal onSave={handleKeySave} />; return <ApiKeyModal onSave={handleKeySave} />;
} }
return ( 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 */}
<header className="flex-shrink-0 flex items-center justify-between px-4 pt-4 pb-0 border-b border-white/5"> <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">
<div className="flex items-center gap-3"> {/* Left: Logo */}
<span className="text-white font-black text-lg tracking-wider uppercase"> <div className="flex items-center gap-2">
Open Higgsfield AI <div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
</span> <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> </div>
{/* Tabs */} {/* Center: Navigation */}
<nav className="flex items-center gap-1"> <nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(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 activeTab === tab.id
? 'bg-[#d9ff00] text-black' ? 'text-[#d9ff00]'
: 'text-white/50 hover:text-white' : 'text-white/50 hover:text-white'
}`} }`}
> >
{tab.label} {tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
)}
</button> </button>
))} ))}
</nav> </nav>
{/* Settings */} {/* Right: Actions */}
<button <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)} onClick={() => setShowSettings(true)}
className="text-white/40 hover:text-white text-sm transition-colors" 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"
> />
Settings </div>
</button>
</header> </header>
{/* Studio Content */} {/* Studio Content */}
@ -83,22 +129,34 @@ export default function StandaloneShell() {
{/* Settings Modal */} {/* Settings Modal */}
{showSettings && ( {showSettings && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"> <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-[#111] border border-white/10 rounded-2xl p-8 w-full max-w-md"> <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-xl mb-6">Settings</h2> <h2 className="text-white font-bold text-lg mb-2">Settings</h2>
<p className="text-white/50 text-sm mb-4"> <p className="text-white/40 text-[13px] mb-8">
Current API key: <span className="text-white/80 font-mono">{apiKey.slice(0, 8)}</span> Manage your AI studio preferences and authentication.
</p> </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"> <div className="flex gap-3">
<button <button
onClick={handleKeyChange} 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>
<button <button
onClick={() => setShowSettings(false)} 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 Close
</button> </button>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from "react";
import { generateImage } from '../muapi.js'; import { generateImage } from "../muapi.js";
// Constants (inlined from promptUtils) // Constants (inlined from promptUtils)
@ -11,7 +11,7 @@ const CAMERA_MAP = {
"Grand Format 70mm Film": "grand format 70mm film camera", "Grand Format 70mm Film": "grand format 70mm film camera",
"Studio Digital S35": "Super 35 studio digital camera", "Studio Digital S35": "Super 35 studio digital camera",
"Classic 16mm Film": "classic 16mm film 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 = { const LENS_MAP = {
@ -25,7 +25,7 @@ const LENS_MAP = {
"Swirl Bokeh Portrait": "swirl bokeh portrait lens", "Swirl Bokeh Portrait": "swirl bokeh portrait lens",
"Vintage Prime": "vintage prime lens", "Vintage Prime": "vintage prime lens",
"Halation Diffusion": "halation diffusion filter", "Halation Diffusion": "halation diffusion filter",
"Clinical Sharp Prime": "ultra-sharp clinical prime lens" "Clinical Sharp Prime": "ultra-sharp clinical prime lens",
}; };
const FOCAL_PERSPECTIVE = { const FOCAL_PERSPECTIVE = {
@ -34,13 +34,13 @@ const FOCAL_PERSPECTIVE = {
24: "wide-angle dynamic perspective", 24: "wide-angle dynamic perspective",
35: "natural cinematic perspective", 35: "natural cinematic perspective",
50: "standard portrait perspective", 50: "standard portrait perspective",
85: "classic portrait perspective" 85: "classic portrait perspective",
}; };
const APERTURE_EFFECT = { const APERTURE_EFFECT = {
"f/1.4": "shallow depth of field, creamy bokeh", "f/1.4": "shallow depth of field, creamy bokeh",
"f/4": "balanced depth of field", "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 = { const ASSET_URLS = {
@ -49,7 +49,8 @@ const ASSET_URLS = {
"Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp", "Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
"Studio Digital S35": "/assets/cinema/studio_digital_s35.webp", "Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
"Classic 16mm Film": "/assets/cinema/classic_16mm_film.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", "Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
"Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp", "Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
"Extreme Macro": "/assets/cinema/extreme_macro.webp", "Extreme Macro": "/assets/cinema/extreme_macro.webp",
@ -63,34 +64,44 @@ const ASSET_URLS = {
"Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp", "Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
"f/1.4": "/assets/cinema/f_1_4.webp", "f/1.4": "/assets/cinema/f_1_4.webp",
"f/4": "/assets/cinema/f_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 ASPECT_RATIOS = ["16:9", "21:9", "9:16", "1:1", "4:5"];
const RESOLUTIONS = ['1K', '2K', '4K']; const RESOLUTIONS = ["1K", "2K", "4K"];
const CAMERAS = Object.keys(CAMERA_MAP); const CAMERAS = Object.keys(CAMERA_MAP);
const LENSES = Object.keys(LENS_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); 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 cameraDesc = CAMERA_MAP[camera] || camera;
const lensDesc = LENS_MAP[lens] || lens; const lensDesc = LENS_MAP[lens] || lens;
const perspective = FOCAL_PERSPECTIVE[focalLength] || ""; const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
const depthEffect = APERTURE_EFFECT[aperture] || ""; const depthEffect = APERTURE_EFFECT[aperture] || "";
const qualityTags = ["professional photography", "ultra-detailed", "8K resolution"]; const qualityTags = [
"professional photography",
"ultra-detailed",
"8K resolution",
];
const parts = [ const parts = [
basePrompt, basePrompt,
`shot on a ${cameraDesc}`, `shot on a ${cameraDesc}`,
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ''}`, `using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ""}`,
`aperture ${aperture}`, `aperture ${aperture}`,
depthEffect, depthEffect,
"cinematic lighting", "cinematic lighting",
"natural color science", "natural color science",
"high dynamic range", "high dynamic range",
qualityTags.join(", ") qualityTags.join(", "),
]; ];
return parts.filter(p => p && p.trim() !== "").join(", "); return parts.filter((p) => p && p.trim() !== "").join(", ");
} }
// Dropdown // Dropdown
@ -104,7 +115,7 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
const rect = triggerRef.current.getBoundingClientRect(); const rect = triggerRef.current.getBoundingClientRect();
setPosition({ setPosition({
bottom: window.innerHeight - rect.top + 8, bottom: window.innerHeight - rect.top + 8,
left: rect.left left: rect.left,
}); });
} }
@ -118,10 +129,13 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
onClose(); onClose();
} }
}; };
const timer = setTimeout(() => document.addEventListener('click', handler), 0); const timer = setTimeout(
() => document.addEventListener("click", handler),
0,
);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
document.removeEventListener('click', handler); document.removeEventListener("click", handler);
}; };
}, [triggerRef, onClose]); }, [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" 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 }} style={{ bottom: position.bottom, left: position.left }}
> >
{items.map(item => ( {items.map((item) => (
<button <button
key={item} 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelect(item); onSelect(item);
@ -163,9 +177,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
if (!list) return; if (!list) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
const target = Array.from(list.children).find( 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); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -177,8 +191,8 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
let closest = null; let closest = null;
let minDist = Infinity; let minDist = Infinity;
const children = Array.from(list.children).filter(c => c.dataset.value); const children = Array.from(list.children).filter((c) => c.dataset.value);
children.forEach(child => { children.forEach((child) => {
const childCenter = child.offsetTop + child.offsetHeight / 2; const childCenter = child.offsetTop + child.offsetHeight / 2;
const dist = Math.abs(centerY - childCenter); const dist = Math.abs(centerY - childCenter);
if (dist < minDist) { if (dist < minDist) {
@ -187,35 +201,44 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
} }
}); });
children.forEach(child => { children.forEach((child) => {
const imgBox = child.querySelector('[data-imgbox]'); const imgBox = child.querySelector("[data-imgbox]");
const label = child.querySelector('[data-label]'); const label = child.querySelector("[data-label]");
const focalSpan = imgBox?.querySelector('[data-focal-text]'); const focalSpan = imgBox?.querySelector("[data-focal-text]");
const isClosest = child === closest; const isClosest = child === closest;
if (isClosest) { if (isClosest) {
child.classList.remove('opacity-30', 'scale-75', 'blur-[1px]'); child.classList.remove("opacity-30", "scale-75", "blur-[1px]");
child.classList.add('opacity-100', 'scale-100', 'blur-0', 'z-30'); child.classList.add("opacity-100", "scale-100", "blur-0", "z-30");
if (imgBox) { if (imgBox) {
imgBox.classList.add('border-primary/50', 'shadow-glow-sm', 'scale-110'); imgBox.classList.add(
imgBox.classList.remove('border-white/10', 'bg-white/5'); "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 (focalSpan) focalSpan.classList.add("text-primary");
if (label) label.classList.add('text-primary', 'text-shadow-sm'); if (label) label.classList.add("text-primary", "text-shadow-sm");
} else { } else {
child.classList.add('opacity-30', 'scale-75', 'blur-[1px]'); child.classList.add("opacity-30", "scale-75", "blur-[1px]");
child.classList.remove('opacity-100', 'scale-100', 'blur-0', 'z-30'); child.classList.remove("opacity-100", "scale-100", "blur-0", "z-30");
if (imgBox) { if (imgBox) {
imgBox.classList.remove('border-primary/50', 'shadow-glow-sm', 'scale-110'); imgBox.classList.remove(
imgBox.classList.add('border-white/10', 'bg-white/5'); "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 (focalSpan) focalSpan.classList.remove("text-primary");
if (label) label.classList.remove('text-primary', 'text-shadow-sm'); if (label) label.classList.remove("text-primary", "text-shadow-sm");
} }
}); });
if (closest) { if (closest) {
const newVal = columnKey === 'focal' const newVal =
columnKey === "focal"
? parseInt(closest.dataset.value) ? parseInt(closest.dataset.value)
: closest.dataset.value; : closest.dataset.value;
if (String(newVal) !== String(value)) { if (String(newVal) !== String(value)) {
@ -228,10 +251,10 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
useEffect(() => { useEffect(() => {
const list = listRef.current; const list = listRef.current;
if (!list) return; if (!list) return;
list.addEventListener('scroll', handleScroll); list.addEventListener("scroll", handleScroll);
const timer = setTimeout(handleScroll, 150); const timer = setTimeout(handleScroll, 150);
return () => { return () => {
list.removeEventListener('scroll', handleScroll); list.removeEventListener("scroll", handleScroll);
clearTimeout(timer); clearTimeout(timer);
}; };
}, [handleScroll]); }, [handleScroll]);
@ -240,8 +263,8 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
const onMouseDown = (e) => { const onMouseDown = (e) => {
isDragging.current = true; isDragging.current = true;
isSnapEnabled.current = false; isSnapEnabled.current = false;
listRef.current.classList.add('cursor-grabbing'); listRef.current.classList.add("cursor-grabbing");
listRef.current.classList.remove('snap-y'); listRef.current.classList.remove("snap-y");
startY.current = e.pageY - listRef.current.offsetTop; startY.current = e.pageY - listRef.current.offsetTop;
scrollTopStart.current = listRef.current.scrollTop; scrollTopStart.current = listRef.current.scrollTop;
e.preventDefault(); e.preventDefault();
@ -249,14 +272,14 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
const onMouseLeave = () => { const onMouseLeave = () => {
isDragging.current = false; isDragging.current = false;
listRef.current.classList.remove('cursor-grabbing'); listRef.current.classList.remove("cursor-grabbing");
listRef.current.classList.add('snap-y'); listRef.current.classList.add("snap-y");
}; };
const onMouseUp = () => { const onMouseUp = () => {
isDragging.current = false; isDragging.current = false;
listRef.current.classList.remove('cursor-grabbing'); listRef.current.classList.remove("cursor-grabbing");
listRef.current.classList.add('snap-y'); listRef.current.classList.add("snap-y");
}; };
const onMouseMove = (e) => { const onMouseMove = (e) => {
@ -271,9 +294,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
const list = listRef.current; const list = listRef.current;
if (!list) return; if (!list) return;
const target = Array.from(list.children).find( 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 ( 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"> <div className="mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center">
{title} {title}
</div> </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 */} {/* 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 */} {/* 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]" /> <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 glow */} {/* Center selection indicator */}
<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 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 <div
ref={listRef} ref={listRef}
@ -298,9 +321,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
onMouseMove={onMouseMove} onMouseMove={onMouseMove}
> >
{/* Top spacer */} {/* 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]; const imageUrl = ASSET_URLS[item];
return ( return (
<div <div
@ -319,8 +342,11 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
alt={String(item)} alt={String(item)}
className="w-full h-full object-cover opacity-80" className="w-full h-full object-cover opacity-80"
/> />
) : columnKey === 'focal' ? ( ) : columnKey === "focal" ? (
<span data-focal-text="true" className="text-lg font-bold text-white/50"> <span
data-focal-text="true"
className="text-lg font-bold text-white/50"
>
{item} {item}
</span> </span>
) : ( ) : (
@ -338,16 +364,19 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
})} })}
{/* Bottom spacer */} {/* Bottom spacer */}
<div style={{ height: 'calc(50% - 50px)' }} /> <div style={{ height: "calc(50% - 50px)" }} />
</div> </div>
</div> </div>
</div> </div>
); );
} }
// Camera Controls Overlay function CameraControlsOverlay({
isOpen,
function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange }) { onClose,
settings,
onSettingsChange,
}) {
const backdropRef = useRef(null); const backdropRef = useRef(null);
const handleBackdropClick = (e) => { const handleBackdropClick = (e) => {
@ -355,28 +384,40 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
}; };
const updateSetting = (key) => (val) => { const updateSetting = (key) => (val) => {
onSettingsChange(prev => ({ ...prev, [key]: val })); onSettingsChange((prev) => ({ ...prev, [key]: val }));
}; };
return ( return (
<div <div
ref={backdropRef} 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} onClick={handleBackdropClick}
> >
<div <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 */} {/* Header */}
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-10">
<div className="flex gap-4"> <div className="flex flex-col gap-1">
<button className="px-4 py-2 bg-white text-black text-xs font-bold rounded-full">All</button> <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> </div>
<button <button
onClick={onClose} 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" /> <path d="M18 6L6 18M6 6l12 12" />
</svg> </svg>
</button> </button>
@ -389,28 +430,28 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
items={CAMERAS} items={CAMERAS}
columnKey="camera" columnKey="camera"
value={settings.camera} value={settings.camera}
onChange={updateSetting('camera')} onChange={updateSetting("camera")}
/> />
<ScrollColumn <ScrollColumn
title="Lens" title="Lens"
items={LENSES} items={LENSES}
columnKey="lens" columnKey="lens"
value={settings.lens} value={settings.lens}
onChange={updateSetting('lens')} onChange={updateSetting("lens")}
/> />
<ScrollColumn <ScrollColumn
title="Focal Length" title="Focal Length"
items={FOCAL_LENGTHS} items={FOCAL_LENGTHS}
columnKey="focal" columnKey="focal"
value={settings.focal} value={settings.focal}
onChange={updateSetting('focal')} onChange={updateSetting("focal")}
/> />
<ScrollColumn <ScrollColumn
title="Aperture" title="Aperture"
items={APERTURES} items={APERTURES}
columnKey="aperture" columnKey="aperture"
value={settings.aperture} value={settings.aperture}
onChange={updateSetting('aperture')} onChange={updateSetting("aperture")}
/> />
</div> </div>
</div> </div>
@ -420,23 +461,30 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
// Main Component // 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 // Settings state
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
prompt: '', prompt: "",
aspect_ratio: '16:9', aspect_ratio: "16:9",
camera: CAMERAS[0], camera: CAMERAS[0],
lens: LENSES[0], lens: LENSES[0],
focal: 35, focal: 35,
aperture: 'f/1.4' aperture: "f/1.4",
}); });
const [resolution, setResolution] = useState('2K'); const [resolution, setResolution] = useState("2K");
// UI state // UI state
const [isOverlayOpen, setIsOverlayOpen] = useState(false); const [isOverlayOpen, setIsOverlayOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view 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) // Internal history state (used when historyItems prop is not provided)
const [internalHistory, setInternalHistory] = useState([]); const [internalHistory, setInternalHistory] = useState([]);
@ -450,18 +498,55 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
const textareaRef = useRef(null); const textareaRef = useRef(null);
const resultImgRef = 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) // Derive effective history (prop wins over internal)
const history = historyItems != null ? historyItems : internalHistory; const history = historyItems != null ? historyItems : internalHistory;
useEffect(() => {
setCanvasUrl(history[0]?.url || null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [historyItems]);
const formatSummaryValue = () => const formatSummaryValue = () =>
`${settings.lens}, ${settings.focal}mm, ${settings.aperture}`; `${settings.lens}, ${settings.focal}mm, ${settings.aperture}`;
// Textarea auto-height // Textarea auto-height
const handleTextareaInput = (e) => { const handleTextareaInput = (e) => {
const el = e.target; const el = e.target;
el.style.height = 'auto'; el.style.height = "auto";
el.style.height = el.scrollHeight + 'px'; el.style.height = el.scrollHeight + "px";
setSettings(prev => ({ ...prev, prompt: el.value })); setSettings((prev) => ({ ...prev, prompt: el.value }));
}; };
// Generate // Generate
@ -476,16 +561,16 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
settings.camera, settings.camera,
settings.lens, settings.lens,
settings.focal, settings.focal,
settings.aperture settings.aperture,
); );
try { try {
const res = await generateImage(apiKey, { const res = await generateImage(apiKey, {
model: 'nano-banana-pro', model: "nano-banana-pro",
prompt: finalPrompt, prompt: finalPrompt,
aspect_ratio: settings.aspect_ratio, aspect_ratio: settings.aspect_ratio,
resolution: resolution.toLowerCase(), resolution: resolution.toLowerCase(),
negative_prompt: 'blurry, low quality, distortion, bad composition' negative_prompt: "blurry, low quality, distortion, bad composition",
}); });
if (res && res.url) { if (res && res.url) {
@ -499,36 +584,42 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
focal: settings.focal, focal: settings.focal,
aperture: settings.aperture, aperture: settings.aperture,
aspect_ratio: settings.aspect_ratio, aspect_ratio: settings.aspect_ratio,
resolution resolution,
} },
}; };
// Only update internal history if not using prop-driven history // Only update internal history if not using prop-driven history
if (historyItems == null) { if (historyItems == null) {
setInternalHistory(prev => [entry, ...prev].slice(0, 50)); setInternalHistory((prev) => [entry, ...prev].slice(0, 50));
} }
setActiveHistoryIndex(0);
setCanvasUrl(res.url); setCanvasUrl(res.url);
if (onGenerationComplete) { if (onGenerationComplete) {
onGenerationComplete({ onGenerationComplete({
url: res.url, url: res.url,
model: 'nano-banana-pro', model: "nano-banana-pro",
prompt: basePrompt, prompt: basePrompt,
type: 'cinema' type: "cinema",
}); });
} }
} else { } else {
throw new Error('No data returned'); throw new Error("No data returned");
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Generation Failed: ' + e.message); alert("Generation Failed: " + e.message);
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
}, [settings, resolution, apiKey, isGenerating, onGenerationComplete, historyItems]); }, [
settings,
resolution,
apiKey,
isGenerating,
onGenerationComplete,
historyItems,
]);
// Regenerate // Regenerate
const handleRegenerate = useCallback(() => { const handleRegenerate = useCallback(() => {
@ -544,7 +635,7 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
const response = await fetch(canvasUrl); const response = await fetch(canvasUrl);
const blob = await response.blob(); const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = blobUrl; a.href = blobUrl;
a.download = `cinema-shot-${Date.now()}.jpg`; a.download = `cinema-shot-${Date.now()}.jpg`;
document.body.appendChild(a); document.body.appendChild(a);
@ -552,140 +643,194 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
} catch { } catch {
window.open(canvasUrl, '_blank'); window.open(canvasUrl, "_blank");
} }
}, [canvasUrl]); }, [canvasUrl]);
// Load history item // Load history item
const loadHistoryItem = (entry, idx) => { const loadHistoryItem = (entry, idx) => {
if (entry.settings) { if (entry.settings) {
setSettings(prev => ({ setSettings((prev) => ({
...prev, ...prev,
camera: entry.settings.camera ?? prev.camera, camera: entry.settings.camera ?? prev.camera,
lens: entry.settings.lens ?? prev.lens, lens: entry.settings.lens ?? prev.lens,
focal: entry.settings.focal ?? prev.focal, focal: entry.settings.focal ?? prev.focal,
aperture: entry.settings.aperture ?? prev.aperture, aperture: entry.settings.aperture ?? prev.aperture,
aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio, 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); if (entry.settings.resolution) setResolution(entry.settings.resolution);
// Sync textarea height // Sync textarea height
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.value = entry.settings.prompt || ''; textareaRef.current.value = entry.settings.prompt || "";
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = "auto";
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; textareaRef.current.style.height =
textareaRef.current.scrollHeight + "px";
} }
} }
setActiveHistoryIndex(idx);
setCanvasUrl(entry.url); setCanvasUrl(entry.url);
}; };
const resetToPrompt = () => { const resetToPrompt = () => {
setCanvasUrl(null); setCanvasUrl(null);
setSettings(prev => ({ ...prev, prompt: '' })); setSettings((prev) => ({ ...prev, prompt: "" }));
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.value = ''; textareaRef.current.value = "";
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = "auto";
setTimeout(() => textareaRef.current?.focus(), 50); setTimeout(() => textareaRef.current?.focus(), 50);
} }
}; };
const showCanvas = canvasUrl !== null; // Render
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center bg-black relative overflow-hidden"> <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 <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"> <img
Cinema Studio 2.0 src={entry.url}
</div> alt={`History item ${idx + 1}`}
<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"> className="w-full aspect-[4/3] object-cover bg-black/40"
What would you shoot<br />with infinite budget? />
</h1>
{/* 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> </div>
{/* ── 2. Canvas Area (Result View) ── */} {/* Details */}
<div <div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
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'}`} <p className="text-white/70 text-xs line-clamp-3 leading-relaxed">
> {entry.settings?.prompt || "No prompt"}
<div className="relative group max-w-full max-h-[70vh] flex items-center justify-center"> </p>
{canvasUrl && ( <div className="flex items-center justify-between mt-1 flex-wrap gap-1">
<img <span className="text-[10px] font-bold text-[#d9ff00] px-2 py-0.5 bg-[#d9ff00]/10 rounded border border-[#d9ff00]/20">
ref={resultImgRef} {entry.settings?.camera || "Standard"}
src={canvasUrl} </span>
alt="Generated cinema shot" <div className="flex gap-2">
className="max-h-[60vh] max-w-[90vw] rounded-2xl shadow-2xl border border-white/10 object-contain" <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> </div>
{/* Canvas Controls */} {/* ── BOTTOM PROMPT BAR ── */}
<div <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">
className={`mt-8 flex gap-3 justify-center transition-opacity duration-500 delay-500 ${showCanvas ? 'opacity-100' : 'opacity-0'}`} <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">
>
<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">
{/* Left Column */} {/* 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 */} {/* Input Row */}
<div className="flex items-start gap-3 w-full"> <div className="flex items-start gap-4 w-full">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
placeholder="Describe your scene - use @ to add characters & props" placeholder="Describe your cinema scene..."
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" 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} rows={1}
onInput={handleTextareaInput} onInput={handleTextareaInput}
/> />
</div> </div>
<div className="flex justify-between gap-2">
{/* Settings Toolbar */} <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
{/* Aspect Ratio Button */} {/* Aspect Ratio Button */}
<div className="relative"> <div className="relative">
<button <button
ref={arBtnRef} 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" 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')} 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" /> <rect x="2" y="7" width="20" height="10" rx="2" ry="2" />
</svg> </svg>
{settings.aspect_ratio} {settings.aspect_ratio}
</button> </button>
{openDropdown === 'ar' && ( {openDropdown === "ar" && (
<Dropdown <Dropdown
items={ASPECT_RATIOS} items={ASPECT_RATIOS}
selected={settings.aspect_ratio} selected={settings.aspect_ratio}
onSelect={(val) => setSettings(prev => ({ ...prev, aspect_ratio: val }))} onSelect={(val) =>
setSettings((prev) => ({ ...prev, aspect_ratio: val }))
}
triggerRef={arBtnRef} triggerRef={arBtnRef}
onClose={() => setOpenDropdown(null)} onClose={() => setOpenDropdown(null)}
/> />
@ -696,15 +841,17 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
<div className="relative"> <div className="relative">
<button <button
ref={resBtnRef} 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" 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')} 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" /> <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> </svg>
{resolution} {resolution}
</button> </button>
{openDropdown === 'res' && ( {openDropdown === "res" && (
<Dropdown <Dropdown
items={RESOLUTIONS} items={RESOLUTIONS}
selected={resolution} selected={resolution}
@ -715,62 +862,44 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
)} )}
</div> </div>
</div> </div>
</div> <div className="flex items-center gap-3 h-full self-end mb-1">
{/* Right Group */}
<div className="flex items-center gap-2 h-full self-end mb-1">
{/* Summary Card (triggers overlay) */} {/* Summary Card (triggers overlay) */}
<button <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)} onClick={() => setIsOverlayOpen(true)}
> >
<div className="absolute top-2 right-2 w-2 h-2 bg-primary rounded-full shadow-glow-sm" /> <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-[10px] font-bold text-white uppercase truncate w-full tracking-wide"> <span className="text-[9px] font-bold text-white/30 uppercase truncate w-full tracking-wider group-hover:text-white transition-colors">
{settings.camera} {settings.camera}
</span> </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()} {formatSummaryValue()}
</span> </span>
</button> </button>
{/* Generate Button */} {/* Generate Button */}
<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()} disabled={isGenerating || !settings.prompt.trim()}
onClick={handleGenerate} onClick={handleGenerate}
> >
{isGenerating ? 'SHOOTING...' : 'GENERATE ✨'} {isGenerating ? (
<>
<span className="animate-spin inline-block text-black"></span> SHOOTING...
</>
) : (
<>
<span>SHOOT</span>
</>
)}
</button> </button>
</div> </div>
</div> </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>
</div> </div>
{/* ── 5. Camera Controls Overlay ── */} {/* ── Camera Controls Overlay ── */}
<CameraControlsOverlay <CameraControlsOverlay
isOpen={isOverlayOpen} isOpen={isOverlayOpen}
onClose={() => setIsOverlayOpen(false)} 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 VideoStudio } from './components/VideoStudio';
export { default as LipSyncStudio } from './components/LipSyncStudio'; export { default as LipSyncStudio } from './components/LipSyncStudio';
export { default as CinemaStudio } from './components/CinemaStudio'; 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); 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 url = `${BASE_URL}/api/v1/upload_file`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch(url, {
method: 'POST', const xhr = new XMLHttpRequest();
headers: { 'x-api-key': apiKey }, xhr.open('POST', url);
body: formData 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) { if (!response.ok) {
const errText = await response.text(); 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(); return 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;
} }