All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 27s
962 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|