feat: modernize studio upload pipeline with cloud-only previews and progress indicators

This commit is contained in:
Jaya Prasad Kavuru 2026-04-09 17:25:07 +05:30
parent 776a325e77
commit b924f0caf8
6 changed files with 292 additions and 135 deletions

View file

@ -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>

View file

@ -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} />;
} }

View file

@ -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 && (

View file

@ -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}
/> />

View file

@ -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" />
)} )}

View file

@ -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;
} }