Spaces:
Sleeping
Sleeping
| import { useState } from 'react'; | |
| import { motion } from 'framer-motion'; | |
| const CAPTION_STYLES = [ | |
| { id: 'heatwave', name: 'HeatWave', emoji: '🔥', desc: 'Yellow + red active, bold energy', colors: ['#FFFF00', '#FF0000'] }, | |
| { id: 'neonpop', name: 'NeonPop', emoji: '💜', desc: 'Cyan glow on dark, bouncy', colors: ['#00FFFF', '#FF00FF'] }, | |
| { id: 'cinematic', name: 'Cinematic', emoji: '🎬', desc: 'White elegance, fade-in', colors: ['#FFFFFF', '#FFD700'] }, | |
| { id: 'hiphop', name: 'HipHop', emoji: '🎤', desc: 'Impact font, slam effect', colors: ['#FFFFFF', '#FF4444'] }, | |
| { id: 'minimal', name: 'Minimal', emoji: '✨', desc: 'Clean white, pure opacity', colors: ['#FFFFFF', '#CCCCCC'] }, | |
| ]; | |
| const VOICE_PRESETS = [ | |
| { id: 'energetic_male', name: 'Energetic Male', emoji: '🔊' }, | |
| { id: 'clear_female', name: 'Clear Female', emoji: '🎙️' }, | |
| { id: 'deep_narrator', name: 'Deep Narrator', emoji: '🎧' }, | |
| { id: 'young_excited', name: 'Young & Excited', emoji: '⚡' }, | |
| { id: 'calm_storyteller', name: 'Calm Storyteller', emoji: '📖' }, | |
| ]; | |
| const MUSIC_GENRES = [ | |
| { id: 'energetic_electronic', name: 'Energetic Electronic', emoji: '🎵' }, | |
| { id: 'lofi_hiphop', name: 'Lo-fi Hip Hop', emoji: '🎶' }, | |
| { id: 'dramatic_orchestral', name: 'Dramatic Orchestral', emoji: '🎻' }, | |
| { id: 'comedic_quirky', name: 'Comedic Quirky', emoji: '🤪' }, | |
| { id: 'suspenseful', name: 'Suspenseful', emoji: '😱' }, | |
| ]; | |
| const COLOR_GRADES = [ | |
| { id: 'teal_orange', name: 'Teal & Orange', color: 'from-teal-500 to-orange-500' }, | |
| { id: 'high_contrast_bw', name: 'High Contrast B&W', color: 'from-gray-900 to-white' }, | |
| { id: 'warm_golden', name: 'Warm Golden', color: 'from-yellow-500 to-orange-600' }, | |
| { id: 'cold_blue', name: 'Cold Blue', color: 'from-blue-700 to-cyan-400' }, | |
| { id: 'vivid_oversaturated', name: 'Vivid', color: 'from-pink-500 to-violet-600' }, | |
| ]; | |
| export default function ClipCustomizer({ candidates, configs, onDone }) { | |
| const [clipConfigs, setClipConfigs] = useState(configs); | |
| const [activeClip, setActiveClip] = useState(0); | |
| const updateConfig = (index, field, value) => { | |
| const updated = [...clipConfigs]; | |
| updated[index] = { ...updated[index], [field]: value }; | |
| setClipConfigs(updated); | |
| }; | |
| const formatTime = (seconds) => { | |
| const m = Math.floor(seconds / 60); | |
| const s = Math.floor(seconds % 60); | |
| return `${m}:${s.toString().padStart(2, '0')}`; | |
| }; | |
| const activeCandidate = candidates[activeClip]; | |
| const activeConfig = clipConfigs[activeClip]; | |
| if (!activeCandidate || !activeConfig) return null; | |
| return ( | |
| <div className="max-w-6xl mx-auto"> | |
| <h2 className="text-3xl font-bold mb-2">🎨 Customize Your Clips</h2> | |
| <p className="text-white/50 mb-8">Style each clip with unique captions, voice, music, and color grading</p> | |
| {/* Clip Tabs */} | |
| <div className="flex gap-2 mb-8 overflow-x-auto pb-2"> | |
| {candidates.map((candidate, i) => ( | |
| <button | |
| key={i} | |
| onClick={() => setActiveClip(i)} | |
| className={`flex-shrink-0 px-4 py-3 rounded-xl text-sm font-medium transition-all | |
| ${activeClip === i | |
| ? 'bg-primary-500 text-white shadow-lg shadow-primary-500/25' | |
| : 'bg-dark-800 text-white/50 hover:text-white/80 border border-white/5'}`} | |
| > | |
| <span className="font-bold">Clip {i + 1}</span> | |
| <span className="ml-2 text-xs opacity-60"> | |
| {formatTime(candidate.start_time)} - {formatTime(candidate.end_time)} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| {/* Left: Clip Info */} | |
| <div className="card"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h3 className="font-semibold">📝 Clip Info</h3> | |
| <span className="px-2 py-1 bg-green-500/10 text-green-400 text-xs rounded-full font-medium"> | |
| Score: {activeCandidate.virality_score.toFixed(0)} | |
| </span> | |
| </div> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="text-xs text-white/40 uppercase tracking-wider">Title</label> | |
| <input | |
| type="text" | |
| value={activeConfig.title} | |
| onChange={(e) => updateConfig(activeClip, 'title', e.target.value)} | |
| placeholder="AI will generate if empty" | |
| className="w-full mt-1 bg-dark-900 border border-white/10 rounded-lg px-3 py-2 text-sm | |
| focus:outline-none focus:border-primary-500/50" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs text-white/40 uppercase tracking-wider">Duration</label> | |
| <p className="text-sm mt-1">{activeCandidate.duration.toFixed(1)}s</p> | |
| </div> | |
| <div> | |
| <label className="text-xs text-white/40 uppercase tracking-wider">Transcript Preview</label> | |
| <p className="text-xs text-white/60 mt-1 max-h-32 overflow-y-auto leading-relaxed"> | |
| {activeCandidate.transcript.slice(0, 300)}... | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Middle: Style Options */} | |
| <div className="card"> | |
| <h3 className="font-semibold mb-4">🎨 Caption Style</h3> | |
| <div className="grid grid-cols-1 gap-2 mb-6"> | |
| {CAPTION_STYLES.map((style) => ( | |
| <button | |
| key={style.id} | |
| onClick={() => updateConfig(activeClip, 'caption_style', style.id)} | |
| className={`flex items-center gap-3 p-3 rounded-lg text-left transition-all text-sm | |
| ${activeConfig.caption_style === style.id | |
| ? 'bg-primary-500/10 border border-primary-500/30' | |
| : 'bg-dark-900/50 border border-transparent hover:border-white/10'}`} | |
| > | |
| <span className="text-lg">{style.emoji}</span> | |
| <div className="flex-1"> | |
| <div className="font-medium">{style.name}</div> | |
| <div className="text-xs text-white/40">{style.desc}</div> | |
| </div> | |
| <div className="flex gap-1"> | |
| {style.colors.map((c, i) => ( | |
| <div key={i} className="w-3 h-3 rounded-full" style={{ backgroundColor: c }} /> | |
| ))} | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| <h3 className="font-semibold mb-3">🎬 Color Grade</h3> | |
| <div className="grid grid-cols-5 gap-2"> | |
| {COLOR_GRADES.map((grade) => ( | |
| <button | |
| key={grade.id} | |
| onClick={() => updateConfig(activeClip, 'color_grade', grade.id)} | |
| className={`aspect-square rounded-lg bg-gradient-to-br ${grade.color} transition-all | |
| ${activeConfig.color_grade === grade.id | |
| ? 'ring-2 ring-primary-500 ring-offset-2 ring-offset-dark-800' : 'opacity-60 hover:opacity-100'}`} | |
| title={grade.name} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Right: Audio Options */} | |
| <div className="card"> | |
| <h3 className="font-semibold mb-4">🔊 Voice</h3> | |
| <div className="space-y-2 mb-6"> | |
| {VOICE_PRESETS.map((voice) => ( | |
| <button | |
| key={voice.id} | |
| onClick={() => updateConfig(activeClip, 'voice_preset', voice.id)} | |
| className={`w-full flex items-center gap-3 p-3 rounded-lg text-left text-sm transition-all | |
| ${activeConfig.voice_preset === voice.id | |
| ? 'bg-primary-500/10 border border-primary-500/30' | |
| : 'bg-dark-900/50 border border-transparent hover:border-white/10'}`} | |
| > | |
| <span>{voice.emoji}</span> | |
| <span className="font-medium">{voice.name}</span> | |
| </button> | |
| ))} | |
| </div> | |
| <h3 className="font-semibold mb-3">🎵 Background Music</h3> | |
| <div className="space-y-2 mb-6"> | |
| {MUSIC_GENRES.map((genre) => ( | |
| <button | |
| key={genre.id} | |
| onClick={() => updateConfig(activeClip, 'music_genre', genre.id)} | |
| className={`w-full flex items-center gap-3 p-2 rounded-lg text-left text-sm transition-all | |
| ${activeConfig.music_genre === genre.id | |
| ? 'bg-primary-500/10 border border-primary-500/30' | |
| : 'bg-dark-900/50 border border-transparent hover:border-white/10'}`} | |
| > | |
| <span>{genre.emoji}</span> | |
| <span className="font-medium">{genre.name}</span> | |
| </button> | |
| ))} | |
| </div> | |
| {/* Toggles */} | |
| <div className="space-y-3 pt-4 border-t border-white/5"> | |
| <label className="flex items-center justify-between cursor-pointer"> | |
| <span className="text-sm">Include original audio</span> | |
| <input | |
| type="checkbox" | |
| checked={activeConfig.include_original_audio} | |
| onChange={(e) => updateConfig(activeClip, 'include_original_audio', e.target.checked)} | |
| className="w-4 h-4 rounded accent-primary-500" | |
| /> | |
| </label> | |
| <label className="flex items-center justify-between cursor-pointer"> | |
| <span className="text-sm">Include stock images</span> | |
| <input | |
| type="checkbox" | |
| checked={activeConfig.include_stock_images} | |
| onChange={(e) => updateConfig(activeClip, 'include_stock_images', e.target.checked)} | |
| className="w-4 h-4 rounded accent-primary-500" | |
| /> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Generate Button */} | |
| <div className="text-center mt-10"> | |
| <button | |
| onClick={() => onDone(clipConfigs)} | |
| className="btn-primary text-lg px-12 py-4" | |
| > | |
| ⚡ Generate {candidates.length} Viral Clips | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |