Merge pull request #58 from jaiprasad04/feat/modernize-studio-upload
feat: modernize studio upload pipeline with cloud-only previews and p…
This commit is contained in:
commit
b578108936
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(''); }}
|
||||
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 && <p className="mt-1 text-red-400 text-xs">{error}</p>}
|
||||
</div>
|
||||
|
|
@ -48,6 +49,7 @@ export default function ApiKeyModal({ onSave }) {
|
|||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
Launch Studio
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
||||
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
return <ApiKeyModal onSave={handleKeySave} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<span className="animate-spin text-primary text-sm">◌</span>
|
||||
);
|
||||
} 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 = (
|
||||
<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 = (
|
||||
<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>
|
||||
|
|
@ -236,8 +241,21 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
|||
}
|
||||
triggerContent = (
|
||||
<>
|
||||
<img src={selectedEntries[0].thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
{badge}
|
||||
{uploading && hasSelection && (
|
||||
<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 {
|
||||
|
|
@ -382,27 +400,40 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
|||
<div
|
||||
key={entry.id}
|
||||
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 ${
|
||||
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 */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1">
|
||||
<button
|
||||
type="button"
|
||||
title="Remove from history"
|
||||
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" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{entry.url && (
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1">
|
||||
<button
|
||||
type="button"
|
||||
title="Remove from history"
|
||||
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" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection badge */}
|
||||
{isSelected && (
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading spinner */}
|
||||
{/* Uploading indicator */}
|
||||
{uploadState === UPLOAD_STATE.UPLOADING && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<span className="animate-spin text-primary text-sm">◌</span>
|
||||
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/60 z-10 animate-pulse">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Ready state */}
|
||||
{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">
|
||||
{icon}
|
||||
<span className="text-[9px] text-primary font-bold">READY</span>
|
||||
{previewUrl ? (
|
||||
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>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -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
|
|||
</svg>
|
||||
}
|
||||
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={<VideoIcon />}
|
||||
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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<span className="animate-spin text-primary text-sm">◌</span>
|
||||
) : uploadedImageUrl ? (
|
||||
<img src={uploadedImagePreview} alt="" className="w-full h-full object-cover rounded-xl" />
|
||||
) : (
|
||||
<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">{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">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<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`}
|
||||
>
|
||||
{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 ? (
|
||||
<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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -121,21 +121,52 @@ export async function processLipSync(apiKey, params) {
|
|||
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
|
||||
}
|
||||
|
||||
export async function uploadFile(apiKey, file) {
|
||||
const url = `${BASE_URL}/api/v1/upload_file`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
body: formData
|
||||
export function uploadFile(apiKey, file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${BASE_URL}/api/v1/upload_file`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
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