diff --git a/packages/studio/src/components/CinemaStudio.jsx b/packages/studio/src/components/CinemaStudio.jsx index d41d568..946bd9f 100644 --- a/packages/studio/src/components/CinemaStudio.jsx +++ b/packages/studio/src/components/CinemaStudio.jsx @@ -2,6 +2,21 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { generateImage, uploadFile } from "../muapi.js"; +import { + PromptAspectRatioIcon, + PromptAction, + PromptComposer, + PromptControls, + PromptFooter, + PromptMenuItem, + PromptMenuList, + PromptPopover, + PromptPopoverHeader, + PromptQualityIcon, + PromptTextarea, + promptControlClassName, + promptMediaButtonClassName, +} from "./prompt/PromptComposer.jsx"; // ─── Constants (inlined from promptUtils) ─────────────────────────────────── @@ -106,9 +121,8 @@ function buildNanoBananaPrompt( // ─── Dropdown ──────────────────────────────────────────────────────────────── -function Dropdown({ items, selected, onSelect, triggerRef, onClose }) { +function Dropdown({ title, items, selected, onSelect, triggerRef, onClose }) { const menuRef = useRef(null); - const [position, setPosition] = useState({ bottom: 0, left: 0 }); useEffect(() => { const handler = (e) => { @@ -126,14 +140,15 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) { }, [onClose, triggerRef]); return ( -
+ {title} + {items.map((item) => ( - + ))} -
+ + ); } -// ─── Scroll Column (Camera Controls) ───────────────────────────────────────── +// Camera configuration controls function ScrollColumn({ title, items, columnKey, value, onChange }) { const listRef = useRef(null); const isDragging = useRef(false); const startY = useRef(0); const scrollTopStart = useRef(0); - const isSnapEnabled = useRef(true); - // Scroll to initial value on mount useEffect(() => { const list = listRef.current; - if (!list) return; + if (!list) return undefined; + const timer = setTimeout(() => { const target = Array.from(list.children).find( - (c) => c.dataset.value == String(value), + (child) => child.dataset.value === String(value), ); if (target) target.scrollIntoView({ block: "center" }); }, 100); + return () => clearTimeout(timer); }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleScroll = useCallback(() => { const list = listRef.current; if (!list) return; - const centerY = list.scrollTop + list.clientHeight / 2; - let closest = null; - let minDist = Infinity; - const children = Array.from(list.children).filter((c) => c.dataset.value); + const centerY = list.scrollTop + list.clientHeight / 2; + const children = Array.from(list.children).filter( + (child) => child.dataset.value, + ); + let closest = null; + let minimumDistance = Infinity; + children.forEach((child) => { const childCenter = child.offsetTop + child.offsetHeight / 2; - const dist = Math.abs(centerY - childCenter); - if (dist < minDist) { - minDist = dist; + const distance = Math.abs(centerY - childCenter); + if (distance < minimumDistance) { + minimumDistance = distance; closest = child; } }); children.forEach((child) => { - const imgBox = child.querySelector("[data-imgbox]"); - const label = child.querySelector("[data-label]"); - const isClosest = child === closest; - - if (isClosest) { - child.classList.remove("opacity-20", "scale-90"); - child.classList.add("opacity-100", "scale-100", "z-30"); - if (imgBox) { - imgBox.classList.add("border-primary/40", "bg-primary/5", "scale-110"); - imgBox.classList.remove("border-transparent", "bg-transparent"); - } - if (label) label.classList.add("text-primary"); - } else { - child.classList.add("opacity-20", "scale-90"); - child.classList.remove("opacity-100", "scale-100", "z-30"); - if (imgBox) { - imgBox.classList.remove("border-primary/40", "bg-primary/5", "scale-110"); - imgBox.classList.add("border-transparent", "bg-transparent"); - } - if (label) label.classList.remove("text-primary"); - } + const selected = child === closest; + child.dataset.selected = String(selected); + child.setAttribute("aria-selected", String(selected)); }); if (closest) { - const newVal = + const nextValue = columnKey === "focal" - ? parseInt(closest.dataset.value) + ? parseInt(closest.dataset.value, 10) : closest.dataset.value; - if (String(newVal) !== String(value)) { - onChange(newVal); - } + if (String(nextValue) !== String(value)) onChange(nextValue); } - }, [columnKey, value, onChange]); + }, [columnKey, onChange, value]); - // Attach scroll handler with initial check useEffect(() => { const list = listRef.current; - if (!list) return; + if (!list) return undefined; + list.addEventListener("scroll", handleScroll); const timer = setTimeout(handleScroll, 150); + return () => { list.removeEventListener("scroll", handleScroll); clearTimeout(timer); }; }, [handleScroll]); - // Mouse drag handlers - const onMouseDown = (e) => { - isDragging.current = true; - isSnapEnabled.current = false; - listRef.current.classList.add("cursor-grabbing"); - listRef.current.classList.remove("snap-y"); - startY.current = e.pageY - listRef.current.offsetTop; - scrollTopStart.current = listRef.current.scrollTop; - e.preventDefault(); - }; - - const onMouseLeave = () => { - isDragging.current = false; - listRef.current.classList.remove("cursor-grabbing"); - listRef.current.classList.add("snap-y"); - }; - - const onMouseUp = () => { - isDragging.current = false; - listRef.current.classList.remove("cursor-grabbing"); - listRef.current.classList.add("snap-y"); - }; - - const onMouseMove = (e) => { - if (!isDragging.current) return; - e.preventDefault(); - const y = e.pageY - listRef.current.offsetTop; - const walk = (y - startY.current) * 1.5; - listRef.current.scrollTop = scrollTopStart.current - walk; - }; - - const onItemClick = (item) => { + const handleMouseDown = (event) => { const list = listRef.current; if (!list) return; + + isDragging.current = true; + list.classList.add("cursor-grabbing"); + list.classList.remove("snap-y"); + startY.current = event.pageY - list.offsetTop; + scrollTopStart.current = list.scrollTop; + event.preventDefault(); + }; + + const stopDragging = () => { + const list = listRef.current; + isDragging.current = false; + if (!list) return; + list.classList.remove("cursor-grabbing"); + list.classList.add("snap-y"); + }; + + const handleMouseMove = (event) => { + const list = listRef.current; + if (!isDragging.current || !list) return; + + event.preventDefault(); + const y = event.pageY - list.offsetTop; + list.scrollTop = scrollTopStart.current - (y - startY.current) * 1.5; + }; + + const handleItemClick = (item) => { + const list = listRef.current; + if (!list) return; + const target = Array.from(list.children).find( - (c) => c.dataset.value == String(item), + (child) => child.dataset.value === String(item), ); if (target) target.scrollIntoView({ behavior: "smooth", block: "center" }); }; - const getSelectedDescription = () => { - if (columnKey === 'camera') return CAMERA_MAP[value] || ''; - if (columnKey === 'lens') return LENS_MAP[value] || ''; - if (columnKey === 'focal') return FOCAL_PERSPECTIVE[value] || ''; - if (columnKey === 'aperture') return APERTURE_EFFECT[value] || ''; - return ''; - }; - return ( -
-
- {title} +
+
+

{title}

+
-
- {/* Masks */} -
-
- - {/* Active Selection Ring */} -
+ +
+
+
+
-
- + + +
); } @@ -369,30 +362,75 @@ function CameraControlsOverlay({ onSettingsChange((prev) => ({ ...prev, [key]: val })); }; + useEffect(() => { + if (!isOpen) return undefined; + + const handleKeyDown = (event) => { + if (event.key === "Escape") onClose(); + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + return (
- {/* Header */} -
-
-

- Camera Config +
+
+
+ + Cinema Studio +
+

+ Camera settings

-
+

+ Build a consistent cinematic look by choosing the camera, lens, + focal length, and depth of field. +

- {/* Scroll columns */} -
- - - - +
+
+ + + + +
@@ -470,6 +509,8 @@ export default function CinemaStudio({ const [imageUploadProgress, setImageUploadProgress] = useState(0); const imageInputRef = useRef(null); const [activeHistoryIndex, setactiveHistoryIndex] = useState(null); + const [copiedPromptIndex, setCopiedPromptIndex] = useState(null); + const [copiedImageIndex, setCopiedImageIndex] = useState(null); // ── Internal history state (used when historyItems prop is not provided) ── const [internalHistory, setInternalHistory] = useState([]); @@ -525,17 +566,6 @@ export default function CinemaStudio({ }, []); // ── Adjust height on load ──────────────────────────────────────────────── - useEffect(() => { - const timer = setTimeout(() => { - if (textareaRef.current) { - const el = textareaRef.current; - el.style.height = "auto"; - el.style.height = el.scrollHeight + "px"; - } - }, 150); - return () => clearTimeout(timer); - }, []); - // ── Persistence: Save ──────────────────────────────────────────────────── useEffect(() => { const timer = setTimeout(() => { @@ -566,13 +596,6 @@ export default function CinemaStudio({ `${settings.lens}, ${settings.focal}mm, ${settings.aperture}`; // ── Textarea auto-height ── - const handleTextareaInput = (e) => { - const el = e.target; - el.style.height = "auto"; - el.style.height = el.scrollHeight + "px"; - setSettings((prev) => ({ ...prev, prompt: el.value })); - }; - // ── Generate ── const handleGenerate = useCallback(async () => { const basePrompt = settings.prompt.trim(); @@ -672,6 +695,75 @@ export default function CinemaStudio({ } }, [canvasUrl]); + const handleCopyPrompt = useCallback( + async (prompt, index) => { + if (!prompt) return; + + try { + await navigator.clipboard.writeText(prompt); + setCopiedPromptIndex(index); + window.setTimeout(() => { + setCopiedPromptIndex((current) => (current === index ? null : current)); + }, 1600); + } catch (error) { + console.error("Failed to copy the prompt:", error); + onGenerationError?.("Could not copy the prompt to the clipboard."); + } + }, + [onGenerationError], + ); + + const handleCopyImage = useCallback( + async (imageUrl, index) => { + try { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error("Image clipboard access is not supported."); + } + + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Image request failed with status ${response.status}.`); + } + + const sourceBlob = await response.blob(); + let clipboardBlob = sourceBlob; + + if (sourceBlob.type !== "image/png") { + const bitmap = await createImageBitmap(sourceBlob); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Could not create an image canvas."); + context.drawImage(bitmap, 0, 0); + bitmap.close?.(); + + clipboardBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => + blob + ? resolve(blob) + : reject(new Error("Could not convert the image to PNG.")), + "image/png", + ); + }); + } + + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": clipboardBlob }), + ]); + setCopiedImageIndex(index); + window.setTimeout(() => { + setCopiedImageIndex((current) => (current === index ? null : current)); + }, 1600); + } catch (error) { + console.error("Failed to copy the image:", error); + onGenerationError?.("Could not copy the image to the clipboard."); + } + }, + [onGenerationError], + ); + // ── Load history item ── const loadHistoryItem = (entry, idx) => { if (entry.settings) { @@ -686,13 +778,6 @@ export default function CinemaStudio({ })); 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); }; @@ -701,8 +786,6 @@ export default function CinemaStudio({ setCanvasUrl(null); setSettings((prev) => ({ ...prev, prompt: "" })); if (textareaRef.current) { - textareaRef.current.value = ""; - textareaRef.current.style.height = "auto"; setTimeout(() => textareaRef.current?.focus(), 50); } }; @@ -771,6 +854,49 @@ export default function CinemaStudio({ + + + {copiedPromptIndex === idx ? "Prompt copied" : ""} + +
Cinema Studio @@ -805,22 +955,6 @@ export default function CinemaStudio({ {entry.settings.camera} )}
- {entry.settings?.prompt && ( - - )}

@@ -874,8 +1008,10 @@ export default function CinemaStudio({
{/* ── BOTTOM PROMPT BAR ── */} -
-
+ {/* Upper Row: Image Upload & Textarea */}
{/* Image Upload Button */} @@ -895,7 +1031,9 @@ export default function CinemaStudio({ : imageInputRef.current?.click() } disabled={isUploadingImage} - className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImage ? "border-[#22d3ee]/60 bg-white/5" : "bg-white/[0.03] border-white/[0.03] hover:bg-white/10 hover:border-[#22d3ee]/40"} group`} + className={promptMediaButtonClassName({ + active: Boolean(uploadedImage), + })} > {isUploadingImage ? (
@@ -948,51 +1086,45 @@ export default function CinemaStudio({
-