From b924f0caf8efc3be533ac05edc76a71afe423a3c Mon Sep 17 00:00:00 2001 From: Jaya Prasad Kavuru Date: Thu, 9 Apr 2026 17:25:07 +0530 Subject: [PATCH] feat: modernize studio upload pipeline with cloud-only previews and progress indicators --- components/ApiKeyModal.js | 2 + components/StandaloneShell.js | 8 + .../studio/src/components/ImageStudio.jsx | 223 ++++++++++-------- .../studio/src/components/LipSyncStudio.jsx | 82 ++++++- .../studio/src/components/VideoStudio.jsx | 49 +++- packages/studio/src/muapi.js | 63 +++-- 6 files changed, 292 insertions(+), 135 deletions(-) diff --git a/components/ApiKeyModal.js b/components/ApiKeyModal.js index 012d373..f01004b 100644 --- a/components/ApiKeyModal.js +++ b/components/ApiKeyModal.js @@ -41,6 +41,7 @@ export default function ApiKeyModal({ onSave }) { onChange={(e) => { setKey(e.target.value); setError(''); }} 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" + suppressHydrationWarning /> {error &&

{error}

} @@ -48,6 +49,7 @@ export default function ApiKeyModal({ onSave }) { diff --git a/components/StandaloneShell.js b/components/StandaloneShell.js index 2b8e4f2..b142421 100644 --- a/components/StandaloneShell.js +++ b/components/StandaloneShell.js @@ -17,8 +17,10 @@ export default function StandaloneShell() { const [apiKey, setApiKey] = useState(null); const [activeTab, setActiveTab] = useState('image'); const [showSettings, setShowSettings] = useState(false); + const [hasMounted, setHasMounted] = useState(false); useEffect(() => { + setHasMounted(true); const stored = localStorage.getItem(STORAGE_KEY); if (stored) setApiKey(stored); }, []); @@ -33,6 +35,12 @@ export default function StandaloneShell() { setApiKey(null); }, []); + if (!hasMounted) return ( +
+
+
+ ); + if (!apiKey) { return ; } diff --git a/packages/studio/src/components/ImageStudio.jsx b/packages/studio/src/components/ImageStudio.jsx index 4cbf337..ce59eac 100644 --- a/packages/studio/src/components/ImageStudio.jsx +++ b/packages/studio/src/components/ImageStudio.jsx @@ -16,13 +16,6 @@ import { // ─── 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) { try { @@ -48,6 +41,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { const [uploading, setUploading] = useState(false); const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}] const [uploadHistory, setUploadHistory] = useState([]); // [{id, name, url, thumbnail}] + const [lastUploadProgress, setLastUploadProgress] = useState(0); const fileInputRef = useRef(null); const panelRef = useRef(null); const triggerRef = useRef(null); @@ -71,14 +65,11 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { // When maxImages changes, trim excess selections useEffect(() => { - setSelectedEntries((prev) => { - if (prev.length > maxImages) { - const trimmed = prev.slice(0, maxImages); - if (trimmed.length === 0) onClear?.(); - return trimmed; - } - return prev; - }); + if (selectedEntries.length > maxImages) { + const trimmed = selectedEntries.slice(0, maxImages); + setSelectedEntries(trimmed); + if (trimmed.length === 0) onClear?.(); + } if (fileInputRef.current) { fileInputRef.current.multiple = maxImages > 1; } @@ -88,7 +79,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { (entries) => { if (!entries.length) return; 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] ); @@ -98,56 +89,66 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { if (!files.length) return; 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); try { - if (maxImages === 1) { - const file = files[0]; - 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 toUpload = + maxImages === 1 ? files.slice(0, 1) : files.slice(0, maxImages - selectedEntries.length || 1); - const results = await Promise.all( - toUpload.map(async (file) => { - const [uploadedUrl, thumbnail] = await Promise.all([ - uploadFile(apiKey, file), - generateThumbnail(file), - ]); - return { - id: Date.now().toString() + Math.random(), - name: file.name, - url: uploadedUrl, - thumbnail, - }; - }) - ); + await Promise.all( + toUpload.map(async (file) => { + const id = Date.now().toString() + Math.random(); - setUploadHistory((prev) => [...results, ...prev]); - setSelectedEntries((prev) => { - const next = [...prev]; - results.forEach((r) => { - if (next.length < maxImages) { - next.push({ url: r.url, thumbnail: r.thumbnail }); + // Add a placeholder to history immediately without local preview + const placeholder = { id, name: file.name, url: null, progress: 0 }; + setUploadHistory((prev) => [placeholder, ...prev]); + + try { + 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); + } } - }); - return next; - }); - setPanelOpen(true); - } + } catch (err) { + console.error("[UploadButton] Upload failed for", file.name, err); + setUploadHistory((prev) => prev.filter((h) => h.id !== id)); + throw err; + } + }) + ); } catch (err) { - console.error("[UploadButton] Upload failed:", err); alert(`Image upload failed: ${err.message}`); } finally { setUploading(false); + setLastUploadProgress(0); } }; @@ -158,32 +159,32 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { if (atMax) return; if (maxImages === 1) { - const newSelected = [{ url: entry.url, thumbnail: entry.thumbnail }]; + const newSelected = [{ url: entry.url, localUrl: entry.localUrl }]; setSelectedEntries(newSelected); fireOnSelect(newSelected); setPanelOpen(false); } else { - setSelectedEntries((prev) => { - let next; - if (isSelected) { - next = prev.filter((_, i) => i !== selIdx); - if (next.length === 0) onClear?.(); - } else { - next = [...prev, { url: entry.url, thumbnail: entry.thumbnail }]; - } - return next; - }); + let next; + if (isSelected) { + next = selectedEntries.filter((_, i) => i !== selIdx); + if (next.length === 0) onClear?.(); + } else { + next = [...selectedEntries, { url: entry.url, localUrl: entry.localUrl }]; + } + setSelectedEntries(next); } }; const handleRemoveFromHistory = (e, entry) => { e.stopPropagation(); + if (entry.localUrl) URL.revokeObjectURL(entry.localUrl); 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?.(); - return next; - }); + } }; const handleDone = (e) => { @@ -206,14 +207,18 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { // Trigger icon content let triggerContent; - if (uploading) { - triggerContent = ( - - ); - } else if (hasSelection) { + if (hasSelection || uploading) { + const mainEntry = selectedEntries[0] || uploadHistory[0]; const canAddMore = isMulti && count < maxImages; let badge; - if (count > 1) { + if (uploading && !hasSelection) { + badge = ( +
+
+ {lastUploadProgress}% +
+ ); + } else if (count > 1) { badge = (
{count} @@ -236,8 +241,21 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) { } triggerContent = ( <> - - {badge} + {uploading && hasSelection && ( +
+
+ {lastUploadProgress}% +
+ )} + {mainEntry?.url ? ( + + ) : ( +
+
+ {lastUploadProgress}% +
+ )} + {!uploading && badge} ); } else { @@ -382,27 +400,40 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
handleCellClick(entry)} + onClick={() => entry.url && handleCellClick(entry)} 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" - } ${atMax ? "opacity-40 cursor-not-allowed" : ""}`} + } ${atMax ? "opacity-40 cursor-not-allowed" : ""} ${!entry.url ? "cursor-wait" : ""}`} > - {entry.name} + {entry.url ? ( + {entry.name} + ) : ( +
+
+ {entry.progress}% +
+ )} {/* Hover overlay with delete */} -
- -
+ {entry.url && ( +
+ +
+ )} {/* Selection badge */} {isSelected && ( diff --git a/packages/studio/src/components/LipSyncStudio.jsx b/packages/studio/src/components/LipSyncStudio.jsx index c58626a..6355299 100644 --- a/packages/studio/src/components/LipSyncStudio.jsx +++ b/packages/studio/src/components/LipSyncStudio.jsx @@ -19,7 +19,7 @@ const UPLOAD_STATE = { 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 handleClick = (e) => { @@ -72,18 +72,29 @@ function MediaPickerButton({ accept, label, icon, onUpload, onClear, uploadState
)} - {/* Uploading spinner */} + {/* Uploading indicator */} {uploadState === UPLOAD_STATE.UPLOADING && ( -
- +
+
+ {progress}%
)} {/* Ready state */} {uploadState === UPLOAD_STATE.READY && (
- {icon} - READY + {previewUrl ? ( + isVideo ? ( +
)} @@ -233,6 +244,11 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte const [audioName, setAudioName] = useState(''); const [audioUrl, setAudioUrl] = useState(null); + // ── Individual progress states ── + const [imageProgress, setImageProgress] = useState(0); + const [videoProgress, setVideoProgress] = useState(0); + const [audioProgress, setAudioProgress] = useState(0); + // ── Prompt ────────────────────────────────────────────────────────────── const [prompt, setPrompt] = useState(''); @@ -273,41 +289,68 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte // ── Upload handlers ───────────────────────────────────────────────────── const handleImageUpload = useCallback(async (file) => { + if (file.size > 10 * 1024 * 1024) { + alert("Image exceeds 10MB limit."); + return; + } setImageState(UPLOAD_STATE.UPLOADING); + setImageProgress(0); try { - const url = await uploadFile(apiKey, file); + const url = await uploadFile(apiKey, file, (pct) => { + setImageProgress(pct); + }); setImageUrl(url); setImageName(file.name); setImageState(UPLOAD_STATE.READY); } catch (err) { setImageState(UPLOAD_STATE.IDLE); alert(`Image upload failed: ${err.message}`); + } finally { + setImageProgress(0); } }, [apiKey]); const handleVideoPick = useCallback(async (file) => { + if (file.size > 50 * 1024 * 1024) { + alert("Video exceeds 50MB limit."); + return; + } setVideoState(UPLOAD_STATE.UPLOADING); + setVideoProgress(0); try { - const url = await uploadFile(apiKey, file); + const url = await uploadFile(apiKey, file, (pct) => { + setVideoProgress(pct); + }); setVideoUrl(url); setVideoName(file.name); setVideoState(UPLOAD_STATE.READY); } catch (err) { setVideoState(UPLOAD_STATE.IDLE); alert(`Video upload failed: ${err.message}`); + } finally { + setVideoProgress(0); } }, [apiKey]); const handleAudioPick = useCallback(async (file) => { + if (file.size > 10 * 1024 * 1024) { + alert("Audio file exceeds 10MB limit."); + return; + } setAudioState(UPLOAD_STATE.UPLOADING); + setAudioProgress(0); try { - const url = await uploadFile(apiKey, file); + const url = await uploadFile(apiKey, file, (pct) => { + setAudioProgress(pct); + }); setAudioUrl(url); setAudioName(file.name); setAudioState(UPLOAD_STATE.READY); } catch (err) { setAudioState(UPLOAD_STATE.IDLE); alert(`Audio upload failed: ${err.message}`); + } finally { + setAudioProgress(0); } }, [apiKey]); @@ -541,9 +584,16 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte } onUpload={handleImageUpload} - onClear={() => { setImageUrl(null); setImageState(UPLOAD_STATE.IDLE); setImageName(''); }} + onClear={() => { + setImageUrl(null); + setImageState(UPLOAD_STATE.IDLE); + setImageName(''); + }} uploadState={imageState} + progress={imageProgress} fileName={imageName} + previewUrl={imageUrl} + isVideo={false} apiKey={apiKey} /> )} @@ -555,9 +605,16 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte label="Video" icon={} onUpload={handleVideoPick} - onClear={() => { setVideoUrl(null); setVideoState(UPLOAD_STATE.IDLE); setVideoName(''); }} + onClear={() => { + setVideoUrl(null); + setVideoState(UPLOAD_STATE.IDLE); + setVideoName(''); + }} uploadState={videoState} + progress={videoProgress} fileName={videoName} + previewUrl={videoUrl} + isVideo={true} apiKey={apiKey} /> )} @@ -570,7 +627,10 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyIte onUpload={handleAudioPick} onClear={() => { setAudioUrl(null); setAudioState(UPLOAD_STATE.IDLE); setAudioName(''); }} uploadState={audioState} + progress={audioProgress} fileName={audioName} + previewUrl={null} + isVideo={false} apiKey={apiKey} /> diff --git a/packages/studio/src/components/VideoStudio.jsx b/packages/studio/src/components/VideoStudio.jsx index 06f4c4c..24d6d30 100644 --- a/packages/studio/src/components/VideoStudio.jsx +++ b/packages/studio/src/components/VideoStudio.jsx @@ -192,6 +192,10 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems const [selectedQuality, setSelectedQuality] = useState(defaultModel.inputs?.quality?.default || ''); const [selectedMode, setSelectedMode] = useState(''); + // ── upload progress ── + const [imageProgress, setImageProgress] = useState(0); + const [videoProgress, setVideoProgress] = useState(0); + // ── control visibility ── const [showAr, setShowAr] = useState(true); const [showDuration, setShowDuration] = useState(true); @@ -201,7 +205,6 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems // ── uploads ── const [uploadedImageUrl, setUploadedImageUrl] = useState(null); - const [uploadedImagePreview, setUploadedImagePreview] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [uploadedVideoUrl, setUploadedVideoUrl] = useState(null); const [videoUploading, setVideoUploading] = useState(false); @@ -323,11 +326,18 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems const handleImageFileChange = async (e) => { const file = e.target.files[0]; if (!file) return; + if (file.size > 10 * 1024 * 1024) { + alert("Image exceeds 10MB limit."); + return; + } setImageUploading(true); + setImageProgress(0); + try { - const url = await uploadFile(apiKey, file); + const url = await uploadFile(apiKey, file, (pct) => { + setImageProgress(pct); + }); setUploadedImageUrl(url); - setUploadedImagePreview(URL.createObjectURL(file)); // Clear v2v if active setUploadedVideoUrl(null); @@ -347,13 +357,13 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems alert(`Image upload failed: ${err.message}`); } finally { setImageUploading(false); + setImageProgress(0); if (imageFileInputRef.current) imageFileInputRef.current.value = ''; } }; const clearImageUpload = () => { setUploadedImageUrl(null); - setUploadedImagePreview(null); setImageMode(false); const first = t2vModels[0]; setSelectedModel(first.id); @@ -366,16 +376,22 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems const handleVideoFileChange = async (e) => { const file = e.target.files[0]; if (!file) return; + if (file.size > 50 * 1024 * 1024) { + alert("Video exceeds 50MB limit."); + return; + } setVideoUploading(true); + setVideoProgress(0); try { - const url = await uploadFile(apiKey, file); + const url = await uploadFile(apiKey, file, (pct) => { + setVideoProgress(pct); + }); setUploadedVideoUrl(url); setUploadedVideoName(file.name); // Clear image mode if active if (imageMode) { setUploadedImageUrl(null); - setUploadedImagePreview(null); setImageMode(false); } setV2vMode(true); @@ -390,6 +406,7 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems alert(`Video upload failed: ${err.message}`); } finally { setVideoUploading(false); + setVideoProgress(0); 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`} > {imageUploading ? ( - - ) : uploadedImageUrl ? ( - - ) : ( +
+
+ {imageProgress}% +
+ ) : null} + + {uploadedImageUrl ? ( + + ) : !imageUploading && ( @@ -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`} > {videoUploading ? ( - +
+
+ {videoProgress}% +
) : uploadedVideoUrl ? ( - +