Files
viral/openshorts/dashboard/src/components/ResultCard.jsx
T

686 lines
34 KiB
React

import React, { useState, useEffect } from 'react';
import { Download, Share2, Instagram, Youtube, Video, CheckCircle, AlertCircle, X, Loader2, Copy, Wand2, Type, Calendar, Clock, Languages } from 'lucide-react';
import { getApiUrl } from '../config';
import SubtitleModal from './SubtitleModal';
import HookModal from './HookModal';
import TranslateModal from './TranslateModal';
import { renderInBrowser } from '../lib/renderInBrowser';
export default function ResultCard({ clip, index, jobId, uploadPostKey, uploadUserId, geminiApiKey, elevenLabsKey, onPlay, onPause }) {
const [showModal, setShowModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const videoRef = React.useRef(null);
const originalVideoUrl = getApiUrl(clip.video_url); // Never changes — used for Remotion previews
const [currentVideoUrl, setCurrentVideoUrl] = useState(originalVideoUrl);
const [platforms, setPlatforms] = useState({
tiktok: true,
instagram: true,
youtube: true
});
const [postTitle, setPostTitle] = useState("");
const [postDescription, setPostDescription] = useState("");
const [isScheduling, setIsScheduling] = useState(false);
const [scheduleDate, setScheduleDate] = useState("");
const [posting, setPosting] = useState(false);
const [postResult, setPostResult] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [isSubtitling, setIsSubtitling] = useState(false);
const [isHooking, setIsHooking] = useState(false);
const [isTranslating, setIsTranslating] = useState(false);
const [showHookModal, setShowHookModal] = useState(false);
const [showTranslateModal, setShowTranslateModal] = useState(false);
const [editError, setEditError] = useState(null);
const [clipDuration, setClipDuration] = useState(clip.end && clip.start ? clip.end - clip.start : 30);
// Accumulate Remotion layers across operations
const [activeLayers, setActiveLayers] = useState({ subtitles: null, hook: null, effects: null });
// Fetch clip duration from transcript endpoint
useEffect(() => {
if (!jobId || index === undefined) return;
fetch(getApiUrl(`/api/clip/${jobId}/${index}/transcript`))
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.durationSec) setClipDuration(data.durationSec);
})
.catch(() => {});
}, [jobId, index]);
// Initialize/Reset form when modal opens
useEffect(() => {
if (showModal) {
setPostTitle(clip.video_title_for_youtube_short || "Viral Short");
setPostDescription(clip.video_description_for_instagram || clip.video_description_for_tiktok || "");
setIsScheduling(false);
setScheduleDate("");
setPostResult(null);
}
}, [showModal, clip]);
const handleAutoEdit = async () => {
setIsEditing(true);
setEditError(null);
try {
const apiKey = geminiApiKey || localStorage.getItem('gemini_key');
if (!apiKey) {
throw new Error("Gemini API Key is missing. Please set it in Settings.");
}
// Try Remotion effects endpoint first
const effectsRes = await fetch(getApiUrl('/api/effects/generate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gemini-Key': apiKey
},
body: JSON.stringify({
job_id: jobId,
clip_index: index,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (effectsRes.ok) {
const data = await effectsRes.json();
if (data.effects && data.effects.segments) {
const newLayers = { ...activeLayers, effects: data.effects };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
return;
}
}
// Fallback: legacy FFmpeg edit endpoint
const res = await fetch(getApiUrl('/api/edit'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gemini-Key': apiKey
},
body: JSON.stringify({
job_id: jobId,
clip_index: index,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) {
const errText = await res.text();
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
throw new Error(errText);
}
}
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) {
videoRef.current.load();
}
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsEditing(false);
}
};
const handleSubtitle = async (options) => {
setIsSubtitling(true);
setEditError(null);
try {
if (options.remotion) {
// Accumulate layer and render all layers together
const newLayers = { ...activeLayers, subtitles: options.remotion };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
setShowSubtitleModal(false);
return;
}
// Fallback: legacy FFmpeg
const res = await fetch(getApiUrl('/api/subtitle'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: jobId,
clip_index: index,
position: options.position,
font_size: options.fontSize,
font_name: options.fontName,
font_color: options.fontColor,
border_color: options.borderColor,
border_width: options.borderWidth,
bg_color: options.bgColor,
bg_opacity: options.bgOpacity,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) videoRef.current.load();
setShowSubtitleModal(false);
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsSubtitling(false);
}
};
const handleHook = async (hookData) => {
setIsHooking(true);
setEditError(null);
try {
if (hookData.remotion) {
// Accumulate layer and render all layers together
const newLayers = { ...activeLayers, hook: hookData.remotion };
setActiveLayers(newLayers);
const blobUrl = await renderInBrowser({
videoUrl: originalVideoUrl,
durationInSeconds: clipDuration,
subtitles: newLayers.subtitles,
hook: newLayers.hook,
effects: newLayers.effects,
});
setCurrentVideoUrl(blobUrl);
if (videoRef.current) videoRef.current.load();
setShowHookModal(false);
return;
}
// Fallback: legacy FFmpeg
const payload = typeof hookData === 'string'
? { text: hookData, position: 'top', size: 'M' }
: hookData;
const res = await fetch(getApiUrl('/api/hook'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: jobId,
clip_index: index,
text: payload.text,
position: payload.position,
size: payload.size,
input_filename: currentVideoUrl.split('/').pop()
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) videoRef.current.load();
setShowHookModal(false);
}
} catch (e) {
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsHooking(false);
}
};
const handleTranslate = async (options) => {
console.log('[Translate] Starting translation with options:', options);
setIsTranslating(true);
setEditError(null);
try {
const apiKey = elevenLabsKey;
console.log('[Translate] API Key available:', !!apiKey);
if (!apiKey) {
throw new Error("ElevenLabs API Key is missing. Please set it in Settings.");
}
const requestBody = {
job_id: jobId,
clip_index: index,
target_language: options.targetLanguage,
input_filename: currentVideoUrl.split('/').pop()
};
console.log('[Translate] Request body:', requestBody);
console.log('[Translate] Sending request to /api/translate');
const res = await fetch(getApiUrl('/api/translate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ElevenLabs-Key': apiKey
},
body: JSON.stringify(requestBody)
});
console.log('[Translate] Response status:', res.status);
if (!res.ok) {
const errText = await res.text();
console.error('[Translate] Error response:', errText);
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
if (e.message !== errText) throw e;
throw new Error(errText);
}
}
const data = await res.json();
console.log('[Translate] Success response:', data);
if (data.new_video_url) {
setCurrentVideoUrl(getApiUrl(data.new_video_url));
if (videoRef.current) {
videoRef.current.load();
}
setShowTranslateModal(false);
}
} catch (e) {
console.error('[Translate] Exception:', e);
setEditError(e.message);
setTimeout(() => setEditError(null), 5000);
} finally {
setIsTranslating(false);
}
};
const handlePost = async () => {
if (!uploadPostKey || !uploadUserId) {
setPostResult({ success: false, msg: "Missing API Key or User ID." });
return;
}
const selectedPlatforms = Object.keys(platforms).filter(k => platforms[k]);
if (selectedPlatforms.length === 0) {
setPostResult({ success: false, msg: "Select at least one platform." });
return;
}
if (isScheduling && !scheduleDate) {
setPostResult({ success: false, msg: "Please select a date and time." });
return;
}
setPosting(true);
setPostResult(null);
try {
const payload = {
job_id: jobId,
clip_index: index,
api_key: uploadPostKey,
user_id: uploadUserId,
platforms: selectedPlatforms,
title: postTitle,
description: postDescription
};
if (isScheduling && scheduleDate) {
// Convert to ISO-8601
payload.scheduled_date = new Date(scheduleDate).toISOString();
// Optional: pass timezone if needed, backend defaults to UTC or we can send user's timezone
payload.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
const res = await fetch(getApiUrl('/api/social/post'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
try {
const jsonErr = JSON.parse(errText);
throw new Error(jsonErr.detail || errText);
} catch (e) {
throw new Error(errText);
}
}
setPostResult({ success: true, msg: isScheduling ? "Scheduled successfully!" : "Posted successfully!" });
setTimeout(() => {
setShowModal(false);
setPostResult(null);
}, 3000);
} catch (e) {
setPostResult({ success: false, msg: `Failed: ${e.message}` });
} finally {
setPosting(false);
}
};
return (
<div className="bg-surface border border-white/5 rounded-2xl overflow-hidden flex flex-col md:flex-row group hover:border-white/10 transition-all animate-[fadeIn_0.5s_ease-out] min-h-[300px] h-auto" style={{ animationDelay: `${index * 0.1}s` }}>
{/* Left: Video Preview (Responsive Width) */}
<div className="w-full md:w-[180px] lg:w-[200px] bg-black relative shrink-0 aspect-[9/16] md:aspect-auto group/video">
<video
ref={videoRef}
src={currentVideoUrl}
controls
className="w-full h-full object-cover"
playsInline
onPlay={() => {
const currentTime = videoRef.current ? videoRef.current.currentTime : 0;
onPlay && onPlay(clip.start + currentTime);
}}
onPause={() => onPause && onPause()}
onEnded={() => {
if (videoRef.current) {
videoRef.current.currentTime = 0;
videoRef.current.play();
}
}}
/>
<div className="absolute top-3 left-3 flex gap-2">
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-md border border-white/10 uppercase tracking-wide">
Clip {index + 1}
</span>
</div>
{/* Auto Edit Overlay if Processing */}
{isEditing && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center z-10 p-4 text-center">
<Loader2 size={32} className="text-primary animate-spin mb-3" />
<span className="text-xs font-bold text-white uppercase tracking-wider">AI Magic in Progress...</span>
<span className="text-[10px] text-zinc-400 mt-1">Applying viral edits & zooms</span>
</div>
)}
</div>
{/* Right: Content & Details */}
<div className="flex-1 p-4 md:p-5 flex flex-col bg-[#121214] overflow-hidden min-w-0">
<div className="mb-4">
<h3 className="text-base font-bold text-white leading-tight line-clamp-2 mb-2 break-words" title={clip.video_title_for_youtube_short}>
{clip.video_title_for_youtube_short || "Viral Clip Generated"}
</h3>
<div className="flex flex-wrap gap-2 text-[10px] text-zinc-500 font-mono">
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">{Math.floor(clip.end - clip.start)}s</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#shorts</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded border border-white/5 shrink-0">#viral</span>
</div>
</div>
{/* Scrollable Descriptions Area */}
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-2 mb-4">
{/* YouTube */}
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
<div className="flex items-center gap-2 text-[10px] font-bold text-red-400 mb-1.5 uppercase tracking-wider">
<Youtube size={12} className="shrink-0" /> <span className="truncate">YouTube Title</span>
</div>
<p className="text-xs text-zinc-300 select-all break-words">
{clip.video_title_for_youtube_short || "Viral Short Video"}
</p>
</div>
{/* TikTok / IG */}
<div className="bg-black/20 rounded-lg p-3 border border-white/5">
<div className="flex items-center gap-2 text-[10px] font-bold text-zinc-400 mb-1.5 uppercase tracking-wider">
<Video size={12} className="text-cyan-400 shrink-0" />
<span className="text-zinc-500">/</span>
<Instagram size={12} className="text-pink-400 shrink-0" />
<span className="truncate">Caption</span>
</div>
<p className="text-xs text-zinc-300 line-clamp-3 hover:line-clamp-none transition-all cursor-pointer select-all break-words">
{clip.video_description_for_tiktok || clip.video_description_for_instagram}
</p>
</div>
</div>
{/* Error Message */}
{editError && (
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] rounded-lg flex items-center gap-2">
<AlertCircle size={12} className="shrink-0" />
{editError}
</div>
)}
{/* Actions Footer */}
<div className="grid grid-cols-2 gap-3 mt-auto pt-4 border-t border-white/5">
<button
onClick={handleAutoEdit}
disabled={isEditing}
className="col-span-1 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-purple-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isEditing ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isEditing ? 'Editing...' : 'Auto Edit'}
</button>
<button
onClick={() => setShowSubtitleModal(true)}
disabled={isSubtitling}
className="col-span-1 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-orange-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isSubtitling ? <Loader2 size={14} className="animate-spin" /> : <Type size={14} />}
{isSubtitling ? 'Adding...' : 'Subtitles'}
</button>
<button
onClick={() => setShowHookModal(true)}
disabled={isHooking}
className="col-span-1 py-2 bg-gradient-to-r from-amber-400 to-yellow-500 hover:from-amber-300 hover:to-yellow-400 text-black rounded-lg text-xs font-bold shadow-lg shadow-yellow-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isHooking ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isHooking ? 'Adding...' : 'Viral Hook'}
</button>
<button
onClick={() => setShowTranslateModal(true)}
disabled={isTranslating}
className="col-span-1 py-2 bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-400 hover:to-teal-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-green-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 mb-1 truncate px-1"
>
{isTranslating ? <Loader2 size={14} className="animate-spin" /> : <Languages size={14} />}
{isTranslating ? 'Translating...' : 'Dub Voice'}
</button>
<button
onClick={() => setShowModal(true)}
className="col-span-1 py-2 bg-primary hover:bg-blue-600 text-white rounded-lg text-xs font-bold shadow-lg shadow-primary/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 truncate px-2"
>
<Share2 size={14} className="shrink-0" /> Post
</button>
<button
onClick={async (e) => {
e.preventDefault();
try {
const response = await fetch(currentVideoUrl);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `clip-${index + 1}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download error:', err);
window.open(currentVideoUrl, '_blank');
}
}}
className="col-span-1 py-2 bg-white/5 hover:bg-white/10 text-zinc-300 hover:text-white rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-2 border border-white/5 truncate px-2"
>
<Download size={14} className="shrink-0" /> Download
</button>
</div>
</div>
{/* Post Modal */}
{showModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.2s_ease-out]">
<div className="bg-[#121214] border border-white/10 p-6 rounded-2xl w-full max-w-md shadow-2xl relative max-h-[90vh] overflow-y-auto custom-scrollbar">
<button
onClick={() => setShowModal(false)}
className="absolute top-4 right-4 text-zinc-500 hover:text-white"
>
<X size={20} />
</button>
<h3 className="text-lg font-bold text-white mb-4">Post / Schedule</h3>
{!uploadPostKey && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-200 text-xs rounded-lg flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>Configure API Key in Settings first.</div>
</div>
)}
<div className="space-y-4 mb-6">
{/* Title & Description */}
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Video Title</label>
<input
type="text"
value={postTitle}
onChange={(e) => setPostTitle(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600"
placeholder="Enter a catchy title..."
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Caption / Description</label>
<textarea
value={postDescription}
onChange={(e) => setPostDescription(e.target.value)}
rows={4}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-primary/50 placeholder-zinc-600 resize-none"
placeholder="Write a caption for your post..."
/>
</div>
{/* Scheduling */}
<div className="p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-white font-medium">
<Calendar size={16} className="text-purple-400" /> Schedule Post
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={isScheduling} onChange={(e) => setIsScheduling(e.target.checked)} className="sr-only peer" />
<div className="w-9 h-5 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{isScheduling && (
<div className="mt-3 animate-[fadeIn_0.2s_ease-out]">
<label className="block text-xs text-zinc-400 mb-1">Select Date & Time</label>
<div className="relative">
<input
type="datetime-local"
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 pl-9 text-sm text-white focus:outline-none focus:border-purple-500/50 [color-scheme:dark]"
/>
<Clock size={14} className="absolute left-3 top-2.5 text-zinc-500" />
</div>
</div>
)}
</div>
{/* Platforms */}
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2">Select Platforms</label>
<div className="grid grid-cols-1 gap-2">
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.tiktok} onChange={e => setPlatforms({ ...platforms, tiktok: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Video size={16} className="text-cyan-400" /> TikTok</div>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.instagram} onChange={e => setPlatforms({ ...platforms, instagram: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Instagram size={16} className="text-pink-400" /> Instagram</div>
</label>
<label className="flex items-center gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors border border-white/5">
<input type="checkbox" checked={platforms.youtube} onChange={e => setPlatforms({ ...platforms, youtube: e.target.checked })} className="w-4 h-4 rounded border-zinc-600 bg-black/50 text-primary focus:ring-primary" />
<div className="flex items-center gap-2 text-sm text-white"><Youtube size={16} className="text-red-400" /> YouTube Shorts</div>
</label>
</div>
</div>
</div>
{postResult && (
<div className={`mb-4 p-3 rounded-lg text-xs flex items-start gap-2 ${postResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{postResult.success ? <CheckCircle size={14} className="mt-0.5 shrink-0" /> : <AlertCircle size={14} className="mt-0.5 shrink-0" />}
<div>{postResult.msg}</div>
</div>
)}
<button
onClick={handlePost}
disabled={posting || !uploadPostKey}
className="w-full py-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-white font-bold transition-all flex items-center justify-center gap-2"
>
{posting ? <><Loader2 size={16} className="animate-spin" /> {isScheduling ? 'Scheduling...' : 'Publishing...'}</> : <><Share2 size={16} /> {isScheduling ? 'Schedule Post' : 'Publish Now'}</>}
</button>
</div>
</div>
)}
<SubtitleModal
isOpen={showSubtitleModal}
onClose={() => setShowSubtitleModal(false)}
onGenerate={handleSubtitle}
isProcessing={isSubtitling}
videoUrl={originalVideoUrl}
jobId={jobId}
clipIndex={index}
existingHook={activeLayers.hook}
/>
<HookModal
isOpen={showHookModal}
onClose={() => setShowHookModal(false)}
onGenerate={handleHook}
isProcessing={isHooking}
videoUrl={originalVideoUrl}
initialText={clip.viral_hook_text}
durationInSeconds={clip.end && clip.start ? clip.end - clip.start : 30}
existingSubtitles={activeLayers.subtitles}
/>
<TranslateModal
isOpen={showTranslateModal}
onClose={() => setShowTranslateModal(false)}
onTranslate={handleTranslate}
isProcessing={isTranslating}
videoUrl={currentVideoUrl}
hasApiKey={!!elevenLabsKey}
/>
</div>
);
}