diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 937b456..26460ed 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -39,6 +39,8 @@ function closeGallery() { galleryFolder = null; currentView = 'main'; + // Check for any running job (e.g., video compilation started from gallery) + checkAndPollProgress(); } function handleConnectionChange(data) { diff --git a/src/video/ffmpeg.rs b/src/video/ffmpeg.rs index 2a746fe..7fd12d0 100644 --- a/src/video/ffmpeg.rs +++ b/src/video/ffmpeg.rs @@ -4,13 +4,13 @@ use crate::config::VideoConfig; use crate::error::{Error, Result}; use std::path::Path; use std::process::Stdio; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; /// Compile images into a timelapse video. /// /// # Arguments -/// * `input_dir` - Directory containing numbered JPEG images +/// * `input_dir` - Directory containing JPEG images (sorted alphabetically for order) /// * `output_path` - Path for the output video file /// * `config` - Video configuration /// * `progress_callback` - Called with (current_frame, total_frames) @@ -23,35 +23,65 @@ pub async fn compile_timelapse( where F: FnMut(u32, u32), { - // Count input images - let mut image_count = 0u32; + // Collect and sort image files for reliable ordering. + // Filenames are timestamp-based, so alphabetical sort = chronological order. + let mut image_files: Vec<_> = Vec::new(); let mut entries = tokio::fs::read_dir(input_dir).await?; + while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().map(|e| e == "jpg").unwrap_or(false) { - image_count += 1; + image_files.push(path); } } - if image_count == 0 { + if image_files.is_empty() { return Err(Error::VideoCompilation( "No images found in input directory".to_string(), )); } + // Sort alphabetically (timestamp-based names ensure chronological order) + image_files.sort(); + let image_count = image_files.len() as u32; + tracing::info!("Compiling {} images into video", image_count); - // Build ffmpeg command - let input_pattern = input_dir.join("*.jpg").to_string_lossy().to_string(); + // Create concat file for ffmpeg (more reliable than glob pattern). + // Format: file '/path/to/image.jpg'\nduration 0.0667\n... + let frame_duration = 1.0 / config.framerate as f64; + let concat_path = input_dir.join(".concat.txt"); + let mut concat_content = String::new(); + for (i, path) in image_files.iter().enumerate() { + // Use just the filename since concat file is in the same directory as images + let filename = path + .file_name() + .map(|f| f.to_string_lossy()) + .unwrap_or_default() + .replace('\'', "'\\''"); + concat_content.push_str(&format!("file '{}'\n", filename)); + + // Don't add duration after the last frame + if i < image_files.len() - 1 { + concat_content.push_str(&format!("duration {:.6}\n", frame_duration)); + } + } + + // Write concat file + let mut file = tokio::fs::File::create(&concat_path).await?; + file.write_all(concat_content.as_bytes()).await?; + file.flush().await?; + + // Build ffmpeg command using concat demuxer let mut cmd = Command::new("ffmpeg"); cmd.arg("-y") // Overwrite output - .arg("-framerate") - .arg(config.framerate.to_string()) - .arg("-pattern_type") - .arg("glob") + .arg("-f") + .arg("concat") + .arg("-safe") + .arg("0") // Allow absolute paths .arg("-i") - .arg(&input_pattern) + .arg(&concat_path) .arg("-c:v") .arg(&config.codec) .arg("-crf") @@ -71,10 +101,29 @@ where )) })?; - // Parse progress from stdout - let stdout = child.stdout.take().unwrap(); - let mut reader = BufReader::new(stdout).lines(); + // Capture stdout for progress and stderr for errors + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::FFmpeg("Failed to capture ffmpeg stdout".to_string()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| Error::FFmpeg("Failed to capture ffmpeg stderr".to_string()))?; + // Read stderr in background to avoid blocking + let stderr_handle = tokio::spawn(async move { + let mut stderr_reader = BufReader::new(stderr).lines(); + let mut stderr_output = String::new(); + while let Ok(Some(line)) = stderr_reader.next_line().await { + stderr_output.push_str(&line); + stderr_output.push('\n'); + } + stderr_output + }); + + // Parse progress from stdout + let mut reader = BufReader::new(stdout).lines(); while let Some(line) = reader.next_line().await? { if line.starts_with("frame=") { if let Some(frame_str) = line.strip_prefix("frame=") { @@ -86,11 +135,20 @@ where } let status = child.wait().await?; + let stderr_output = stderr_handle.await.unwrap_or_default(); + + // Clean up concat file + let _ = tokio::fs::remove_file(&concat_path).await; if !status.success() { + // Get last few lines of stderr for the error message + let error_lines: Vec<&str> = stderr_output.lines().rev().take(10).collect(); + let error_detail = error_lines.into_iter().rev().collect::>().join("\n"); + return Err(Error::FFmpeg(format!( - "ffmpeg exited with status {}", - status + "ffmpeg exited with status {}\n{}", + status, + error_detail ))); } diff --git a/src/web/handlers.rs b/src/web/handlers.rs index 3e806f5..6840765 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -942,7 +942,6 @@ async fn compile_folder_video( // Update final status match result { Ok(_) => { - tracing::info!("Video compilation complete for folder: {}", folder_name_clone); job_state .update_progress(Progress { status: JobStatus::Completed, diff --git a/src/web/state.rs b/src/web/state.rs index 9cbe299..b9f292c 100644 --- a/src/web/state.rs +++ b/src/web/state.rs @@ -17,6 +17,14 @@ pub enum JobStatus { Error(String), } +impl JobStatus { + /// Returns true if this is a terminal status (job has finished). + /// Terminal statuses should not be overwritten by non-terminal statuses. + pub fn is_terminal(&self) -> bool { + matches!(self, JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_)) + } +} + /// Detailed statistics for skipped images. #[derive(Debug, Clone, Default)] pub struct SkipStats { @@ -102,8 +110,26 @@ impl AppState { } /// Update progress and broadcast to all listeners. + /// + /// Note: This method prevents non-terminal statuses (Running, CompilingVideo) + /// from overwriting terminal statuses (Completed, Cancelled, Error). This + /// avoids race conditions where fire-and-forget progress updates arrive after + /// the job has finished. pub async fn update_progress(&self, progress: Progress) { - *self.progress.write().await = progress.clone(); + let mut current = self.progress.write().await; + + // Don't allow non-terminal statuses to overwrite terminal statuses. + // This prevents race conditions from fire-and-forget progress callbacks. + if current.status.is_terminal() && !progress.status.is_terminal() { + tracing::debug!( + "Ignoring progress update: current status {:?} is terminal, incoming {:?} is not", + current.status, + progress.status + ); + return; + } + + *current = progress.clone(); let _ = self.progress_tx.send(progress); } @@ -112,9 +138,11 @@ impl AppState { let cancel_token = self.cancel_token.read().await; if let Some(token) = cancel_token.as_ref() { token.cancel(); - // Update progress to show cancelling state immediately + // Update progress to show cancelling state immediately. + // Only change to Cancelling if in an active (non-terminal) state. let mut progress = self.progress.write().await; - if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo + if progress.status == JobStatus::Running + || progress.status == JobStatus::CompilingVideo { progress.status = JobStatus::Cancelling; progress.message = Some("Cancelling...".to_string());