subtitle-merge/src/App.tsx

302 lines
10 KiB
TypeScript

import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { FileSelector } from './components/FileSelector';
import { GeminiSettings, GeminiConfig } from './components/GeminiSettings';
import { VideoTimeline } from './components/VideoTimeline';
import './styles/index.css';
interface FileState {
path: string;
name: string;
}
// Must match the Rust TranscriptionResult struct exactly
interface TranscriptionState {
visualDescription: string; // from visual_description in Rust
subtitleNarrative: string; // from subtitle_narrative in Rust
}
interface Thumbnail {
path: string;
time: number;
}
function App() {
const [videoFile, setVideoFile] = useState<FileState | null>(null);
const [subtitleFile, setSubtitleFile] = useState<FileState | null>(null);
const [progress, setProgress] = useState<number>(0);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [addBlackBar, setAddBlackBar] = useState(true);
const [resizeTo720p, setResizeTo720p] = useState(true);
const [geminiConfig, setGeminiConfig] = useState<GeminiConfig>({
enabled: false,
apiKey: 'AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc',
isDefault: true
});
const [selectedTime, setSelectedTime] = useState<number>(10);
const [transcription, setTranscription] = useState<TranscriptionState | null>(null);
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
useEffect(() => {
invoke('check_dependencies')
.catch(err => {
setError(err as string);
console.error('Dependency check failed:', err);
});
}, []);
const handleVideoSelect = (path: string) => {
const name = path.split('/').pop() || '';
setVideoFile({ path, name });
setThumbnails([]); // Clear thumbnails when new video is selected
};
const handleSubtitleSelect = (path: string) => {
const name = path.split('/').pop() || '';
setSubtitleFile({ path, name });
};
const handleThumbnailsGenerated = (newThumbnails: Thumbnail[]) => {
setThumbnails(newThumbnails);
};
useEffect(() => {
let intervalId: number | undefined;
if (isProcessing) {
// Poll for progress every 100ms while processing
intervalId = window.setInterval(async () => {
try {
const currentProgress = await invoke<number>('get_progress');
console.log('Progress update:', currentProgress);
setProgress(currentProgress);
} catch (error) {
console.error('Failed to get progress:', error);
}
}, 100);
}
return () => {
if (intervalId !== undefined) {
window.clearInterval(intervalId);
}
};
}, [isProcessing]);
const handleMerge = async () => {
if (!videoFile || !subtitleFile) return;
setIsProcessing(true);
setProgress(0);
setTranscription(null);
try {
console.log('Starting merge with args:', {
videoPath: videoFile.path,
subtitlePath: subtitleFile.path,
addBlackBar,
resizeTo720p,
geminiEnabled: geminiConfig.enabled,
frameTime: selectedTime
});
// Process transcription first if Gemini is enabled
if (geminiConfig.enabled) {
try {
console.log('Starting transcription process...');
const transcriptionResult = await invoke<TranscriptionState>('process_transcription', {
args: {
video_path: videoFile.path,
subtitle_path: subtitleFile.path,
frame_time: selectedTime,
api_key: geminiConfig.apiKey
}
});
console.log('Transcription completed with full response:', transcriptionResult);
// Log the raw response first
console.log('Raw transcription result:', JSON.stringify(transcriptionResult, null, 2));
// Validate the response structure
if (!transcriptionResult || typeof transcriptionResult !== 'object') {
console.error('Invalid transcription result:', transcriptionResult);
setError('Invalid transcription response format');
return;
}
// Validate and clean the content
const visualDescription = String(transcriptionResult.visualDescription || '').trim();
const subtitleNarrative = String(transcriptionResult.subtitleNarrative || '').trim();
if (!visualDescription || !subtitleNarrative) {
console.error('Missing transcription content:', {
visualDescription: !!visualDescription,
subtitleNarrative: !!subtitleNarrative
});
setError('Transcription response is missing required content');
return;
}
// Create a new state object with the validated content
const newTranscription: TranscriptionState = {
visualDescription,
subtitleNarrative
};
console.log('Setting transcription state:', {
visualDescriptionLength: visualDescription.length,
subtitleNarrativeLength: subtitleNarrative.length
});
setTranscription(newTranscription);
} catch (error) {
console.error('Transcription error:', error);
setError(`Transcription error: ${error}`);
return;
}
}
// Then start video merging process
const mergeResult = await invoke<string>('merge_video_subtitle', {
args: {
video_path: videoFile.path,
subtitle_path: subtitleFile.path,
add_black_bar: addBlackBar,
resize_to_720p: resizeTo720p
}
});
console.log('Merge completed:', mergeResult);
setProgress(100);
} catch (error) {
console.error('Error merging files:', error);
setError(error as string);
} finally {
setIsProcessing(false);
}
};
return (
<div className="min-h-screen bg-gray-100 py-8 px-4">
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-center mb-8">Video Subtitle Merger <span className="text-blue-500">2.0</span></h1>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex-1 mr-4">
<p className="text-sm text-gray-600 mb-2">Video file:</p>
<p className="text-gray-800">{videoFile?.name || 'No file selected'}</p>
</div>
<FileSelector
label="Select Video"
onFileSelect={handleVideoSelect}
accept={['mp4', 'avi', 'mov', 'mkv']}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex-1 mr-4">
<p className="text-sm text-gray-600 mb-2">Subtitle file:</p>
<p className="text-gray-800">{subtitleFile?.name || 'No file selected'}</p>
</div>
<FileSelector
label="Select Subtitle"
onFileSelect={handleSubtitleSelect}
accept={['srt', 'ass']}
/>
</div>
<GeminiSettings
onSettingsChange={setGeminiConfig}
videoFile={videoFile?.path || null}
onThumbnailsGenerated={handleThumbnailsGenerated}
/>
{geminiConfig.enabled && videoFile && (
<VideoTimeline
videoPath={videoFile.path}
onTimeSelect={setSelectedTime}
thumbnails={thumbnails}
/>
)}
<div className="space-y-3">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={addBlackBar}
onChange={(e) => setAddBlackBar(e.target.checked)}
className="input-checkbox"
/>
<span>Add black bar for subtitles</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={resizeTo720p}
onChange={(e) => setResizeTo720p(e.target.checked)}
className="input-checkbox"
/>
<span>Resize to 720p resolution</span>
</label>
</div>
{transcription ? (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-semibold">DeafBlind Accessibility Transcription</h3>
<div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm space-y-6">
<div>
<h4 className="font-semibold text-gray-700 mb-2">Visual Description</h4>
<div className="bg-white p-4 rounded border border-gray-200">
<p className="whitespace-pre-wrap leading-relaxed">{transcription.visualDescription}</p>
</div>
</div>
<div>
<h4 className="font-semibold text-gray-700 mb-2">Subtitle Narrative</h4>
<div className="bg-white p-4 rounded border border-gray-200">
<p className="whitespace-pre-wrap leading-relaxed">{transcription.subtitleNarrative}</p>
</div>
</div>
</div>
</div>
) : null}
<div className="space-y-2">
<div className="h-2.5 bg-gray-200 rounded-full">
<div
className="h-2.5 bg-blue-600 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-center text-sm text-gray-600">
{isProcessing ? `${Math.round(progress)}%` : ''}
</p>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4">
<strong className="font-bold">Error: </strong>
<span className="block sm:inline">{error}</span>
</div>
)}
<button
onClick={handleMerge}
disabled={!videoFile || !subtitleFile || isProcessing}
className={`w-full btn ${
!videoFile || !subtitleFile || isProcessing
? 'bg-gray-400 cursor-not-allowed'
: 'btn-primary'
}`}
>
{isProcessing ? 'Processing...' : 'Merge Video and Subtitle'}
</button>
</div>
</div>
</div>
);
}
export default App;