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:
Anil Chandra Naidu Matcha 2026-04-09 17:33:40 +05:30 committed by GitHub
commit b578108936
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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(''); }}
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>

View file

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

View file

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

View file

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

View file

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

View file

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