use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result}; use sqlx::PgPool; use tokio::sync::Semaphore; use uuid::Uuid; use crate::models::upload::Upload; #[derive(Clone)] pub struct CompressionWorker { semaphore: Arc, pool: PgPool, media_path: PathBuf, } impl CompressionWorker { pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize) -> Self { Self { semaphore: Arc::new(Semaphore::new(concurrency)), pool, media_path, } } /// Spawn a background task to process an uploaded file. pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) { let worker = self.clone(); tokio::spawn(async move { let _permit = worker.semaphore.acquire().await; if let Err(e) = worker.do_process(upload_id, &original_path, &mime_type).await { tracing::error!("compression failed for upload {upload_id}: {e:#}"); } }); } async fn do_process( &self, upload_id: Uuid, original_path: &str, mime_type: &str, ) -> Result<()> { let original = self.media_path.join(original_path); if mime_type.starts_with("image/") { let preview_rel = self.generate_image_preview(upload_id, &original, mime_type).await?; Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?; tracing::info!("preview generated for upload {upload_id}"); } else if mime_type.starts_with("video/") { let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?; Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?; tracing::info!("thumbnail generated for upload {upload_id}"); } Ok(()) } async fn generate_image_preview( &self, upload_id: Uuid, original: &Path, mime_type: &str, ) -> Result { let previews_dir = self.media_path.join("previews"); tokio::fs::create_dir_all(&previews_dir).await?; let preview_filename = format!("{upload_id}.jpg"); let preview_path = previews_dir.join(&preview_filename); let original = original.to_path_buf(); let preview_path_clone = preview_path.clone(); let mime_owned = mime_type.to_string(); // Run blocking image operations in a spawn_blocking task tokio::task::spawn_blocking(move || -> Result<()> { let img = image::open(&original) .context("failed to open image")?; // Resize to max 800px wide, preserving aspect ratio let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3); preview.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg) .context("failed to save preview")?; // If the original is PNG, try lossless compression in-place if mime_owned == "image/png" { let opts = oxipng::Options::from_preset(2); let _ = oxipng::optimize( &oxipng::InFile::Path(original), &oxipng::OutFile::Path { path: None, preserve_attrs: true, }, &opts, ); } Ok(()) }) .await??; Ok(format!("previews/{preview_filename}")) } async fn generate_video_thumbnail( &self, upload_id: Uuid, original: &Path, ) -> Result { let thumbs_dir = self.media_path.join("thumbnails"); tokio::fs::create_dir_all(&thumbs_dir).await?; let thumb_filename = format!("{upload_id}.jpg"); let thumb_path = thumbs_dir.join(&thumb_filename); let output = tokio::process::Command::new("ffmpeg") .args([ "-i", original.to_str().unwrap_or_default(), "-vframes", "1", "-ss", "00:00:01", "-vf", "scale=800:-1", "-y", thumb_path.to_str().unwrap_or_default(), ]) .output() .await .context("failed to run ffmpeg")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("ffmpeg failed: {stderr}"); } Ok(format!("thumbnails/{thumb_filename}")) } }