302 lines
10 KiB
TypeScript
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;
|