video_translate/src/components/EditorScreen.tsx
Song367 a0c1dc6ad5
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 27s
文件上传
2026-03-19 11:17:10 +08:00

962 lines
42 KiB
TypeScript

import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import axios from 'axios';
import { ChevronLeft, Play, Pause, Volume2, Settings, Download, Save, LayoutTemplate, Type, Image as ImageIcon, Music, Scissors, Plus, Trash2, Maximize2, Loader2 } from 'lucide-react';
import VoiceMarketModal from './VoiceMarketModal';
import ExportModal from './ExportModal';
import { LlmProvider, PipelineQuality, Subtitle, TextStyles } from '../types';
import { generateSubtitlePipeline } from '../services/subtitleService';
import { generateTTS } from '../services/ttsService';
import { MINIMAX_VOICES } from '../voices';
import { apiUrl } from '../lib/apiBasePath';
export default function EditorScreen({ videoFile, targetLanguage, trimRange, onBack }: { videoFile: File | null; targetLanguage: string; trimRange?: {start: number, end: number} | null; onBack: () => void }) {
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
const [activeSubtitleId, setActiveSubtitleId] = useState<string>('');
const [showVoiceMarket, setShowVoiceMarket] = useState(false);
const [voiceMarketTargetId, setVoiceMarketTargetId] = useState<string | null>(null);
const [showExportModal, setShowExportModal] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [isDubbingGenerating, setIsDubbingGenerating] = useState(false);
const [generatingAudioIds, setGeneratingAudioIds] = useState<Set<string>>(new Set());
const [generationError, setGenerationError] = useState<string | null>(null);
const [subtitleQuality, setSubtitleQuality] = useState<PipelineQuality>('fallback');
const [llmProvider, setLlmProvider] = useState<LlmProvider>('doubao');
// Video Player State
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const bgmRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const lastPlayedSubId = useRef<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [bgmUrl, setBgmUrl] = useState<string | null>(null);
const [bgmBase64, setBgmBase64] = useState<string | null>(null);
const [isSeparating, setIsSeparating] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(30); // Default 30s, updated on load
const [videoUrl, setVideoUrl] = useState<string>('');
const [videoAspectRatio, setVideoAspectRatio] = useState<number>(16/9);
const containerRef = useRef<HTMLDivElement>(null);
const [renderedVideoWidth, setRenderedVideoWidth] = useState<number | '100%'>('100%');
const autoGenerationKeyRef = useRef<string | null>(null);
// Timeline Dragging State
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragType, setDragType] = useState<'move' | 'resize-left' | 'resize-right' | null>(null);
const [dragStartX, setDragStartX] = useState(0);
const [initialSubTimes, setInitialSubTimes] = useState<{startTime: number, endTime: number} | null>(null);
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
const timelineTrackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
const { width, height } = entry.contentRect;
const containerAspect = width / height;
if (containerAspect > videoAspectRatio) {
// Container is wider than video. Video height is 100%, width is scaled.
setRenderedVideoWidth(height * videoAspectRatio);
} else {
// Container is taller than video. Video width is 100%.
setRenderedVideoWidth(width);
}
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [videoAspectRatio]);
useEffect(() => {
if (!videoFile) {
setVideoUrl('');
return;
}
const url = URL.createObjectURL(videoFile);
setVideoUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [videoFile]);
const fetchSubtitles = useCallback(async () => {
if (!videoFile) return;
setIsGenerating(true);
setGenerationError(null);
try {
const pipelineResult = await generateSubtitlePipeline(
videoFile,
targetLanguage,
llmProvider,
trimRange,
);
const generatedSubs = pipelineResult.subtitles;
setSubtitleQuality(pipelineResult.quality);
let adjustedSubs = generatedSubs;
if (trimRange) {
adjustedSubs = generatedSubs
.filter(sub => sub.endTime > trimRange.start && sub.startTime < trimRange.end)
.map(sub => ({
...sub,
startTime: Math.max(0, sub.startTime - trimRange.start),
endTime: Math.min(trimRange.end - trimRange.start, sub.endTime - trimRange.start)
}));
}
setSubtitles(adjustedSubs);
if (adjustedSubs.length > 0) {
setActiveSubtitleId(adjustedSubs[0].id);
}
} catch (err: any) {
console.error("Failed to generate subtitles:", err);
setSubtitleQuality('fallback');
let errorMessage = "Failed to generate subtitles. Please try again or check your API key.";
const errString = err instanceof Error ? err.message : JSON.stringify(err);
if (
errString.includes("429") ||
errString.includes("quota") ||
errString.includes("RESOURCE_EXHAUSTED") ||
err?.status === 429 ||
err?.error?.code === 429
) {
errorMessage = "You have exceeded your Volcengine API quota. Please check your plan and billing details.";
} else if (err instanceof Error) {
errorMessage = err.message;
}
setGenerationError(errorMessage);
setSubtitles([]);
setActiveSubtitleId('');
} finally {
setIsGenerating(false);
}
}, [videoFile, targetLanguage, trimRange, llmProvider]);
// Generate subtitles on mount
useEffect(() => {
const autoGenerationKey = JSON.stringify({
fileName: videoFile?.name || '',
fileSize: videoFile?.size || 0,
targetLanguage,
trimRange,
llmProvider,
});
if (autoGenerationKeyRef.current === autoGenerationKey) {
return;
}
autoGenerationKeyRef.current = autoGenerationKey;
fetchSubtitles();
}, [fetchSubtitles, videoFile, targetLanguage, trimRange, llmProvider]);
const [textStyles, setTextStyles] = useState<TextStyles>({
fontFamily: 'MiSans-Late',
fontSize: 24,
color: '#FFFFFF',
backgroundColor: 'transparent',
alignment: 'center',
isBold: false,
isItalic: false,
isUnderline: false,
});
const togglePlay = async () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
if (bgmRef.current) bgmRef.current.pause();
if (audioRef.current) audioRef.current.pause();
} else {
if (trimRange && (videoRef.current.currentTime < trimRange.start || videoRef.current.currentTime >= trimRange.end)) {
videoRef.current.currentTime = trimRange.start;
}
videoRef.current.play();
if (bgmRef.current) {
bgmRef.current.currentTime = videoRef.current.currentTime;
bgmRef.current.play();
}
}
setIsPlaying(!isPlaying);
}
};
const handleGenerateDubbing = async () => {
if (subtitles.length === 0) return;
setIsDubbingGenerating(true);
// Step 1: Vocal Separation (Scheme A: Server-side AI-like separation)
if (!bgmUrl && videoFile) {
setIsSeparating(true);
try {
const formData = new FormData();
formData.append('video', videoFile);
const response = await axios.post(apiUrl('/separate-vocal'), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { instrumental } = response.data;
const blob = await (await fetch(`data:audio/mp3;base64,${instrumental}`)).blob();
const url = URL.createObjectURL(blob);
setBgmUrl(url);
setBgmBase64(instrumental);
console.log("Vocal separation completed (Instrumental extracted via Scheme A)");
} catch (err) {
console.error("Vocal separation failed:", err);
} finally {
setIsSeparating(false);
}
}
// Step 2: TTS Generation
const toGenerate = subtitles.filter(s => !s.audioUrl).map(s => s.id);
setGeneratingAudioIds(new Set(toGenerate));
try {
const updatedSubtitles = [...subtitles];
for (let i = 0; i < updatedSubtitles.length; i++) {
const sub = updatedSubtitles[i];
if (sub.audioUrl) continue;
try {
const textToSpeak = sub.translatedText || sub.text;
const audioUrl = await generateTTS(textToSpeak, sub.voiceId);
updatedSubtitles[i] = { ...sub, audioUrl };
// Update state incrementally so user sees progress
setSubtitles([...updatedSubtitles]);
} catch (err) {
console.error(`Failed to generate TTS for subtitle ${sub.id}:`, err);
} finally {
setGeneratingAudioIds(prev => {
const next = new Set(prev);
next.delete(sub.id);
return next;
});
}
}
} catch (err) {
console.error("Failed to generate dubbing:", err);
} finally {
setIsDubbingGenerating(false);
setGeneratingAudioIds(new Set());
if (subtitles.some(s => s.audioUrl)) {
console.log("Dubbing generation completed.");
}
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
let time = videoRef.current.currentTime;
if (trimRange) {
if (time < trimRange.start) {
videoRef.current.currentTime = trimRange.start;
time = trimRange.start;
} else if (time >= trimRange.end) {
videoRef.current.pause();
if (bgmRef.current) bgmRef.current.pause();
if (audioRef.current) audioRef.current.pause();
setIsPlaying(false);
videoRef.current.currentTime = trimRange.start;
time = trimRange.start;
}
}
setCurrentTime(time);
const displayTime = trimRange ? time - trimRange.start : time;
// Auto-select active subtitle based on time
const activeSub = subtitles.find(s => displayTime >= s.startTime && displayTime <= s.endTime);
// Sync BGM with video
if (bgmRef.current && videoRef.current && isPlaying) {
if (Math.abs(bgmRef.current.currentTime - videoRef.current.currentTime) > 0.3) {
bgmRef.current.currentTime = videoRef.current.currentTime;
}
}
if (activeSub) {
if (activeSub.id !== activeSubtitleId) {
setActiveSubtitleId(activeSub.id);
}
// Play dubbing if available and not already playing for this sub
if (activeSub.audioUrl && lastPlayedSubId.current !== activeSub.id && isPlaying) {
if (audioRef.current) {
audioRef.current.pause();
}
const audio = new Audio(activeSub.audioUrl);
audio.volume = activeSub.volume !== undefined ? activeSub.volume : 1.0;
audioRef.current = audio;
audio.play().catch(e => console.error("Audio playback failed:", e));
lastPlayedSubId.current = activeSub.id;
}
} else {
lastPlayedSubId.current = null;
}
}
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.volume = bgmUrl ? 0 : 0.3;
}
}, [bgmUrl]);
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
videoRef.current.volume = bgmUrl ? 0 : 0.3; // Mute if BGM (instrumental) is present
if (bgmRef.current) {
bgmRef.current.volume = 0.5; // BGM volume
}
if (videoRef.current.videoHeight > 0) {
setVideoAspectRatio(videoRef.current.videoWidth / videoRef.current.videoHeight);
}
if (trimRange) {
videoRef.current.currentTime = trimRange.start;
setCurrentTime(trimRange.start);
}
}
};
const handleTimelineMouseDown = (e: React.MouseEvent, subId: string, type: 'move' | 'resize-left' | 'resize-right') => {
e.stopPropagation();
const sub = subtitles.find(s => s.id === subId);
if (!sub) return;
setDraggingId(subId);
setDragType(type);
setDragStartX(e.clientX);
setInitialSubTimes({ startTime: sub.startTime, endTime: sub.endTime });
setActiveSubtitleId(subId);
};
const handleTimelineMouseMove = useCallback((e: MouseEvent) => {
if (!timelineTrackRef.current) return;
const rect = timelineTrackRef.current.getBoundingClientRect();
const timelineWidth = rect.width - 32; // Subtract padding (1rem each side)
const displayDuration = trimRange ? trimRange.end - trimRange.start : duration;
if (isDraggingPlayhead) {
const deltaX = e.clientX - rect.left - 16; // 1rem padding
const percent = Math.max(0, Math.min(1, deltaX / timelineWidth));
const newTime = percent * displayDuration;
if (videoRef.current) {
videoRef.current.currentTime = newTime + (trimRange?.start || 0);
}
return;
}
if (!draggingId || !initialSubTimes) return;
const deltaX = e.clientX - dragStartX;
const deltaSeconds = (deltaX / timelineWidth) * displayDuration;
setSubtitles(prev => prev.map(sub => {
if (sub.id !== draggingId) return sub;
let newStart = sub.startTime;
let newEnd = sub.endTime;
if (dragType === 'move') {
newStart = Math.max(0, Math.min(displayDuration - (initialSubTimes.endTime - initialSubTimes.startTime), initialSubTimes.startTime + deltaSeconds));
newEnd = newStart + (initialSubTimes.endTime - initialSubTimes.startTime);
} else if (dragType === 'resize-left') {
newStart = Math.max(0, Math.min(initialSubTimes.endTime - 0.2, initialSubTimes.startTime + deltaSeconds));
} else if (dragType === 'resize-right') {
newEnd = Math.max(initialSubTimes.startTime + 0.2, Math.min(displayDuration, initialSubTimes.endTime + deltaSeconds));
}
return { ...sub, startTime: newStart, endTime: newEnd };
}));
}, [draggingId, dragType, dragStartX, initialSubTimes, duration, trimRange, isDraggingPlayhead]);
const handleTimelineMouseUp = useCallback(() => {
setDraggingId(null);
setDragType(null);
setInitialSubTimes(null);
setIsDraggingPlayhead(false);
}, []);
useEffect(() => {
if (draggingId || isDraggingPlayhead) {
window.addEventListener('mousemove', handleTimelineMouseMove);
window.addEventListener('mouseup', handleTimelineMouseUp);
} else {
window.removeEventListener('mousemove', handleTimelineMouseMove);
window.removeEventListener('mouseup', handleTimelineMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleTimelineMouseMove);
window.removeEventListener('mouseup', handleTimelineMouseUp);
};
}, [draggingId, isDraggingPlayhead, handleTimelineMouseMove, handleTimelineMouseUp]);
const formatTime = (seconds: number) => {
if (isNaN(seconds) || seconds < 0) seconds = 0;
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `00:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const displayDuration = trimRange ? trimRange.end - trimRange.start : duration;
const displayCurrentTime = trimRange ? Math.max(0, currentTime - trimRange.start) : currentTime;
// Calculate playhead position percentage
const playheadPercent = displayDuration > 0 ? (displayCurrentTime / displayDuration) * 100 : 0;
// Get current subtitle text
const currentSubtitleText = subtitles.find(s => displayCurrentTime >= s.startTime && displayCurrentTime <= s.endTime)?.translatedText || '';
return (
<div className="h-[100dvh] flex flex-col bg-white overflow-hidden">
{/* Top Header */}
<header className="h-14 border-b border-gray-200 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-md">
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-orange-500 rounded flex items-center justify-center text-white font-bold text-xs">M</div>
<span className="font-medium text-sm">Translate 1.0</span>
</div>
</div>
<div className="flex items-center gap-4">
<button className="p-2 hover:bg-gray-100 rounded-md text-gray-600">
<LayoutTemplate className="w-5 h-5" />
</button>
<button className="p-2 hover:bg-gray-100 rounded-md text-gray-600">
<Type className="w-5 h-5" />
</button>
<div className="h-4 w-px bg-gray-300 mx-2"></div>
<button
onClick={togglePlay}
className="flex items-center gap-2 px-3 py-1.5 bg-red-50 text-red-600 rounded-md text-sm font-medium hover:bg-red-100"
>
<Play className="w-4 h-4 fill-current" />
Watch Video
</button>
<button className="flex items-center gap-2 px-3 py-1.5 text-gray-600 hover:bg-gray-100 rounded-md text-sm font-medium">
<Save className="w-4 h-4" />
Save Editing
</button>
<button
onClick={() => setShowExportModal(true)}
className="flex items-center gap-2 px-4 py-1.5 bg-[#52c41a] text-white rounded-md text-sm font-medium hover:bg-[#46a616]"
>
<Download className="w-4 h-4" />
Export
</button>
</div>
</header>
{/* Main Workspace */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Left Sidebar - Subtitles */}
<div className="w-80 border-r border-gray-200 flex flex-col bg-gray-50 shrink-0">
<div className="p-4 border-b border-gray-200 bg-white shrink-0">
<div className="flex bg-gray-100 p-1 rounded-md mb-4">
<button className="flex-1 py-1.5 bg-white shadow-sm rounded text-sm font-medium">AI Dub</button>
<button className="flex-1 py-1.5 text-gray-600 text-sm font-medium">Voice Clone</button>
</div>
<p className="text-xs text-gray-500 mb-4">
Tip: After modifying the subtitle text, you need to regenerate the dubbing to take effect!
</p>
<div className="mb-4">
<label htmlFor="llm-provider" className="block text-xs font-medium text-gray-500 mb-1">
LLM
</label>
<select
id="llm-provider"
aria-label="LLM"
value={llmProvider}
onChange={(e) => setLlmProvider(e.target.value as LlmProvider)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-blue-500 bg-white"
>
<option value="doubao">Doubao</option>
<option value="gemini">Gemini</option>
</select>
</div>
<button
onClick={handleGenerateDubbing}
disabled={isDubbingGenerating || subtitles.length === 0}
className="w-full py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{isDubbingGenerating && <Loader2 className="w-4 h-4 animate-spin" />}
{isSeparating ? 'Separating Vocals...' : isDubbingGenerating ? 'Generating TTS...' : 'Generate Dubbing'}
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
{isGenerating ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-4">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<p className="text-sm font-medium">AI is analyzing and translating...</p>
<p className="text-xs text-center px-4">This may take a minute depending on the video length.</p>
</div>
) : (
<>
{generationError && (
<div className="p-3 mb-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md flex flex-col gap-2">
<p>{generationError}</p>
<button
onClick={() => fetchSubtitles()}
className="px-3 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 rounded font-medium transition-colors self-start"
>
Retry Generation
</button>
</div>
)}
{!generationError && subtitleQuality === 'fallback' && (
<div className="p-3 mb-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md">
Low-precision subtitle timing is active for this generation. You can still edit subtitles before dubbing.
</div>
)}
{subtitles.map((sub, index) => (
<div
key={sub.id}
className={`p-3 rounded-lg border transition-all ${
activeSubtitleId === sub.id
? 'border-blue-500 bg-blue-50/50 shadow-sm'
: sub.audioUrl
? 'border-green-200 bg-white'
: 'border-gray-200 bg-white'
} cursor-pointer relative group`}
onClick={() => {
setActiveSubtitleId(sub.id);
if (videoRef.current) {
videoRef.current.currentTime = sub.startTime + (trimRange?.start || 0);
}
}}
>
{sub.audioUrl && (
<div className="absolute -right-1 -top-1 w-4 h-4 bg-green-500 rounded-full flex items-center justify-center text-white shadow-sm">
<Music className="w-2.5 h-2.5" />
</div>
)}
{generatingAudioIds.has(sub.id) && (
<div className="absolute right-2 top-2">
<Loader2 className="w-3 h-3 animate-spin text-blue-500" />
</div>
)}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-500">
{index + 1}. {formatTime(sub.startTime)} - {formatTime(sub.endTime)}
</span>
<div className="flex items-center gap-2">
{sub.audioUrl && (
<div className="flex items-center gap-1 bg-gray-100 px-1.5 py-0.5 rounded">
<Volume2 className="w-3 h-3 text-gray-500" />
<input
type="range"
min="0"
max="1"
step="0.1"
value={sub.volume ?? 1.0}
onChange={(e) => {
const newSubs = [...subtitles];
newSubs[index].volume = parseFloat(e.target.value);
setSubtitles(newSubs);
}}
className="w-12 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<button
className="flex items-center gap-1 text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded"
onClick={(e) => {
e.stopPropagation();
setVoiceMarketTargetId(sub.id);
setShowVoiceMarket(true);
}}
>
<div className="w-4 h-4 rounded-full bg-orange-200 overflow-hidden">
<img src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${sub.voiceId}`} alt="avatar" />
</div>
{MINIMAX_VOICES.find(v => v.id === sub.voiceId)?.name || 'Select Voice'}
</button>
</div>
</div>
<textarea
className="w-full text-sm bg-transparent border-none resize-none focus:ring-0 p-0 text-gray-500 mb-2"
rows={2}
value={sub.originalText}
readOnly
/>
<textarea
className="w-full text-sm bg-transparent border-none resize-none focus:ring-0 p-0 text-gray-800 font-medium"
rows={2}
value={sub.translatedText}
onChange={(e) => {
const newSubs = [...subtitles];
newSubs[index].translatedText = e.target.value;
setSubtitles(newSubs);
}}
/>
</div>
))}
</>
)}
</div>
</div>
{/* Center - Video Player */}
<div className="flex-1 flex flex-col bg-gray-100 relative min-w-0 min-h-0">
<div className="flex-1 flex items-center justify-center p-8 relative min-h-0">
{/* Video Container */}
<div ref={containerRef} className="relative w-full h-full bg-black rounded-lg overflow-hidden shadow-lg flex items-center justify-center min-h-0">
{videoUrl ? (
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => {
setIsPlaying(false);
if (bgmRef.current) bgmRef.current.pause();
if (audioRef.current) audioRef.current.pause();
}}
onClick={togglePlay}
playsInline
preload="metadata"
/>
) : (
<div className="text-gray-500 flex flex-col items-center justify-center h-full w-full">
<ImageIcon className="w-12 h-12 mb-2 opacity-50" />
<p>No video loaded</p>
</div>
)}
{/* BGM Audio Element */}
{bgmUrl && (
<audio
ref={bgmRef}
src={bgmUrl}
className="hidden"
onEnded={() => {
if (videoRef.current) videoRef.current.pause();
setIsPlaying(false);
}}
/>
)}
{/* Subtitle Overlay */}
{currentSubtitleText && (
<div
className="absolute bottom-[10%] left-1/2 -translate-x-1/2 flex justify-center pointer-events-none px-4"
style={{ width: renderedVideoWidth }}
>
<p
className="text-white text-base md:text-lg font-bold drop-shadow-md break-words whitespace-pre-wrap text-center max-w-[90%]"
style={{
fontFamily: textStyles.fontFamily,
color: textStyles.color,
textAlign: textStyles.alignment,
fontWeight: textStyles.isBold ? 'bold' : 'normal',
fontStyle: textStyles.isItalic ? 'italic' : 'normal',
textDecoration: textStyles.isUnderline ? 'underline' : 'none',
}}
>
{currentSubtitleText}
</p>
</div>
)}
</div>
</div>
{/* Player Controls */}
<div className="h-12 bg-white border-t border-gray-200 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-4">
<button className="p-1.5 hover:bg-gray-100 rounded-full" onClick={togglePlay}>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<span className="text-sm font-medium font-mono">
{formatTime(displayCurrentTime)} / {formatTime(displayDuration)}
</span>
</div>
<div className="flex items-center gap-4">
{bgmUrl && (
<div className="flex items-center gap-2 bg-gray-100 px-2 py-1 rounded-md">
<Music className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-gray-600">BGM</span>
<input
type="range"
min="0"
max="1"
step="0.1"
defaultValue="0.5"
onChange={(e) => {
if (bgmRef.current) bgmRef.current.volume = parseFloat(e.target.value);
}}
className="w-16 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-green-500"
/>
</div>
)}
<Maximize2 className="w-4 h-4 text-gray-500" />
</div>
</div>
</div>
{/* Right Sidebar - Properties */}
<div className="w-72 border-l border-gray-200 bg-white flex flex-col shrink-0 overflow-y-auto">
<div className="p-4 border-b border-gray-200">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="checkbox" className="rounded text-blue-600 focus:ring-blue-500" defaultChecked />
Apply to all subtitles
</label>
</div>
<div className="p-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-green-100 flex items-center justify-center text-green-600 text-xs font-bold">
W
</div>
<span className="text-sm font-medium text-gray-700">Wife</span>
</div>
<input
type="text"
value="Husband"
className="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:border-blue-500"
readOnly
/>
</div>
<div className="p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Text Styles</h3>
{/* Style Presets Grid */}
<div className="grid grid-cols-4 gap-2 mb-6">
{['T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T'].map((item, i) => (
<button key={i} className={`aspect-square rounded border flex items-center justify-center text-lg font-bold ${i === 8 ? 'border-blue-500 bg-blue-50 text-blue-600' : 'border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100'}`}>
{item}
</button>
))}
</div>
{/* Font Family */}
<div className="mb-4">
<select
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-blue-500"
value={textStyles.fontFamily}
onChange={(e) => setTextStyles({...textStyles, fontFamily: e.target.value})}
>
<option value="MiSans-Late">MiSans-Late</option>
<option value="Arial">Arial</option>
<option value="Roboto">Roboto</option>
<option value="serif">Serif</option>
</select>
</div>
{/* Font Size & Alignment */}
<div className="flex gap-2 mb-4">
<select className="flex-1 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-blue-500">
<option>Normal</option>
<option>Large</option>
<option>Small</option>
</select>
<div className="flex border border-gray-300 rounded-md overflow-hidden">
<button
className={`px-3 py-2 border-r border-gray-300 font-bold ${textStyles.isBold ? 'bg-gray-200' : 'bg-gray-50 hover:bg-gray-100'}`}
onClick={() => setTextStyles({...textStyles, isBold: !textStyles.isBold})}
>B</button>
<button
className={`px-3 py-2 border-r border-gray-300 italic ${textStyles.isItalic ? 'bg-gray-200' : 'bg-gray-50 hover:bg-gray-100'}`}
onClick={() => setTextStyles({...textStyles, isItalic: !textStyles.isItalic})}
>I</button>
<button
className={`px-3 py-2 underline ${textStyles.isUnderline ? 'bg-gray-200' : 'bg-gray-50 hover:bg-gray-100'}`}
onClick={() => setTextStyles({...textStyles, isUnderline: !textStyles.isUnderline})}
>U</button>
</div>
</div>
{/* Colors */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Color</span>
<div className="flex items-center gap-2">
<input
type="color"
value={textStyles.color}
onChange={(e) => setTextStyles({...textStyles, color: e.target.value})}
className="w-6 h-6 rounded border border-gray-300 p-0 cursor-pointer"
/>
<span className="text-sm text-gray-500">100%</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Stroke</span>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded border border-gray-300 bg-black"></div>
<span className="text-sm text-gray-500">100%</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Timeline */}
<div className="h-48 border-t border-gray-200 bg-white flex flex-col shrink-0">
{/* Timeline Toolbar */}
<div className="h-10 border-b border-gray-100 flex items-center px-4 gap-4">
<button className="p-1.5 hover:bg-gray-100 rounded text-gray-600"><Scissors className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-gray-100 rounded text-gray-600"><Plus className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-gray-100 rounded text-gray-600"><Trash2 className="w-4 h-4" /></button>
<div className="h-4 w-px bg-gray-300 mx-2"></div>
<span className="text-xs text-orange-500 bg-orange-50 px-2 py-1 rounded border border-orange-200">
Stretch the dubbing to control the speed
</span>
<div className="flex-1"></div>
{/* Zoom slider placeholder */}
<div className="w-32 h-1 bg-gray-200 rounded-full relative">
<div className="absolute left-1/2 top-1/2 -translate-y-1/2 w-3 h-3 bg-white border border-gray-400 rounded-full"></div>
</div>
</div>
{/* Timeline Tracks */}
<div ref={timelineTrackRef} className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar bg-gray-50">
{/* Time Ruler */}
<div className="h-6 border-b border-gray-200 flex items-end px-4 relative min-w-[1000px] bg-white">
{[0, 20, 40, 60, 80, 100].map(percent => (
<span key={percent} className="absolute text-[10px] text-gray-400" style={{ left: `${percent}%` }}>
{formatTime((displayDuration * percent) / 100)}
</span>
))}
</div>
{/* Video Track */}
<div className="h-12 border-b border-gray-200 flex items-center px-4 min-w-[1000px] relative">
<div className="absolute left-4 right-4 h-10 bg-blue-50 rounded overflow-hidden flex border border-blue-100 items-center justify-center text-xs text-blue-400">
Video Track
</div>
</div>
{/* Subtitle Track */}
<div className="h-12 border-b border-gray-200 flex items-center px-4 min-w-[1000px] relative">
{subtitles.map((sub) => {
const leftPercent = (sub.startTime / displayDuration) * 100;
const widthPercent = ((sub.endTime - sub.startTime) / displayDuration) * 100;
return (
<div
key={sub.id}
className={`absolute h-10 rounded flex flex-col justify-start px-2 cursor-pointer border transition-colors select-none overflow-hidden ${
activeSubtitleId === sub.id ? 'bg-[#e6f4ff] border-[#1677ff] z-10' : 'bg-white border-gray-200 hover:border-blue-300'
}`}
style={{ left: `calc(1rem + ${leftPercent}%)`, width: `${widthPercent}%` }}
onClick={() => {
setActiveSubtitleId(sub.id);
if (videoRef.current) videoRef.current.currentTime = sub.startTime + (trimRange?.start || 0);
}}
onMouseDown={(e) => handleTimelineMouseDown(e, sub.id, 'move')}
>
<div
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize hover:bg-blue-400/50 z-20"
onMouseDown={(e) => handleTimelineMouseDown(e, sub.id, 'resize-left')}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-ew-resize hover:bg-blue-400/50 z-20"
onMouseDown={(e) => handleTimelineMouseDown(e, sub.id, 'resize-right')}
/>
<span className="text-[9px] font-bold text-blue-800 truncate pointer-events-none mt-0.5">T {sub.speaker}</span>
<span className="text-[9px] text-blue-600 truncate pointer-events-none leading-tight">{sub.translatedText}</span>
</div>
);
})}
</div>
{/* Audio Track */}
<div className="h-12 flex items-center px-4 min-w-[1000px] relative">
{subtitles.map((sub) => {
if (!sub.audioUrl && !generatingAudioIds.has(sub.id)) return null;
const leftPercent = (sub.startTime / displayDuration) * 100;
const widthPercent = ((sub.endTime - sub.startTime) / displayDuration) * 100;
return (
<div
key={`audio-${sub.id}`}
className={`absolute h-8 border rounded flex items-center justify-center overflow-hidden transition-all ${
generatingAudioIds.has(sub.id)
? 'bg-blue-50 border-blue-200 animate-pulse'
: 'bg-white border-green-200'
}`}
style={{ left: `calc(1rem + ${leftPercent}%)`, width: `${widthPercent}%` }}
>
{generatingAudioIds.has(sub.id) ? (
<Loader2 className="w-3 h-3 animate-spin text-blue-400" />
) : (
<svg width="100%" height="100%" preserveAspectRatio="none" viewBox="0 0 100 100">
<path d="M0,50 Q10,20 20,50 T40,50 T60,50 T80,50 T100,50" stroke="#52c41a" strokeWidth="1" fill="none" />
<path d="M0,50 Q10,80 20,50 T40,50 T60,50 T80,50 T100,50" stroke="#52c41a" strokeWidth="1" fill="none" />
</svg>
)}
</div>
);
})}
</div>
{/* Playhead */}
<div
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 cursor-ew-resize group"
style={{ left: `calc(1rem + ${playheadPercent}%)` }}
onMouseDown={(e) => {
e.stopPropagation();
setIsDraggingPlayhead(true);
}}
>
<div className="absolute -top-1 -translate-x-1/2 w-3 h-3 bg-red-500 rotate-45 shadow-sm"></div>
<div className="absolute top-0 bottom-0 -left-1 -right-1 cursor-ew-resize"></div>
</div>
</div>
</div>
{/* Modals */}
{showVoiceMarket && (
<VoiceMarketModal
onClose={() => setShowVoiceMarket(false)}
onSelect={(voiceId) => {
if (voiceMarketTargetId) {
const newSubs = subtitles.map(s => s.id === voiceMarketTargetId ? { ...s, voiceId, audioUrl: undefined } : s);
setSubtitles(newSubs);
setShowVoiceMarket(false);
}
}}
onSelectAll={(voiceId) => {
const newSubs = subtitles.map(s => ({ ...s, voiceId, audioUrl: undefined }));
setSubtitles(newSubs);
setShowVoiceMarket(false);
}}
/>
)}
{showExportModal && (
<ExportModal
onClose={() => setShowExportModal(false)}
videoFile={videoFile}
subtitles={subtitles}
bgmUrl={bgmUrl}
bgmBase64={bgmBase64}
textStyles={textStyles}
trimRange={trimRange}
/>
)}
</div>
);
}