mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-08-31 01:40:10 +08:00
Merge branch 'pr-297'
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user