From 2b4462565590a19ab17f1484748194d15444b7f9 Mon Sep 17 00:00:00 2001 From: TheMaddax Date: Sun, 26 Jan 2025 16:23:25 -0600 Subject: [PATCH] Break into two components --- src-tauri/src/lib.rs | 175 +++++++------- src/App.tsx | 338 +++++---------------------- src/components/GeminiSettings.tsx | 71 ++---- src/components/MergeTask.tsx | 158 +++++++++++++ src/components/Settings.tsx | 3 + src/components/TranscriptionTask.tsx | 210 +++++++++++++++++ 6 files changed, 546 insertions(+), 409 deletions(-) create mode 100644 src/components/MergeTask.tsx create mode 100644 src/components/TranscriptionTask.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97849c8..87d222f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,7 +50,7 @@ pub struct SelectedFrame { #[derive(Debug, Deserialize)] pub struct TranscriptionArgs { pub video_path: String, - pub subtitle_path: String, + pub subtitle_path: Option, pub selected_frames: Vec, pub api_key: String, pub visual_prompt: String, @@ -61,7 +61,7 @@ pub struct TranscriptionArgs { #[serde(rename_all = "camelCase")] pub struct TranscriptionResult { pub visual_description: String, - pub subtitle_narrative: String, + pub narrative: String, } #[derive(Debug, Serialize)] @@ -244,7 +244,9 @@ pub async fn extract_thumbnails_impl(video_path: &str, interval: u32) -> Result< pub async fn process_transcription_impl(args: TranscriptionArgs) -> Result { println!("\n=== Starting Transcription Process ==="); println!("Video Path: {}", args.video_path); - println!("Subtitle Path: {}", args.subtitle_path); + if let Some(subtitle_path) = &args.subtitle_path { + println!("Subtitle Path: {}", subtitle_path); + } println!("Number of selected frames: {}", args.selected_frames.len()); let temp_dir = TempDir::new().map_err(|e| Error::IoError(e))?; @@ -267,64 +269,6 @@ pub async fn process_transcription_impl(args: TranscriptionArgs) -> Result = subtitle_content.lines().collect(); - - // Check if it's an ASS file - if args.subtitle_path.to_lowercase().ends_with(".ass") { - let mut in_events = false; - for line in lines { - if line.starts_with("[Events]") { - in_events = true; - continue; - } - if in_events && line.starts_with("Dialogue:") { - let parts: Vec<&str> = line.split(',').collect(); - if parts.len() >= 10 { - let start_time = parts[1].trim(); - // Combine all remaining parts as the text, skipping style and positioning info - let text = parts[9..].join(",").trim().to_string(); - if !text.is_empty() { - subtitle_lines.push((start_time.to_string(), text)); - } - } - } - } - } else { - // SRT format parsing - let mut i = 0; - while i < lines.len() { - if i + 3 <= lines.len() && lines[i + 1].contains("-->") { - let timestamp_parts: Vec<&str> = lines[i + 1].split(" --> ").collect(); - if timestamp_parts.len() == 2 { - let start_time = timestamp_parts[0].trim(); - let mut text = lines[i + 2].trim().to_string(); - - // Collect multi-line subtitle text - let mut j = i + 3; - while j < lines.len() && !lines[j].trim().is_empty() && !lines[j].contains("-->") { - text.push_str(" "); - text.push_str(lines[j].trim()); - j += 1; - } - - subtitle_lines.push((start_time.to_string(), text)); - i = j; - } - } - i += 1; - } - } - - // Sort subtitles by timestamp - subtitle_lines.sort_by(|a, b| a.0.cmp(&b.0)); - // Format scene descriptions with timestamps in MM:SS format let scene_descriptions = frame_descriptions.iter() .map(|(time, desc)| { @@ -335,47 +279,112 @@ pub async fn process_transcription_impl(args: TranscriptionArgs) -> Result>() .join("\n\n"); - // Format subtitle content with timestamps - let formatted_subtitles = subtitle_lines.iter() - .map(|(time, text)| format!("[{}] {}", time, text)) - .collect::>() - .join("\n"); - - let subtitle_prompt = format!("{}\n\nScene Descriptions:\n{}\n\nSubtitle Content with Timestamps:\n{}", - args.subtitle_prompt, - scene_descriptions, - formatted_subtitles - ); - - // Process the subtitle content with scene descriptions - let subtitle_narrative = query_gemini(&args.api_key, &subtitle_prompt, None).await?; - - // Combine all visual descriptions - let visual_description = frame_descriptions.into_iter() + // Combine all visual descriptions for the raw visual description output + let visual_description = frame_descriptions.iter() .map(|(time, desc)| format!("At timestamp {}:\n{}", time, desc)) .collect::>() .join("\n\n"); + // Process subtitle content if provided + let narrative = if let Some(subtitle_path) = &args.subtitle_path { + println!("\nReading and parsing subtitle content..."); + let subtitle_content = fs::read_to_string(subtitle_path) + .map_err(|e| Error::IoError(e))?; + println!("Subtitle content length: {} characters", subtitle_content.len()); + + // Parse subtitle timestamps and content, handling both SRT and ASS formats + let mut subtitle_lines = Vec::new(); + let lines: Vec<&str> = subtitle_content.lines().collect(); + + // Check if it's an ASS file + if subtitle_path.to_lowercase().ends_with(".ass") { + let mut in_events = false; + for line in lines { + if line.starts_with("[Events]") { + in_events = true; + continue; + } + if in_events && line.starts_with("Dialogue:") { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() >= 10 { + let start_time = parts[1].trim(); + // Combine all remaining parts as the text, skipping style and positioning info + let text = parts[9..].join(",").trim().to_string(); + if !text.is_empty() { + subtitle_lines.push((start_time.to_string(), text)); + } + } + } + } + } else { + // SRT format parsing + let mut i = 0; + while i < lines.len() { + if i + 3 <= lines.len() && lines[i + 1].contains("-->") { + let timestamp_parts: Vec<&str> = lines[i + 1].split(" --> ").collect(); + if timestamp_parts.len() == 2 { + let start_time = timestamp_parts[0].trim(); + let mut text = lines[i + 2].trim().to_string(); + + // Collect multi-line subtitle text + let mut j = i + 3; + while j < lines.len() && !lines[j].trim().is_empty() && !lines[j].contains("-->") { + text.push_str(" "); + text.push_str(lines[j].trim()); + j += 1; + } + + subtitle_lines.push((start_time.to_string(), text)); + i = j; + } + } + i += 1; + } + } + + // Sort subtitles by timestamp + subtitle_lines.sort_by(|a, b| a.0.cmp(&b.0)); + + // Format subtitle content with timestamps + let formatted_subtitles = subtitle_lines.iter() + .map(|(time, text)| format!("[{}] {}", time, text)) + .collect::>() + .join("\n"); + + let narrative_prompt = format!("{}\n\nScene Descriptions:\n{}\n\nSubtitle Content with Timestamps:\n{}", + args.subtitle_prompt, + scene_descriptions, + formatted_subtitles + ); + + // Process the subtitle content with scene descriptions + query_gemini(&args.api_key, &narrative_prompt, None).await? + } else { + // If no subtitle file, just format the scene descriptions as a narrative + let narrative_prompt = format!("Create a flowing narrative from these scene descriptions, maintaining their chronological order and focusing on spatial relationships and visual details that would be helpful for DeafBlind readers:\n\n{}", scene_descriptions); + query_gemini(&args.api_key, &narrative_prompt, None).await? + }; + println!("\n=== Final Transcription Result ==="); println!("\nVisual Description:"); println!("{}", visual_description); - println!("\nSubtitle Narrative:"); - println!("{}", subtitle_narrative); + println!("\nNarrative:"); + println!("{}", narrative); // Create the result struct let result = TranscriptionResult { visual_description: visual_description.clone(), - subtitle_narrative: subtitle_narrative.clone(), + narrative: narrative.clone(), }; // Log the final result structure println!("\nReturning transcription result with:"); println!("- Visual description length: {}", result.visual_description.len()); - println!("- Subtitle narrative length: {}", result.subtitle_narrative.len()); + println!("- Narrative length: {}", result.narrative.len()); println!("\nFinal TranscriptionResult struct:"); println!("{{"); println!(" \"visualDescription\": \"{}\",", result.visual_description); - println!(" \"subtitleNarrative\": \"{}\"", result.subtitle_narrative); + println!(" \"narrative\": \"{}\"", result.narrative); println!("}}"); Ok(result) diff --git a/src/App.tsx b/src/App.tsx index 3b24e68..4127ad7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,36 +1,16 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { FileSelector } from './components/FileSelector'; -import { GeminiSettings } from './components/GeminiSettings'; import { Settings, DEFAULT_VISUAL_PROMPT, DEFAULT_SUBTITLE_PROMPT } from './components/Settings'; -import { VideoTimeline } from './components/VideoTimeline'; +import { TranscriptionTask } from './components/TranscriptionTask'; +import { MergeTask } from './components/MergeTask'; 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; -} +type TaskType = 'transcription' | 'merge' | null; function App() { - const [videoFile, setVideoFile] = useState(null); - const [subtitleFile, setSubtitleFile] = useState(null); - const [progress, setProgress] = useState(0); - const [isProcessing, setIsProcessing] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); const [error, setError] = useState(null); - const [addBlackBar, setAddBlackBar] = useState(true); - const [resizeTo720p, setResizeTo720p] = useState(true); - const [geminiEnabled, setGeminiEnabled] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [apiKey, setApiKey] = useState('AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc'); const [visualPrompt, setVisualPrompt] = useState(() => { const saved = localStorage.getItem('visualPrompt'); @@ -40,17 +20,6 @@ function App() { const saved = localStorage.getItem('subtitlePrompt'); return saved || DEFAULT_SUBTITLE_PROMPT; }); - const [selectedFrames, setSelectedFrames] = useState([]); - const [transcription, setTranscription] = useState(null); - const [thumbnails, setThumbnails] = useState([]); - - interface SelectedFrame { - path: string; - time: number; - base64Image: string; - description?: string; - } - const [isSettingsOpen, setIsSettingsOpen] = useState(false); // Persist prompts to localStorage when they change useEffect(() => { @@ -69,143 +38,6 @@ function App() { }); }, []); - 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('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, - selectedFrames: selectedFrames - }); - - // Process transcription first if Gemini is enabled - if (geminiEnabled) { - try { - console.log('Starting transcription process...'); - const transcriptionResult = await invoke('process_transcription', { - args: { - video_path: videoFile.path, - subtitle_path: subtitleFile.path, - selected_frames: selectedFrames.map(frame => ({ - path: frame.path, - time: frame.time, - base64_image: frame.base64Image - })), - api_key: apiKey, - visual_prompt: visualPrompt, - subtitle_prompt: subtitlePrompt - } - }); - 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('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 (
@@ -222,120 +54,68 @@ function App() { Settings
- -
-
-
-

Video file:

-

{videoFile?.name || 'No file selected'}

-
- + + {error && ( +
+ Error: + {error}
+ )} -
-
-

Subtitle file:

-

{subtitleFile?.name || 'No file selected'}

+ {!selectedTask ? ( +
+

Choose a Task

+
+ + +
-
+ ) : ( +
+
+ +
- - - {geminiEnabled && videoFile && ( - - )} - -
- + )} - + {selectedTask === 'merge' && ( + + )}
- - {transcription ? ( -
-

DeafBlind Accessibility Transcription

-
-
-

Visual Description

-
-

{transcription.visualDescription}

-
-
-
-

Subtitle Narrative

-
-

{transcription.subtitleNarrative}

-
-
-
-
- ) : null} - -
-
-
-
-

- {isProcessing ? `${Math.round(progress)}%` : ''} -

-
- - {error && ( -
- Error: - {error} -
- )} - - -
+ )}
void; videoFile: string | null; onThumbnailsGenerated: (thumbnails: { path: string; time: number; }[]) => void; } export const GeminiSettings: React.FC = ({ - enabled, - onEnableChange, videoFile, onThumbnailsGenerated }) => { const [generatingThumbnails, setGeneratingThumbnails] = useState(false); const [thumbnailResult, setThumbnailResult] = useState(null); - const handleEnableChange = (e: React.ChangeEvent) => { - onEnableChange(e.target.checked); - }; - const generateThumbnails = useCallback(async () => { if (!videoFile) { setThumbnailResult("Please select a video file before generating thumbnails"); @@ -42,46 +34,31 @@ export const GeminiSettings: React.FC = ({ return (
-
- - -
- - {enabled && ( -
- - - {thumbnailResult && ( -
- {thumbnailResult} -
+
+
- )} + + + {thumbnailResult && ( +
+ {thumbnailResult} +
+ )} +
); }; diff --git a/src/components/MergeTask.tsx b/src/components/MergeTask.tsx new file mode 100644 index 0000000..60afabe --- /dev/null +++ b/src/components/MergeTask.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { FileSelector } from './FileSelector'; + +interface FileState { + path: string; + name: string; +} + +export const MergeTask: React.FC = () => { + const [videoFile, setVideoFile] = useState(null); + const [subtitleFile, setSubtitleFile] = useState(null); + const [progress, setProgress] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [addBlackBar, setAddBlackBar] = useState(true); + const [resizeTo720p, setResizeTo720p] = useState(true); + + useEffect(() => { + let intervalId: number | undefined; + + if (isProcessing) { + // Poll for progress every 100ms while processing + intervalId = window.setInterval(async () => { + try { + const currentProgress = await invoke('get_progress'); + setProgress(currentProgress); + } catch (error) { + console.error('Failed to get progress:', error); + } + }, 100); + } + + return () => { + if (intervalId !== undefined) { + window.clearInterval(intervalId); + } + }; + }, [isProcessing]); + + const handleVideoSelect = (path: string) => { + const name = path.split('/').pop() || ''; + setVideoFile({ path, name }); + }; + + const handleSubtitleSelect = (path: string) => { + const name = path.split('/').pop() || ''; + setSubtitleFile({ path, name }); + }; + + const handleMerge = async () => { + if (!videoFile || !subtitleFile) return; + + setIsProcessing(true); + setProgress(0); + setError(null); + + try { + const mergeResult = await invoke('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 ( +
+
+
+

Video file:

+

{videoFile?.name || 'No file selected'}

+
+ +
+ +
+
+

Subtitle file:

+

{subtitleFile?.name || 'No file selected'}

+
+ +
+ +
+ + + +
+ +
+
+
+
+

+ {isProcessing ? `${Math.round(progress)}%` : ''} +

+
+ + {error && ( +
+ Error: + {error} +
+ )} + + +
+ ); +}; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 857bfd3..4e5a5c3 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -12,6 +12,8 @@ interface SettingsProps { onSubtitlePromptChange: (prompt: string) => void; } +// Note: We keep subtitle prompt in Settings since it's still used by the merge task + export const DEFAULT_API_KEY = 'AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc'; export const DEFAULT_VISUAL_PROMPT = `Analyze this video frame from timestamp {} and provide a visual description focusing on: 1. The visual setup and environment @@ -21,6 +23,7 @@ export const DEFAULT_VISUAL_PROMPT = `Analyze this video frame from timestamp {} Format the description in clear, concise paragraphs that would be helpful for DeafBlind readers to understand the visual context. Focus on spatial relationships and important visual details that contribute to understanding the scene. Use maximum of 200 words and do not include your own thoughts-- just provide the visual description alone.`; +// Note: We keep subtitle prompt since it's still used by the merge task export const DEFAULT_SUBTITLE_PROMPT = `I will provide you with timestamped scene descriptions and subtitle content. Create a chronological narrative that combines these elements with these strict rules: Required Format Rules: diff --git a/src/components/TranscriptionTask.tsx b/src/components/TranscriptionTask.tsx new file mode 100644 index 0000000..3ae12cb --- /dev/null +++ b/src/components/TranscriptionTask.tsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { FileSelector } from './FileSelector'; +import { GeminiSettings } from './GeminiSettings'; +import { VideoTimeline } from './VideoTimeline'; + +interface FileState { + path: string; + name: string; +} + +interface TranscriptionState { + visualDescription: string; + narrative: string; +} + +interface Thumbnail { + path: string; + time: number; +} + +interface SelectedFrame { + path: string; + time: number; + base64Image: string; + description?: string; +} + +interface TranscriptionTaskProps { + apiKey: string; + visualPrompt: string; + subtitlePrompt: string; +} + +export const TranscriptionTask: React.FC = ({ + apiKey, + visualPrompt, + subtitlePrompt, +}) => { + const [videoFile, setVideoFile] = useState(null); + const [subtitleFile, setSubtitleFile] = useState(null); + const [progress, setProgress] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [selectedFrames, setSelectedFrames] = useState([]); + const [transcription, setTranscription] = useState(null); + const [thumbnails, setThumbnails] = useState([]); + + 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); + }; + + const handleGenerateTranscription = async () => { + if (!videoFile || selectedFrames.length === 0) { + setError('Please select a video and at least one frame'); + return; + } + + setIsProcessing(true); + setProgress(0); + setError(null); + setTranscription(null); + + try { + setProgress(10); // Starting + console.log('Starting transcription process...'); + + setProgress(20); // Processing frames + const transcriptionResult = await invoke('process_transcription', { + args: { + video_path: videoFile.path, + subtitle_path: subtitleFile?.path, + selected_frames: selectedFrames.map(frame => ({ + path: frame.path, + time: frame.time, + base64_image: frame.base64Image + })), + api_key: apiKey, + visual_prompt: visualPrompt, + subtitle_prompt: subtitlePrompt + } + }); + + // Validate and clean the content + const visualDescription = String(transcriptionResult.visualDescription || '').trim(); + const narrative = String(transcriptionResult.narrative || '').trim(); + + if (!visualDescription) { + throw new Error('Visual description is missing from the response'); + } + + setTranscription({ + visualDescription, + narrative + }); + + setProgress(100); // Complete + } catch (error) { + console.error('Transcription error:', error); + setError(`Transcription error: ${error}`); + } finally { + setIsProcessing(false); + } + }; + + return ( +
+
+
+
+

Video file:

+

{videoFile?.name || 'No file selected'}

+
+ +
+ +
+
+

Subtitle file (optional):

+

{subtitleFile?.name || 'No file selected'}

+
+ +
+
+ + + + {videoFile && ( + + )} + + {transcription && ( +
+

DeafBlind Accessibility Transcription

+
+
+

Visual Description

+
+

{transcription.visualDescription}

+
+
+
+

Narrative

+
+

{transcription.narrative}

+
+
+
+
+ )} + +
+
+
+
+

+ {isProcessing ? `${Math.round(progress)}%` : ''} +

+
+ + {error && ( +
+ Error: + {error} +
+ )} + + +
+ ); +};