Implemented the ffmpeg video compilation
This commit is contained in:
parent
1278c6290c
commit
1854ecd8e0
4 changed files with 109 additions and 22 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<F>(
|
|||
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::<Vec<_>>().join("\n");
|
||||
|
||||
return Err(Error::FFmpeg(format!(
|
||||
"ffmpeg exited with status {}",
|
||||
status
|
||||
"ffmpeg exited with status {}\n{}",
|
||||
status,
|
||||
error_detail
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue