feat: modernize studio upload pipeline with cloud-only previews and progress indicators
This commit is contained in:
parent
776a325e77
commit
b924f0caf8
6 changed files with 292 additions and 135 deletions
|
|
@ -41,6 +41,7 @@ export default function ApiKeyModal({ onSave }) {
|
||||||
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
||||||
placeholder="Enter your API key..."
|
placeholder="Enter your API key..."
|
||||||
className="w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-white placeholder:text-white/20 focus:outline-none focus:border-[#d9ff00]/40 transition-colors"
|
className="w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-white placeholder:text-white/20 focus:outline-none focus:border-[#d9ff00]/40 transition-colors"
|
||||||
|
suppressHydrationWarning
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
|
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -48,6 +49,7 @@ export default function ApiKeyModal({ onSave }) {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
|
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
Launch Studio
|
Launch Studio
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ export default function StandaloneShell() {
|
||||||
const [apiKey, setApiKey] = useState(null);
|
const [apiKey, setApiKey] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('image');
|
const [activeTab, setActiveTab] = useState('image');
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setHasMounted(true);
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) setApiKey(stored);
|
if (stored) setApiKey(stored);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -33,6 +35,12 @@ export default function StandaloneShell() {
|
||||||
setApiKey(null);
|
setApiKey(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!hasMounted) return (
|
||||||
|
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
||||||
|
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return <ApiKeyModal onSave={handleKeySave} />;
|
return <ApiKeyModal onSave={handleKeySave} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,6 @@ import {
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateThumbnail(file) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => resolve(e.target.result);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadImage(url, filename) {
|
async function downloadImage(url, filename) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,6 +41,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}]
|
const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}]
|
||||||
const [uploadHistory, setUploadHistory] = useState([]); // [{id, name, url, thumbnail}]
|
const [uploadHistory, setUploadHistory] = useState([]); // [{id, name, url, thumbnail}]
|
||||||
|
const [lastUploadProgress, setLastUploadProgress] = useState(0);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const panelRef = useRef(null);
|
const panelRef = useRef(null);
|
||||||
const triggerRef = useRef(null);
|
const triggerRef = useRef(null);
|
||||||
|
|
@ -71,14 +65,11 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
|
|
||||||
// When maxImages changes, trim excess selections
|
// When maxImages changes, trim excess selections
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedEntries((prev) => {
|
if (selectedEntries.length > maxImages) {
|
||||||
if (prev.length > maxImages) {
|
const trimmed = selectedEntries.slice(0, maxImages);
|
||||||
const trimmed = prev.slice(0, maxImages);
|
setSelectedEntries(trimmed);
|
||||||
if (trimmed.length === 0) onClear?.();
|
if (trimmed.length === 0) onClear?.();
|
||||||
return trimmed;
|
}
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.multiple = maxImages > 1;
|
fileInputRef.current.multiple = maxImages > 1;
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +79,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (!entries.length) return;
|
if (!entries.length) return;
|
||||||
const urls = entries.map((e) => e.url);
|
const urls = entries.map((e) => e.url);
|
||||||
onSelect({ url: urls[0], urls, thumbnail: entries[0].thumbnail });
|
onSelect({ url: urls[0], urls, thumbnail: entries[0].url });
|
||||||
},
|
},
|
||||||
[onSelect]
|
[onSelect]
|
||||||
);
|
);
|
||||||
|
|
@ -98,56 +89,66 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
const tooLarge = files.filter(f => f.size > MAX_IMAGE_SIZE);
|
||||||
|
if (tooLarge.length > 0) {
|
||||||
|
alert(`The following images are too large (max 10MB): ${tooLarge.map(f => f.name).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
if (maxImages === 1) {
|
const toUpload =
|
||||||
const file = files[0];
|
maxImages === 1 ? files.slice(0, 1) : files.slice(0, maxImages - selectedEntries.length || 1);
|
||||||
const [uploadedUrl, thumbnail] = await Promise.all([
|
|
||||||
uploadFile(apiKey, file),
|
|
||||||
generateThumbnail(file),
|
|
||||||
]);
|
|
||||||
const entry = { id: Date.now().toString(), name: file.name, url: uploadedUrl, thumbnail };
|
|
||||||
setUploadHistory((prev) => [entry, ...prev]);
|
|
||||||
const newSelected = [{ url: uploadedUrl, thumbnail }];
|
|
||||||
setSelectedEntries(newSelected);
|
|
||||||
fireOnSelect(newSelected);
|
|
||||||
setPanelOpen(false);
|
|
||||||
} else {
|
|
||||||
const slots = maxImages - selectedEntries.length;
|
|
||||||
const toUpload = files.slice(0, Math.max(slots, 1));
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
await Promise.all(
|
||||||
toUpload.map(async (file) => {
|
toUpload.map(async (file) => {
|
||||||
const [uploadedUrl, thumbnail] = await Promise.all([
|
const id = Date.now().toString() + Math.random();
|
||||||
uploadFile(apiKey, file),
|
|
||||||
generateThumbnail(file),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
id: Date.now().toString() + Math.random(),
|
|
||||||
name: file.name,
|
|
||||||
url: uploadedUrl,
|
|
||||||
thumbnail,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setUploadHistory((prev) => [...results, ...prev]);
|
// Add a placeholder to history immediately without local preview
|
||||||
setSelectedEntries((prev) => {
|
const placeholder = { id, name: file.name, url: null, progress: 0 };
|
||||||
const next = [...prev];
|
setUploadHistory((prev) => [placeholder, ...prev]);
|
||||||
results.forEach((r) => {
|
|
||||||
if (next.length < maxImages) {
|
try {
|
||||||
next.push({ url: r.url, thumbnail: r.thumbnail });
|
const uploadedUrl = await uploadFile(apiKey, file, (pct) => {
|
||||||
|
setLastUploadProgress(pct);
|
||||||
|
setUploadHistory((prev) =>
|
||||||
|
prev.map((h) => (h.id === id ? { ...h, progress: pct } : h))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update history with real URL and Mark as 100%
|
||||||
|
setUploadHistory((prev) =>
|
||||||
|
prev.map((h) => {
|
||||||
|
if (h.id === id) {
|
||||||
|
return { ...h, url: uploadedUrl, progress: 100 };
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-select if there's room
|
||||||
|
if (selectedEntries.length < maxImages) {
|
||||||
|
const newEntry = { url: uploadedUrl };
|
||||||
|
setSelectedEntries((prev) => [...prev, newEntry]);
|
||||||
|
|
||||||
|
if (maxImages === 1) {
|
||||||
|
fireOnSelect([newEntry]);
|
||||||
|
setPanelOpen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
} catch (err) {
|
||||||
return next;
|
console.error("[UploadButton] Upload failed for", file.name, err);
|
||||||
});
|
setUploadHistory((prev) => prev.filter((h) => h.id !== id));
|
||||||
setPanelOpen(true);
|
throw err;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[UploadButton] Upload failed:", err);
|
|
||||||
alert(`Image upload failed: ${err.message}`);
|
alert(`Image upload failed: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setLastUploadProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -158,32 +159,32 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
if (atMax) return;
|
if (atMax) return;
|
||||||
|
|
||||||
if (maxImages === 1) {
|
if (maxImages === 1) {
|
||||||
const newSelected = [{ url: entry.url, thumbnail: entry.thumbnail }];
|
const newSelected = [{ url: entry.url, localUrl: entry.localUrl }];
|
||||||
setSelectedEntries(newSelected);
|
setSelectedEntries(newSelected);
|
||||||
fireOnSelect(newSelected);
|
fireOnSelect(newSelected);
|
||||||
setPanelOpen(false);
|
setPanelOpen(false);
|
||||||
} else {
|
} else {
|
||||||
setSelectedEntries((prev) => {
|
let next;
|
||||||
let next;
|
if (isSelected) {
|
||||||
if (isSelected) {
|
next = selectedEntries.filter((_, i) => i !== selIdx);
|
||||||
next = prev.filter((_, i) => i !== selIdx);
|
if (next.length === 0) onClear?.();
|
||||||
if (next.length === 0) onClear?.();
|
} else {
|
||||||
} else {
|
next = [...selectedEntries, { url: entry.url, localUrl: entry.localUrl }];
|
||||||
next = [...prev, { url: entry.url, thumbnail: entry.thumbnail }];
|
}
|
||||||
}
|
setSelectedEntries(next);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromHistory = (e, entry) => {
|
const handleRemoveFromHistory = (e, entry) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (entry.localUrl) URL.revokeObjectURL(entry.localUrl);
|
||||||
setUploadHistory((prev) => prev.filter((h) => h.id !== entry.id));
|
setUploadHistory((prev) => prev.filter((h) => h.id !== entry.id));
|
||||||
setSelectedEntries((prev) => {
|
|
||||||
const next = prev.filter((s) => s.url !== entry.url);
|
const next = selectedEntries.filter((s) => s.url !== entry.url);
|
||||||
|
if (next.length !== selectedEntries.length) {
|
||||||
|
setSelectedEntries(next);
|
||||||
if (next.length === 0) onClear?.();
|
if (next.length === 0) onClear?.();
|
||||||
return next;
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDone = (e) => {
|
const handleDone = (e) => {
|
||||||
|
|
@ -206,14 +207,18 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
|
|
||||||
// Trigger icon content
|
// Trigger icon content
|
||||||
let triggerContent;
|
let triggerContent;
|
||||||
if (uploading) {
|
if (hasSelection || uploading) {
|
||||||
triggerContent = (
|
const mainEntry = selectedEntries[0] || uploadHistory[0];
|
||||||
<span className="animate-spin text-primary text-sm">◌</span>
|
|
||||||
);
|
|
||||||
} else if (hasSelection) {
|
|
||||||
const canAddMore = isMulti && count < maxImages;
|
const canAddMore = isMulti && count < maxImages;
|
||||||
let badge;
|
let badge;
|
||||||
if (count > 1) {
|
if (uploading && !hasSelection) {
|
||||||
|
badge = (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
||||||
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
|
<span className="text-[8px] font-black text-primary">{lastUploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (count > 1) {
|
||||||
badge = (
|
badge = (
|
||||||
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5">
|
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5">
|
||||||
<span className="text-[9px] font-black text-black leading-none">{count}</span>
|
<span className="text-[9px] font-black text-black leading-none">{count}</span>
|
||||||
|
|
@ -236,8 +241,21 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
}
|
}
|
||||||
triggerContent = (
|
triggerContent = (
|
||||||
<>
|
<>
|
||||||
<img src={selectedEntries[0].thumbnail} alt="" className="w-full h-full object-cover" />
|
{uploading && hasSelection && (
|
||||||
{badge}
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
||||||
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
|
<span className="text-[8px] font-black text-primary">{lastUploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mainEntry?.url ? (
|
||||||
|
<img src={mainEntry.url} alt="" className={`w-full h-full object-cover transition-all duration-300 ${uploading && hasSelection ? 'blur-[2px] scale-110 opacity-60' : 'blur-0 scale-100 opacity-100'}`} />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center bg-white/5 animate-pulse">
|
||||||
|
<div className="w-4 h-4 rounded-full border border-primary/20 border-t-primary animate-spin mb-0.5" />
|
||||||
|
<span className="text-[8px] font-black text-primary">{lastUploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!uploading && badge}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -382,27 +400,40 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
title={entry.name}
|
title={entry.name}
|
||||||
onClick={() => handleCellClick(entry)}
|
onClick={() => entry.url && handleCellClick(entry)}
|
||||||
className={`relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${
|
className={`relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${
|
||||||
isSelected ? "border-primary shadow-glow" : "border-white/10 hover:border-white/30"
|
isSelected ? "border-primary shadow-glow" : "border-white/10 hover:border-white/30"
|
||||||
} ${atMax ? "opacity-40 cursor-not-allowed" : ""}`}
|
} ${atMax ? "opacity-40 cursor-not-allowed" : ""} ${!entry.url ? "cursor-wait" : ""}`}
|
||||||
>
|
>
|
||||||
<img src={entry.thumbnail} alt={entry.name} className="w-full h-full object-cover" />
|
{entry.url ? (
|
||||||
|
<img
|
||||||
|
src={entry.url}
|
||||||
|
alt={entry.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-white/5 flex flex-col items-center justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-primary/30 border-t-primary animate-spin mb-1" />
|
||||||
|
<span className="text-[10px] font-black text-primary">{entry.progress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hover overlay with delete */}
|
{/* Hover overlay with delete */}
|
||||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1">
|
{entry.url && (
|
||||||
<button
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1">
|
||||||
type="button"
|
<button
|
||||||
title="Remove from history"
|
type="button"
|
||||||
onClick={(e) => handleRemoveFromHistory(e, entry)}
|
title="Remove from history"
|
||||||
className="w-5 h-5 bg-red-500/80 hover:bg-red-500 rounded-md flex items-center justify-center transition-colors"
|
onClick={(e) => handleRemoveFromHistory(e, entry)}
|
||||||
>
|
className="w-5 h-5 bg-red-500/80 hover:bg-red-500 rounded-md flex items-center justify-center transition-colors"
|
||||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
</svg>
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection badge */}
|
{/* Selection badge */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const UPLOAD_STATE = {
|
||||||
READY: 'ready',
|
READY: 'ready',
|
||||||
};
|
};
|
||||||
|
|
||||||
function MediaPickerButton({ accept, label, icon, onUpload, onClear, uploadState, fileName, apiKey }) {
|
function MediaPickerButton({ accept, label, icon, onUpload, onClear, uploadState, progress, fileName, previewUrl, isVideo, apiKey }) {
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
|
|
@ -72,18 +72,29 @@ function MediaPickerButton({ accept, label, icon, onUpload, onClear, uploadState
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Uploading spinner */}
|
{/* Uploading indicator */}
|
||||||
{uploadState === UPLOAD_STATE.UPLOADING && (
|
{uploadState === UPLOAD_STATE.UPLOADING && (
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/60 z-10 animate-pulse">
|
||||||
<span className="animate-spin text-primary text-sm">◌</span>
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
|
<span className="text-[8px] font-black text-primary">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ready state */}
|
{/* Ready state */}
|
||||||
{uploadState === UPLOAD_STATE.READY && (
|
{uploadState === UPLOAD_STATE.READY && (
|
||||||
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10">
|
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10">
|
||||||
{icon}
|
{previewUrl ? (
|
||||||
<span className="text-[9px] text-primary font-bold">READY</span>
|
isVideo ? (
|
||||||
|
<video src={previewUrl} className="w-full h-full object-cover" muted />
|
||||||
|
) : (
|
||||||
|
<img src={previewUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
<span className="text-[9px] text-primary font-bold">READY</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -233,6 +244,11 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte
|
||||||
const [audioName, setAudioName] = useState('');
|
const [audioName, setAudioName] = useState('');
|
||||||
const [audioUrl, setAudioUrl] = useState(null);
|
const [audioUrl, setAudioUrl] = useState(null);
|
||||||
|
|
||||||
|
// ── Individual progress states ──
|
||||||
|
const [imageProgress, setImageProgress] = useState(0);
|
||||||
|
const [videoProgress, setVideoProgress] = useState(0);
|
||||||
|
const [audioProgress, setAudioProgress] = useState(0);
|
||||||
|
|
||||||
// ── Prompt ──────────────────────────────────────────────────────────────
|
// ── Prompt ──────────────────────────────────────────────────────────────
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
|
|
||||||
|
|
@ -273,41 +289,68 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte
|
||||||
|
|
||||||
// ── Upload handlers ─────────────────────────────────────────────────────
|
// ── Upload handlers ─────────────────────────────────────────────────────
|
||||||
const handleImageUpload = useCallback(async (file) => {
|
const handleImageUpload = useCallback(async (file) => {
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert("Image exceeds 10MB limit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImageState(UPLOAD_STATE.UPLOADING);
|
setImageState(UPLOAD_STATE.UPLOADING);
|
||||||
|
setImageProgress(0);
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(apiKey, file);
|
const url = await uploadFile(apiKey, file, (pct) => {
|
||||||
|
setImageProgress(pct);
|
||||||
|
});
|
||||||
setImageUrl(url);
|
setImageUrl(url);
|
||||||
setImageName(file.name);
|
setImageName(file.name);
|
||||||
setImageState(UPLOAD_STATE.READY);
|
setImageState(UPLOAD_STATE.READY);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setImageState(UPLOAD_STATE.IDLE);
|
setImageState(UPLOAD_STATE.IDLE);
|
||||||
alert(`Image upload failed: ${err.message}`);
|
alert(`Image upload failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setImageProgress(0);
|
||||||
}
|
}
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
const handleVideoPick = useCallback(async (file) => {
|
const handleVideoPick = useCallback(async (file) => {
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
alert("Video exceeds 50MB limit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setVideoState(UPLOAD_STATE.UPLOADING);
|
setVideoState(UPLOAD_STATE.UPLOADING);
|
||||||
|
setVideoProgress(0);
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(apiKey, file);
|
const url = await uploadFile(apiKey, file, (pct) => {
|
||||||
|
setVideoProgress(pct);
|
||||||
|
});
|
||||||
setVideoUrl(url);
|
setVideoUrl(url);
|
||||||
setVideoName(file.name);
|
setVideoName(file.name);
|
||||||
setVideoState(UPLOAD_STATE.READY);
|
setVideoState(UPLOAD_STATE.READY);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setVideoState(UPLOAD_STATE.IDLE);
|
setVideoState(UPLOAD_STATE.IDLE);
|
||||||
alert(`Video upload failed: ${err.message}`);
|
alert(`Video upload failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setVideoProgress(0);
|
||||||
}
|
}
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
const handleAudioPick = useCallback(async (file) => {
|
const handleAudioPick = useCallback(async (file) => {
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert("Audio file exceeds 10MB limit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAudioState(UPLOAD_STATE.UPLOADING);
|
setAudioState(UPLOAD_STATE.UPLOADING);
|
||||||
|
setAudioProgress(0);
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(apiKey, file);
|
const url = await uploadFile(apiKey, file, (pct) => {
|
||||||
|
setAudioProgress(pct);
|
||||||
|
});
|
||||||
setAudioUrl(url);
|
setAudioUrl(url);
|
||||||
setAudioName(file.name);
|
setAudioName(file.name);
|
||||||
setAudioState(UPLOAD_STATE.READY);
|
setAudioState(UPLOAD_STATE.READY);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAudioState(UPLOAD_STATE.IDLE);
|
setAudioState(UPLOAD_STATE.IDLE);
|
||||||
alert(`Audio upload failed: ${err.message}`);
|
alert(`Audio upload failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setAudioProgress(0);
|
||||||
}
|
}
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
|
|
@ -541,9 +584,16 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
onUpload={handleImageUpload}
|
onUpload={handleImageUpload}
|
||||||
onClear={() => { setImageUrl(null); setImageState(UPLOAD_STATE.IDLE); setImageName(''); }}
|
onClear={() => {
|
||||||
|
setImageUrl(null);
|
||||||
|
setImageState(UPLOAD_STATE.IDLE);
|
||||||
|
setImageName('');
|
||||||
|
}}
|
||||||
uploadState={imageState}
|
uploadState={imageState}
|
||||||
|
progress={imageProgress}
|
||||||
fileName={imageName}
|
fileName={imageName}
|
||||||
|
previewUrl={imageUrl}
|
||||||
|
isVideo={false}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -555,9 +605,16 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte
|
||||||
label="Video"
|
label="Video"
|
||||||
icon={<VideoIcon />}
|
icon={<VideoIcon />}
|
||||||
onUpload={handleVideoPick}
|
onUpload={handleVideoPick}
|
||||||
onClear={() => { setVideoUrl(null); setVideoState(UPLOAD_STATE.IDLE); setVideoName(''); }}
|
onClear={() => {
|
||||||
|
setVideoUrl(null);
|
||||||
|
setVideoState(UPLOAD_STATE.IDLE);
|
||||||
|
setVideoName('');
|
||||||
|
}}
|
||||||
uploadState={videoState}
|
uploadState={videoState}
|
||||||
|
progress={videoProgress}
|
||||||
fileName={videoName}
|
fileName={videoName}
|
||||||
|
previewUrl={videoUrl}
|
||||||
|
isVideo={true}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -570,7 +627,10 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte
|
||||||
onUpload={handleAudioPick}
|
onUpload={handleAudioPick}
|
||||||
onClear={() => { setAudioUrl(null); setAudioState(UPLOAD_STATE.IDLE); setAudioName(''); }}
|
onClear={() => { setAudioUrl(null); setAudioState(UPLOAD_STATE.IDLE); setAudioName(''); }}
|
||||||
uploadState={audioState}
|
uploadState={audioState}
|
||||||
|
progress={audioProgress}
|
||||||
fileName={audioName}
|
fileName={audioName}
|
||||||
|
previewUrl={null}
|
||||||
|
isVideo={false}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,10 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
const [selectedQuality, setSelectedQuality] = useState(defaultModel.inputs?.quality?.default || '');
|
const [selectedQuality, setSelectedQuality] = useState(defaultModel.inputs?.quality?.default || '');
|
||||||
const [selectedMode, setSelectedMode] = useState('');
|
const [selectedMode, setSelectedMode] = useState('');
|
||||||
|
|
||||||
|
// ── upload progress ──
|
||||||
|
const [imageProgress, setImageProgress] = useState(0);
|
||||||
|
const [videoProgress, setVideoProgress] = useState(0);
|
||||||
|
|
||||||
// ── control visibility ──
|
// ── control visibility ──
|
||||||
const [showAr, setShowAr] = useState(true);
|
const [showAr, setShowAr] = useState(true);
|
||||||
const [showDuration, setShowDuration] = useState(true);
|
const [showDuration, setShowDuration] = useState(true);
|
||||||
|
|
@ -201,7 +205,6 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
|
|
||||||
// ── uploads ──
|
// ── uploads ──
|
||||||
const [uploadedImageUrl, setUploadedImageUrl] = useState(null);
|
const [uploadedImageUrl, setUploadedImageUrl] = useState(null);
|
||||||
const [uploadedImagePreview, setUploadedImagePreview] = useState(null);
|
|
||||||
const [imageUploading, setImageUploading] = useState(false);
|
const [imageUploading, setImageUploading] = useState(false);
|
||||||
const [uploadedVideoUrl, setUploadedVideoUrl] = useState(null);
|
const [uploadedVideoUrl, setUploadedVideoUrl] = useState(null);
|
||||||
const [videoUploading, setVideoUploading] = useState(false);
|
const [videoUploading, setVideoUploading] = useState(false);
|
||||||
|
|
@ -323,11 +326,18 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
const handleImageFileChange = async (e) => {
|
const handleImageFileChange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert("Image exceeds 10MB limit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImageUploading(true);
|
setImageUploading(true);
|
||||||
|
setImageProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(apiKey, file);
|
const url = await uploadFile(apiKey, file, (pct) => {
|
||||||
|
setImageProgress(pct);
|
||||||
|
});
|
||||||
setUploadedImageUrl(url);
|
setUploadedImageUrl(url);
|
||||||
setUploadedImagePreview(URL.createObjectURL(file));
|
|
||||||
|
|
||||||
// Clear v2v if active
|
// Clear v2v if active
|
||||||
setUploadedVideoUrl(null);
|
setUploadedVideoUrl(null);
|
||||||
|
|
@ -347,13 +357,13 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
alert(`Image upload failed: ${err.message}`);
|
alert(`Image upload failed: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setImageUploading(false);
|
setImageUploading(false);
|
||||||
|
setImageProgress(0);
|
||||||
if (imageFileInputRef.current) imageFileInputRef.current.value = '';
|
if (imageFileInputRef.current) imageFileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearImageUpload = () => {
|
const clearImageUpload = () => {
|
||||||
setUploadedImageUrl(null);
|
setUploadedImageUrl(null);
|
||||||
setUploadedImagePreview(null);
|
|
||||||
setImageMode(false);
|
setImageMode(false);
|
||||||
const first = t2vModels[0];
|
const first = t2vModels[0];
|
||||||
setSelectedModel(first.id);
|
setSelectedModel(first.id);
|
||||||
|
|
@ -366,16 +376,22 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
const handleVideoFileChange = async (e) => {
|
const handleVideoFileChange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
alert("Video exceeds 50MB limit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setVideoUploading(true);
|
setVideoUploading(true);
|
||||||
|
setVideoProgress(0);
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(apiKey, file);
|
const url = await uploadFile(apiKey, file, (pct) => {
|
||||||
|
setVideoProgress(pct);
|
||||||
|
});
|
||||||
setUploadedVideoUrl(url);
|
setUploadedVideoUrl(url);
|
||||||
setUploadedVideoName(file.name);
|
setUploadedVideoName(file.name);
|
||||||
|
|
||||||
// Clear image mode if active
|
// Clear image mode if active
|
||||||
if (imageMode) {
|
if (imageMode) {
|
||||||
setUploadedImageUrl(null);
|
setUploadedImageUrl(null);
|
||||||
setUploadedImagePreview(null);
|
|
||||||
setImageMode(false);
|
setImageMode(false);
|
||||||
}
|
}
|
||||||
setV2vMode(true);
|
setV2vMode(true);
|
||||||
|
|
@ -390,6 +406,7 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
alert(`Video upload failed: ${err.message}`);
|
alert(`Video upload failed: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setVideoUploading(false);
|
setVideoUploading(false);
|
||||||
|
setVideoProgress(0);
|
||||||
if (videoFileInputRef.current) videoFileInputRef.current.value = '';
|
if (videoFileInputRef.current) videoFileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -763,10 +780,15 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImageUrl ? 'border-primary/60 bg-primary/10' : 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40'} group`}
|
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImageUrl ? 'border-primary/60 bg-primary/10' : 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40'} group`}
|
||||||
>
|
>
|
||||||
{imageUploading ? (
|
{imageUploading ? (
|
||||||
<span className="animate-spin text-primary text-sm">◌</span>
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
||||||
) : uploadedImageUrl ? (
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
<img src={uploadedImagePreview} alt="" className="w-full h-full object-cover rounded-xl" />
|
<span className="text-[8px] font-black text-primary">{imageProgress}%</span>
|
||||||
) : (
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{uploadedImageUrl ? (
|
||||||
|
<img src={uploadedImageUrl} alt="" className={`w-full h-full object-cover rounded-xl ${imageUploading ? 'opacity-40 blur-[2px]' : 'opacity-100'}`} />
|
||||||
|
) : !imageUploading && (
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted group-hover:text-primary transition-colors">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted group-hover:text-primary transition-colors">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
|
@ -792,9 +814,12 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden ${uploadedVideoUrl ? 'border-primary/60 bg-white/5' : 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40'} group`}
|
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden ${uploadedVideoUrl ? 'border-primary/60 bg-white/5' : 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40'} group`}
|
||||||
>
|
>
|
||||||
{videoUploading ? (
|
{videoUploading ? (
|
||||||
<span className="animate-spin text-primary text-sm">◌</span>
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
||||||
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
|
<span className="text-[8px] font-black text-primary">{videoProgress}%</span>
|
||||||
|
</div>
|
||||||
) : uploadedVideoUrl ? (
|
) : uploadedVideoUrl ? (
|
||||||
<VideoReadySvg />
|
<video src={uploadedVideoUrl} className={`w-full h-full object-cover rounded-xl ${videoUploading ? 'opacity-40 blur-[2px]' : 'opacity-100'}`} muted />
|
||||||
) : (
|
) : (
|
||||||
<VideoIconSvg className="text-muted group-hover:text-primary transition-colors" />
|
<VideoIconSvg className="text-muted group-hover:text-primary transition-colors" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -121,21 +121,52 @@ export async function processLipSync(apiKey, params) {
|
||||||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadFile(apiKey, file) {
|
export function uploadFile(apiKey, file, onProgress) {
|
||||||
const url = `${BASE_URL}/api/v1/upload_file`;
|
return new Promise((resolve, reject) => {
|
||||||
const formData = new FormData();
|
const url = `${BASE_URL}/api/v1/upload_file`;
|
||||||
formData.append('file', file);
|
const formData = new FormData();
|
||||||
const response = await fetch(url, {
|
formData.append('file', file);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'x-api-key': apiKey },
|
const xhr = new XMLHttpRequest();
|
||||||
body: formData
|
xhr.open('POST', url);
|
||||||
|
xhr.setRequestHeader('x-api-key', apiKey);
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percentComplete = Math.round((event.loaded / event.total) * 100);
|
||||||
|
onProgress(percentComplete);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
const fileUrl = data.url || data.file_url || data.data?.url;
|
||||||
|
if (!fileUrl) {
|
||||||
|
reject(new Error('No URL returned from file upload'));
|
||||||
|
} else {
|
||||||
|
resolve(fileUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('Failed to parse upload response'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let detail = xhr.statusText;
|
||||||
|
try {
|
||||||
|
const errObj = JSON.parse(xhr.responseText);
|
||||||
|
detail = errObj.detail || detail;
|
||||||
|
} catch (e) {
|
||||||
|
// fallback to statusText
|
||||||
|
}
|
||||||
|
reject(new Error(`File upload failed: ${xhr.status} - ${detail}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Network error during file upload'));
|
||||||
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
const errText = await response.text();
|
|
||||||
throw new Error(`File upload failed: ${response.status} - ${errText.slice(0, 100)}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
const fileUrl = data.url || data.file_url || data.data?.url;
|
|
||||||
if (!fileUrl) throw new Error('No URL returned from file upload');
|
|
||||||
return fileUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue