Spaces:
Sleeping
Sleeping
| import { useState } from 'react'; | |
| import { motion } from 'framer-motion'; | |
| import { useGeneration } from '@/context/GenerationContext'; | |
| import { CheckIcon, DownloadIcon, PlayIcon, RefreshIcon, VideoIcon } from './Icons'; | |
| import { mergeVideos, ClipMetadata } from '@/utils/api'; | |
| export const GenerationComplete: React.FC = () => { | |
| const { state, reset } = useGeneration(); | |
| const { generatedVideos, provider } = state; | |
| const [playingIndex, setPlayingIndex] = useState<number | null>(null); | |
| const [isMerging, setIsMerging] = useState(false); | |
| const [mergeError, setMergeError] = useState<string | null>(null); | |
| const [mergedVideoUrl, setMergedVideoUrl] = useState<string | null>(null); | |
| const [isPlayingMerged, setIsPlayingMerged] = useState(false); | |
| const accentColor = provider === 'kling' ? 'coral' : 'electric'; | |
| const handleDownload = async (video: typeof generatedVideos[0], index: number) => { | |
| try { | |
| const url = video.blobUrl || video.url; | |
| const response = await fetch(url); | |
| const blob = await response.blob(); | |
| const downloadUrl = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = downloadUrl; | |
| a.download = `video-segment-${index + 1}.mp4`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(downloadUrl); | |
| } catch (error) { | |
| console.error('Download failed:', error); | |
| } | |
| }; | |
| const handleDownloadAll = async () => { | |
| for (let i = 0; i < generatedVideos.length; i++) { | |
| await handleDownload(generatedVideos[i], i); | |
| // Small delay between downloads | |
| await new Promise(r => setTimeout(r, 500)); | |
| } | |
| }; | |
| // Merge all videos into a single file | |
| const handleMergeAndExport = async () => { | |
| if (generatedVideos.length === 0) return; | |
| setIsMerging(true); | |
| setMergeError(null); | |
| try { | |
| // Collect all video blobs | |
| const videoBlobs: Blob[] = []; | |
| const clipMetadata: ClipMetadata[] = []; | |
| for (let i = 0; i < generatedVideos.length; i++) { | |
| const video = generatedVideos[i]; | |
| const url = video.blobUrl || video.url; | |
| // Fetch blob from URL | |
| const response = await fetch(url); | |
| const blob = await response.blob(); | |
| videoBlobs.push(blob); | |
| // Create clip metadata | |
| // Use Whisper-detected trim point if available, otherwise use full duration | |
| // No start trimming - keep full video from beginning | |
| const trimStart = 0; // Always start from beginning (no overlap removal) | |
| const trimEnd = video.trimPoint || video.duration; // Use Whisper trim point if available | |
| clipMetadata.push({ | |
| index: i, | |
| startTime: trimStart, | |
| endTime: trimEnd, | |
| type: 'video', | |
| duration: trimEnd - trimStart, | |
| }); | |
| } | |
| console.log('🎬 Merging videos...', clipMetadata); | |
| // Call merge API | |
| const mergedBlob = await mergeVideos(videoBlobs, clipMetadata); | |
| // Create URL for preview (don't auto-download) | |
| const previewUrl = URL.createObjectURL(mergedBlob); | |
| setMergedVideoUrl(previewUrl); | |
| console.log('✅ Merged video ready for preview!'); | |
| } catch (error) { | |
| console.error('Merge failed:', error); | |
| setMergeError(error instanceof Error ? error.message : 'Failed to merge videos'); | |
| } finally { | |
| setIsMerging(false); | |
| } | |
| }; | |
| // Download the merged video | |
| const handleDownloadMerged = () => { | |
| if (!mergedVideoUrl) return; | |
| const a = document.createElement('a'); | |
| a.href = mergedVideoUrl; | |
| a.download = `final-video-${Date.now()}.mp4`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| className="min-h-[60vh] flex flex-col items-center justify-center p-8" | |
| > | |
| <div className="max-w-4xl w-full"> | |
| {/* Success Header */} | |
| <div className="text-center mb-12"> | |
| <motion.div | |
| initial={{ scale: 0 }} | |
| animate={{ scale: 1 }} | |
| transition={{ type: 'spring', duration: 0.5 }} | |
| className={` | |
| w-20 h-20 mx-auto rounded-full flex items-center justify-center mb-6 | |
| ${accentColor === 'coral' ? 'bg-coral-500/20' : 'bg-electric-500/20'} | |
| `} | |
| > | |
| <CheckIcon | |
| size={40} | |
| className={accentColor === 'coral' ? 'text-coral-400' : 'text-electric-400'} | |
| /> | |
| </motion.div> | |
| <motion.h1 | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.2 }} | |
| className="text-4xl font-display font-bold mb-4" | |
| > | |
| <span className={accentColor === 'coral' ? 'gradient-text' : 'gradient-text-electric'}> | |
| Generation Complete! | |
| </span> | |
| </motion.h1> | |
| <motion.p | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.3 }} | |
| className="text-void-400 text-lg" | |
| > | |
| {generatedVideos.length} video{generatedVideos.length !== 1 ? 's' : ''} generated successfully | |
| </motion.p> | |
| </div> | |
| {/* Video Grid */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.4 }} | |
| className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-8" | |
| > | |
| {generatedVideos.map((video, index) => ( | |
| <div | |
| key={video.id} | |
| className="card group relative overflow-hidden" | |
| > | |
| {/* Video Preview */} | |
| <div className="relative aspect-[9/16] bg-void-950 rounded-lg overflow-hidden mb-4"> | |
| <video | |
| src={video.blobUrl || video.url} | |
| className="w-full h-full object-cover" | |
| controls={playingIndex === index} | |
| poster={video.thumbnails[0]} | |
| onEnded={() => setPlayingIndex(null)} | |
| /> | |
| {playingIndex !== index && ( | |
| <button | |
| onClick={() => setPlayingIndex(index)} | |
| className="absolute inset-0 flex items-center justify-center bg-black/40 group-hover:bg-black/50 transition-colors" | |
| > | |
| <div className={` | |
| w-14 h-14 rounded-full flex items-center justify-center | |
| ${accentColor === 'coral' ? 'bg-coral-500' : 'bg-electric-500'} | |
| group-hover:scale-110 transition-transform | |
| `}> | |
| <PlayIcon size={24} className="text-white ml-1" /> | |
| </div> | |
| </button> | |
| )} | |
| </div> | |
| {/* Video Info */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h3 className="font-semibold text-void-200"> | |
| Segment {index + 1} | |
| </h3> | |
| <p className="text-xs text-void-400"> | |
| ~{Math.round(video.duration)}s duration | |
| </p> | |
| </div> | |
| <button | |
| onClick={() => handleDownload(video, index)} | |
| className={` | |
| p-2 rounded-lg transition-colors | |
| ${accentColor === 'coral' | |
| ? 'hover:bg-coral-500/20 text-coral-400' | |
| : 'hover:bg-electric-500/20 text-electric-400' | |
| } | |
| `} | |
| > | |
| <DownloadIcon size={20} /> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </motion.div> | |
| {/* Merged Video Preview */} | |
| {mergedVideoUrl && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mb-8" | |
| > | |
| <div className={`card border-2 ${accentColor === 'coral' ? 'border-coral-500/50 bg-coral-500/5' : 'border-electric-500/50 bg-electric-500/5'}`}> | |
| <div className="flex items-center gap-3 mb-4"> | |
| <div className={`p-2 rounded-lg ${accentColor === 'coral' ? 'bg-coral-500/20' : 'bg-electric-500/20'}`}> | |
| <VideoIcon size={24} className={accentColor === 'coral' ? 'text-coral-400' : 'text-electric-400'} /> | |
| </div> | |
| <div> | |
| <h3 className="font-bold text-lg text-void-100">Final Exported Video</h3> | |
| <p className="text-sm text-void-400">All segments merged into one video</p> | |
| </div> | |
| </div> | |
| {/* Video Player */} | |
| <div className="relative aspect-[9/16] max-w-md mx-auto bg-void-950 rounded-xl overflow-hidden mb-4"> | |
| <video | |
| src={mergedVideoUrl} | |
| className="w-full h-full object-contain" | |
| controls={isPlayingMerged} | |
| onEnded={() => setIsPlayingMerged(false)} | |
| /> | |
| {!isPlayingMerged && ( | |
| <button | |
| onClick={() => setIsPlayingMerged(true)} | |
| className="absolute inset-0 flex items-center justify-center bg-black/40 hover:bg-black/50 transition-colors" | |
| > | |
| <div className={` | |
| w-16 h-16 rounded-full flex items-center justify-center | |
| ${accentColor === 'coral' ? 'bg-coral-500' : 'bg-electric-500'} | |
| hover:scale-110 transition-transform shadow-lg | |
| `}> | |
| <PlayIcon size={28} className="text-white ml-1" /> | |
| </div> | |
| </button> | |
| )} | |
| </div> | |
| {/* Download Button */} | |
| <button | |
| onClick={handleDownloadMerged} | |
| className={` | |
| w-full flex items-center justify-center gap-2 py-3 font-semibold rounded-xl | |
| ${accentColor === 'coral' ? 'btn-primary' : 'btn-electric'} | |
| `} | |
| > | |
| <DownloadIcon size={20} /> | |
| Download Final Video | |
| </button> | |
| </div> | |
| </motion.div> | |
| )} | |
| {/* Merge Error */} | |
| {mergeError && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-center" | |
| > | |
| <p className="text-red-300 text-sm">{mergeError}</p> | |
| </motion.div> | |
| )} | |
| {/* Actions */} | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.6 }} | |
| className="flex flex-col sm:flex-row items-center justify-center gap-4" | |
| > | |
| {/* Primary: Merge & Export */} | |
| {!mergedVideoUrl && ( | |
| <button | |
| onClick={handleMergeAndExport} | |
| disabled={isMerging || generatedVideos.length < 2} | |
| className={` | |
| flex items-center gap-2 px-6 py-3 font-semibold rounded-xl | |
| ${accentColor === 'coral' ? 'btn-primary' : 'btn-electric'} | |
| disabled:opacity-50 disabled:cursor-not-allowed | |
| `} | |
| > | |
| {isMerging ? ( | |
| <> | |
| <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> | |
| <span>Merging...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <VideoIcon size={20} /> | |
| <span>Export Final Video</span> | |
| </> | |
| )} | |
| </button> | |
| )} | |
| {/* Re-merge option if already merged */} | |
| {mergedVideoUrl && ( | |
| <button | |
| onClick={() => { | |
| URL.revokeObjectURL(mergedVideoUrl); | |
| setMergedVideoUrl(null); | |
| handleMergeAndExport(); | |
| }} | |
| disabled={isMerging} | |
| className="btn-secondary flex items-center gap-2" | |
| > | |
| <RefreshIcon size={20} /> | |
| Re-merge Video | |
| </button> | |
| )} | |
| {/* Secondary: Download All */} | |
| <button | |
| onClick={handleDownloadAll} | |
| className="btn-secondary flex items-center gap-2" | |
| > | |
| <DownloadIcon size={20} /> | |
| Download Segments | |
| </button> | |
| {/* Tertiary: Generate More */} | |
| <button | |
| onClick={reset} | |
| className="btn-secondary flex items-center gap-2" | |
| > | |
| <RefreshIcon size={20} /> | |
| Generate More | |
| </button> | |
| </motion.div> | |
| {/* Tip */} | |
| <motion.p | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.8 }} | |
| className="text-center text-void-500 text-sm mt-8" | |
| > | |
| {mergedVideoUrl | |
| ? 'Your final video is ready! Download it or re-merge with different settings.' | |
| : generatedVideos.length >= 2 | |
| ? '"Export Final Video" will merge all segments into a single video file with Whisper-optimized trim points.' | |
| : 'Videos are ready to use in your video editor or social media.' | |
| } | |
| </motion.p> | |
| </div> | |
| </motion.div> | |
| ); | |
| }; | |