Merge branch 'pr-297'

This commit is contained in:
jaiprasad04
2026-07-27 14:25:33 +05:30
10 changed files with 1619 additions and 1181 deletions
+405 -273
View File
@@ -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 (
<div
<PromptPopover
ref={menuRef}
className="custom-dropdown absolute bottom-[calc(100%+8px)] left-0 bg-[#1a1a1a] border border-white/10 rounded py-1 shadow-2xl z-50 flex flex-col min-w-[120px] animate-fade-in"
>
<PromptPopoverHeader>{title}</PromptPopoverHeader>
<PromptMenuList>
{items.map((item) => (
<button
<PromptMenuItem
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"}`}
selected={item === selected}
onClick={(e) => {
e.stopPropagation();
onSelect(item);
@@ -141,215 +156,193 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
}}
>
{item}
</button>
</PromptMenuItem>
))}
</div>
</PromptMenuList>
</PromptPopover>
);
}
// ─── 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 (
<div className="flex flex-col items-center relative w-[130px] md:w-[150px] shrink-0 snap-center">
<div className="mb-4 text-[10px] font-black text-white/20 uppercase tracking-[0.25em] text-center">
{title}
<section className="flex w-[170px] shrink-0 snap-center flex-col md:w-[190px]">
<div className="mb-3 flex items-center justify-between px-1">
<h3 className="text-xs font-semibold text-white/75">{title}</h3>
<span className="h-1.5 w-1.5 rounded-full bg-gradient-to-b from-[#22d3ee] to-[#a855f7] shadow-[0_0_6px_rgba(34,211,238,0.5)]" />
</div>
<div className="relative overflow-hidden w-full h-[280px] md:h-[300px] bg-gradient-to-b from-white/[0.02] to-transparent rounded-2xl border border-white/[0.03] shadow-2xl backdrop-blur-3xl group">
{/* Masks */}
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#0a0a0a] to-transparent z-20 pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#0a0a0a] to-transparent z-20 pointer-events-none" />
{/* Active Selection Ring */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[85%] h-[70px] bg-white/[0.02] border border-white/[0.05] rounded-xl pointer-events-none z-0" />
<div className="relative h-[320px] overflow-hidden rounded-2xl border border-white/[0.06] bg-[#030303] shadow-inner">
<div className="pointer-events-none absolute inset-x-2 top-1/2 z-0 h-[82px] -translate-y-1/2 rounded-xl border border-[#22d3ee]/20 bg-gradient-to-r from-[#22d3ee]/15 to-purple-500/10 shadow-[0_0_15px_rgba(34,211,238,0.1)]" />
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-20 bg-gradient-to-b from-[#030303] via-[#030303]/85 to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-20 bg-gradient-to-t from-[#030303] via-[#030303]/85 to-transparent" />
<div
ref={listRef}
className="h-full overflow-y-auto no-scrollbar snap-y snap-mandatory relative z-10"
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
role="listbox"
aria-label={title}
className="relative z-10 h-full cursor-grab snap-y snap-mandatory overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onMouseDown={handleMouseDown}
onMouseLeave={stopDragging}
onMouseUp={stopDragging}
onMouseMove={handleMouseMove}
>
<div style={{ height: "calc(50% - 35px)" }} />
<div aria-hidden="true" style={{ height: "calc(50% - 41px)" }} />
{items.map((item) => {
const imageUrl = ASSET_URLS[item];
const selected = String(item) === String(value);
return (
<div
<button
key={item}
type="button"
role="option"
aria-selected={selected}
data-value={item}
className="h-[70px] flex flex-col items-center justify-center gap-2 snap-center cursor-pointer transition-all duration-300 ease-out text-white p-2 select-none opacity-20 scale-90"
onClick={() => onItemClick(item)}
data-selected={selected}
onClick={() => handleItemClick(item)}
className="group flex h-[82px] w-full snap-center select-none items-center justify-center gap-2.5 px-4 text-left opacity-30 transition-all duration-200 data-[selected=true]:opacity-100"
>
<div
data-imgbox="true"
className="w-10 h-10 rounded-lg border border-transparent flex items-center justify-center transition-all duration-300 overflow-hidden relative"
<span
className={`flex shrink-0 items-center justify-center font-semibold transition-colors ${
imageUrl
? "h-10 w-10"
: "text-base text-white/55 group-data-[selected=true]:text-[#22d3ee]"
}`}
>
{imageUrl ? (
<img
src={imageUrl}
alt={String(item)}
className="w-full h-full object-cover opacity-70"
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="text-sm font-bold text-white/40">
<>
{item}
</span>
{columnKey === "focal" ? "mm" : ""}
</>
)}
</div>
<span
data-label="true"
className="text-[8px] md:text-[9px] font-black uppercase text-center leading-tight max-w-full truncate px-1 tracking-widest text-white/60"
>
{item}
</span>
</div>
{columnKey !== "focal" && (
<span className="line-clamp-2 min-w-0 text-[10px] font-medium leading-snug text-white/60 transition-colors group-data-[selected=true]:text-white">
{item}
</span>
)}
</button>
);
})}
<div style={{ height: "calc(50% - 35px)" }} />
<div aria-hidden="true" style={{ height: "calc(50% - 41px)" }} />
</div>
</div>
{/* Selection Helper Text */}
<div className="mt-4 h-8 px-2 text-center">
<span className="text-[9px] font-medium text-primary/60 uppercase tracking-widest animate-fade-in inline-block leading-tight">
{getSelectedDescription()}
</span>
</div>
</div>
</section>
);
}
@@ -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 (
<div
ref={backdropRef}
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"}`}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4 backdrop-blur-xl animate-fade-in"
onClick={handleBackdropClick}
>
<div
className={`w-full max-w-5xl bg-[#0a0a0a] border border-white/5 rounded-3xl p-6 md:p-10 shadow-[0_0_100px_rgba(0,0,0,0.8)] transform transition-all duration-500 flex flex-col max-h-[90vh] ${isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-10"}`}
role="dialog"
aria-modal="true"
aria-labelledby="camera-config-title"
aria-describedby="camera-config-description"
className="flex max-h-[calc(100vh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/[0.08] bg-[#0a0a0b]/95 shadow-[0_24px_100px_rgba(0,0,0,0.75)] backdrop-blur-2xl animate-scale-up"
>
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-black text-white tracking-tighter uppercase italic">
Camera Config
<div className="flex items-start justify-between border-b border-white/[0.05] px-5 py-5 md:px-7 md:py-6">
<div>
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[#22d3ee]">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14.5 4H9.5L8 6H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="12.5" r="3.5" />
</svg>
Cinema Studio
</div>
<h2
id="camera-config-title"
className="text-xl font-semibold tracking-tight text-white md:text-2xl"
>
Camera settings
</h2>
<div className="h-[1px] w-12 bg-primary/40" />
<p
id="camera-config-description"
className="mt-1.5 max-w-2xl text-xs leading-relaxed text-white/45 md:text-sm"
>
Build a consistent cinematic look by choosing the camera, lens,
focal length, and depth of field.
</p>
</div>
<button
type="button"
onClick={onClose}
className="w-10 h-10 rounded-full hover:bg-white/5 flex items-center justify-center text-white/20 hover:text-white transition-all"
aria-label="Close camera settings"
title="Close"
className="ml-4 flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/[0.06] bg-white/[0.03] text-white/40 transition-all hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
>
<svg
width="20"
height="20"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -403,36 +441,37 @@ function CameraControlsOverlay({
</button>
</div>
{/* Scroll columns */}
<div className="w-full flex justify-start md:justify-center gap-3 md:gap-6 py-4 md:py-8 overflow-x-auto no-scrollbar snap-x px-4 md:px-0">
<ScrollColumn
title="Camera"
items={CAMERAS}
columnKey="camera"
value={settings.camera}
onChange={updateSetting("camera")}
/>
<ScrollColumn
title="Lens"
items={LENSES}
columnKey="lens"
value={settings.lens}
onChange={updateSetting("lens")}
/>
<ScrollColumn
title="Focal Length"
items={FOCAL_LENGTHS}
columnKey="focal"
value={settings.focal}
onChange={updateSetting("focal")}
/>
<ScrollColumn
title="Aperture"
items={APERTURES}
columnKey="aperture"
value={settings.aperture}
onChange={updateSetting("aperture")}
/>
<div className="overflow-x-auto px-5 py-6 no-scrollbar md:px-7 md:py-7">
<div className="mx-auto flex w-max min-w-full justify-start gap-3 sm:justify-center md:gap-5">
<ScrollColumn
title="Camera"
items={CAMERAS}
columnKey="camera"
value={settings.camera}
onChange={updateSetting("camera")}
/>
<ScrollColumn
title="Lens"
items={LENSES}
columnKey="lens"
value={settings.lens}
onChange={updateSetting("lens")}
/>
<ScrollColumn
title="Focal length"
items={FOCAL_LENGTHS}
columnKey="focal"
value={settings.focal}
onChange={updateSetting("focal")}
/>
<ScrollColumn
title="Aperture"
items={APERTURES}
columnKey="aperture"
value={settings.aperture}
onChange={updateSetting("aperture")}
/>
</div>
</div>
</div>
</div>
@@ -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({
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
type="button"
title={
copiedImageIndex === idx ? "Image copied" : "Copy image"
}
aria-label={
copiedImageIndex === idx ? "Image copied" : "Copy image"
}
onClick={(e) => {
e.stopPropagation();
handleCopyImage(entry.url, idx);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-[#22d3ee] hover:text-black transition-all border border-white/10"
>
{copiedImageIndex === idx ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m5 12 4 4L19 6" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</svg>
)}
</button>
<button
type="button"
title="Delete"
@@ -793,10 +919,34 @@ export default function CinemaStudio({
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<p className="text-white/70 text-xs line-clamp-3 leading-relaxed">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopyPrompt(entry.settings?.prompt, idx);
}}
className={`w-full text-left text-xs line-clamp-3 leading-relaxed transition-colors cursor-copy ${
copiedPromptIndex === idx
? "text-[#22d3ee]"
: "text-white/70 hover:text-white"
}`}
title={
copiedPromptIndex === idx
? "Prompt copied"
: "Copy full prompt"
}
aria-label={
copiedPromptIndex === idx
? "Prompt copied"
: "Copy full prompt"
}
>
{entry.settings?.prompt || "No prompt"}
</p>
<div className="flex items-center justify-between mt-1 flex-wrap gap-1">
</button>
<span className="sr-only" aria-live="polite">
{copiedPromptIndex === idx ? "Prompt copied" : ""}
</span>
<div className="flex items-center mt-1 flex-wrap gap-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-[#22d3ee] px-2 py-0.5 bg-[#22d3ee]/10 rounded border border-[#22d3ee]/20">
Cinema Studio
@@ -805,22 +955,6 @@ export default function CinemaStudio({
<span className="text-[10px] text-white/40">{entry.settings.camera}</span>
)}
</div>
{entry.settings?.prompt && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(entry.settings.prompt);
const btn = e.currentTarget;
btn.innerText = "Copied!";
setTimeout(() => { btn.innerText = "Copy"; }, 2000);
}}
className="px-2 py-0.5 bg-white/5 hover:bg-primary/20 hover:text-primary rounded text-[10px] font-medium text-white/70 transition-all border border-white/10"
title="Copy prompt"
>
Copy Prompt
</button>
)}
</div>
</div>
</div>
@@ -874,8 +1008,10 @@ export default function CinemaStudio({
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 left-4 right-4 md:left-0 md:right-0 md:mx-auto md:max-w-[95%] lg:max-w-4xl z-30 transition-all duration-700 animate-fade-in-up">
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer
positionClassName="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"
style={null}
>
{/* Upper Row: Image Upload & Textarea */}
<div className="flex items-start gap-4 w-full px-1">
{/* 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 ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
@@ -948,51 +1086,45 @@ export default function CinemaStudio({
</button>
</div>
<textarea
<PromptTextarea
ref={textareaRef}
value={settings.prompt}
onChange={(e) => {
setSettings(prev => ({ ...prev, prompt: e.target.value }));
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
}}
onChange={(e) =>
setSettings((prev) => ({ ...prev, prompt: e.target.value }))
}
placeholder="Describe your cinema scene..."
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 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}
/>
</div>
{/* Bottom Row: Controls & Generate */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
<PromptFooter>
<PromptControls>
{/* Aspect Ratio Button */}
<div className="relative">
<button
ref={arBtnRef}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner text-[11px] font-semibold text-white/70 hover:text-white"
className={promptControlClassName({
active: openDropdown === "ar",
className: "text-xs font-semibold",
})}
onClick={() =>
setOpenDropdown((d) => (d === "ar" ? null : "ar"))
}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
<rect x="2" y="7" width="20" height="10" rx="2" ry="2" />
</svg>
<PromptAspectRatioIcon />
{settings.aspect_ratio}
</button>
{openDropdown === "ar" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[140px]">
<Dropdown
items={ASPECT_RATIOS}
selected={settings.aspect_ratio}
onSelect={(val) =>
setSettings((prev) => ({ ...prev, aspect_ratio: val }))
}
triggerRef={arBtnRef}
onClose={() => setOpenDropdown(null)}
/>
</div>
<Dropdown
title="Aspect Ratio"
items={ASPECT_RATIOS}
selected={settings.aspect_ratio}
onSelect={(val) =>
setSettings((prev) => ({ ...prev, aspect_ratio: val }))
}
triggerRef={arBtnRef}
onClose={() => setOpenDropdown(null)}
/>
)}
</div>
@@ -1000,44 +1132,45 @@ export default function CinemaStudio({
<div className="relative">
<button
ref={resBtnRef}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner text-[11px] font-semibold text-white/70 hover:text-white"
className={promptControlClassName({
active: openDropdown === "res",
className: "text-xs font-semibold",
})}
onClick={() =>
setOpenDropdown((d) => (d === "res" ? null : "res"))
}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
<PromptQualityIcon />
{resolution}
</button>
{openDropdown === "res" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[140px]">
<Dropdown
items={RESOLUTIONS}
selected={resolution}
onSelect={setResolution}
triggerRef={resBtnRef}
onClose={() => setOpenDropdown(null)}
/>
</div>
<Dropdown
title="Resolution"
items={RESOLUTIONS}
selected={resolution}
onSelect={setResolution}
triggerRef={resBtnRef}
onClose={() => setOpenDropdown(null)}
/>
)}
</div>
{/* Summary Card (triggers overlay) */}
<button
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] text-left group overflow-hidden shadow-inner text-[11px] font-semibold text-white/70 hover:text-white"
className={promptControlClassName({
className: "text-left overflow-hidden text-xs font-semibold text-white/70 hover:text-white",
})}
onClick={() => setIsOverlayOpen(true)}
>
<div className="w-1.5 h-1.5 bg-[#22d3ee] rounded-full shadow-lg shadow-[#22d3ee]/20 shrink-0" />
<span className="max-w-[120px] truncate text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<span className="max-w-[120px] truncate text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{settings.camera} · {formatSummaryValue()}
</span>
</button>
</div>
</PromptControls>
{/* Generate Button */}
<button
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
<PromptAction
disabled={isGenerating || !settings.prompt.trim()}
onClick={handleGenerate}
>
@@ -1051,10 +1184,9 @@ export default function CinemaStudio({
<span>Shoot 10</span>
</>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
{fullscreenUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in"
+67 -109
View File
@@ -2,6 +2,23 @@
import { useState, useEffect, useRef } from "react";
import { runClipping, uploadFile } from "../muapi.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PROMPT_MEDIA_PREVIEW_CLASS,
PromptAspectRatioIcon,
PromptAction,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptDurationIcon,
PromptTextarea,
promptControlClassName,
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
// ---------------------------------------------------------------------------
// Inline SVG Icons
@@ -43,34 +60,6 @@ const CopyIcon = () => (
</svg>
);
const ClockIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const ChevronDownIcon = () => (
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-20 group-hover:opacity-100 transition-opacity ml-1"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
const getAspectClass = (ar) => {
switch (ar) {
case "16:9": return "aspect-video";
@@ -108,8 +97,6 @@ export default function ClippingStudio({
const [highlightsDropdownOpen, setHighlightsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
const highlightsDropdownRef = useRef(null);
const textareaRef = useRef(null);
const promptTextareaRef = useRef(null);
// ── Upload State ──
const [videoUploading, setVideoUploading] = useState(false);
@@ -239,18 +226,6 @@ export default function ClippingStudio({
}, [droppedFiles, onFilesHandled, apiKey]);
// Adjust URL textarea height dynamically
useEffect(() => {
const timer = setTimeout(() => {
if (textareaRef.current) {
const el = textareaRef.current;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
}
}, 150);
return () => clearTimeout(timer);
}, [videoUrl]);
// ── Highlight Seeking Helper ─────────────────────────────────────────────
const seekToHighlight = (startSec) => {
if (mainVideoRef.current) {
@@ -290,14 +265,6 @@ export default function ClippingStudio({
}
};
const handleUrlInput = (e) => {
setVideoUrl(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
const handlePromptInput = (e) => {
const val = e.target.value;
if (val.trim().match(/^https?:\/\/[^\s]+$/i)) {
@@ -306,10 +273,6 @@ export default function ClippingStudio({
return;
}
setPrompt(val);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
// ── Video File Handlers ──
@@ -434,7 +397,7 @@ export default function ClippingStudio({
{/* 1. Empty State (No history, no result active) */}
{!result && history.length === 0 && (
<div className="flex-grow flex flex-col items-center justify-center animate-fade-in-up transition-all duration-700 min-h-[55vh]">
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
{/* Overlapping floating cards */}
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
@@ -789,13 +752,12 @@ export default function ClippingStudio({
</div>
{/* ─── FLOATING BOTTOM PROMPT BAR ─── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
{/* Inline list of uploaded media files */}
{videoUrl && (
<div className="flex items-center gap-2.5 px-1 pb-1">
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<div className={PROMPT_MEDIA_PREVIEW_CLASS}>
<video src={videoUrl} className="w-full h-full object-cover" muted playsInline />
<button
type="button"
@@ -826,7 +788,9 @@ export default function ClippingStudio({
type="button"
title="Upload source video"
onClick={() => videoFileInputRef.current?.click()}
className="w-10 h-10 shrink-0 rounded-full border bg-white/5 border-white/[0.03] hover:bg-white/10 hover:border-[#22d3ee]/40 transition-all flex items-center justify-center relative overflow-hidden group"
className={promptMediaButtonClassName({
active: Boolean(videoUrl),
})}
>
{videoUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/85 z-20 backdrop-blur-[1px]">
@@ -859,27 +823,24 @@ export default function ClippingStudio({
{/* Prompt textarea (supports direct URL pasting too) */}
<div className="flex-1 flex flex-col gap-1">
<textarea
ref={promptTextareaRef}
<PromptTextarea
value={prompt}
onChange={handlePromptInput}
placeholder="Describe prompt / highlights to extract"
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] overflow-y-auto custom-scrollbar"
/>
</div>
</div>
{/* Bottom row: controls + generate button */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
<PromptFooter>
<PromptControls>
{/* Model Identifier (C) */}
<div className="flex items-center gap-2 px-3.5 h-[34px] bg-[#16161a]/60 rounded-md border border-white/[0.06] shadow-inner whitespace-nowrap">
<div className={promptControlClassName()}>
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">C</span>
</div>
<span className="text-xs font-semibold text-white/70">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
AI Clipping
</span>
</div>
@@ -889,39 +850,35 @@ export default function ClippingStudio({
<button
type="button"
onClick={() => setAspectDropdownOpen(!aspectDropdownOpen)}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: aspectDropdownOpen,
})}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptAspectRatioIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{aspectRatio}
</span>
<ChevronDownIcon />
</button>
{aspectDropdownOpen && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2 pb-1">
<PromptPopover>
<PromptPopoverHeader>
Aspect Ratio
</div>
<div className="flex flex-col gap-1 max-h-60 overflow-y-auto custom-scrollbar">
</PromptPopoverHeader>
<PromptMenuList>
{ASPECT_RATIOS.map((r) => (
<div
<PromptMenuItem
key={r.value}
className="flex items-center justify-between p-2.5 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
selected={aspectRatio === r.value}
onClick={() => {
setAspectRatio(r.value);
setAspectDropdownOpen(false);
}}
>
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white transition-opacity">
{r.value}
</span>
{aspectRatio === r.value && <CheckIcon />}
</div>
{r.value}
</PromptMenuItem>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
@@ -930,19 +887,20 @@ export default function ClippingStudio({
<button
type="button"
onClick={() => setHighlightsDropdownOpen(!highlightsDropdownOpen)}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: highlightsDropdownOpen,
})}
>
<ClockIcon />
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptDurationIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{numHighlights} Highlights
</span>
<ChevronDownIcon />
</button>
{highlightsDropdownOpen && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[180px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-3 pb-1">
<PromptPopover className="min-w-[180px] overflow-visible">
<PromptPopoverHeader className="mb-3">
Max Highlights
</div>
</PromptPopoverHeader>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-white/60">Limit:</span>
@@ -960,7 +918,7 @@ export default function ClippingStudio({
className="w-full h-1 bg-zinc-850 rounded appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
</PromptPopover>
)}
</div>
@@ -968,24 +926,25 @@ export default function ClippingStudio({
<button
type="button"
onClick={() => setReturnCoordinatesOnly(!returnCoordinatesOnly)}
className={`h-[34px] flex items-center gap-2 px-3.5 rounded-md transition-all border whitespace-nowrap text-[11px] font-semibold shadow-inner ${
returnCoordinatesOnly
? "bg-[#22d3ee]/10 border-[#22d3ee]/20 text-[#22d3ee]"
: "bg-[#16161a]/60 border-white/[0.06] text-white/70 hover:bg-[#202026]/80 hover:text-white"
}`}
className={promptControlClassName({
active: returnCoordinatesOnly,
className: returnCoordinatesOnly
? "text-[#22d3ee]"
: "text-white/70 hover:text-white",
})}
>
<ScissorsIcon className="w-3.5 h-3.5 text-current" />
<span>Coordinates Only</span>
<ScissorsIcon className="w-4 h-4 text-current" />
<span className="text-xs font-semibold">
Coordinates Only
</span>
</button>
</div>
</PromptControls>
{/* Generate button */}
<button
type="button"
<PromptAction
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{isGenerating ? (
<>
@@ -997,10 +956,9 @@ export default function ClippingStudio({
<span>Generate 5</span>
</>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
<style jsx global>{`
.custom-scrollbar::-webkit-scrollbar {
+77 -110
View File
@@ -17,6 +17,24 @@ import {
getDefaultEffectForI2IModel,
getI2IModelById,
} from "../models.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PROMPT_MEDIA_PREVIEW_CLASS,
PromptAspectRatioIcon,
PromptAction,
PromptChevronIcon,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptQualityIcon,
PromptTextarea,
promptControlClassName,
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -338,21 +356,19 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [],
e.stopPropagation();
setPanelOpen((o) => !o);
}}
className={`w-12 h-12 shrink-0 rounded-xl border border-dashed transition-all flex items-center justify-center relative overflow-hidden bg-white/[0.02] hover:bg-white/5 group ${
hasSelection
? "border-[#22d3ee]/40 hover:border-[#22d3ee]/60"
: "border-white/10 hover:border-[#22d3ee]/40"
}`}
className={promptMediaButtonClassName({
active: hasSelection,
})}
>
{triggerContent}
</button>
{/* Panel */}
{panelOpen && (
<div
<PromptPopover
ref={panelRef}
onClick={(e) => e.stopPropagation()}
className="absolute z-50 bottom-[calc(100%+8px)] left-0 bg-[#111] rounded-xl p-3 shadow-4xl border border-white/10 w-96"
className="w-96 max-w-[calc(100vw-2rem)]"
>
{/* Header */}
<div className="flex items-center justify-between px-1 pb-3 mb-2 border-b border-white/5">
@@ -522,7 +538,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [],
</button>
</div>
)}
</div>
</PromptPopover>
)}
</div>
);
@@ -804,38 +820,22 @@ function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
return (
<>
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
{title}
</div>
<div className="flex flex-col gap-1">
<PromptPopoverHeader>{title}</PromptPopoverHeader>
<PromptMenuList>
{options.map((opt) => (
<div
<PromptMenuItem
key={opt}
selected={selected === opt}
onClick={(e) => {
e.stopPropagation();
onSelect(opt);
onClose();
}}
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group"
>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{opt}
</span>
{selected === opt && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#22d3ee"
strokeWidth="4.5"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
{opt}
</PromptMenuItem>
))}
</div>
</PromptMenuList>
</>
);
}
@@ -930,13 +930,6 @@ export default function ImageStudio({
}, []);
// ── Adjust height on load ────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
handleTextareaInput();
}, 150);
return () => clearTimeout(timer);
}, []);
// ── Persistence: Save ────────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
@@ -1040,14 +1033,6 @@ export default function ImageStudio({
const showEffectBtn = currentEffects.length > 0;
// ── Textarea auto-resize ─────────────────────────────────────────────────
const handleTextareaInput = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const maxHeight = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxHeight) + "px";
};
// ── Upload picker callbacks ──────────────────────────────────────────────
const handleUploadSelect = useCallback(
({ url, urls }) => {
@@ -1446,17 +1431,13 @@ export default function ImageStudio({
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div
className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up"
style={{ animationDelay: "0.2s" }}
>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
{/* Top row: upload picker + textarea */}
<div className="flex flex-col gap-3">
{/* Inline list of uploaded files */}
<div className="flex items-center gap-2.5 flex-wrap">
{uploadedImageUrls && uploadedImageUrls.length > 0 && uploadedImageUrls.map((url, idx) => (
<div key={idx} className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<div key={url} className={PROMPT_MEDIA_PREVIEW_CLASS}>
<img src={url} alt="" className="w-full h-full object-cover" />
<button
type="button"
@@ -1499,21 +1480,18 @@ export default function ImageStudio({
</div>
{/* Input prompt text area */}
<textarea
<PromptTextarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onInput={handleTextareaInput}
placeholder={placeholderText}
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar"
/>
</div>
{/* Bottom row: controls + generate */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<PromptFooter>
{/* Left controls */}
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
<PromptControls ref={dropdownRef}>
{/* Model button */}
<div className="relative">
<button
@@ -1522,7 +1500,9 @@ export default function ImageStudio({
e.stopPropagation();
setDropdownOpen((o) => (o === "model" ? null : "model"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: dropdownOpen === "model",
})}
>
<div className="w-4 h-4 rounded overflow-hidden shrink-0 flex items-center justify-center bg-white/5">
{(() => {
@@ -1539,35 +1519,25 @@ export default function ImageStudio({
);
})()}
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedModelName}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
{dropdownOpen === "model" && (
<div
ref={dropdownRef}
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl w-[calc(100vw-2rem)] md:w-[480px] max-w-md md:max-w-none"
className="w-[calc(100vw-2rem)] md:w-[480px] max-w-md md:max-w-none max-h-[70vh]"
>
<PromptPopoverHeader>Model</PromptPopoverHeader>
<ModelDropdown
models={currentModels}
selectedModel={selectedModelId}
onSelect={handleModelSelect}
onClose={() => setDropdownOpen(null)}
/>
</div>
</PromptPopover>
)}
</div>
@@ -1579,20 +1549,19 @@ export default function ImageStudio({
e.stopPropagation();
setDropdownOpen((o) => (o === "ar" ? null : "ar"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: dropdownOpen === "ar",
})}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptAspectRatioIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedAr}
</span>
</button>
{dropdownOpen === "ar" && (
<div
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]"
>
<SimpleDropdown
title="Aspect Ratio"
@@ -1601,7 +1570,7 @@ export default function ImageStudio({
onSelect={(val) => setSelectedAr(val)}
onClose={() => setDropdownOpen(null)}
/>
</div>
</PromptPopover>
)}
</div>
@@ -1614,20 +1583,19 @@ export default function ImageStudio({
e.stopPropagation();
setDropdownOpen((o) => (o === "quality" ? null : "quality"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: dropdownOpen === "quality",
})}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-40 text-white">
<polygon points="12 2 22 12 12 22 2 12" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptQualityIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedQuality || currentResolutions[0]}
</span>
</button>
{dropdownOpen === "quality" && (
<div
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]"
>
<SimpleDropdown
title="Resolution"
@@ -1636,7 +1604,7 @@ export default function ImageStudio({
onSelect={(val) => setSelectedQuality(val)}
onClose={() => setDropdownOpen(null)}
/>
</div>
</PromptPopover>
)}
</div>
)}
@@ -1650,20 +1618,22 @@ export default function ImageStudio({
e.stopPropagation();
setDropdownOpen((o) => (o === "effect" ? null : "effect"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: dropdownOpen === "effect",
})}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors max-w-[140px] truncate">
<span className={`${PROMPT_CONTROL_LABEL_CLASS} max-w-[140px] truncate`}>
{selectedEffect || "Effect"}
</span>
</button>
{dropdownOpen === "effect" && (
<div
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[200px]"
className="min-w-[200px]"
>
<SimpleDropdown
title="Effect Type"
@@ -1672,13 +1642,13 @@ export default function ImageStudio({
onSelect={(val) => setSelectedEffect(val)}
onClose={() => setDropdownOpen(null)}
/>
</div>
</PromptPopover>
)}
</div>
)}
{/* Batch size stepper */}
<div className="h-[34px] flex items-center gap-2 bg-[#16161a]/60 rounded-md px-2.5 border border-white/[0.06] shadow-inner select-none">
<div className={promptControlClassName({ compact: true, className: "select-none" })}>
<button
type="button"
onClick={() => setBatchSize(prev => Math.max(1, prev - 1))}
@@ -1686,7 +1656,7 @@ export default function ImageStudio({
>
-
</button>
<span className="text-[11px] font-black text-white/70 min-w-[24px] text-center">
<span className="text-xs font-semibold text-white/70 min-w-[24px] text-center">
{batchSize}/4
</span>
<button
@@ -1701,25 +1671,23 @@ export default function ImageStudio({
{/* Draw button */}
<button
type="button"
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName()}
onClick={() => setIsDrawModalOpen(true)}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-40 text-white group-hover:text-[#22d3ee] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-40 text-white group-hover:text-[#22d3ee] transition-colors">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
Draw
</span>
</button>
</div>
</PromptControls>
{/* Generate button */}
<button
type="button"
<PromptAction
onClick={handleGenerate}
disabled={generating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{generating ? (
<>
@@ -1733,10 +1701,9 @@ export default function ImageStudio({
<span>Generate {batchSize}</span>
</>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
{/* ── FULLSCREEN IMAGE MODAL ── */}
{fullscreenUrl && (
+91 -104
View File
@@ -9,6 +9,24 @@ import {
getLipSyncModelById,
getResolutionsForLipSyncModel,
} from "../models.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PromptAction,
PromptChevronIcon,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptQualityIcon,
PromptSegmentedControl,
PromptSegmentOption,
PromptTextarea,
promptControlClassName,
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
// ---------------------------------------------------------------------------
// Upload button states
@@ -50,11 +68,6 @@ function MediaPickerButton({
await onUpload(file);
};
const borderClass =
uploadState === UPLOAD_STATE.READY
? "border-primary/60 bg-primary/5"
: "border-white/[0.03] bg-white/[0.03] hover:bg-white/[0.06] hover:border-primary/40";
return (
<button
type="button"
@@ -64,7 +77,9 @@ function MediaPickerButton({
: `Upload ${label.toLowerCase()} file`
}
onClick={handleClick}
className={`flex-shrink-0 w-10 h-10 rounded-full border transition-all flex items-center justify-center relative overflow-hidden group ${borderClass}`}
className={promptMediaButtonClassName({
active: uploadState === UPLOAD_STATE.READY,
})}
>
<input
ref={inputRef}
@@ -150,31 +165,17 @@ function MediaPickerButton({
// ---------------------------------------------------------------------------
// Inline dropdown
// ---------------------------------------------------------------------------
function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
function Dropdown({
isOpen,
title,
items,
selectedId,
onSelect,
onClose,
anchorRef,
className = "",
}) {
const dropRef = useRef(null);
const [style, setStyle] = useState({});
useEffect(() => {
if (!isOpen || !anchorRef?.current || !dropRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const ddHeight = dropRef.current.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
let top, bottom, maxHeight;
if (spaceBelow >= ddHeight || spaceBelow >= spaceAbove) {
top = rect.bottom + 8;
bottom = "auto";
maxHeight = Math.max(150, spaceBelow - 8);
} else {
top = "auto";
bottom = window.innerHeight - rect.top + 8;
maxHeight = Math.max(150, spaceAbove - 8);
}
const left = Math.min(rect.left, window.innerWidth - 220);
setStyle({ top, bottom, left, maxHeight });
}, [isOpen, anchorRef]);
useEffect(() => {
if (!isOpen) return;
@@ -193,39 +194,34 @@ function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
if (!isOpen) return null;
return (
<div
<PromptPopover
ref={dropRef}
style={{
position: "fixed",
zIndex: 100,
overflowY: "auto",
...style,
}}
className="bg-[#111] border border-white/10 rounded-lg shadow-3xl p-2 custom-scrollbar w-[calc(100vw-3rem)] max-w-xs"
className={className}
onClick={(e) => e.stopPropagation()}
>
<PromptPopoverHeader>{title}</PromptPopoverHeader>
<PromptMenuList>
{items.map((item) => (
<button
<PromptMenuItem
key={item.id}
type="button"
selected={item.id === selectedId}
description={
item.description
? `${item.description.slice(0, 60)}${
item.description.length > 60 ? "..." : ""
}`
: undefined
}
onClick={() => {
onSelect(item);
onClose();
}}
className={`w-full text-left px-4 py-2 rounded text-sm transition-all hover:bg-white/10 ${
item.id === selectedId
? "text-primary font-bold bg-primary/5"
: "text-white font-medium"
}`}
>
<div>{item.name}</div>
{item.description && (
<div className="text-xs text-muted mt-0.5">
{item.description.slice(0, 60)}...
</div>
)}
</button>
{item.name}
</PromptMenuItem>
))}
</div>
</PromptMenuList>
</PromptPopover>
);
}
@@ -522,10 +518,6 @@ export default function LipSyncStudio({
const handlePromptInput = (e) => {
setPrompt(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
const handleAudioPick = useCallback(
@@ -907,32 +899,34 @@ export default function LipSyncStudio({
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
{/* Mode toggle row */}
<div className="flex items-center gap-2 px-3">
<button
<div className="flex items-center px-1">
<PromptSegmentedControl>
<PromptSegmentOption
type="button"
onClick={switchToImage}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all border ${
inputMode === "image"
? "border-[#22d3ee]/60 bg-[#22d3ee]/5 text-[#22d3ee]"
: "border-white/[0.03] bg-white/[0.03] text-white/40 hover:border-white/20 hover:text-white"
}`}
selected={inputMode === "image"}
>
🖼 Portrait Image
</button>
<button
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
Portrait Image
</PromptSegmentOption>
<PromptSegmentOption
type="button"
onClick={switchToVideo}
className={`px-3 py-1 rounded-md text-[10px] font-bold transition-all border ${
inputMode === "video"
? "border-[#22d3ee]/60 bg-[#22d3ee]/5 text-[#22d3ee]"
: "border-white/[0.03] bg-white/[0.03] text-white/40 hover:border-white/20 hover:text-white"
}`}
selected={inputMode === "video"}
>
🎬 Video
</button>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="2" y="5" width="15" height="14" rx="2" />
<path d="m17 10 5-3v10l-5-3" />
</svg>
Video
</PromptSegmentOption>
</PromptSegmentedControl>
</div>
{/* Uploads row */}
@@ -1020,20 +1014,18 @@ export default function LipSyncStudio({
{/* Prompt textarea */}
<div className="flex-1 flex flex-col">
<textarea
<PromptTextarea
ref={textareaRef}
value={prompt}
onChange={handlePromptInput}
placeholder="Describe speech style..."
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 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}
/>
</div>
</div>
{/* Bottom controls row */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 px-1">
<PromptFooter>
<PromptControls>
{/* Model selector */}
<div className="relative">
<button
@@ -1045,35 +1037,29 @@ export default function LipSyncStudio({
openDropdown === "model" ? null : "model",
);
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "model",
})}
>
<div className="w-3.5 h-3.5 bg-[#22d3ee] rounded-sm flex items-center justify-center">
<span className="text-[9px] font-black text-black">
S
</span>
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedModel?.name ?? "Select model"}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
<Dropdown
isOpen={openDropdown === "model"}
title="Model"
items={modelDropdownItems}
selectedId={selectedModelId}
onSelect={handleModelSelect}
onClose={() => setOpenDropdown(null)}
anchorRef={modelBtnRef}
className="w-80 max-w-[calc(100vw-3rem)]"
/>
</div>
@@ -1089,14 +1075,18 @@ export default function LipSyncStudio({
openDropdown === "resolution" ? null : "resolution",
);
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "resolution",
})}
>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptQualityIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedResolution}
</span>
</button>
<Dropdown
isOpen={openDropdown === "resolution"}
title="Resolution"
items={resolutionDropdownItems}
selectedId={selectedResolution}
onSelect={(item) => setSelectedResolution(item.id)}
@@ -1105,14 +1095,12 @@ export default function LipSyncStudio({
/>
</div>
)}
</div>
</PromptControls>
{/* Generate button */}
<button
type="button"
<PromptAction
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{isGenerating ? (
<>
@@ -1128,10 +1116,9 @@ export default function LipSyncStudio({
<span>Sync Lip</span>
</>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
{/* ── FULLSCREEN MEDIA MODAL ── */}
{fullscreenUrl && (
@@ -2,6 +2,24 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { uploadFile, generateMarketingStudioAd } from "../muapi.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PromptAspectRatioIcon,
PromptAction,
PromptChevronIcon,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptDurationIcon,
PromptQualityIcon,
PromptTextarea,
promptControlClassName,
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
const SCROLLBAR_STYLE = `
.custom-scrollbar-thin::-webkit-scrollbar {
@@ -103,9 +121,10 @@ function UploadSlot({ icon, url, progress, label, onUpload, onClear, multiple =
<div
onClick={() => inputRef.current?.click()}
title={`Upload ${label}`}
className={`relative w-10 h-10 rounded-full border transition-all flex items-center justify-center cursor-pointer ${
url ? 'border-primary/40 bg-primary/5' : 'border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20'
}`}
className={promptMediaButtonClassName({
active: Boolean(url),
className: "cursor-pointer",
})}
>
<input
ref={inputRef}
@@ -159,11 +178,11 @@ function Dropdown({ isOpen, title, items, selectedId, onSelect, onClose, isVideo
if (!isOpen) return null;
return (
<div
<PromptPopover
ref={ref}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded p-4 shadow-4xl border border-white/10 w-[420px] animate-fade-in-up"
className="w-[420px] max-w-[calc(100vw-2rem)]"
>
<div className="text-[10px] font-black text-white/40 uppercase tracking-widest mb-4 px-1">{title}</div>
<PromptPopoverHeader className="mb-3">{title}</PromptPopoverHeader>
<div className="grid grid-cols-3 gap-3 max-h-[300px] overflow-y-auto custom-scrollbar pr-1">
{items.map(item => (
<div
@@ -208,7 +227,7 @@ function Dropdown({ isOpen, title, items, selectedId, onSelect, onClose, isVideo
</div>
))}
</div>
</div>
</PromptPopover>
);
}
@@ -227,24 +246,22 @@ function SimpleDropdown({ isOpen, title, options, selected, onSelect, onClose })
if (!isOpen) return null;
return (
<div
<PromptPopover
ref={ref}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded p-1 max-h-[200px] overflow-y-auto custom-scrollbar shadow-3xl border border-white/10 min-w-[140px] animate-fade-in-up"
>
<div className="text-[10px] font-black text-white/40 uppercase tracking-widest mb-2 px-3 pt-2">{title}</div>
<PromptPopoverHeader>{title}</PromptPopoverHeader>
<PromptMenuList>
{options.map(opt => (
<button
<PromptMenuItem
key={opt}
selected={selected === opt}
onClick={() => { onSelect(opt); onClose(); }}
className={`w-full text-left px-4 py-2 rounded text-xs font-bold transition-all flex items-center justify-between ${
selected === opt ? 'bg-primary text-black' : 'text-white/60 hover:bg-white/5 hover:text-white'
}`}
>
<span>{opt}</span>
{selected === opt && <CheckSvg />}
</button>
{opt}
</PromptMenuItem>
))}
</div>
</PromptMenuList>
</PromptPopover>
);
}
@@ -382,12 +399,6 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
}
};
const handleTextareaInput = (e) => {
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 250) + "px";
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
@@ -532,8 +543,7 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div style={{ animationDelay: "0.2s" }} className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up">
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
{additionalImages.length > 0 && (
<div className="flex items-center gap-1.5">
{additionalImages.map((img, idx) => (
@@ -551,20 +561,17 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
)}
{/* Top Row: Full-width Textarea */}
<div className="w-full relative">
<textarea
<PromptTextarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onInput={handleTextareaInput}
placeholder="Describe your ad script... Use @image1 for product, @image2 for avatar."
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 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"
/>
</div>
{/* Bottom Row: Uploads + Controls + Generate */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-3 flex-wrap">
<PromptFooter>
<PromptControls>
{/* Asset Uploads Group */}
<div className="flex items-center gap-1.5 pr-3 border-r border-white/10">
@@ -606,13 +613,15 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === 'format' ? null : 'format'); }}
className={`flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all group whitespace-nowrap ${dropdown === 'format' ? 'border-primary/50' : 'border-white/5'}`}
className={promptControlClassName({
active: dropdown === "format",
})}
>
<div className="w-4 h-4 bg-primary/10 rounded flex items-center justify-center border border-primary/20">
<span className="text-[8px] font-black text-primary uppercase">U</span>
</div>
<span className="text-sm font-bold text-white/70 group-hover:text-primary transition-colors">{params.format}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity"><path d="M6 9l6 6 6-6" /></svg>
<span className={PROMPT_CONTROL_LABEL_CLASS}>{params.format}</span>
<PromptChevronIcon />
</button>
<Dropdown
isOpen={dropdown === 'format'}
@@ -629,15 +638,17 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
<div className="relative flex items-center gap-1.5">
<button
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === 'avatar' ? null : 'avatar'); }}
className={`flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all group whitespace-nowrap ${dropdown === 'avatar' ? 'border-primary/50' : 'border-white/5'}`}
className={promptControlClassName({
active: dropdown === "avatar",
})}
>
<div className="w-4 h-4 rounded-full overflow-hidden border border-white/20 shadow-inner">
<img src={avatarImage || ASSETS.avatar[0].url} className="w-full h-full object-cover" />
</div>
<span className="text-sm font-bold text-white/70 group-hover:text-primary transition-colors">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{ASSETS.avatar.find(a => a.url === avatarImage)?.name || "Select Avatar"}
</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity"><path d="M6 9l6 6 6-6" /></svg>
<PromptChevronIcon />
</button>
{avatarImage && (
@@ -653,7 +664,10 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
setPreviewAvatar({ id: "custom", name: "Custom Uploaded Avatar", url: avatarImage });
}
}}
className="h-[34px] w-[34px] flex items-center justify-center bg-white/[0.03] hover:bg-white/[0.08] rounded border border-white/5 text-white/40 hover:text-primary transition-all"
className={promptControlClassName({
iconOnly: true,
className: "text-white/40 hover:text-[#22d3ee]",
})}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" />
@@ -680,13 +694,34 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
<div key={key} className="relative">
<button
onClick={(e) => { e.stopPropagation(); setDropdown(dropdown === key ? null : key); }}
className={`px-3 py-2 bg-white/[0.03] hover:bg-white/[0.08] rounded border transition-all text-sm font-bold ${dropdown === key ? 'border-primary/50 text-primary' : 'border-white/5 text-white/70'}`}
className={promptControlClassName({
active: dropdown === key,
className:
dropdown === key
? "text-xs font-semibold text-[#22d3ee]"
: "text-xs font-semibold text-white/70",
})}
>
{key === 'duration' ? `${params[key]}s` : params[key]}
{key === "ratio" ? (
<PromptAspectRatioIcon />
) : key === "res" ? (
<PromptQualityIcon />
) : (
<PromptDurationIcon />
)}
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{key === "duration" ? `${params[key]}s` : params[key]}
</span>
</button>
<SimpleDropdown
isOpen={dropdown === key}
title={key === 'res' ? 'Resolution' : key.toUpperCase()}
title={
key === "ratio"
? "Aspect Ratio"
: key === "res"
? "Resolution"
: "Duration"
}
options={OPTIONS[key]}
selected={params[key]}
onSelect={(val) => setParams({ ...params, [key]: val })}
@@ -694,12 +729,11 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
/>
</div>
))}
</div>
</PromptControls>
<button
<PromptAction
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{isGenerating ? (
<>
@@ -709,10 +743,9 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
) : (
<span>Launch</span>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
{/* Fullscreen Preview */}
{fullscreenUrl && (
+79 -98
View File
@@ -7,6 +7,22 @@ import {
getRecastModelById,
getAspectRatiosForRecastModel,
} from "../models.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PromptAspectRatioIcon,
PromptAction,
PromptChevronIcon,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptTextarea,
promptControlClassName,
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
// ---------------------------------------------------------------------------
// Upload button states
@@ -47,11 +63,6 @@ function MediaPickerButton({
await onUpload(file);
};
const borderClass =
uploadState === UPLOAD_STATE.READY
? "border-primary/60 bg-primary/5"
: "border-white/[0.03] bg-white/[0.03] hover:bg-white/[0.06] hover:border-primary/40";
return (
<button
type="button"
@@ -61,7 +72,9 @@ function MediaPickerButton({
: `Upload ${label.toLowerCase()} file`
}
onClick={handleClick}
className={`flex-shrink-0 w-10 h-10 rounded-full border transition-all flex items-center justify-center relative overflow-hidden group ${borderClass}`}
className={promptMediaButtonClassName({
active: uploadState === UPLOAD_STATE.READY,
})}
>
<input
ref={inputRef}
@@ -169,11 +182,12 @@ function AssetsDropdown({
const items = activeTab === "videos" ? videos : activeTab === "images" ? images : results;
return (
<div
<PromptPopover
ref={dropRef}
className="absolute bottom-[calc(100%+8px)] left-0 z-50 bg-[#111] border border-white/10 rounded-lg shadow-3xl p-3 custom-scrollbar w-80 max-h-80 flex flex-col gap-2 animate-fade-in"
className="w-80 max-h-80 overflow-hidden flex flex-col gap-2"
onClick={(e) => e.stopPropagation()}
>
<PromptPopoverHeader className="mb-0">Asset Library</PromptPopoverHeader>
{/* Tabs */}
<div className="flex border-b border-white/5 pb-1">
{["videos", "images", "results"].map((tab) => (
@@ -286,14 +300,23 @@ function AssetsDropdown({
))
)}
</div>
</div>
</PromptPopover>
);
}
// ---------------------------------------------------------------------------
// Inline dropdown
// ---------------------------------------------------------------------------
function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
function Dropdown({
isOpen,
title,
items,
selectedId,
onSelect,
onClose,
anchorRef,
className = "",
}) {
const dropRef = useRef(null);
useEffect(() => {
@@ -313,34 +336,28 @@ function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
if (!isOpen) return null;
return (
<div
<PromptPopover
ref={dropRef}
className="absolute bottom-[calc(100%+8px)] left-0 z-50 bg-[#111] border border-white/10 rounded-lg shadow-3xl p-2 custom-scrollbar w-64 max-h-60 overflow-y-auto animate-fade-in"
className={className}
onClick={(e) => e.stopPropagation()}
>
<PromptPopoverHeader>{title}</PromptPopoverHeader>
<PromptMenuList>
{items.map((item) => (
<button
<PromptMenuItem
key={item.id}
type="button"
selected={item.id === selectedId}
description={item.description?.slice(0, 75)}
onClick={() => {
onSelect(item);
onClose();
}}
className={`w-full text-left px-4 py-2 rounded text-sm transition-all hover:bg-white/10 ${
item.id === selectedId
? "text-primary font-bold bg-primary/5"
: "text-white font-medium"
}`}
>
<div>{item.name}</div>
{item.description && (
<div className="text-xs text-white/40 mt-0.5">
{item.description.slice(0, 75)}
</div>
)}
</button>
{item.name}
</PromptMenuItem>
))}
</div>
</PromptMenuList>
</PromptPopover>
);
}
@@ -606,10 +623,6 @@ export default function RecastStudio({
const handlePromptInput = (e) => {
setPrompt(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
const handleImageUpload = useCallback(
@@ -914,8 +927,7 @@ export default function RecastStudio({
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
{/* Uploads row */}
<div className="flex items-center gap-2 px-1">
<div className="flex items-center gap-2">
@@ -958,20 +970,18 @@ export default function RecastStudio({
{/* Prompt textarea */}
<div className="flex-1 flex flex-col">
<textarea
<PromptTextarea
ref={textareaRef}
value={prompt}
onChange={handlePromptInput}
placeholder="Optional — describe the motion or scene..."
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
rows={1}
/>
</div>
</div>
{/* Bottom controls row */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 px-1">
<PromptFooter>
<PromptControls>
{/* Model selector */}
<div className="relative">
<button
@@ -981,33 +991,27 @@ export default function RecastStudio({
e.stopPropagation();
setOpenDropdown(openDropdown === "model" ? null : "model");
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "model",
})}
>
<div className="w-3.5 h-3.5 bg-[#22d3ee] rounded-sm flex items-center justify-center">
<span className="text-[9px] font-black text-black">R</span>
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedModel?.name ?? "Select model"}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
<Dropdown
isOpen={openDropdown === "model"}
title="Model"
items={recastModels}
selectedId={selectedModelId}
onSelect={handleModelSelect}
onClose={() => setOpenDropdown(null)}
anchorRef={modelBtnRef}
className="w-80 max-w-[calc(100vw-2rem)]"
/>
</div>
@@ -1021,25 +1025,19 @@ export default function RecastStudio({
e.stopPropagation();
setOpenDropdown(openDropdown === "aspect" ? null : "aspect");
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "aspect",
})}
>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptAspectRatioIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedAspectRatio}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
<Dropdown
isOpen={openDropdown === "aspect"}
title="Aspect Ratio"
items={aspectDropdownItems}
selectedId={selectedAspectRatio}
onSelect={(item) => setSelectedAspectRatio(item.id)}
@@ -1059,28 +1057,21 @@ export default function RecastStudio({
e.stopPropagation();
setOpenDropdown(openDropdown === "orientation" ? null : "orientation");
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "orientation",
})}
>
<span className="text-xs font-semibold text-white/50 group-hover:text-[#22d3ee] transition-colors">
<span className="text-xs font-semibold text-current opacity-50 group-hover:opacity-100 transition-opacity">
Orientation:
</span>
<span className="text-xs font-bold text-white group-hover:text-[#22d3ee] transition-colors capitalize">
<span className="text-xs font-semibold text-current capitalize">
{characterOrientation}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
<Dropdown
isOpen={openDropdown === "orientation"}
title="Orientation"
items={[
{ id: "image", name: "Image", description: "Use image orientation (Max 10s video)" },
{ id: "video", name: "Video", description: "Use video orientation (Max 30s video)" },
@@ -1089,6 +1080,7 @@ export default function RecastStudio({
onSelect={(item) => setCharacterOrientation(item.id)}
onClose={() => setOpenDropdown(null)}
anchorRef={orientationBtnRef}
className="w-64"
/>
</div>
)}
@@ -1102,7 +1094,9 @@ export default function RecastStudio({
e.stopPropagation();
setOpenDropdown(openDropdown === "assets" ? null : "assets");
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "assets",
})}
>
<svg
width="14"
@@ -1119,17 +1113,7 @@ export default function RecastStudio({
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
Library
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
{openDropdown === "assets" && (
<AssetsDropdown
@@ -1161,14 +1145,12 @@ export default function RecastStudio({
/>
)}
</div>
</div>
</PromptControls>
{/* Generate button */}
<button
type="button"
<PromptAction
onClick={handleGenerate}
disabled={isGenerating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{isGenerating ? (
<>
@@ -1180,10 +1162,9 @@ export default function RecastStudio({
) : (
<span>Swap Body</span>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
{/* ── FULLSCREEN MEDIA MODAL ── */}
{fullscreenUrl && (
@@ -2,6 +2,24 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { runMotionGraphics, runMotionGraphicsEdit } from "../muapi.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PromptAspectRatioIcon,
PromptAction,
PromptChevronIcon,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptDurationIcon,
PromptSegmentedControl,
PromptSegmentOption,
PromptTextarea,
promptControlClassName,
} from "./prompt/PromptComposer.jsx";
// ── helpers ───────────────────────────────────────────────────────────────────
async function downloadFile(url, filename) {
@@ -34,15 +52,12 @@ const CheckSvg = () => (
// ── Dropdown helper ───────────────────────────────────────────────────────────
function DropdownItem({ label, selected, onClick }) {
return (
<div
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded cursor-pointer transition-all group"
<PromptMenuItem
selected={selected}
onClick={onClick}
>
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100">
{label}
</span>
{selected && <CheckSvg />}
</div>
{label}
</PromptMenuItem>
);
}
@@ -61,7 +76,7 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
// ── Dropdown open state ───────────────────────────────────────────────────
const [openDropdown, setOpenDropdown] = useState(null); // "ar" | "dur" | "source"
const containerRef = useRef(null);
const controlsRef = useRef(null);
const textareaRef = useRef(null);
// ── Generation state ──────────────────────────────────────────────────────
@@ -100,26 +115,13 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
// ── Close dropdowns on outside click ─────────────────────────────────────
useEffect(() => {
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
if (controlsRef.current && !controlsRef.current.contains(e.target)) {
setOpenDropdown(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// ── Textarea auto-resize ──────────────────────────────────────────────────
const handleTextareaInput = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const maxHeight = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxHeight) + "px";
};
useEffect(() => {
handleTextareaInput();
}, [prompt, editMode]);
// ── Timer ─────────────────────────────────────────────────────────────────
const startTimer = () => {
setElapsedTime(0);
@@ -222,10 +224,7 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
// ── Render ────────────────────────────────────────────────────────────────
return (
<div
ref={containerRef}
className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden"
>
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden">
{/* ── Fullscreen overlay ── */}
{fullscreenUrl && (
<div
@@ -472,33 +471,29 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
</div>
{/* ── BOTTOM PROMPT BAR — matches VideoStudio exactly ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
{/* ── Top Row: Mode Toggle & Edit Source Banner ── */}
<div className="flex items-center justify-between gap-3 px-1">
{/* Left: Mode toggle pill */}
<div className="flex items-center gap-1 bg-white/[0.03] border border-white/[0.05] rounded-full p-0.5 flex-shrink-0">
<button
<PromptSegmentedControl className="flex-shrink-0">
<PromptSegmentOption
type="button"
onClick={() => { setEditMode(false); setEditSourceId(null); }}
className={`px-3 py-1 rounded-full text-[11px] font-semibold transition-all ${
!editMode ? "bg-[#22d3ee] text-black shadow-md shadow-[#22d3ee]/20" : "text-white/40 hover:text-white/70"
}`}
selected={!editMode}
>
Generate
</button>
<button
</PromptSegmentOption>
<PromptSegmentOption
type="button"
onClick={() => setEditMode(true)}
disabled={editSources.length === 0}
className={`px-3 py-1 rounded-full text-[11px] font-semibold transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
editMode ? "bg-[#22d3ee] text-black shadow-md shadow-[#22d3ee]/20" : "text-white/40 hover:text-white/70"
}`}
selected={editMode}
className="disabled:opacity-30 disabled:cursor-not-allowed"
>
Edit
</button>
</div>
</PromptSegmentOption>
</PromptSegmentedControl>
{/* Right: Edit mode status banner beside toggle buttons */}
{editMode && (
@@ -525,19 +520,16 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
{/* Bottom: Textarea full width */}
<div className="w-full">
<textarea
<PromptTextarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onInput={handleTextareaInput}
onKeyDown={handleKeyDown}
placeholder={
editMode
? "Describe what to change — 'change background to dark navy, make bars gold, add particles…'"
: "Describe the motion graphic — 'Animated sales dashboard with glowing bar charts and rising numbers'"
}
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar"
/>
</div>
@@ -550,42 +542,37 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
)}
{/* ── Controls row: dropdowns + generate button ── */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
<PromptFooter>
<PromptControls ref={controlsRef}>
{/* ── Aspect Ratio dropdown ── */}
<div className="relative">
<button
type="button"
onClick={toggleDropdown("ar")}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
className={promptControlClassName({
active: openDropdown === "ar",
})}
>
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">A</span>
</div>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptAspectRatioIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{aspectRatio}
</span>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity ml-1">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{openDropdown === "ar" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] min-w-[140px]">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">Aspect Ratio</div>
<div className="flex flex-col gap-1">
<PromptPopover>
<PromptPopoverHeader>Aspect Ratio</PromptPopoverHeader>
<PromptMenuList>
{ASPECT_RATIOS.map((ar) => (
<div
<DropdownItem
key={ar}
className="flex items-center justify-between p-3 hover:bg-white/5 rounded cursor-pointer transition-all group/opt"
label={ar}
selected={aspectRatio === ar}
onClick={() => { setAspectRatio(ar); setOpenDropdown(null); }}
>
<span className="text-[11px] font-semibold text-white/70 group-hover/opt:text-white transition-opacity">{ar}</span>
{aspectRatio === ar && <CheckSvg />}
</div>
/>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
@@ -594,34 +581,29 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
<button
type="button"
onClick={toggleDropdown("dur")}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
className={promptControlClassName({
active: openDropdown === "dur",
})}
>
<div className="w-4 h-4 bg-[#22d3ee] rounded flex items-center justify-center shadow-lg shadow-[#22d3ee]/10">
<span className="text-[9px] font-bold text-black uppercase">T</span>
</div>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptDurationIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{duration}s
</span>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4" className="opacity-20 group-hover:opacity-100 transition-opacity ml-1">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{openDropdown === "dur" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/10 min-w-[140px] max-h-52 overflow-y-auto custom-scrollbar">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">Duration</div>
<div className="flex flex-col gap-1">
<PromptPopover>
<PromptPopoverHeader>Duration</PromptPopoverHeader>
<PromptMenuList>
{DURATION_OPTIONS.map((d) => (
<div
<DropdownItem
key={d}
className="flex items-center justify-between p-2 hover:bg-white/5 rounded-md cursor-pointer transition-all group/opt"
label={`${d}s`}
selected={duration === d}
onClick={() => { setDuration(d); setOpenDropdown(null); }}
>
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-white">{d}s</span>
{duration === d && <CheckSvg />}
</div>
/>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
@@ -631,7 +613,7 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
<button
type="button"
onClick={toggleDropdown("source")}
className="flex items-center gap-2 px-3 py-2 bg-[#22d3ee]/[0.04] hover:bg-[#22d3ee]/[0.08] rounded-md transition-all border border-[#22d3ee]/[0.08] group whitespace-nowrap"
className={promptControlClassName({ active: true })}
>
<div className="w-4 h-4 bg-[#22d3ee]/20 rounded flex items-center justify-center border border-[#22d3ee]/30">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
@@ -639,16 +621,14 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<span className="text-xs font-semibold text-[#22d3ee]/70 group-hover:text-[#22d3ee] transition-colors max-w-[120px] truncate">
<span className={`${PROMPT_CONTROL_LABEL_CLASS} text-[#22d3ee]/70 max-w-[120px] truncate`}>
{sourceEntry ? `Source: ${sourceEntry.prompt?.slice(0, 20)}` : "Pick source…"}
</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="opacity-30 flex-shrink-0">
<path d="M6 9l6 6 6-6"/>
</svg>
<PromptChevronIcon />
</button>
{openDropdown === "source" && (
<div className="absolute bottom-[calc(100%+12px)] left-0 z-50 w-64 bg-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] max-h-64 overflow-y-auto custom-scrollbar">
<div className="text-xs font-bold text-white/20 border-b border-white/[0.03] mb-2">Source Generation</div>
<PromptPopover className="w-64">
<PromptPopoverHeader>Source Generation</PromptPopoverHeader>
<div className="flex flex-col gap-1">
{editSources.map((src) => (
<div
@@ -667,20 +647,18 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
</div>
))}
</div>
</div>
</PromptPopover>
)}
</div>
)}
<span className="text-[10px] text-white/20 hidden sm:block ml-2">Ctrl+Enter to run</span>
</div>
</PromptControls>
{/* ── Generate Button — matches VideoStudio exactly ── */}
<button
type="button"
<PromptAction
onClick={handleGenerate}
disabled={generating || !prompt.trim() || (editMode && !editSourceId)}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{generating ? (
<>
@@ -694,10 +672,9 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
) : (
<span>Generate</span>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
</div>
);
}
+257 -338
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { generateVideo, generateI2V, processV2V, uploadFile } from "../muapi.js";
import DrawModal from "./DrawModal.jsx";
import {
t2vModels,
i2vModels,
@@ -17,6 +18,25 @@ import {
getModesForModel,
getMaxImagesForI2VModel,
} from "../models.js";
import {
PROMPT_CONTROL_LABEL_CLASS,
PROMPT_MEDIA_PREVIEW_CLASS,
PromptAspectRatioIcon,
PromptAction,
PromptChevronIcon,
PromptComposer,
PromptControls,
PromptFooter,
PromptMenuItem,
PromptMenuList,
PromptPopover,
PromptPopoverHeader,
PromptDurationIcon,
PromptQualityIcon,
PromptTextarea,
promptControlClassName,
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
// ── tiny helpers ──────────────────────────────────────────────────────────────
@@ -90,20 +110,6 @@ const VideoReadySvg = () => (
// ── Dropdown components ───────────────────────────────────────────────────────
function DropdownItem({ label, selected, onClick }) {
return (
<div
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group"
onClick={onClick}
>
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100 capitalize">
{label}
</span>
{selected && <CheckSvg />}
</div>
);
}
const PROVIDER_LOGOS = {
openai: "https://cdn.muapi.ai/models/openai.png",
google: "https://cdn.muapi.ai/models/gemini.png",
@@ -388,33 +394,6 @@ function ModelDropdown({ imageMode, selectedModel, onSelect, onClose }) {
// ── Control button ────────────────────────────────────────────────────────────
function ControlBtn({ icon, label, onClick, style }) {
return (
<button
type="button"
onClick={onClick}
style={style}
className="flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap"
>
{icon}
<span className="text-xs font-bold text-white group-hover:text-primary transition-colors">
{label}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-20 group-hover:opacity-100 transition-opacity"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
);
}
// ── Dropdown panel ─────────────────────────────────────────────────────────────
// Rendered inside a `relative` wrapper div; floats above the anchor button.
@@ -483,6 +462,7 @@ export default function VideoStudio({
const [canvasUrl, setCanvasUrl] = useState(null);
const [canvasModel, setCanvasModel] = useState(null);
const [showCanvas, setShowCanvas] = useState(false);
const [isDrawModalOpen, setIsDrawModalOpen] = useState(false);
const [lastGenerationId, setLastGenerationId] = useState(null);
const [lastGenerationModel, setLastGenerationModel] = useState(null);
@@ -668,19 +648,6 @@ export default function VideoStudio({
}
}, [applyControlsForModel, defaultModel.id]);
// ── Adjust height on load ────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
if (textareaRef.current) {
const el = textareaRef.current;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
}
}, 150);
return () => clearTimeout(timer);
}, []);
// ── Persistence: Save ────────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
@@ -730,85 +697,134 @@ export default function VideoStudio({
// ── Derived UI values ────────────────────────────────────────────────────
const processDroppedImage = async (file) => {
if (file.size > 10 * 1024 * 1024) {
alert("Image exceeds 10MB limit.");
return;
}
setImageUploading(true);
setImageProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setImageProgress(pct);
});
const applyImageReferenceUrl = useCallback(
(url) => {
if (!url) return;
setUploadedImageUrl(url);
// Motion-control models use the image alongside the uploaded video.
if (isMotionControlSelection(selectedModel, v2vMode)) {
setUploadedImageUrls([url]);
setPromptDisabled(false);
return;
}
const currentT2V = t2vModels.find((model) => model.id === selectedModel);
// Models with native image inputs stay in their current mode.
if (currentT2V?.inputs?.images_list) {
const maxImages = currentT2V.inputs.images_list.maxItems || 8;
setUploadedImageUrls((previousUrls) => {
if (previousUrls.includes(url)) return previousUrls;
return [...previousUrls, url].slice(0, maxImages);
});
setPromptDisabled(false);
return;
}
setUploadedVideoUrl(null);
setUploadedVideoName(null);
setV2vMode(false);
let targetModelId = selectedModel;
const sibling = currentT2V?.family
? i2vModels.find((model) => model.family === currentT2V.family)
: null;
const targetModel = imageMode
? i2vModels.find((model) => model.id === selectedModel)
: sibling || i2vModels[0];
if (!targetModel) return;
if (!imageMode) {
const currentT2V = t2vModels.find((m) => m.id === selectedModel);
const sibling = currentT2V?.family
? i2vModels.find((m) => m.family === currentT2V.family)
: null;
const target = sibling || i2vModels[0];
targetModelId = target.id;
setImageMode(true);
setSelectedModel(target.id);
setSelectedModelName(target.name);
applyControlsForModel(target.id, true, false);
setSelectedModel(targetModel.id);
setSelectedModelName(targetModel.name);
applyControlsForModel(targetModel.id, true, false);
}
const maxImgs = getMaxImagesForI2VModel(targetModelId);
if (maxImgs > 2) {
setUploadedImageUrls((prev) => {
if (prev.includes(url)) return prev;
return [...prev, url].slice(0, maxImgs);
const maxImages = getMaxImagesForI2VModel(targetModel.id);
if (maxImages > 2) {
setUploadedImageUrls((previousUrls) => {
if (previousUrls.includes(url)) return previousUrls;
return [...previousUrls, url].slice(0, maxImages);
});
} else {
setUploadedImageUrls([url]);
}
setPromptDisabled(false);
} catch (err) {
alert(`Image upload failed: ${err.message}`);
} finally {
setImageUploading(false);
setImageProgress(0);
}
};
},
[
applyControlsForModel,
imageMode,
isMotionControlSelection,
selectedModel,
v2vMode,
],
);
const processDroppedVideo = async (file) => {
if (file.size > 50 * 1024 * 1024) {
alert("Video exceeds 50MB limit.");
return;
}
setVideoUploading(true);
setVideoProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setVideoProgress(pct);
});
setUploadedVideoUrl(url);
setUploadedVideoName(file.name);
if (imageMode) {
setUploadedImageUrl(null);
setImageMode(false);
const handleDrawReference = useCallback(
(entry) => {
applyImageReferenceUrl(entry?.url);
},
[applyImageReferenceUrl],
);
const uploadImageReference = useCallback(
async (file) => {
if (file.size > 10 * 1024 * 1024) {
alert("Image exceeds 10MB limit.");
return;
}
setV2vMode(true);
const firstV2V = v2vModels[0];
setSelectedModel(firstV2V.id);
setSelectedModelName(firstV2V.name);
applyControlsForModel(firstV2V.id, false, true);
setPrompt("");
setPromptDisabled(true);
} catch (err) {
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoUploading(false);
setImageUploading(true);
setImageProgress(0);
try {
const url = await uploadFile(apiKey, file, setImageProgress);
applyImageReferenceUrl(url);
} catch (err) {
console.error("[VideoStudio] Image upload failed:", err);
alert(`Image upload failed: ${err.message}`);
} finally {
setImageUploading(false);
setImageProgress(0);
}
},
[apiKey, applyImageReferenceUrl],
);
const processDroppedVideo = useCallback(
async (file) => {
if (file.size > 50 * 1024 * 1024) {
alert("Video exceeds 50MB limit.");
return;
}
setVideoUploading(true);
setVideoProgress(0);
}
};
try {
const url = await uploadFile(apiKey, file, setVideoProgress);
setUploadedVideoUrl(url);
setUploadedVideoName(file.name);
if (imageMode) {
setUploadedImageUrl(null);
setImageMode(false);
}
setV2vMode(true);
const firstV2V = v2vModels[0];
setSelectedModel(firstV2V.id);
setSelectedModelName(firstV2V.name);
applyControlsForModel(firstV2V.id, false, true);
setPrompt("");
setPromptDisabled(true);
} catch (err) {
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoUploading(false);
setVideoProgress(0);
}
},
[apiKey, applyControlsForModel, imageMode],
);
// ── Handle Dropped Files ────────────────────────────────────────────────
useEffect(() => {
@@ -819,11 +835,11 @@ export default function VideoStudio({
if (videoFiles.length > 0) {
processDroppedVideo(videoFiles[0]);
} else if (imageFiles.length > 0) {
processDroppedImage(imageFiles[0]);
uploadImageReference(imageFiles[0]);
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, processDroppedImage, processDroppedVideo]);
}, [droppedFiles, onFilesHandled, processDroppedVideo, uploadImageReference]);
// Initialise controls for default model on mount
useEffect(() => {
@@ -844,84 +860,17 @@ export default function VideoStudio({
return () => window.removeEventListener("click", handler);
}, [openDropdown]);
// ── textarea auto-resize ──────────────────────────────────────────────────
const handlePromptInput = (e) => {
setPrompt(e.target.value);
const el = e.target;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
};
// ── image upload ─────────────────────────────────────────────────────────
const handleImageFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert("Image exceeds 10MB limit.");
return;
}
setImageUploading(true);
setImageProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setImageProgress(pct);
});
setUploadedImageUrl(url);
// Motion-control v2v: image is a second input, not a mode switch
if (isMotionControlSelection(selectedModel, v2vMode)) {
setPromptDisabled(false);
setUploadedImageUrls([url]);
} else {
// Model-native image reference (e.g. Seedance 2.0 Extend with inputs.images_list):
// keep the current model & mode; just accumulate the image URL
const currentT2VOrExtend = t2vModels.find((m) => m.id === selectedModel);
if (currentT2VOrExtend?.inputs?.images_list) {
const maxImgs = currentT2VOrExtend.inputs?.images_list?.maxItems || 8;
setUploadedImageUrls((prev) => {
if (prev.includes(url)) return prev;
return [...prev, url].slice(0, maxImgs);
});
setPromptDisabled(false);
} else {
// Standard flow: clear v2v and switch to an I2V sibling model
setUploadedVideoUrl(null);
setUploadedVideoName(null);
setV2vMode(false);
let targetModelId = selectedModel;
if (!imageMode) {
const sibling = currentT2VOrExtend?.family
? i2vModels.find((m) => m.family === currentT2VOrExtend.family)
: null;
const target = sibling || i2vModels[0];
targetModelId = target.id;
setImageMode(true);
setSelectedModel(target.id);
setSelectedModelName(target.name);
applyControlsForModel(target.id, true, false);
}
const maxImgs = getMaxImagesForI2VModel(targetModelId);
if (maxImgs > 2) {
setUploadedImageUrls((prev) => {
if (prev.includes(url)) return prev;
return [...prev, url].slice(0, maxImgs);
});
} else {
setUploadedImageUrls([url]);
}
setPromptDisabled(false);
}
}
} catch (err) {
console.error("[VideoStudio] Image upload failed:", err);
alert(`Image upload failed: ${err.message}`);
await uploadImageReference(file);
} finally {
setImageUploading(false);
setImageProgress(0);
if (imageFileInputRef.current) imageFileInputRef.current.value = "";
}
};
@@ -1369,6 +1318,9 @@ export default function VideoStudio({
canvasModel === "seedance-v2.0-t2v" || canvasModel === "seedance-v2.0-i2v";
const currentModelObj = getCurrentModel();
const isExtendMode = currentModelObj?.requiresRequestId;
const canUploadImageReference =
(!v2vMode || isMotionControlSelection(selectedModel, v2vMode)) &&
(!isExtendMode || currentModelObj?.inputs?.images_list);
const promptPlaceholder = v2vMode
? currentModelObj?.imageField
@@ -1575,14 +1527,13 @@ export default function VideoStudio({
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
<PromptComposer>
<div className="flex flex-col gap-3">
{/* Inline list of uploaded media files */}
<div className="flex items-center gap-2.5 flex-wrap">
{/* Main image preview */}
{uploadedImageUrl && (
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<div className={PROMPT_MEDIA_PREVIEW_CLASS}>
<img src={uploadedImageUrl} alt="" className="w-full h-full object-cover" />
<button
type="button"
@@ -1596,7 +1547,7 @@ export default function VideoStudio({
{/* End frame image preview */}
{uploadedEndImageUrl && (
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<div className={PROMPT_MEDIA_PREVIEW_CLASS}>
<img src={uploadedEndImageUrl} alt="" className="w-full h-full object-cover" />
<button
type="button"
@@ -1613,7 +1564,7 @@ export default function VideoStudio({
{/* Video preview */}
{uploadedVideoUrl && (
<div className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<div className={PROMPT_MEDIA_PREVIEW_CLASS}>
<video src={uploadedVideoUrl} className="w-full h-full object-cover" muted />
<button
type="button"
@@ -1629,7 +1580,7 @@ export default function VideoStudio({
{imageMode && getMaxImagesForI2VModel(selectedModel) > 2 && (
<>
{uploadedImageUrls.map((url, idx) => (
<div key={idx} className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<div key={url} className={PROMPT_MEDIA_PREVIEW_CLASS}>
<img src={url} alt="" className="w-full h-full object-cover" />
<button
type="button"
@@ -1654,7 +1605,7 @@ export default function VideoStudio({
• T2V with inputs.images_list: optional reference images (e.g. Seedance 2.0 Extend)
• Hidden in regular V2V mode (watermark remover etc. needs no image)
• Hidden for extend-type models without inputs.images_list */}
{((!v2vMode || isMotionControlSelection(selectedModel, v2vMode)) && (!isExtendMode || currentModelObj?.inputs?.images_list)) && (
{canUploadImageReference && (
getMaxImagesForI2VModel(selectedModel) > 2 ? (
uploadedImageUrls.length < getMaxImagesForI2VModel(selectedModel) && (
<div className="relative">
@@ -1669,7 +1620,7 @@ export default function VideoStudio({
type="button"
title="Upload reference image"
onClick={() => imageFileInputRef.current?.click()}
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
className={promptMediaButtonClassName()}
>
{imageUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
@@ -1712,7 +1663,7 @@ export default function VideoStudio({
type="button"
title="Upload reference image"
onClick={() => imageFileInputRef.current?.click()}
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
className={promptMediaButtonClassName()}
>
{imageUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
@@ -1758,7 +1709,7 @@ export default function VideoStudio({
type="button"
title="Upload end frame (optional)"
onClick={() => endImageFileInputRef.current?.click()}
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
className={promptMediaButtonClassName()}
>
{endImageUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
@@ -1803,7 +1754,7 @@ export default function VideoStudio({
type="button"
title="Upload video to remove watermark"
onClick={() => videoFileInputRef.current?.click()}
className="w-12 h-12 shrink-0 rounded-xl border border-dashed border-white/10 hover:border-[#22d3ee]/40 bg-white/[0.02] hover:bg-white/5 transition-all flex items-center justify-center relative overflow-hidden group"
className={promptMediaButtonClassName()}
>
{videoUploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
@@ -1844,14 +1795,12 @@ export default function VideoStudio({
{/* Prompt textarea */}
<div className="flex-1 flex flex-col gap-1">
<textarea
<PromptTextarea
ref={textareaRef}
value={prompt}
onChange={handlePromptInput}
placeholder={promptPlaceholder}
disabled={promptDisabled}
rows={1}
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"
/>
</div>
</div>
@@ -1874,14 +1823,16 @@ export default function VideoStudio({
)}
{/* Bottom row: controls + generate */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
<PromptFooter>
<PromptControls ref={dropdownRef}>
{/* Model btn */}
<div className="relative">
<button
type="button"
onClick={toggleDropdown("model")}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "model",
})}
>
<div className="w-4 h-4 rounded overflow-hidden shrink-0 flex items-center justify-center bg-white/5">
{(() => {
@@ -1899,34 +1850,24 @@ export default function VideoStudio({
);
})()}
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedModelName}
</span>
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-20 group-hover:opacity-100 transition-opacity"
>
<path d="M6 9l6 6 6-6" />
</svg>
<PromptChevronIcon />
</button>
{openDropdown === "model" && (
<div
ref={dropdownRef}
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-[1.5rem] p-3.5 shadow-2xl border border-white/[0.05] w-[calc(100vw-2rem)] md:w-[480px] max-w-md md:max-w-none"
className="w-[calc(100vw-2rem)] md:w-[480px] max-w-md md:max-w-none max-h-[70vh]"
>
<PromptPopoverHeader>Model</PromptPopoverHeader>
<ModelDropdown
imageMode={imageMode}
selectedModel={selectedModel}
onSelect={handleModelSelect}
onClose={() => setOpenDropdown(null)}
/>
</div>
</PromptPopover>
)}
</div>
@@ -1936,58 +1877,38 @@ export default function VideoStudio({
<button
type="button"
onClick={toggleDropdown("ar")}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "ar",
})}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="opacity-40 text-white"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
ry="2"
/>
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptAspectRatioIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedAr}
</span>
</button>
{openDropdown === "ar" && (
<div
ref={dropdownRef}
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-80 overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]"
>
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
<PromptPopoverHeader>
Aspect Ratio
</div>
<div className="flex flex-col gap-1">
</PromptPopoverHeader>
<PromptMenuList>
{getCurrentAspectRatios(selectedModel).map((r) => (
<div
<PromptMenuItem
key={r}
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
selected={selectedAr === r}
onClick={(e) => {
e.stopPropagation();
setSelectedAr(r);
setOpenDropdown(null);
}}
>
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
{r}
</span>
{selectedAr === r && <CheckSvg />}
</div>
{r}
</PromptMenuItem>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
)}
@@ -1998,11 +1919,13 @@ export default function VideoStudio({
<button
type="button"
onClick={toggleDropdown("effect")}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "effect",
})}
>
<svg
width="14"
height="14"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -2011,38 +1934,34 @@ export default function VideoStudio({
>
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors max-w-[140px] truncate">
<span className={`${PROMPT_CONTROL_LABEL_CLASS} max-w-[140px] truncate`}>
{selectedEffect || "Effect"}
</span>
</button>
{openDropdown === "effect" && (
<div
ref={dropdownRef}
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-80 overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[200px]"
className="min-w-[200px]"
>
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
<PromptPopoverHeader>
Effect Type
</div>
<div className="flex flex-col gap-1">
</PromptPopoverHeader>
<PromptMenuList>
{getEffectsForI2VModel(selectedModel).map((eff) => (
<div
<PromptMenuItem
key={eff}
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
selected={selectedEffect === eff}
onClick={(e) => {
e.stopPropagation();
setSelectedEffect(eff);
setOpenDropdown(null);
}}
>
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
{eff}
</span>
{selectedEffect === eff && <CheckSvg />}
</div>
{eff}
</PromptMenuItem>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
)}
@@ -2053,52 +1972,38 @@ export default function VideoStudio({
<button
type="button"
onClick={toggleDropdown("duration")}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "duration",
})}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="opacity-40 text-white"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptDurationIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedDuration}s
</span>
</button>
{openDropdown === "duration" && (
<div
ref={dropdownRef}
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[140px]"
>
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
<PromptPopoverHeader>
Duration
</div>
<div className="flex flex-col gap-1">
</PromptPopoverHeader>
<PromptMenuList>
{getCurrentDurations(selectedModel).map((d) => (
<div
<PromptMenuItem
key={d}
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
selected={selectedDuration === d}
onClick={(e) => {
e.stopPropagation();
setSelectedDuration(d);
setOpenDropdown(null);
}}
>
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
{d}s
</span>
{selectedDuration === d && <CheckSvg />}
</div>
{d}s
</PromptMenuItem>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
)}
@@ -2109,62 +2014,69 @@ export default function VideoStudio({
<button
type="button"
onClick={toggleDropdown("resolution")}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
className={promptControlClassName({
active: openDropdown === "resolution",
})}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="opacity-40 text-white"
>
<polygon points="12 2 22 12 12 22 2 12" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
<PromptQualityIcon />
<span className={PROMPT_CONTROL_LABEL_CLASS}>
{selectedResolution || "720p"}
</span>
</button>
{openDropdown === "resolution" && (
<div
ref={dropdownRef}
<PromptPopover
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[140px]"
>
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
<PromptPopoverHeader>
Resolution
</div>
<div className="flex flex-col gap-1">
</PromptPopoverHeader>
<PromptMenuList>
{getCurrentResolutions(selectedModel).map((r) => (
<div
<PromptMenuItem
key={r}
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group/opt"
selected={selectedResolution === r}
onClick={(e) => {
e.stopPropagation();
setSelectedResolution(r);
setOpenDropdown(null);
}}
>
<span className="text-xs font-semibold text-white/70 group-hover/opt:text-[#22d3ee] transition-colors">
{r}
</span>
{selectedResolution === r && <CheckSvg />}
</div>
{r}
</PromptMenuItem>
))}
</div>
</div>
</PromptMenuList>
</PromptPopover>
)}
</div>
)}
</div>
{canUploadImageReference && (
<button
type="button"
className={promptControlClassName()}
onClick={() => setIsDrawModalOpen(true)}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="opacity-40 text-white group-hover:text-[#22d3ee] transition-colors"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span className={PROMPT_CONTROL_LABEL_CLASS}>Draw</span>
</button>
)}
</PromptControls>
{/* Generate button */}
<button
type="button"
<PromptAction
onClick={handleGenerate}
disabled={generating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
{generating ? (
<>
@@ -2180,10 +2092,9 @@ export default function VideoStudio({
<span>Generate</span>
</>
)}
</button>
</div>
</div>
</div>
</PromptAction>
</PromptFooter>
</PromptComposer>
{/* ── FULLSCREEN VIDEO MODAL ── */}
{fullscreenUrl && (
@@ -2214,6 +2125,14 @@ export default function VideoStudio({
/>
</div>
)}
<DrawModal
isOpen={isDrawModalOpen}
onClose={() => setIsDrawModalOpen(false)}
apiKey={apiKey}
batchSize={1}
onAddHistoryItem={handleDrawReference}
/>
</div>
);
}
@@ -0,0 +1,409 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from "react";
const DEFAULT_POSITION_CLASS =
"absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-30 animate-fade-in-up";
const DEFAULT_PANEL_CLASS =
"w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]";
const DEFAULT_TEXTAREA_CLASS =
"w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 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";
const DEFAULT_ACTION_CLASS =
"bg-[#22d3ee] text-black px-7 py-3 rounded-full font-bold text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10 disabled:opacity-50 disabled:cursor-not-allowed";
const CONTROL_LAYOUT_CLASS =
"h-[38px] flex items-center gap-2 rounded-md transition-all border group whitespace-nowrap shadow-inner focus:outline-none focus-visible:border-[#22d3ee]/45 focus-visible:ring-1 focus-visible:ring-[#22d3ee]/30";
const CONTROL_IDLE_CLASS =
"text-white bg-[#16161a]/60 hover:bg-[#202026]/80 border-white/[0.06]";
const CONTROL_ACTIVE_CLASS =
"text-[#22d3ee] bg-[#22d3ee]/10 hover:bg-[#22d3ee]/15 border-[#22d3ee]/25";
const MEDIA_CONTROL_LAYOUT_CLASS =
"w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden group focus:outline-none focus-visible:border-[#22d3ee]/45 focus-visible:ring-1 focus-visible:ring-[#22d3ee]/30";
const DEFAULT_POPOVER_POSITION_CLASS =
"absolute bottom-[calc(100%+12px)] left-0 z-50";
const DEFAULT_POPOVER_CLASS =
"bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px] max-h-[40vh] overflow-y-auto custom-scrollbar";
function joinClasses(...classes) {
return classes.filter(Boolean).join(" ");
}
export function promptControlClassName({
active = false,
compact = false,
iconOnly = false,
className = "",
} = {}) {
return joinClasses(
CONTROL_LAYOUT_CLASS,
iconOnly
? "w-[38px] px-0 justify-center"
: compact
? "px-3"
: "px-4",
active ? CONTROL_ACTIVE_CLASS : CONTROL_IDLE_CLASS,
className,
);
}
export function promptMediaButtonClassName({
active = false,
className = "",
} = {}) {
return joinClasses(
MEDIA_CONTROL_LAYOUT_CLASS,
active
? "border-[#22d3ee]/60 bg-[#22d3ee]/5 hover:border-[#22d3ee]/70"
: "border-white/[0.03] bg-white/[0.03] hover:bg-white/[0.06] hover:border-[#22d3ee]/40",
className,
);
}
export const PROMPT_MEDIA_PREVIEW_CLASS =
"relative w-10 h-10 shrink-0 rounded-full border border-white/10 overflow-hidden shadow-md group";
export const PROMPT_CONTROL_LABEL_CLASS =
"text-xs font-semibold text-current opacity-70 group-hover:text-[#22d3ee] group-hover:opacity-100 transition-all";
export function PromptChevronIcon({ className = "" }) {
return (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className={joinClasses(
"text-current opacity-[0.45] group-hover:opacity-100 flex-shrink-0 transition-opacity",
className,
)}
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
export function PromptAspectRatioIcon({ className = "" }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={joinClasses("text-current opacity-[0.45] flex-shrink-0", className)}
aria-hidden="true"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
</svg>
);
}
export function PromptDurationIcon({ className = "" }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={joinClasses("text-current opacity-[0.45] flex-shrink-0", className)}
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}
export function PromptQualityIcon({ className = "" }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={joinClasses("text-current opacity-70 flex-shrink-0", className)}
aria-hidden="true"
>
<path d="M6.5 3.5h11L22 9 12 21 2 9l4.5-5.5Z" />
<path d="M2 9h20" />
<path d="m6.5 3.5 3 5.5L12 21" />
<path d="m17.5 3.5-3 5.5L12 21" />
</svg>
);
}
export const PromptPopover = forwardRef(function PromptPopover(
{
children,
className = "",
positionClassName = DEFAULT_POPOVER_POSITION_CLASS,
...props
},
ref,
) {
return (
<div
{...props}
ref={ref}
className={joinClasses(
positionClassName,
DEFAULT_POPOVER_CLASS,
className,
)}
>
{children}
</div>
);
});
export function PromptPopoverHeader({ children, className = "" }) {
return (
<div
className={joinClasses(
"text-[11px] font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1",
className,
)}
>
{children}
</div>
);
}
export function PromptMenuList({ children, className = "" }) {
return (
<div role="menu" className={joinClasses("flex flex-col gap-1", className)}>
{children}
</div>
);
}
export function PromptMenuItem({
children,
description,
selected = false,
className = "",
type = "button",
...props
}) {
return (
<button
{...props}
type={type}
aria-checked={selected}
role="menuitemradio"
className={joinClasses(
"w-full min-h-10 flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl text-left cursor-pointer transition-all group/menu-item",
"text-xs font-semibold text-white/70 hover:bg-[#22d3ee]/10 hover:text-[#22d3ee] focus:outline-none focus-visible:bg-[#22d3ee]/10 focus-visible:text-[#22d3ee]",
className,
)}
>
<span className="min-w-0">
<span className="block truncate">{children}</span>
{description && (
<span className="block text-[9px] font-medium text-white/35 mt-0.5 truncate group-hover/menu-item:text-white/50">
{description}
</span>
)}
</span>
{selected && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#22d3ee"
strokeWidth="4.5"
className="flex-shrink-0"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
);
}
export function PromptSegmentedControl({ children, className = "" }) {
return (
<div
className={joinClasses(
"inline-flex items-center gap-1 bg-white/[0.03] border border-white/[0.05] rounded-full p-0.5",
className,
)}
>
{children}
</div>
);
}
export function PromptSegmentOption({
children,
selected = false,
className = "",
type = "button",
...props
}) {
return (
<button
{...props}
type={type}
aria-pressed={selected}
className={joinClasses(
"min-h-7 px-3 py-1 rounded-full text-xs font-semibold transition-all flex items-center justify-center gap-1.5",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-[#22d3ee]/40",
selected
? "bg-[#22d3ee] text-black shadow-md shadow-[#22d3ee]/20"
: "text-white/40 hover:text-white/70",
className,
)}
>
{children}
</button>
);
}
export function PromptComposer({
children,
className = "",
panelClassName = "",
positionClassName = DEFAULT_POSITION_CLASS,
style = { animationDelay: "0.2s" },
}) {
return (
<div className={joinClasses(positionClassName, className)} style={style}>
<div className={joinClasses(DEFAULT_PANEL_CLASS, panelClassName)}>
{children}
</div>
</div>
);
}
export const PromptTextarea = forwardRef(function PromptTextarea(
{
value,
onChange,
onInput,
className = "",
maxHeightMobile = 150,
maxHeightDesktop = 250,
rows = 1,
...props
},
forwardedRef,
) {
const internalRef = useRef(null);
useImperativeHandle(forwardedRef, () => internalRef.current);
const resize = useCallback(
(element = internalRef.current) => {
if (!element) return;
element.style.height = "auto";
const maxHeight =
window.innerWidth < 768 ? maxHeightMobile : maxHeightDesktop;
element.style.height = `${Math.min(element.scrollHeight, maxHeight)}px`;
},
[maxHeightDesktop, maxHeightMobile],
);
useEffect(() => {
resize();
}, [resize, value]);
const handleChange = (event) => {
onChange?.(event);
resize(event.currentTarget);
};
const handleInput = (event) => {
onInput?.(event);
resize(event.currentTarget);
};
return (
<textarea
{...props}
ref={internalRef}
value={value}
onChange={handleChange}
onInput={handleInput}
rows={rows}
className={joinClasses(DEFAULT_TEXTAREA_CLASS, className)}
/>
);
});
export function PromptFooter({ children, className = "" }) {
return (
<div
className={joinClasses(
"flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative",
className,
)}
>
{children}
</div>
);
}
export const PromptControls = forwardRef(function PromptControls(
{ children, className = "" },
ref,
) {
return (
<div
ref={ref}
className={joinClasses(
"flex items-center gap-2 relative flex-wrap pb-1 md:pb-0",
className,
)}
>
{children}
</div>
);
});
export const PromptAction = forwardRef(function PromptAction(
{ children, className = "", type = "button", ...props },
ref,
) {
return (
<button
{...props}
ref={ref}
type={type}
className={joinClasses(DEFAULT_ACTION_CLASS, className)}
>
{children}
</button>
);
});
@@ -0,0 +1,75 @@
# Prompt Composer UI
Use the shared prompt composer primitives for every floating generation prompt
panel in Studio. Do not recreate the panel shell, textarea resizing logic,
parameter-control sizing, footer layout, or primary action button inside an
individual Studio component.
## Required primitives
- `PromptComposer` provides the floating panel position, background, border,
radius, padding, shadow, and animation.
- `PromptTextarea` provides the shared typography, placeholder treatment,
scrolling, and responsive auto-resize behavior.
- `PromptFooter` provides the responsive divider and action-row layout.
- `PromptControls` keeps parameter controls aligned and spaced consistently.
- `PromptAction` provides the shared primary generation button.
- `promptControlClassName()` provides the 38 px parameter-control contract.
- `promptMediaButtonClassName()` provides the 40 px circular media-attachment
contract.
- `PROMPT_MEDIA_PREVIEW_CLASS` keeps uploaded media previews on the same 40 px
circular contract.
- `PromptPopover` provides the shared dropdown surface, placement, border,
radius, shadow, scrolling, and responsive height limit.
- `PromptPopoverHeader` provides the shared uppercase section heading.
- `PromptMenuList` and `PromptMenuItem` provide consistent option spacing,
typography, hover feedback, and selected-state checkmarks.
- `PromptSegmentedControl` and `PromptSegmentOption` provide the shared
two-state or multi-state mode switch.
## Control contract
Every model, aspect-ratio, duration, resolution, quality, preset, or similar
control inside a floating prompt panel must:
- use `promptControlClassName()`;
- remain 38 px high;
- use a 12 px semibold label unless the content has a documented accessibility
requirement;
- use the shared 16 px parameter icon for aspect ratio, duration, or quality;
- preserve the shared background, border, radius, hover state, and spacing.
Use `promptControlClassName({ active: true })` for an open or selected control.
Use the `compact` or `iconOnly` option instead of overriding horizontal padding
or width with conflicting utility classes.
Studio-specific dropdown content may remain local because models and parameters
vary between tools.
Primary media attachments inside the composer must use
`promptMediaButtonClassName()`. Selected media uses `active: true`. This keeps
upload targets 40 px round with the same border and hover feedback.
If selected media is rendered as a separate preview, its wrapper must use
`PROMPT_MEDIA_PREVIEW_CLASS` so the control does not change shape or size after
upload.
Every dropdown opened from a prompt control must use the shared popover
primitives. Gallery and model-picker layouts may customize width and inner
content, but they must not recreate the surface, header, or simple option-row
styles.
Use `PromptAspectRatioIcon`, `PromptDurationIcon`, `PromptQualityIcon`, and
`PromptChevronIcon` for their matching parameters. `PromptQualityIcon` is the
single approved resolution/quality symbol: an unfilled, faceted gemstone
outline.
`PromptChevronIcon` is the shared affordance for controls that open a list.
## Behavior boundaries
Keep API calls, validation, uploads, persistence, and generation handlers inside
the owning Studio component. The shared prompt primitives own presentation and
textarea resizing only. Pass Studio-specific behavior through React props such
as `value`, `onChange`, `onClick`, and `disabled`.
Use slots through normal React children for media pickers, mode switches,
status messages, and Studio-specific controls. Avoid adding model-specific
conditionals to the shared prompt components.