Spaces:
Sleeping
Sleeping
| import { useState, useCallback } from 'react'; | |
| import { useDropzone } from 'react-dropzone'; | |
| import { motion } from 'framer-motion'; | |
| import toast from 'react-hot-toast'; | |
| import { ingestUrl, ingestUpload, getSessionStatus } from '../api/client'; | |
| export default function VideoInput({ onVideoLoaded }) { | |
| const [url, setUrl] = useState(''); | |
| const [loading, setLoading] = useState(false); | |
| const [progress, setProgress] = useState(0); | |
| const [statusMessage, setStatusMessage] = useState(''); | |
| const handleUrlSubmit = async (e) => { | |
| e.preventDefault(); | |
| if (!url.trim()) return; | |
| setLoading(true); | |
| setStatusMessage('Starting download...'); | |
| try { | |
| const result = await ingestUrl(url.trim()); | |
| // Poll for download completion | |
| const sessionId = result.session_id; | |
| let attempts = 0; | |
| const maxAttempts = 120; // 10 minutes max | |
| const pollStatus = async () => { | |
| while (attempts < maxAttempts) { | |
| await new Promise(r => setTimeout(r, 5000)); | |
| attempts++; | |
| setProgress(Math.min(attempts * 2, 90)); | |
| try { | |
| const status = await getSessionStatus(sessionId); | |
| if (status.status === 'downloaded') { | |
| setProgress(100); | |
| setStatusMessage('Download complete!'); | |
| toast.success('Video downloaded successfully!'); | |
| onVideoLoaded(status); | |
| return; | |
| } else if (status.status === 'error') { | |
| throw new Error(status.error || 'Download failed'); | |
| } | |
| setStatusMessage(`Downloading... (${attempts * 5}s)`); | |
| } catch (err) { | |
| if (err.message !== 'Download failed') continue; | |
| throw err; | |
| } | |
| } | |
| throw new Error('Download timed out'); | |
| }; | |
| await pollStatus(); | |
| } catch (err) { | |
| toast.error(err.response?.data?.detail || err.message || 'Failed to download video'); | |
| setStatusMessage(''); | |
| } finally { | |
| setLoading(false); | |
| setProgress(0); | |
| } | |
| }; | |
| const onDrop = useCallback(async (acceptedFiles) => { | |
| const file = acceptedFiles[0]; | |
| if (!file) return; | |
| // Validate size | |
| if (file.size > 2 * 1024 * 1024 * 1024) { | |
| toast.error('File too large. Maximum size is 2GB.'); | |
| return; | |
| } | |
| setLoading(true); | |
| setStatusMessage('Uploading...'); | |
| try { | |
| const result = await ingestUpload(file, (percent) => { | |
| setProgress(percent); | |
| setStatusMessage(`Uploading... ${percent}%`); | |
| }); | |
| toast.success('Video uploaded successfully!'); | |
| onVideoLoaded(result); | |
| } catch (err) { | |
| toast.error(err.response?.data?.detail || 'Upload failed'); | |
| setStatusMessage(''); | |
| } finally { | |
| setLoading(false); | |
| setProgress(0); | |
| } | |
| }, [onVideoLoaded]); | |
| const { getRootProps, getInputProps, isDragActive } = useDropzone({ | |
| onDrop, | |
| accept: { | |
| 'video/*': ['.mp4', '.mov', '.mkv', '.avi', '.webm'] | |
| }, | |
| maxFiles: 1, | |
| disabled: loading, | |
| }); | |
| return ( | |
| <div className="max-w-3xl mx-auto"> | |
| {/* Hero */} | |
| <div className="text-center mb-12"> | |
| <motion.h2 | |
| className="text-5xl font-black mb-4 gradient-text" | |
| initial={{ opacity: 0, y: -20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| > | |
| Turn Long Videos Into Viral Clips | |
| </motion.h2> | |
| <p className="text-lg text-white/50"> | |
| Drop a video or paste a URL. We'll AI-generate multiple viral-ready short clips with | |
| voiceover, captions, sound effects, and more. | |
| </p> | |
| </div> | |
| {/* URL Input */} | |
| <div className="card mb-6"> | |
| <h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-4"> | |
| π Paste Video URL | |
| </h3> | |
| <form onSubmit={handleUrlSubmit} className="flex gap-3"> | |
| <input | |
| type="url" | |
| value={url} | |
| onChange={(e) => setUrl(e.target.value)} | |
| placeholder="https://youtube.com/watch?v=... or any video URL" | |
| className="flex-1 bg-dark-900 border border-white/10 rounded-xl px-4 py-3 text-white | |
| placeholder:text-white/30 focus:outline-none focus:border-primary-500/50 transition-colors" | |
| disabled={loading} | |
| /> | |
| <button | |
| type="submit" | |
| className="btn-primary whitespace-nowrap disabled:opacity-50" | |
| disabled={loading || !url.trim()} | |
| > | |
| {loading ? 'β³ Processing...' : 'π Analyze Video'} | |
| </button> | |
| </form> | |
| <p className="text-xs text-white/30 mt-2"> | |
| Supports YouTube, Vimeo, Twitter/X, and most public video URLs | |
| </p> | |
| </div> | |
| {/* Divider */} | |
| <div className="flex items-center gap-4 my-8"> | |
| <div className="flex-1 h-px bg-white/10" /> | |
| <span className="text-white/30 text-sm font-medium">OR</span> | |
| <div className="flex-1 h-px bg-white/10" /> | |
| </div> | |
| {/* File Upload Dropzone */} | |
| <div | |
| {...getRootProps()} | |
| className={`card cursor-pointer transition-all duration-300 border-2 border-dashed | |
| ${isDragActive ? 'border-primary-500 bg-primary-500/5' : 'border-white/10 hover:border-white/20'} | |
| ${loading ? 'opacity-50 cursor-not-allowed' : ''}`} | |
| > | |
| <input {...getInputProps()} /> | |
| <div className="text-center py-12"> | |
| <div className="text-5xl mb-4"> | |
| {isDragActive ? 'π₯' : 'π₯'} | |
| </div> | |
| <h3 className="text-xl font-semibold mb-2"> | |
| {isDragActive ? 'Drop it here!' : 'Drag & Drop Video File'} | |
| </h3> | |
| <p className="text-white/40 mb-4"> | |
| or click to browse β’ MP4, MOV, MKV, AVI, WebM up to 2GB | |
| </p> | |
| <div className="inline-flex items-center gap-2 px-4 py-2 bg-dark-900 rounded-lg text-sm text-white/50"> | |
| <span>π</span> Requirements: 60 seconds β 3 hours duration | |
| </div> | |
| </div> | |
| </div> | |
| {/* Progress Bar */} | |
| {loading && ( | |
| <motion.div | |
| className="mt-6 card" | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: 'auto' }} | |
| > | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-sm text-white/60">{statusMessage}</span> | |
| <span className="text-sm font-mono text-primary-500">{progress}%</span> | |
| </div> | |
| <div className="w-full h-2 bg-dark-900 rounded-full overflow-hidden"> | |
| <motion.div | |
| className="h-full bg-gradient-to-r from-primary-500 to-accent-500 rounded-full" | |
| initial={{ width: 0 }} | |
| animate={{ width: `${progress}%` }} | |
| transition={{ duration: 0.3 }} | |
| /> | |
| </div> | |
| </motion.div> | |
| )} | |
| </div> | |
| ); | |
| } | |