feat: modernize Cinema Studio upload UI, implement batch generation in Image Studio, and fix textarea auto-resize across studios
This commit is contained in:
parent
911bcdd558
commit
4efb8593a4
4 changed files with 296 additions and 125 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { generateImage } from "../muapi.js";
|
||||
import { generateImage, uploadFile } from "../muapi.js";
|
||||
|
||||
// ─── Constants (inlined from promptUtils) ───────────────────────────────────
|
||||
|
||||
|
|
@ -111,14 +111,6 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
|
|||
const [position, setPosition] = useState({ bottom: 0, left: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
|
||||
const handler = (e) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
|
|
@ -129,21 +121,14 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
|
|||
onClose();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => document.addEventListener("click", handler),
|
||||
0,
|
||||
);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("click", handler);
|
||||
};
|
||||
}, [triggerRef, onClose]);
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onClose, triggerRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="custom-dropdown fixed bg-[#1a1a1a] border border-white/10 rounded-xl py-1 shadow-2xl z-50 flex flex-col min-w-[100px] animate-fade-in"
|
||||
style={{ bottom: position.bottom, left: position.left }}
|
||||
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"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
|
|
@ -204,35 +189,24 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
|||
children.forEach((child) => {
|
||||
const imgBox = child.querySelector("[data-imgbox]");
|
||||
const label = child.querySelector("[data-label]");
|
||||
const focalSpan = imgBox?.querySelector("[data-focal-text]");
|
||||
const isClosest = child === closest;
|
||||
|
||||
if (isClosest) {
|
||||
child.classList.remove("opacity-30", "scale-75", "blur-[1px]");
|
||||
child.classList.add("opacity-100", "scale-100", "blur-0", "z-30");
|
||||
child.classList.remove("opacity-20", "scale-90");
|
||||
child.classList.add("opacity-100", "scale-100", "z-30");
|
||||
if (imgBox) {
|
||||
imgBox.classList.add(
|
||||
"border-primary/50",
|
||||
"shadow-glow-sm",
|
||||
"scale-110",
|
||||
);
|
||||
imgBox.classList.remove("border-white/10", "bg-white/5");
|
||||
imgBox.classList.add("border-primary/40", "bg-primary/5", "scale-110");
|
||||
imgBox.classList.remove("border-transparent", "bg-transparent");
|
||||
}
|
||||
if (focalSpan) focalSpan.classList.add("text-primary");
|
||||
if (label) label.classList.add("text-primary", "text-shadow-sm");
|
||||
if (label) label.classList.add("text-primary");
|
||||
} else {
|
||||
child.classList.add("opacity-30", "scale-75", "blur-[1px]");
|
||||
child.classList.remove("opacity-100", "scale-100", "blur-0", "z-30");
|
||||
child.classList.add("opacity-20", "scale-90");
|
||||
child.classList.remove("opacity-100", "scale-100", "z-30");
|
||||
if (imgBox) {
|
||||
imgBox.classList.remove(
|
||||
"border-primary/50",
|
||||
"shadow-glow-sm",
|
||||
"scale-110",
|
||||
);
|
||||
imgBox.classList.add("border-white/10", "bg-white/5");
|
||||
imgBox.classList.remove("border-primary/40", "bg-primary/5", "scale-110");
|
||||
imgBox.classList.add("border-transparent", "bg-transparent");
|
||||
}
|
||||
if (focalSpan) focalSpan.classList.remove("text-primary");
|
||||
if (label) label.classList.remove("text-primary", "text-shadow-sm");
|
||||
if (label) label.classList.remove("text-primary");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -299,18 +273,26 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
|||
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-[140px] md:w-[160px] shrink-0 snap-center group">
|
||||
<div className="mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center">
|
||||
<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}
|
||||
</div>
|
||||
<div className="relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#050505]/60 rounded-2xl border border-white/[0.05] shadow-2xl backdrop-blur-2xl transition-transform duration-500 hover:scale-[1.01] hover:border-white/[0.1]">
|
||||
{/* Top mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
|
||||
{/* Bottom mask */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
|
||||
{/* Center selection indicator */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[85%] h-[80px] bg-primary/[0.03] border border-primary/[0.1] rounded-2xl pointer-events-none z-0" />
|
||||
<div 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
|
||||
ref={listRef}
|
||||
|
|
@ -320,8 +302,7 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
|||
onMouseUp={onMouseUp}
|
||||
onMouseMove={onMouseMove}
|
||||
>
|
||||
{/* Top spacer */}
|
||||
<div style={{ height: "calc(50% - 50px)" }} />
|
||||
<div style={{ height: "calc(50% - 35px)" }} />
|
||||
|
||||
{items.map((item) => {
|
||||
const imageUrl = ASSET_URLS[item];
|
||||
|
|
@ -329,33 +310,28 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
|||
<div
|
||||
key={item}
|
||||
data-value={item}
|
||||
className="h-[100px] flex flex-col items-center justify-center gap-3 snap-center cursor-pointer transition-all duration-500 ease-out text-white p-2 select-none opacity-30 scale-75 blur-[1px]"
|
||||
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)}
|
||||
>
|
||||
<div
|
||||
data-imgbox="true"
|
||||
className="w-14 h-14 rounded-xl border border-white/10 bg-white/5 flex items-center justify-center transition-all duration-500 shadow-inner overflow-hidden relative"
|
||||
className="w-10 h-10 rounded-lg border border-transparent flex items-center justify-center transition-all duration-300 overflow-hidden relative"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={String(item)}
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
className="w-full h-full object-cover opacity-70"
|
||||
/>
|
||||
) : columnKey === "focal" ? (
|
||||
<span
|
||||
data-focal-text="true"
|
||||
className="text-lg font-bold text-white/50"
|
||||
>
|
||||
) : (
|
||||
<span className="text-sm font-bold text-white/40">
|
||||
{item}
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-3 h-3 bg-white/20 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
data-label="true"
|
||||
className="text-[9px] md:text-[10px] font-bold uppercase text-center leading-tight max-w-full truncate px-1 tracking-wider"
|
||||
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>
|
||||
|
|
@ -363,10 +339,16 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{ height: "calc(50% - 50px)" }} />
|
||||
<div style={{ height: "calc(50% - 35px)" }} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -394,21 +376,19 @@ function CameraControlsOverlay({
|
|||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
className={`w-full max-w-5xl bg-[#0a0a0a]/60 border border-white/10 rounded-2xl p-6 md:p-10 shadow-3xl transform transition-all duration-500 flex flex-col max-h-[90vh] ${isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-10"}`}
|
||||
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"}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||
Camera Configuration
|
||||
<h2 className="text-2xl font-black text-white tracking-tighter uppercase italic">
|
||||
Camera Config
|
||||
</h2>
|
||||
<p className="text-[11px] font-medium text-white/20 uppercase tracking-[0.2em]">
|
||||
Select hardware & optics
|
||||
</p>
|
||||
<div className="h-[1px] w-12 bg-primary/40" />
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 rounded-full bg-white/[0.03] border border-white/[0.05] flex items-center justify-center text-white/40 hover:text-white hover:bg-white/[0.06] transition-all"
|
||||
className="w-10 h-10 rounded-full hover:bg-white/5 flex items-center justify-center text-white/20 hover:text-white transition-all"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
|
|
@ -416,7 +396,7 @@ function CameraControlsOverlay({
|
|||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
|
|
@ -484,6 +464,10 @@ export default function CinemaStudio({
|
|||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view
|
||||
const [fullscreenUrl, setFullscreenUrl] = useState(null);
|
||||
const [uploadedImage, setUploadedImage] = useState(null);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [imageUploadProgress, setImageUploadProgress] = useState(0);
|
||||
const imageInputRef = useRef(null);
|
||||
const [activeHistoryIndex, setactiveHistoryIndex] = useState(null);
|
||||
|
||||
// ── Internal history state (used when historyItems prop is not provided) ──
|
||||
|
|
@ -498,6 +482,31 @@ export default function CinemaStudio({
|
|||
const textareaRef = useRef(null);
|
||||
const resultImgRef = useRef(null);
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploadingImage(true);
|
||||
setImageUploadProgress(0);
|
||||
|
||||
try {
|
||||
const url = await uploadFile(apiKey, file, (progress) => {
|
||||
setImageUploadProgress(progress);
|
||||
});
|
||||
if (url) setUploadedImage(url);
|
||||
} catch (err) {
|
||||
console.error("Image upload failed:", err);
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
setImageUploadProgress(0);
|
||||
if (imageInputRef.current) imageInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = () => {
|
||||
setUploadedImage(null);
|
||||
};
|
||||
|
||||
// ── Persistence: Load ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -507,12 +516,25 @@ export default function CinemaStudio({
|
|||
if (data.settings) setSettings(data.settings);
|
||||
if (data.resolution) setResolution(data.resolution);
|
||||
if (data.internalHistory) setInternalHistory(data.internalHistory);
|
||||
if (data.uploadedImage) setUploadedImage(data.uploadedImage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load CinemaStudio persistence:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── 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(() => {
|
||||
|
|
@ -521,6 +543,7 @@ export default function CinemaStudio({
|
|||
settings,
|
||||
resolution,
|
||||
internalHistory,
|
||||
uploadedImage,
|
||||
};
|
||||
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
|
||||
} catch (err) {
|
||||
|
|
@ -528,7 +551,7 @@ export default function CinemaStudio({
|
|||
}
|
||||
}, 500); // 500ms debounce
|
||||
return () => clearTimeout(timer);
|
||||
}, [settings, resolution, internalHistory]);
|
||||
}, [settings, resolution, internalHistory, uploadedImage]);
|
||||
|
||||
// Derive effective history (prop wins over internal)
|
||||
const history = historyItems != null ? historyItems : internalHistory;
|
||||
|
|
@ -566,11 +589,12 @@ export default function CinemaStudio({
|
|||
|
||||
try {
|
||||
const res = await generateImage(apiKey, {
|
||||
model: "nano-banana-pro",
|
||||
model: uploadedImage ? "nano-banana-pro-edit" : "nano-banana-pro",
|
||||
prompt: finalPrompt,
|
||||
aspect_ratio: settings.aspect_ratio,
|
||||
resolution: resolution.toLowerCase(),
|
||||
negative_prompt: "blurry, low quality, distortion, bad composition",
|
||||
images_list: uploadedImage ? [uploadedImage] : [],
|
||||
});
|
||||
|
||||
if (res && res.url) {
|
||||
|
|
@ -693,7 +717,7 @@ export default function CinemaStudio({
|
|||
{history.map((entry, idx) => (
|
||||
<div
|
||||
key={entry.timestamp ?? idx}
|
||||
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#d9ff00]/50 transition-all duration-300 flex flex-col cursor-pointer"
|
||||
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#d9ff00]/50 transition-all duration-300 flex flex-col cursor-pointer"
|
||||
onClick={() => loadHistoryItem(entry, idx)}
|
||||
>
|
||||
<img
|
||||
|
|
@ -799,7 +823,77 @@ export default function CinemaStudio({
|
|||
{/* Left Column */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1">
|
||||
{/* Input Row */}
|
||||
<div className="flex items-start gap-4 w-full">
|
||||
<div className="flex items-start gap-4 w-full px-1">
|
||||
{/* Image Upload Button */}
|
||||
<div className="relative pt-0.5">
|
||||
<input
|
||||
type="file"
|
||||
ref={imageInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
uploadedImage
|
||||
? removeImage()
|
||||
: 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-primary/60 bg-white/5" : "bg-white/[0.03] border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`}
|
||||
>
|
||||
{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]">
|
||||
<svg className="w-8 h-8 -rotate-90">
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="transparent"
|
||||
className="text-white/10"
|
||||
/>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="transparent"
|
||||
strokeDasharray={88}
|
||||
strokeDashoffset={88 - (88 * imageUploadProgress) / 100}
|
||||
className="text-primary transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[8px] font-bold text-white">
|
||||
{imageUploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
) : uploadedImage ? (
|
||||
<div className="relative w-full h-full group">
|
||||
<img
|
||||
src={uploadedImage}
|
||||
alt="Reference"
|
||||
className="w-full h-full object-cover opacity-80 group-hover:opacity-40 transition-opacity"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-white">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-white/40 group-hover:text-white transition-colors">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Describe your cinema scene..."
|
||||
|
|
@ -898,7 +992,32 @@ export default function CinemaStudio({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fullscreenUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => setFullscreenUrl(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors border border-white/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFullscreenUrl(null);
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src={fullscreenUrl}
|
||||
alt="Fullscreen Preview"
|
||||
className="max-w-[95vw] max-h-[95vh] rounded-2xl shadow-2xl object-contain animate-scale-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ── Camera Controls Overlay ── */}
|
||||
<CameraControlsOverlay
|
||||
isOpen={isOverlayOpen}
|
||||
|
|
|
|||
|
|
@ -770,6 +770,7 @@ export default function ImageStudio({
|
|||
// ── Canvas / history state ──────────────────────────────────────────────
|
||||
const [currentImageUrl, setCurrentImageUrl] = useState(null);
|
||||
const [activeHistoryIdx, setActiveHistoryIdx] = useState(0);
|
||||
const [batchSize, setBatchSize] = useState(1);
|
||||
const [localHistory, setLocalHistory] = useState([]); // [{id,url,prompt,model,aspect_ratio,timestamp}]
|
||||
|
||||
// Use prop history if provided, otherwise local
|
||||
|
|
@ -806,6 +807,7 @@ export default function ImageStudio({
|
|||
if (data.maxImages) setMaxImages(data.maxImages);
|
||||
if (data.prompt) setPrompt(data.prompt);
|
||||
if (data.uploadedImageUrls) setUploadedImageUrls(data.uploadedImageUrls);
|
||||
if (data.batchSize) setBatchSize(data.batchSize);
|
||||
if (data.localHistory) setLocalHistory(data.localHistory);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -813,6 +815,14 @@ export default function ImageStudio({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// ── Adjust height on load ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
handleTextareaInput();
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// ── Persistence: Save ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
|
@ -826,6 +836,7 @@ export default function ImageStudio({
|
|||
maxImages,
|
||||
prompt,
|
||||
uploadedImageUrls,
|
||||
batchSize,
|
||||
localHistory,
|
||||
};
|
||||
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
|
||||
|
|
@ -843,6 +854,7 @@ export default function ImageStudio({
|
|||
maxImages,
|
||||
prompt,
|
||||
uploadedImageUrls,
|
||||
batchSize,
|
||||
localHistory,
|
||||
]);
|
||||
|
||||
|
|
@ -1014,50 +1026,53 @@ export default function ImageStudio({
|
|||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (imageMode) {
|
||||
const genParams = {
|
||||
model: selectedModelId,
|
||||
images_list: uploadedImageUrls,
|
||||
image_url: uploadedImageUrls[0],
|
||||
aspect_ratio: selectedAr,
|
||||
};
|
||||
if (prompt.trim()) genParams.prompt = prompt.trim();
|
||||
if (currentQualityField && selectedQuality) {
|
||||
genParams[currentQualityField] = selectedQuality;
|
||||
}
|
||||
res = await generateI2I(apiKey, genParams);
|
||||
} else {
|
||||
const genParams = {
|
||||
model: selectedModelId,
|
||||
prompt: prompt.trim(),
|
||||
aspect_ratio: selectedAr,
|
||||
};
|
||||
if (currentQualityField && selectedQuality) {
|
||||
genParams[currentQualityField] = selectedQuality;
|
||||
}
|
||||
res = await generateImage(apiKey, genParams);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: batchSize }).map(async () => {
|
||||
if (imageMode) {
|
||||
const genParams = {
|
||||
model: selectedModelId,
|
||||
images_list: uploadedImageUrls,
|
||||
image_url: uploadedImageUrls[0],
|
||||
aspect_ratio: selectedAr,
|
||||
};
|
||||
if (prompt.trim()) genParams.prompt = prompt.trim();
|
||||
if (currentQualityField && selectedQuality) {
|
||||
genParams[currentQualityField] = selectedQuality;
|
||||
}
|
||||
return await generateI2I(apiKey, genParams);
|
||||
} else {
|
||||
const genParams = {
|
||||
model: selectedModelId,
|
||||
prompt: prompt.trim(),
|
||||
aspect_ratio: selectedAr,
|
||||
};
|
||||
if (currentQualityField && selectedQuality) {
|
||||
genParams[currentQualityField] = selectedQuality;
|
||||
}
|
||||
return await generateImage(apiKey, genParams);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (res && res.url) {
|
||||
const entry = {
|
||||
id: res.id || Date.now().toString(),
|
||||
url: res.url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModelId,
|
||||
aspect_ratio: selectedAr,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addToHistory(entry);
|
||||
onGenerationComplete?.({
|
||||
url: res.url,
|
||||
model: selectedModelId,
|
||||
prompt: prompt.trim(),
|
||||
type: "image",
|
||||
});
|
||||
} else {
|
||||
throw new Error("No image URL returned by API");
|
||||
}
|
||||
results.forEach((res) => {
|
||||
if (res && res.url) {
|
||||
const entry = {
|
||||
id: res.id || Math.random().toString(36).substring(7),
|
||||
url: res.url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModelId,
|
||||
aspect_ratio: selectedAr,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addToHistory(entry);
|
||||
onGenerationComplete?.({
|
||||
url: res.url,
|
||||
model: selectedModelId,
|
||||
prompt: prompt.trim(),
|
||||
type: "image",
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[ImageStudio] Generation failed:", e);
|
||||
setGenerateError(e.message.slice(0, 80));
|
||||
|
|
@ -1325,6 +1340,24 @@ export default function ImageStudio({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch size selector */}
|
||||
<div className="flex items-center gap-1 bg-white/[0.03] rounded-md p-1 border border-white/[0.03]">
|
||||
{[1, 2, 3, 4].map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => setBatchSize(num)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md text-[10px] font-black transition-all ${
|
||||
batchSize === num
|
||||
? "bg-[#d9ff00] text-black shadow-lg shadow-[#d9ff00]/20"
|
||||
: "text-white/40 hover:text-white/80 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
|
|
|
|||
|
|
@ -446,6 +446,19 @@ 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(() => {
|
||||
|
|
@ -992,7 +1005,7 @@ export default function VideoStudio({
|
|||
return (
|
||||
<div
|
||||
key={entry.id || idx}
|
||||
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
|
||||
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
|
||||
>
|
||||
<video
|
||||
src={entry.url}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,14 @@ export async function generateImage(apiKey, params) {
|
|||
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
|
||||
if (params.resolution) payload.resolution = params.resolution;
|
||||
if (params.quality) payload.quality = params.quality;
|
||||
if (params.image_url) { payload.image_url = params.image_url; payload.strength = params.strength || 0.6; }
|
||||
else payload.image_url = null;
|
||||
if (params.image_url) {
|
||||
payload.image_url = params.image_url;
|
||||
payload.strength = params.strength || 0.6;
|
||||
} else if (params.images_list) {
|
||||
payload.images_list = params.images_list;
|
||||
} else {
|
||||
payload.image_url = null;
|
||||
}
|
||||
if (params.seed && params.seed !== -1) payload.seed = params.seed;
|
||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue