Fix temp dir for thumbnails

This commit is contained in:
TheMaddax 2025-01-27 08:44:42 -06:00
parent 2b44625655
commit 8639e5cdd6
9 changed files with 204 additions and 93 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "subtitle-merge", "name": "deafblind-video-assistant",
"private": true, "private": true,
"version": "0.1.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -5,6 +5,7 @@ use std::io::BufRead;
use tauri::{Runtime, State, Emitter}; use tauri::{Runtime, State, Emitter};
use tempfile::TempDir; use tempfile::TempDir;
use std::fs; use std::fs;
use base64::Engine;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
@ -60,7 +61,6 @@ pub struct TranscriptionArgs {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TranscriptionResult { pub struct TranscriptionResult {
pub visual_description: String,
pub narrative: String, pub narrative: String,
} }
@ -70,8 +70,27 @@ pub struct Thumbnail {
pub time: u32, pub time: u32,
} }
// Note: Removed unused Gemini request/response structs since we're handling pub struct ThumbnailState {
// the API interaction through the Python script temp_dir: Option<TempDir>,
}
impl Default for ThumbnailState {
fn default() -> Self {
Self {
temp_dir: None,
}
}
}
impl ThumbnailState {
pub fn set_temp_dir(&mut self, dir: TempDir) {
self.temp_dir = Some(dir);
}
pub fn get_temp_dir(&self) -> Option<&TempDir> {
self.temp_dir.as_ref()
}
}
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct ProgressUpdate { pub struct ProgressUpdate {
@ -201,24 +220,21 @@ pub async fn test_gemini_api_impl(api_key: &str) -> Result<(), Error> {
Ok(()) Ok(())
} }
pub async fn extract_thumbnails_impl(video_path: &str, interval: u32) -> Result<Vec<Thumbnail>, Error> { pub async fn extract_thumbnails_impl(
// Create a directory in the system temp directory video_path: &str,
let temp_dir = std::env::temp_dir().join("video_thumbs"); interval: u32,
let thumb_dir = temp_dir.join(format!("session_{}", std::time::SystemTime::now() thumbnail_state: State<'_, Mutex<ThumbnailState>>
.duration_since(std::time::UNIX_EPOCH) ) -> Result<Vec<Thumbnail>, Error> {
.unwrap() // Create a new temporary directory
.as_secs())); let temp_dir = TempDir::new().map_err(|e| Error::IoError(e))?;
println!("Extracting thumbnails to temporary directory: {:?}", temp_dir.path());
std::fs::create_dir_all(&thumb_dir)
.map_err(|e| Error::IoError(e))?;
println!("Extracting thumbnails to: {:?}", thumb_dir);
let duration = get_video_duration(video_path).await? as u32; let duration = get_video_duration(video_path).await? as u32;
let mut thumbnails = Vec::new(); let mut thumbnails = Vec::new();
// Process all frames before moving temp_dir into state
for time in (0..duration).step_by(interval as usize) { for time in (0..duration).step_by(interval as usize) {
let output_path = thumb_dir.join(format!("thumb_{}.jpg", time)); let output_path = temp_dir.path().join(format!("thumb_{}.jpg", time));
match extract_frame(video_path, time, output_path.to_str().unwrap()).await { match extract_frame(video_path, time, output_path.to_str().unwrap()).await {
Ok(_) => { Ok(_) => {
println!("Successfully extracted frame at time {}", time); println!("Successfully extracted frame at time {}", time);
@ -238,6 +254,8 @@ pub async fn extract_thumbnails_impl(video_path: &str, interval: u32) -> Result<
return Err(Error::FFmpegError("Failed to extract any thumbnails".to_string())); return Err(Error::FFmpegError("Failed to extract any thumbnails".to_string()));
} }
// Store the TempDir in the state only after we're done using it
thumbnail_state.lock().unwrap().set_temp_dir(temp_dir);
Ok(thumbnails) Ok(thumbnails)
} }
@ -258,7 +276,7 @@ pub async fn process_transcription_impl(args: TranscriptionArgs) -> Result<Trans
// Save base64 image to temp file // Save base64 image to temp file
let frame_path = temp_dir.path().join(format!("frame_{}.jpg", frame.time)); let frame_path = temp_dir.path().join(format!("frame_{}.jpg", frame.time));
let image_data = base64::decode(&frame.base64_image) let image_data = base64::engine::general_purpose::STANDARD.decode(&frame.base64_image)
.map_err(|e| Error::ImageError(format!("Failed to decode base64 image: {}", e)))?; .map_err(|e| Error::ImageError(format!("Failed to decode base64 image: {}", e)))?;
fs::write(&frame_path, image_data) fs::write(&frame_path, image_data)
.map_err(|e| Error::IoError(e))?; .map_err(|e| Error::IoError(e))?;
@ -279,12 +297,6 @@ pub async fn process_transcription_impl(args: TranscriptionArgs) -> Result<Trans
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n\n"); .join("\n\n");
// 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::<Vec<_>>()
.join("\n\n");
// Process subtitle content if provided // Process subtitle content if provided
let narrative = if let Some(subtitle_path) = &args.subtitle_path { let narrative = if let Some(subtitle_path) = &args.subtitle_path {
println!("\nReading and parsing subtitle content..."); println!("\nReading and parsing subtitle content...");
@ -366,24 +378,19 @@ pub async fn process_transcription_impl(args: TranscriptionArgs) -> Result<Trans
}; };
println!("\n=== Final Transcription Result ==="); println!("\n=== Final Transcription Result ===");
println!("\nVisual Description:");
println!("{}", visual_description);
println!("\nNarrative:"); println!("\nNarrative:");
println!("{}", narrative); println!("{}", narrative);
// Create the result struct // Create the result struct
let result = TranscriptionResult { let result = TranscriptionResult {
visual_description: visual_description.clone(),
narrative: narrative.clone(), narrative: narrative.clone(),
}; };
// Log the final result structure // Log the final result structure
println!("\nReturning transcription result with:"); println!("\nReturning transcription result with:");
println!("- Visual description length: {}", result.visual_description.len());
println!("- Narrative length: {}", result.narrative.len()); println!("- Narrative length: {}", result.narrative.len());
println!("\nFinal TranscriptionResult struct:"); println!("\nFinal TranscriptionResult struct:");
println!("{{"); println!("{{");
println!(" \"visualDescription\": \"{}\",", result.visual_description);
println!(" \"narrative\": \"{}\"", result.narrative); println!(" \"narrative\": \"{}\"", result.narrative);
println!("}}"); println!("}}");

View file

@ -4,11 +4,12 @@
use subtitle_merge_lib::{ use subtitle_merge_lib::{
ProgressState, MergeArgs, Error, merge_video_subtitle_impl, check_ffmpeg, ProgressState, MergeArgs, Error, merge_video_subtitle_impl, check_ffmpeg,
test_gemini_api_impl, extract_thumbnails_impl, process_transcription_impl, test_gemini_api_impl, extract_thumbnails_impl, process_transcription_impl,
TranscriptionArgs, get_video_duration TranscriptionArgs, get_video_duration, ThumbnailState
}; };
use std::fs; use std::fs;
use base64::prelude::*; use base64::Engine;
use tauri::Runtime; use tauri::Runtime;
use std::sync::Mutex;
#[tauri::command] #[tauri::command]
async fn check_dependencies() -> Result<(), Error> { async fn check_dependencies() -> Result<(), Error> {
@ -40,8 +41,12 @@ async fn get_video_duration_command(path: String) -> Result<f32, Error> {
} }
#[tauri::command] #[tauri::command]
async fn extract_thumbnails(path: String, interval: u32) -> Result<Vec<subtitle_merge_lib::Thumbnail>, Error> { async fn extract_thumbnails(
extract_thumbnails_impl(&path, interval).await path: String,
interval: u32,
thumbnail_state: tauri::State<'_, Mutex<ThumbnailState>>,
) -> Result<Vec<subtitle_merge_lib::Thumbnail>, Error> {
extract_thumbnails_impl(&path, interval, thumbnail_state).await
} }
#[tauri::command] #[tauri::command]
@ -50,15 +55,41 @@ async fn process_transcription(args: TranscriptionArgs) -> Result<subtitle_merge
} }
#[tauri::command] #[tauri::command]
async fn generate_thumbnails(video_path: String) -> Result<Vec<subtitle_merge_lib::Thumbnail>, Error> { async fn generate_thumbnails(
video_path: String,
thumbnail_state: tauri::State<'_, Mutex<ThumbnailState>>,
) -> Result<Vec<subtitle_merge_lib::Thumbnail>, Error> {
// Generate thumbnails every 10 seconds // Generate thumbnails every 10 seconds
extract_thumbnails_impl(&video_path, 10).await extract_thumbnails_impl(&video_path, 10, thumbnail_state).await
} }
#[tauri::command] #[tauri::command]
async fn read_thumbnail(path: String) -> Result<String, Error> { async fn read_thumbnail(
let data = fs::read(&path).map_err(|e| Error::IoError(e))?; path: String,
Ok(BASE64_STANDARD.encode(&data)) thumbnail_state: tauri::State<'_, Mutex<ThumbnailState>>,
) -> Result<String, Error> {
// Verify the path is within our temp directory to prevent unauthorized access
let state = thumbnail_state.lock().unwrap();
if let Some(temp_dir) = state.get_temp_dir() {
let temp_path = temp_dir.path();
let requested_path = std::path::Path::new(&path);
// Check if the requested path is within our temp directory
if !requested_path.starts_with(temp_path) {
return Err(Error::IoError(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Attempted to access file outside of temporary directory",
)));
}
let data = fs::read(&path).map_err(|e| Error::IoError(e))?;
Ok(base64::engine::general_purpose::STANDARD.encode(&data))
} else {
Err(Error::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No active thumbnail session",
)))
}
} }
fn main() { fn main() {
@ -67,6 +98,7 @@ fn main() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.manage(ProgressState::default()) .manage(ProgressState::default())
.manage(Mutex::new(ThumbnailState::default()))
.setup(|_app| { .setup(|_app| {
Ok(()) Ok(())
}) })

View file

@ -1,8 +1,8 @@
{ {
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/tooling/cli/schema.json", "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/tooling/cli/schema.json",
"productName": "Video Subtitle Merger 2.0", "productName": "DeafBlind Video Assistant",
"version": "0.1.0", "version": "1.0.0",
"identifier": "com.subtitle-merge.app", "identifier": "com.deafblind-video-assistant.app",
"app": { "app": {
"security": { "security": {
"csp": null, "csp": null,
@ -12,7 +12,7 @@
{ {
"fullscreen": false, "fullscreen": false,
"resizable": true, "resizable": true,
"title": "Video Subtitle Merger 2.0", "title": "DeafBlind Video Assistant",
"width": 800, "width": 800,
"height": 600, "height": 600,
"visible": true, "visible": true,

View file

@ -41,18 +41,8 @@ function App() {
return ( return (
<div className="min-h-screen bg-gray-100 py-8 px-4"> <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"> <div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
<div className="flex justify-between items-center mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold">Video Subtitle Merger <span className="text-blue-500">3.0</span></h1> <h1 className="text-2xl font-bold text-center">DeafBlind Video Assistant <span className="text-blue-500">1.0</span></h1>
<button
onClick={() => setIsSettingsOpen(true)}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
</div> </div>
{error && ( {error && (
@ -64,6 +54,18 @@ function App() {
{!selectedTask ? ( {!selectedTask ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-end mb-4">
<button
onClick={() => setIsSettingsOpen(true)}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
</div>
<h2 className="text-lg font-semibold text-center mb-8">Choose a Task</h2> <h2 className="text-lg font-semibold text-center mb-8">Choose a Task</h2>
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
<button <button

View file

@ -0,0 +1,51 @@
import React from 'react';
import '../styles/ProcessingAnimation.css';
export const ProcessingAnimation: React.FC = () => {
return (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
{/* Brain container with pulse and glow animation */}
<div className="relative">
{/* Brain icon with pulse animation */}
<div className="w-16 h-16 animate-pulse-gentle text-gray-600">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="animate-spin-slow"
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
</div>
{/* Glowing effect */}
<div className="absolute inset-0 animate-glow bg-blue-400 rounded-full opacity-20" />
</div>
{/* Thought bubbles */}
<div className="relative h-16 w-16">
{/* Multiple bubbles with different animations */}
{[...Array(3)].map((_, i) => (
<div
key={i}
className="absolute left-1/2 bottom-0 w-3 h-3 rounded-full bg-white border-2 border-blue-400 thought-bubble opacity-0"
style={{
transform: 'translateX(-50%)',
}}
/>
))}
</div>
{/* Processing text */}
<p className="text-gray-600 font-medium animate-pulse">
Processing with AI...
</p>
</div>
);
};

View file

@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import { FileSelector } from './FileSelector'; import { FileSelector } from './FileSelector';
import { GeminiSettings } from './GeminiSettings'; import { GeminiSettings } from './GeminiSettings';
import { VideoTimeline } from './VideoTimeline'; import { VideoTimeline } from './VideoTimeline';
import { ProcessingAnimation } from './ProcessingAnimation';
interface FileState { interface FileState {
path: string; path: string;
@ -10,7 +11,6 @@ interface FileState {
} }
interface TranscriptionState { interface TranscriptionState {
visualDescription: string;
narrative: string; narrative: string;
} }
@ -39,7 +39,6 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
}) => { }) => {
const [videoFile, setVideoFile] = useState<FileState | null>(null); const [videoFile, setVideoFile] = useState<FileState | null>(null);
const [subtitleFile, setSubtitleFile] = useState<FileState | null>(null); const [subtitleFile, setSubtitleFile] = useState<FileState | null>(null);
const [progress, setProgress] = useState<number>(0);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]); const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
@ -68,15 +67,12 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
} }
setIsProcessing(true); setIsProcessing(true);
setProgress(0);
setError(null); setError(null);
setTranscription(null); setTranscription(null);
try { try {
setProgress(10); // Starting
console.log('Starting transcription process...'); console.log('Starting transcription process...');
setProgress(20); // Processing frames
const transcriptionResult = await invoke<TranscriptionState>('process_transcription', { const transcriptionResult = await invoke<TranscriptionState>('process_transcription', {
args: { args: {
video_path: videoFile.path, video_path: videoFile.path,
@ -92,20 +88,13 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
} }
}); });
// Validate and clean the content // Clean the content
const visualDescription = String(transcriptionResult.visualDescription || '').trim();
const narrative = String(transcriptionResult.narrative || '').trim(); const narrative = String(transcriptionResult.narrative || '').trim();
if (!visualDescription) {
throw new Error('Visual description is missing from the response');
}
setTranscription({ setTranscription({
visualDescription,
narrative narrative
}); });
setProgress(100); // Complete
} catch (error) { } catch (error) {
console.error('Transcription error:', error); console.error('Transcription error:', error);
setError(`Transcription error: ${error}`); setError(`Transcription error: ${error}`);
@ -155,38 +144,19 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
/> />
)} )}
{isProcessing && <ProcessingAnimation />}
{transcription && ( {transcription && (
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<h3 className="text-lg font-semibold">DeafBlind Accessibility Transcription</h3> <h3 className="text-lg font-semibold">Scene Narrative</h3>
<div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm space-y-6"> <div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm">
<div> <div className="bg-white p-4 rounded border border-gray-200">
<h4 className="font-semibold text-gray-700 mb-2">Visual Description</h4> <p className="whitespace-pre-wrap leading-relaxed">{transcription.narrative}</p>
<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">Narrative</h4>
<div className="bg-white p-4 rounded border border-gray-200">
<p className="whitespace-pre-wrap leading-relaxed">{transcription.narrative}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
<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 && ( {error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4"> <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> <strong className="font-bold">Error: </strong>

View file

@ -0,0 +1,33 @@
@keyframes float {
0% {
transform: translate(-50%, 0) scale(0.5);
opacity: 0;
}
50% {
transform: translate(-50%, -40px) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -80px) scale(0.5);
opacity: 0;
}
}
.thought-bubble {
animation: float ease-in-out infinite;
}
.thought-bubble:nth-child(1) {
animation-duration: 1.5s;
animation-delay: 0s;
}
.thought-bubble:nth-child(2) {
animation-duration: 2s;
animation-delay: 0.5s;
}
.thought-bubble:nth-child(3) {
animation-duration: 2.5s;
animation-delay: 1s;
}

View file

@ -15,6 +15,22 @@ export default {
spacing: { spacing: {
'128': '32rem', '128': '32rem',
}, },
animation: {
'spin-slow': 'spin 8s linear infinite',
'pulse-gentle': 'pulse 2s ease-in-out infinite',
'glow': 'ping 2s ease-in-out infinite',
},
keyframes: {
ping: {
'0%': { transform: 'scale(1)', opacity: '0.2' },
'50%': { transform: 'scale(1.2)', opacity: '0.3' },
'100%': { transform: 'scale(1)', opacity: '0.2' },
},
pulse: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
},
},
}, },
}, },
plugins: [], plugins: [],