+ Manage your AI studio preferences and authentication.
diff --git a/packages/studio/src/components/CinemaStudio.jsx b/packages/studio/src/components/CinemaStudio.jsx
index fea7ebf..aae308e 100644
--- a/packages/studio/src/components/CinemaStudio.jsx
+++ b/packages/studio/src/components/CinemaStudio.jsx
@@ -1,782 +1,911 @@
"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) ───────────────────────────────────
const CAMERA_MAP = {
- "Modular 8K Digital": "modular 8K digital cinema camera",
- "Full-Frame Cine Digital": "full-frame digital cinema camera",
- "Grand Format 70mm Film": "grand format 70mm film camera",
- "Studio Digital S35": "Super 35 studio digital camera",
- "Classic 16mm Film": "classic 16mm film camera",
- "Premium Large Format Digital": "premium large-format digital cinema camera"
+ "Modular 8K Digital": "modular 8K digital cinema camera",
+ "Full-Frame Cine Digital": "full-frame digital cinema camera",
+ "Grand Format 70mm Film": "grand format 70mm film camera",
+ "Studio Digital S35": "Super 35 studio digital camera",
+ "Classic 16mm Film": "classic 16mm film camera",
+ "Premium Large Format Digital": "premium large-format digital cinema camera",
};
const LENS_MAP = {
- "Creative Tilt Lens": "creative tilt lens effect",
- "Compact Anamorphic": "compact anamorphic lens",
- "Extreme Macro": "extreme macro lens",
- "70s Cinema Prime": "1970s cinema prime lens",
- "Classic Anamorphic": "classic anamorphic lens",
- "Premium Modern Prime": "premium modern prime lens",
- "Warm Cinema Prime": "warm-toned cinema prime lens",
- "Swirl Bokeh Portrait": "swirl bokeh portrait lens",
- "Vintage Prime": "vintage prime lens",
- "Halation Diffusion": "halation diffusion filter",
- "Clinical Sharp Prime": "ultra-sharp clinical prime lens"
+ "Creative Tilt Lens": "creative tilt lens effect",
+ "Compact Anamorphic": "compact anamorphic lens",
+ "Extreme Macro": "extreme macro lens",
+ "70s Cinema Prime": "1970s cinema prime lens",
+ "Classic Anamorphic": "classic anamorphic lens",
+ "Premium Modern Prime": "premium modern prime lens",
+ "Warm Cinema Prime": "warm-toned cinema prime lens",
+ "Swirl Bokeh Portrait": "swirl bokeh portrait lens",
+ "Vintage Prime": "vintage prime lens",
+ "Halation Diffusion": "halation diffusion filter",
+ "Clinical Sharp Prime": "ultra-sharp clinical prime lens",
};
const FOCAL_PERSPECTIVE = {
- 8: "ultra-wide perspective",
- 14: "wide-angle perspective",
- 24: "wide-angle dynamic perspective",
- 35: "natural cinematic perspective",
- 50: "standard portrait perspective",
- 85: "classic portrait perspective"
+ 8: "ultra-wide perspective",
+ 14: "wide-angle perspective",
+ 24: "wide-angle dynamic perspective",
+ 35: "natural cinematic perspective",
+ 50: "standard portrait perspective",
+ 85: "classic portrait perspective",
};
const APERTURE_EFFECT = {
- "f/1.4": "shallow depth of field, creamy bokeh",
- "f/4": "balanced depth of field",
- "f/11": "deep focus clarity, sharp foreground to background"
+ "f/1.4": "shallow depth of field, creamy bokeh",
+ "f/4": "balanced depth of field",
+ "f/11": "deep focus clarity, sharp foreground to background",
};
const ASSET_URLS = {
- "Modular 8K Digital": "/assets/cinema/modular_8k_digital.webp",
- "Full-Frame Cine Digital": "/assets/cinema/full_frame_cine_digital.webp",
- "Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
- "Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
- "Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
- "Premium Large Format Digital": "/assets/cinema/premium_large_format_digital.webp",
- "Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
- "Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
- "Extreme Macro": "/assets/cinema/extreme_macro.webp",
- "70s Cinema Prime": "/assets/cinema/70s_cinema_prime.webp",
- "Classic Anamorphic": "/assets/cinema/classic_anamorphic.webp",
- "Premium Modern Prime": "/assets/cinema/premium_modern_prime.webp",
- "Warm Cinema Prime": "/assets/cinema/warm_cinema_prime.webp",
- "Swirl Bokeh Portrait": "/assets/cinema/swirl_bokeh_portrait.webp",
- "Vintage Prime": "/assets/cinema/vintage_prime.webp",
- "Halation Diffusion": "/assets/cinema/halation_diffusion.webp",
- "Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
- "f/1.4": "/assets/cinema/f_1_4.webp",
- "f/4": "/assets/cinema/f_4.webp",
- "f/11": "/assets/cinema/f_11.webp"
+ "Modular 8K Digital": "/assets/cinema/modular_8k_digital.webp",
+ "Full-Frame Cine Digital": "/assets/cinema/full_frame_cine_digital.webp",
+ "Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
+ "Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
+ "Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
+ "Premium Large Format Digital":
+ "/assets/cinema/premium_large_format_digital.webp",
+ "Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
+ "Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
+ "Extreme Macro": "/assets/cinema/extreme_macro.webp",
+ "70s Cinema Prime": "/assets/cinema/70s_cinema_prime.webp",
+ "Classic Anamorphic": "/assets/cinema/classic_anamorphic.webp",
+ "Premium Modern Prime": "/assets/cinema/premium_modern_prime.webp",
+ "Warm Cinema Prime": "/assets/cinema/warm_cinema_prime.webp",
+ "Swirl Bokeh Portrait": "/assets/cinema/swirl_bokeh_portrait.webp",
+ "Vintage Prime": "/assets/cinema/vintage_prime.webp",
+ "Halation Diffusion": "/assets/cinema/halation_diffusion.webp",
+ "Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
+ "f/1.4": "/assets/cinema/f_1_4.webp",
+ "f/4": "/assets/cinema/f_4.webp",
+ "f/11": "/assets/cinema/f_11.webp",
};
-const ASPECT_RATIOS = ['16:9', '21:9', '9:16', '1:1', '4:5'];
-const RESOLUTIONS = ['1K', '2K', '4K'];
+const 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) {
- const cameraDesc = CAMERA_MAP[camera] || camera;
- const lensDesc = LENS_MAP[lens] || lens;
- const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
- const depthEffect = APERTURE_EFFECT[aperture] || "";
- const qualityTags = ["professional photography", "ultra-detailed", "8K resolution"];
- const parts = [
- basePrompt,
- `shot on a ${cameraDesc}`,
- `using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ''}`,
- `aperture ${aperture}`,
- depthEffect,
- "cinematic lighting",
- "natural color science",
- "high dynamic range",
- qualityTags.join(", ")
- ];
- return parts.filter(p => p && p.trim() !== "").join(", ");
+function buildNanoBananaPrompt(
+ basePrompt,
+ camera,
+ lens,
+ focalLength,
+ aperture,
+) {
+ const cameraDesc = CAMERA_MAP[camera] || camera;
+ const lensDesc = LENS_MAP[lens] || lens;
+ const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
+ const depthEffect = APERTURE_EFFECT[aperture] || "";
+ const qualityTags = [
+ "professional photography",
+ "ultra-detailed",
+ "8K resolution",
+ ];
+ const parts = [
+ basePrompt,
+ `shot on a ${cameraDesc}`,
+ `using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ""}`,
+ `aperture ${aperture}`,
+ depthEffect,
+ "cinematic lighting",
+ "natural color science",
+ "high dynamic range",
+ qualityTags.join(", "),
+ ];
+ return parts.filter((p) => p && p.trim() !== "").join(", ");
}
// ─── Dropdown ────────────────────────────────────────────────────────────────
function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
- const menuRef = useRef(null);
- const [position, setPosition] = useState({ bottom: 0, left: 0 });
+ const menuRef = useRef(null);
+ const [position, setPosition] = useState({ bottom: 0, left: 0 });
- useEffect(() => {
- if (triggerRef.current) {
- const rect = triggerRef.current.getBoundingClientRect();
- setPosition({
- bottom: window.innerHeight - rect.top + 8,
- left: rect.left
- });
- }
+ useEffect(() => {
+ if (triggerRef.current) {
+ const rect = triggerRef.current.getBoundingClientRect();
+ setPosition({
+ bottom: window.innerHeight - rect.top + 8,
+ left: rect.left,
+ });
+ }
- const handler = (e) => {
- if (
- menuRef.current &&
- !menuRef.current.contains(e.target) &&
- triggerRef.current &&
- !triggerRef.current.contains(e.target)
- ) {
- onClose();
- }
- };
- const timer = setTimeout(() => document.addEventListener('click', handler), 0);
- return () => {
- clearTimeout(timer);
- document.removeEventListener('click', handler);
- };
- }, [triggerRef, onClose]);
-
- return (
-
- {items.map(item => (
-
- ))}
-
+ const handler = (e) => {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(e.target) &&
+ triggerRef.current &&
+ !triggerRef.current.contains(e.target)
+ ) {
+ onClose();
+ }
+ };
+ const timer = setTimeout(
+ () => document.addEventListener("click", handler),
+ 0,
);
+ return () => {
+ clearTimeout(timer);
+ document.removeEventListener("click", handler);
+ };
+ }, [triggerRef, onClose]);
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
}
// ─── Scroll Column (Camera Controls) ─────────────────────────────────────────
function ScrollColumn({ title, items, columnKey, value, onChange }) {
- const listRef = useRef(null);
- const isDragging = useRef(false);
- const startY = useRef(0);
- const scrollTopStart = useRef(0);
- const isSnapEnabled = useRef(true);
+ const listRef = useRef(null);
+ const isDragging = useRef(false);
+ const startY = useRef(0);
+ const scrollTopStart = useRef(0);
+ const isSnapEnabled = useRef(true);
- // Scroll to initial value on mount
- useEffect(() => {
- const list = listRef.current;
- if (!list) return;
- const timer = setTimeout(() => {
- const target = Array.from(list.children).find(
- c => c.dataset.value == String(value)
- );
- if (target) target.scrollIntoView({ block: 'center' });
- }, 100);
- return () => clearTimeout(timer);
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ // Scroll to initial value on mount
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+ const timer = setTimeout(() => {
+ const target = Array.from(list.children).find(
+ (c) => c.dataset.value == String(value),
+ );
+ if (target) target.scrollIntoView({ block: "center" });
+ }, 100);
+ return () => clearTimeout(timer);
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
- const handleScroll = useCallback(() => {
- const list = listRef.current;
- if (!list) return;
- const centerY = list.scrollTop + list.clientHeight / 2;
- let closest = null;
- let minDist = Infinity;
+ const handleScroll = useCallback(() => {
+ const list = listRef.current;
+ if (!list) return;
+ const centerY = list.scrollTop + list.clientHeight / 2;
+ let closest = null;
+ let minDist = Infinity;
- const children = Array.from(list.children).filter(c => c.dataset.value);
- children.forEach(child => {
- const childCenter = child.offsetTop + child.offsetHeight / 2;
- const dist = Math.abs(centerY - childCenter);
- if (dist < minDist) {
- minDist = dist;
- closest = child;
- }
- });
+ const children = Array.from(list.children).filter((c) => c.dataset.value);
+ children.forEach((child) => {
+ const childCenter = child.offsetTop + child.offsetHeight / 2;
+ const dist = Math.abs(centerY - childCenter);
+ if (dist < minDist) {
+ minDist = dist;
+ closest = child;
+ }
+ });
- children.forEach(child => {
- const imgBox = child.querySelector('[data-imgbox]');
- const label = child.querySelector('[data-label]');
- const focalSpan = imgBox?.querySelector('[data-focal-text]');
- const isClosest = child === closest;
+ children.forEach((child) => {
+ const imgBox = child.querySelector("[data-imgbox]");
+ const label = child.querySelector("[data-label]");
+ const focalSpan = imgBox?.querySelector("[data-focal-text]");
+ const isClosest = child === closest;
- if (isClosest) {
- child.classList.remove('opacity-30', 'scale-75', 'blur-[1px]');
- child.classList.add('opacity-100', 'scale-100', 'blur-0', 'z-30');
- if (imgBox) {
- imgBox.classList.add('border-primary/50', 'shadow-glow-sm', 'scale-110');
- imgBox.classList.remove('border-white/10', 'bg-white/5');
- }
- if (focalSpan) focalSpan.classList.add('text-primary');
- if (label) label.classList.add('text-primary', 'text-shadow-sm');
- } else {
- child.classList.add('opacity-30', 'scale-75', 'blur-[1px]');
- child.classList.remove('opacity-100', 'scale-100', 'blur-0', 'z-30');
- if (imgBox) {
- imgBox.classList.remove('border-primary/50', 'shadow-glow-sm', 'scale-110');
- imgBox.classList.add('border-white/10', 'bg-white/5');
- }
- if (focalSpan) focalSpan.classList.remove('text-primary');
- if (label) label.classList.remove('text-primary', 'text-shadow-sm');
- }
- });
-
- if (closest) {
- const newVal = columnKey === 'focal'
- ? parseInt(closest.dataset.value)
- : closest.dataset.value;
- if (String(newVal) !== String(value)) {
- onChange(newVal);
- }
+ if (isClosest) {
+ child.classList.remove("opacity-30", "scale-75", "blur-[1px]");
+ child.classList.add("opacity-100", "scale-100", "blur-0", "z-30");
+ if (imgBox) {
+ imgBox.classList.add(
+ "border-primary/50",
+ "shadow-glow-sm",
+ "scale-110",
+ );
+ imgBox.classList.remove("border-white/10", "bg-white/5");
}
- }, [columnKey, value, onChange]);
+ if (focalSpan) focalSpan.classList.add("text-primary");
+ if (label) label.classList.add("text-primary", "text-shadow-sm");
+ } else {
+ child.classList.add("opacity-30", "scale-75", "blur-[1px]");
+ child.classList.remove("opacity-100", "scale-100", "blur-0", "z-30");
+ if (imgBox) {
+ imgBox.classList.remove(
+ "border-primary/50",
+ "shadow-glow-sm",
+ "scale-110",
+ );
+ imgBox.classList.add("border-white/10", "bg-white/5");
+ }
+ if (focalSpan) focalSpan.classList.remove("text-primary");
+ if (label) label.classList.remove("text-primary", "text-shadow-sm");
+ }
+ });
- // Attach scroll handler with initial check
- useEffect(() => {
- const list = listRef.current;
- if (!list) return;
- list.addEventListener('scroll', handleScroll);
- const timer = setTimeout(handleScroll, 150);
- return () => {
- list.removeEventListener('scroll', handleScroll);
- clearTimeout(timer);
- };
- }, [handleScroll]);
+ if (closest) {
+ const newVal =
+ columnKey === "focal"
+ ? parseInt(closest.dataset.value)
+ : closest.dataset.value;
+ if (String(newVal) !== String(value)) {
+ onChange(newVal);
+ }
+ }
+ }, [columnKey, value, onChange]);
- // Mouse drag handlers
- const onMouseDown = (e) => {
- isDragging.current = true;
- isSnapEnabled.current = false;
- listRef.current.classList.add('cursor-grabbing');
- listRef.current.classList.remove('snap-y');
- startY.current = e.pageY - listRef.current.offsetTop;
- scrollTopStart.current = listRef.current.scrollTop;
- e.preventDefault();
+ // Attach scroll handler with initial check
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+ list.addEventListener("scroll", handleScroll);
+ const timer = setTimeout(handleScroll, 150);
+ return () => {
+ list.removeEventListener("scroll", handleScroll);
+ clearTimeout(timer);
};
+ }, [handleScroll]);
- const onMouseLeave = () => {
- isDragging.current = false;
- listRef.current.classList.remove('cursor-grabbing');
- listRef.current.classList.add('snap-y');
- };
+ // Mouse drag handlers
+ const onMouseDown = (e) => {
+ isDragging.current = true;
+ isSnapEnabled.current = false;
+ listRef.current.classList.add("cursor-grabbing");
+ listRef.current.classList.remove("snap-y");
+ startY.current = e.pageY - listRef.current.offsetTop;
+ scrollTopStart.current = listRef.current.scrollTop;
+ e.preventDefault();
+ };
- const onMouseUp = () => {
- isDragging.current = false;
- listRef.current.classList.remove('cursor-grabbing');
- listRef.current.classList.add('snap-y');
- };
+ const onMouseLeave = () => {
+ isDragging.current = false;
+ listRef.current.classList.remove("cursor-grabbing");
+ listRef.current.classList.add("snap-y");
+ };
- const onMouseMove = (e) => {
- if (!isDragging.current) return;
- e.preventDefault();
- const y = e.pageY - listRef.current.offsetTop;
- const walk = (y - startY.current) * 1.5;
- listRef.current.scrollTop = scrollTopStart.current - walk;
- };
+ const onMouseUp = () => {
+ isDragging.current = false;
+ listRef.current.classList.remove("cursor-grabbing");
+ listRef.current.classList.add("snap-y");
+ };
- const onItemClick = (item) => {
- const list = listRef.current;
- if (!list) return;
- const target = Array.from(list.children).find(
- c => c.dataset.value == String(item)
- );
- if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
- };
+ const onMouseMove = (e) => {
+ if (!isDragging.current) return;
+ e.preventDefault();
+ const y = e.pageY - listRef.current.offsetTop;
+ const walk = (y - startY.current) * 1.5;
+ listRef.current.scrollTop = scrollTopStart.current - walk;
+ };
- return (
-
-
- {title}
-
-
- {/* Top mask */}
-
- {/* Bottom mask */}
-
- {/* Center glow */}
-
-
-
- {/* Top spacer */}
-
-
- {items.map(item => {
- const imageUrl = ASSET_URLS[item];
- return (
-
onItemClick(item)}
- >
-
- {imageUrl ? (
-

- ) : columnKey === 'focal' ? (
-
- {item}
-
- ) : (
-
- )}
-
-
- {item}
-
-
- );
- })}
-
- {/* Bottom spacer */}
-
-
-
-
+ const onItemClick = (item) => {
+ const list = listRef.current;
+ if (!list) return;
+ const target = Array.from(list.children).find(
+ (c) => c.dataset.value == String(item),
);
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
+ };
+
+ return (
+
+
+ {title}
+
+
+ {/* Top mask */}
+
+ {/* Bottom mask */}
+
+ {/* Center selection indicator */}
+
+
+
+ {/* Top spacer */}
+
+
+ {items.map((item) => {
+ const imageUrl = ASSET_URLS[item];
+ return (
+
onItemClick(item)}
+ >
+
+ {imageUrl ? (
+

+ ) : columnKey === "focal" ? (
+
+ {item}
+
+ ) : (
+
+ )}
+
+
+ {item}
+
+
+ );
+ })}
+
+ {/* Bottom spacer */}
+
+
+
+
+ );
}
-// ─── Camera Controls Overlay ─────────────────────────────────────────────────
+function CameraControlsOverlay({
+ isOpen,
+ onClose,
+ settings,
+ onSettingsChange,
+}) {
+ const backdropRef = useRef(null);
-function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange }) {
- const backdropRef = useRef(null);
+ const handleBackdropClick = (e) => {
+ if (e.target === backdropRef.current) onClose();
+ };
- const handleBackdropClick = (e) => {
- if (e.target === backdropRef.current) onClose();
- };
+ const updateSetting = (key) => (val) => {
+ onSettingsChange((prev) => ({ ...prev, [key]: val }));
+ };
- const updateSetting = (key) => (val) => {
- onSettingsChange(prev => ({ ...prev, [key]: val }));
- };
-
- return (
-
-
+
+ {/* Header */}
+
+
+
+ Camera Configuration
+
+
+ Select hardware & optics
+
+
+
+
+
+
- );
+
+ {/* Scroll columns */}
+
+
+
+
+
+
+
+
+ );
}
// ─── Main Component ───────────────────────────────────────────────────────────
-export default function CinemaStudio({ apiKey, onGenerationComplete, historyItems }) {
- // ── Settings state ──
- const [settings, setSettings] = useState({
- prompt: '',
- aspect_ratio: '16:9',
- camera: CAMERAS[0],
- lens: LENSES[0],
- focal: 35,
- aperture: 'f/1.4'
- });
- const [resolution, setResolution] = useState('2K');
+export default function CinemaStudio({
+ apiKey,
+ onGenerationComplete,
+ historyItems,
+}) {
+ const PERSIST_KEY = "hg_cinema_studio_persistent";
- // ── 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);
+ // ── Settings state ──
+ const [settings, setSettings] = useState({
+ prompt: "",
+ aspect_ratio: "16:9",
+ camera: CAMERAS[0],
+ lens: LENSES[0],
+ focal: 35,
+ aperture: "f/1.4",
+ });
+ const [resolution, setResolution] = useState("2K");
- // ── Internal history state (used when historyItems prop is not provided) ──
- const [internalHistory, setInternalHistory] = useState([]);
+ // ── UI state ──
+ const [isOverlayOpen, setIsOverlayOpen] = useState(false);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view
+ const [fullscreenUrl, setFullscreenUrl] = useState(null);
+ const [activeHistoryIndex, setactiveHistoryIndex] = useState(null);
- // ── Dropdown state ──
- const [openDropdown, setOpenDropdown] = useState(null); // 'ar' | 'res' | null
- const arBtnRef = useRef(null);
- const resBtnRef = useRef(null);
+ // ── Internal history state (used when historyItems prop is not provided) ──
+ const [internalHistory, setInternalHistory] = useState([]);
- // ── Textarea auto-grow ──
- const textareaRef = useRef(null);
- const resultImgRef = useRef(null);
+ // ── Dropdown state ──
+ const [openDropdown, setOpenDropdown] = useState(null); // 'ar' | 'res' | null
+ const arBtnRef = useRef(null);
+ const resBtnRef = useRef(null);
- // Derive effective history (prop wins over internal)
- const history = historyItems != null ? historyItems : internalHistory;
+ // ── Textarea auto-grow ──
+ const textareaRef = useRef(null);
+ const resultImgRef = useRef(null);
- const formatSummaryValue = () =>
- `${settings.lens}, ${settings.focal}mm, ${settings.aperture}`;
+ // ── 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);
+ }
+ }, []);
- // ── 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 }));
- };
+ // ── 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]);
- // ── Generate ──
- const handleGenerate = useCallback(async () => {
- const basePrompt = settings.prompt.trim();
- if (!basePrompt || isGenerating) return;
+ // Derive effective history (prop wins over internal)
+ const history = historyItems != null ? historyItems : internalHistory;
- setIsGenerating(true);
+ useEffect(() => {
+ setCanvasUrl(history[0]?.url || null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [historyItems]);
- const finalPrompt = buildNanoBananaPrompt(
- basePrompt,
- settings.camera,
- settings.lens,
- settings.focal,
- settings.aperture
- );
+ const formatSummaryValue = () =>
+ `${settings.lens}, ${settings.focal}mm, ${settings.aperture}`;
- try {
- const res = await generateImage(apiKey, {
- model: 'nano-banana-pro',
- prompt: finalPrompt,
- aspect_ratio: settings.aspect_ratio,
- resolution: resolution.toLowerCase(),
- negative_prompt: 'blurry, low quality, distortion, bad composition'
- });
+ // ── 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 }));
+ };
- if (res && res.url) {
- const entry = {
- url: res.url,
- timestamp: Date.now(),
- settings: {
- prompt: basePrompt,
- camera: settings.camera,
- lens: settings.lens,
- focal: settings.focal,
- aperture: settings.aperture,
- aspect_ratio: settings.aspect_ratio,
- resolution
- }
- };
+ // ── Generate ──
+ const handleGenerate = useCallback(async () => {
+ const basePrompt = settings.prompt.trim();
+ if (!basePrompt || isGenerating) return;
- // Only update internal history if not using prop-driven history
- if (historyItems == null) {
- setInternalHistory(prev => [entry, ...prev].slice(0, 50));
- }
+ setIsGenerating(true);
- setActiveHistoryIndex(0);
- setCanvasUrl(res.url);
-
- if (onGenerationComplete) {
- onGenerationComplete({
- url: res.url,
- model: 'nano-banana-pro',
- prompt: basePrompt,
- type: 'cinema'
- });
- }
- } else {
- throw new Error('No data returned');
- }
- } catch (e) {
- console.error(e);
- alert('Generation Failed: ' + e.message);
- } finally {
- setIsGenerating(false);
- }
- }, [settings, resolution, apiKey, isGenerating, onGenerationComplete, historyItems]);
-
- // ── Regenerate ──
- const handleRegenerate = useCallback(() => {
- setCanvasUrl(null);
- // Small delay then generate
- setTimeout(() => handleGenerate(), 300);
- }, [handleGenerate]);
-
- // ── Download ──
- const handleDownload = useCallback(async () => {
- if (!canvasUrl) return;
- try {
- const response = await fetch(canvasUrl);
- const blob = await response.blob();
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = `cinema-shot-${Date.now()}.jpg`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(blobUrl);
- } catch {
- window.open(canvasUrl, '_blank');
- }
- }, [canvasUrl]);
-
- // ── Load history item ──
- const loadHistoryItem = (entry, idx) => {
- if (entry.settings) {
- setSettings(prev => ({
- ...prev,
- camera: entry.settings.camera ?? prev.camera,
- lens: entry.settings.lens ?? prev.lens,
- focal: entry.settings.focal ?? prev.focal,
- aperture: entry.settings.aperture ?? prev.aperture,
- aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio,
- prompt: entry.settings.prompt ?? prev.prompt
- }));
- if (entry.settings.resolution) setResolution(entry.settings.resolution);
-
- // Sync textarea height
- if (textareaRef.current) {
- textareaRef.current.value = entry.settings.prompt || '';
- textareaRef.current.style.height = 'auto';
- textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
- }
- }
- setActiveHistoryIndex(idx);
- setCanvasUrl(entry.url);
- };
-
- const resetToPrompt = () => {
- setCanvasUrl(null);
- setSettings(prev => ({ ...prev, prompt: '' }));
- if (textareaRef.current) {
- textareaRef.current.value = '';
- textareaRef.current.style.height = 'auto';
- setTimeout(() => textareaRef.current?.focus(), 50);
- }
- };
-
- const showCanvas = canvasUrl !== null;
-
- return (
-
-
- {/* ── 1. Hero Section (Empty State) ── */}
-
-
- Cinema Studio 2.0
-
-
- What would you shoot
with infinite budget?
-
-
-
- {/* ── 2. Canvas Area (Result View) ── */}
-
-
- {canvasUrl && (
-

- )}
-
-
- {/* Canvas Controls */}
-
-
-
-
-
-
-
- {/* ── 3. Floating Prompt Bar ── */}
-
-
-
- {/* Left Column */}
-
- {/* Input Row */}
-
-
-
-
- {/* Settings Toolbar */}
-
- {/* Aspect Ratio Button */}
-
-
- {openDropdown === 'ar' && (
- setSettings(prev => ({ ...prev, aspect_ratio: val }))}
- triggerRef={arBtnRef}
- onClose={() => setOpenDropdown(null)}
- />
- )}
-
-
- {/* Resolution Button */}
-
-
- {openDropdown === 'res' && (
-
setOpenDropdown(null)}
- />
- )}
-
-
-
-
- {/* Right Group */}
-
- {/* Summary Card (triggers overlay) */}
-
-
- {/* Generate Button */}
-
-
-
-
-
- {/* ── 4. History Sidebar ── */}
-
-
- History
-
-
- {history.map((entry, idx) => (
-
loadHistoryItem(entry, idx)}
- >
-

-
- Load
-
-
- ))}
-
-
-
- {/* ── 5. Camera Controls Overlay ── */}
-
setIsOverlayOpen(false)}
- settings={settings}
- onSettingsChange={setSettings}
- />
-
+ const finalPrompt = buildNanoBananaPrompt(
+ basePrompt,
+ settings.camera,
+ settings.lens,
+ settings.focal,
+ settings.aperture,
);
+
+ try {
+ const res = await generateImage(apiKey, {
+ model: "nano-banana-pro",
+ prompt: finalPrompt,
+ aspect_ratio: settings.aspect_ratio,
+ resolution: resolution.toLowerCase(),
+ negative_prompt: "blurry, low quality, distortion, bad composition",
+ });
+
+ if (res && res.url) {
+ const entry = {
+ url: res.url,
+ timestamp: Date.now(),
+ settings: {
+ prompt: basePrompt,
+ camera: settings.camera,
+ lens: settings.lens,
+ focal: settings.focal,
+ aperture: settings.aperture,
+ aspect_ratio: settings.aspect_ratio,
+ resolution,
+ },
+ };
+
+ // Only update internal history if not using prop-driven history
+ if (historyItems == null) {
+ setInternalHistory((prev) => [entry, ...prev].slice(0, 50));
+ }
+
+ setCanvasUrl(res.url);
+
+ if (onGenerationComplete) {
+ onGenerationComplete({
+ url: res.url,
+ model: "nano-banana-pro",
+ prompt: basePrompt,
+ type: "cinema",
+ });
+ }
+ } else {
+ throw new Error("No data returned");
+ }
+ } catch (e) {
+ console.error(e);
+ alert("Generation Failed: " + e.message);
+ } finally {
+ setIsGenerating(false);
+ }
+ }, [
+ settings,
+ resolution,
+ apiKey,
+ isGenerating,
+ onGenerationComplete,
+ historyItems,
+ ]);
+
+ // ── Regenerate ──
+ const handleRegenerate = useCallback(() => {
+ setCanvasUrl(null);
+ // Small delay then generate
+ setTimeout(() => handleGenerate(), 300);
+ }, [handleGenerate]);
+
+ // ── Download ──
+ const handleDownload = useCallback(async () => {
+ if (!canvasUrl) return;
+ try {
+ const response = await fetch(canvasUrl);
+ const blob = await response.blob();
+ const blobUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = blobUrl;
+ a.download = `cinema-shot-${Date.now()}.jpg`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(blobUrl);
+ } catch {
+ window.open(canvasUrl, "_blank");
+ }
+ }, [canvasUrl]);
+
+ // ── Load history item ──
+ const loadHistoryItem = (entry, idx) => {
+ if (entry.settings) {
+ setSettings((prev) => ({
+ ...prev,
+ camera: entry.settings.camera ?? prev.camera,
+ lens: entry.settings.lens ?? prev.lens,
+ focal: entry.settings.focal ?? prev.focal,
+ aperture: entry.settings.aperture ?? prev.aperture,
+ aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio,
+ prompt: entry.settings.prompt ?? prev.prompt,
+ }));
+ if (entry.settings.resolution) setResolution(entry.settings.resolution);
+
+ // Sync textarea height
+ if (textareaRef.current) {
+ textareaRef.current.value = entry.settings.prompt || "";
+ textareaRef.current.style.height = "auto";
+ textareaRef.current.style.height =
+ textareaRef.current.scrollHeight + "px";
+ }
+ }
+ setCanvasUrl(entry.url);
+ };
+
+ const resetToPrompt = () => {
+ setCanvasUrl(null);
+ setSettings((prev) => ({ ...prev, prompt: "" }));
+ if (textareaRef.current) {
+ textareaRef.current.value = "";
+ textareaRef.current.style.height = "auto";
+ setTimeout(() => textareaRef.current?.focus(), 50);
+ }
+ };
+
+ // ── Render ───────────────────────────────────────────────────────────────
+ return (
+
+
+ {/* ── CENTRAL GALLERY AREA ── */}
+
+ {history.length > 0 ? (
+
+ {history.map((entry, idx) => (
+
loadHistoryItem(entry, idx)}
+ >
+

+
+ {/* Overlay actions */}
+
+
+
+
+
+ {/* Details */}
+
+
+ {entry.settings?.prompt || "No prompt"}
+
+
+
+ {entry.settings?.camera || "Standard"}
+
+
+ {entry.settings?.lens || "35mm"}
+ {entry.settings?.aspect_ratio && (
+ {entry.settings.aspect_ratio}
+ )}
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ START CREATING WITH
+ Cinema Studio
+
+
+ What would you shoot with infinite budget?
+
+
+ )}
+
+
+ {/* ── BOTTOM PROMPT BAR ── */}
+
+
+ {/* Left Column */}
+
+ {/* Input Row */}
+
+
+
+
+
+ {/* Aspect Ratio Button */}
+
+
+ {openDropdown === "ar" && (
+
+ setSettings((prev) => ({ ...prev, aspect_ratio: val }))
+ }
+ triggerRef={arBtnRef}
+ onClose={() => setOpenDropdown(null)}
+ />
+ )}
+
+
+ {/* Resolution Button */}
+
+
+ {openDropdown === "res" && (
+
setOpenDropdown(null)}
+ />
+ )}
+
+
+
+ {/* Summary Card (triggers overlay) */}
+
+
+ {/* Generate Button */}
+
+
+
+
+
+
+
+ {/* ── Camera Controls Overlay ── */}
+
setIsOverlayOpen(false)}
+ settings={settings}
+ onSettingsChange={setSettings}
+ />
+
+ );
}
diff --git a/packages/studio/src/components/ImageStudio.jsx b/packages/studio/src/components/ImageStudio.jsx
index 4cbf337..8f454fa 100644
--- a/packages/studio/src/components/ImageStudio.jsx
+++ b/packages/studio/src/components/ImageStudio.jsx
@@ -16,14 +16,6 @@ import {
// ─── helpers ────────────────────────────────────────────────────────────────
-function generateThumbnail(file) {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.onload = (e) => resolve(e.target.result);
- reader.readAsDataURL(file);
- });
-}
-
async function downloadImage(url, filename) {
try {
const response = await fetch(url);
@@ -43,11 +35,12 @@ async function downloadImage(url, filename) {
// ─── UploadButton (inline picker) ───────────────────────────────────────────
-function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
+function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [] }) {
const [panelOpen, setPanelOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}]
const [uploadHistory, setUploadHistory] = useState([]); // [{id, name, url, thumbnail}]
+ const [lastUploadProgress, setLastUploadProgress] = useState(0);
const fileInputRef = useRef(null);
const panelRef = useRef(null);
const triggerRef = useRef(null);
@@ -69,16 +62,35 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
return () => window.removeEventListener("click", handler);
}, [panelOpen]);
+ // Sync initialUrls from parent (e.g. restored from localStorage)
+ useEffect(() => {
+ if (initialUrls && initialUrls.length > 0) {
+ // Avoid infinite loops by only updating if URLs actually changed
+ const currentUrls = selectedEntries.map(e => e.url);
+ const isSame = initialUrls.length === currentUrls.length && initialUrls.every(u => currentUrls.includes(u));
+ if (isSame) return;
+
+ const newEntries = initialUrls.map(url => ({ url }));
+ setSelectedEntries(newEntries);
+
+ // Also ensure they are in the history panel
+ setUploadHistory(prev => {
+ const existingUrls = prev.map(h => h.url);
+ const missing = initialUrls
+ .filter(u => !existingUrls.includes(u))
+ .map(u => ({ id: `restored-${u}`, name: "Restored Image", url: u, progress: 100 }));
+ return [...missing, ...prev];
+ });
+ }
+ }, [initialUrls]); // eslint-disable-line react-hooks/exhaustive-deps
+
// When maxImages changes, trim excess selections
useEffect(() => {
- setSelectedEntries((prev) => {
- if (prev.length > maxImages) {
- const trimmed = prev.slice(0, maxImages);
- if (trimmed.length === 0) onClear?.();
- return trimmed;
- }
- return prev;
- });
+ if (selectedEntries.length > maxImages) {
+ const trimmed = selectedEntries.slice(0, maxImages);
+ setSelectedEntries(trimmed);
+ if (trimmed.length === 0) onClear?.();
+ }
if (fileInputRef.current) {
fileInputRef.current.multiple = maxImages > 1;
}
@@ -88,9 +100,9 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
(entries) => {
if (!entries.length) return;
const urls = entries.map((e) => e.url);
- onSelect({ url: urls[0], urls, thumbnail: entries[0].thumbnail });
+ onSelect({ url: urls[0], urls, thumbnail: entries[0].url });
},
- [onSelect]
+ [onSelect],
);
const handleFileChange = async (e) => {
@@ -98,92 +110,110 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
if (!files.length) return;
e.target.value = "";
+ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
+ const tooLarge = files.filter((f) => f.size > MAX_IMAGE_SIZE);
+ if (tooLarge.length > 0) {
+ alert(
+ `The following images are too large (max 10MB): ${tooLarge.map((f) => f.name).join(", ")}`,
+ );
+ return;
+ }
+
setUploading(true);
try {
- if (maxImages === 1) {
- const file = files[0];
- const [uploadedUrl, thumbnail] = await Promise.all([
- uploadFile(apiKey, file),
- generateThumbnail(file),
- ]);
- const entry = { id: Date.now().toString(), name: file.name, url: uploadedUrl, thumbnail };
- setUploadHistory((prev) => [entry, ...prev]);
- const newSelected = [{ url: uploadedUrl, thumbnail }];
- setSelectedEntries(newSelected);
- fireOnSelect(newSelected);
- setPanelOpen(false);
- } else {
- const slots = maxImages - selectedEntries.length;
- const toUpload = files.slice(0, Math.max(slots, 1));
+ const toUpload =
+ maxImages === 1
+ ? files.slice(0, 1)
+ : files.slice(0, maxImages - selectedEntries.length || 1);
- const results = await Promise.all(
- toUpload.map(async (file) => {
- const [uploadedUrl, thumbnail] = await Promise.all([
- uploadFile(apiKey, file),
- generateThumbnail(file),
- ]);
- return {
- id: Date.now().toString() + Math.random(),
- name: file.name,
- url: uploadedUrl,
- thumbnail,
- };
- })
- );
+ await Promise.all(
+ toUpload.map(async (file) => {
+ const id = Date.now().toString() + Math.random();
- setUploadHistory((prev) => [...results, ...prev]);
- setSelectedEntries((prev) => {
- const next = [...prev];
- results.forEach((r) => {
- if (next.length < maxImages) {
- next.push({ url: r.url, thumbnail: r.thumbnail });
+ // Add a placeholder to history immediately without local preview
+ const placeholder = { id, name: file.name, url: null, progress: 0 };
+ setUploadHistory((prev) => [placeholder, ...prev]);
+
+ try {
+ const uploadedUrl = await uploadFile(apiKey, file, (pct) => {
+ setLastUploadProgress(pct);
+ setUploadHistory((prev) =>
+ prev.map((h) => (h.id === id ? { ...h, progress: pct } : h)),
+ );
+ });
+
+ // Update history with real URL and Mark as 100%
+ setUploadHistory((prev) =>
+ prev.map((h) => {
+ if (h.id === id) {
+ return { ...h, url: uploadedUrl, progress: 100 };
+ }
+ return h;
+ }),
+ );
+
+ // Auto-select if there's room
+ if (selectedEntries.length < maxImages) {
+ const newEntry = { url: uploadedUrl };
+ setSelectedEntries((prev) => [...prev, newEntry]);
+
+ if (maxImages === 1) {
+ fireOnSelect([newEntry]);
+ setPanelOpen(false);
+ }
}
- });
- return next;
- });
- setPanelOpen(true);
- }
+ } catch (err) {
+ console.error("[UploadButton] Upload failed for", file.name, err);
+ setUploadHistory((prev) => prev.filter((h) => h.id !== id));
+ throw err;
+ }
+ }),
+ );
} catch (err) {
- console.error("[UploadButton] Upload failed:", err);
alert(`Image upload failed: ${err.message}`);
} finally {
setUploading(false);
+ setLastUploadProgress(0);
}
};
const handleCellClick = (entry) => {
const selIdx = selectedEntries.findIndex((e) => e.url === entry.url);
const isSelected = selIdx !== -1;
- const atMax = maxImages > 1 && !isSelected && selectedEntries.length >= maxImages;
+ const atMax =
+ maxImages > 1 && !isSelected && selectedEntries.length >= maxImages;
if (atMax) return;
if (maxImages === 1) {
- const newSelected = [{ url: entry.url, thumbnail: entry.thumbnail }];
+ const newSelected = [{ url: entry.url, localUrl: entry.localUrl }];
setSelectedEntries(newSelected);
fireOnSelect(newSelected);
setPanelOpen(false);
} else {
- setSelectedEntries((prev) => {
- let next;
- if (isSelected) {
- next = prev.filter((_, i) => i !== selIdx);
- if (next.length === 0) onClear?.();
- } else {
- next = [...prev, { url: entry.url, thumbnail: entry.thumbnail }];
- }
- return next;
- });
+ let next;
+ if (isSelected) {
+ next = selectedEntries.filter((_, i) => i !== selIdx);
+ if (next.length === 0) onClear?.();
+ } else {
+ next = [
+ ...selectedEntries,
+ { url: entry.url, localUrl: entry.localUrl },
+ ];
+ }
+ setSelectedEntries(next);
}
};
const handleRemoveFromHistory = (e, entry) => {
e.stopPropagation();
+ if (entry.localUrl) URL.revokeObjectURL(entry.localUrl);
setUploadHistory((prev) => prev.filter((h) => h.id !== entry.id));
- setSelectedEntries((prev) => {
- const next = prev.filter((s) => s.url !== entry.url);
+
+ const next = selectedEntries.filter((s) => s.url !== entry.url);
+ if (next.length !== selectedEntries.length) {
+ setSelectedEntries(next);
if (next.length === 0) onClear?.();
- return next;
- });
+ }
};
const handleDone = (e) => {
@@ -206,29 +236,46 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
// Trigger icon content
let triggerContent;
- if (uploading) {
- triggerContent = (
-
◌
- );
- } else if (hasSelection) {
+ if (hasSelection || uploading) {
+ const mainEntry = selectedEntries[0] || uploadHistory[0];
const canAddMore = isMulti && count < maxImages;
let badge;
- if (count > 1) {
+ if (uploading && !hasSelection) {
+ badge = (
+
+
+
+ {lastUploadProgress}%
+
+
+ );
+ } else if (count > 1) {
badge = (
- {count}
+
+ {count}
+
);
} else if (canAddMore) {
badge = (
- +
+
+ +
+
);
} else {
badge = (
@@ -236,8 +283,56 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
}
triggerContent = (
<>
-

- {badge}
+ {uploading && hasSelection && (
+
+
+
+ {lastUploadProgress}%
+
+
+ )}
+ {count > 1 ? (
+
+ {/* Bottom Image */}
+ {selectedEntries[1]?.url && (
+
+

+
+ )}
+ {/* Top Image */}
+ {selectedEntries[0]?.url && (
+
+

+
+ )}
+
+ ) : mainEntry?.url ? (
+

+ ) : (
+
+
+
+ {lastUploadProgress}%
+
+
+ )}
+ {!uploading && badge}
>
);
} else {
@@ -249,11 +344,18 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
fill="none"
stroke="currentColor"
strokeWidth="2"
- className="text-muted group-hover:text-primary transition-colors"
+ className="text-white/40 group-hover:text-primary transition-colors"
>
-
-
-
+
+
+
);
}
@@ -262,11 +364,11 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
? count > 1
? `${count} of ${maxImages} images selected — click to manage`
: isMulti
- ? `1 image selected — click to add more (up to ${maxImages})`
- : "Reference image"
+ ? `1 image selected — click to add more (up to ${maxImages})`
+ : "Reference image"
: isMulti
- ? `Add up to ${maxImages} images`
- : "Reference image";
+ ? `Add up to ${maxImages} images`
+ : "Reference image";
return (
@@ -289,7 +391,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
e.stopPropagation();
setPanelOpen((o) => !o);
}}
- className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 hover:bg-white/10 group ${
+ className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 hover:bg-white/10 group ${
hasSelection
? "border-primary/60 hover:border-primary/40"
: "border-white/10 hover:border-primary/40"
@@ -303,12 +405,12 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
e.stopPropagation()}
- className="absolute z-50 bottom-[calc(100%+8px)] left-0 bg-[#111] rounded-3xl p-3 shadow-4xl border border-white/10 w-72"
+ className="absolute z-50 bottom-[calc(100%+8px)] left-0 bg-[#111] rounded-xl p-3 shadow-4xl border border-white/10 w-96"
>
{/* Header */}
-
+
Reference Images
{isMulti && (
@@ -334,7 +436,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
setPanelOpen(false);
fileInputRef.current?.click();
}}
- className="flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl text-xs font-bold transition-all border border-primary/20"
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-full text-xs font-bold transition-all border border-primary/20"
>