diff --git a/Cargo.lock b/Cargo.lock index dca8e11..7d32785 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,6 +1423,7 @@ dependencies = [ "rayon", "serde", "serde_json", + "tempfile", "tokio", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 9b5e957..c82e667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,3 +37,4 @@ prometheus = "0.13" lazy_static = "1.5" anyhow = "1.0" rand = "0.8.5" +tempfile = "3.14.0" diff --git a/src/main.rs b/src/main.rs index a5f7e39..040ba28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,6 @@ use crate::files::{ use crate::service::ServiceBuilder; use crate::state::AppState; use crate::tags::*; -use crate::video::*; use crate::video::actors::{ create_playlist, generate_video_thumbnail, ProcessMessage, ScanDirectoryMessage, }; diff --git a/src/video/ffmpeg.rs b/src/video/ffmpeg.rs new file mode 100644 index 0000000..61c1b5c --- /dev/null +++ b/src/video/ffmpeg.rs @@ -0,0 +1,136 @@ +use futures::future::{Inspect, MapOk}; +use futures::task::SpawnExt; +use futures::{FutureExt, TryFutureExt}; +use log::{debug, error, info, trace, warn}; +use std::future::Future; +use std::io::{Result, Stdout}; +use std::process::{Output, Stdio}; +use tokio::process::Command; + +pub struct Ffmpeg; + +impl Ffmpeg { + async fn generate_playlist(&self, input_file: &str, output_file: &str) -> Result { + let ffmpeg_result: Result = tokio::process::Command::new("ffmpeg") + .arg("-i") + .arg(input_file) + .arg("-c:v") + .arg("h264") + .arg("-crf") + .arg("21") + .arg("-preset") + .arg("veryfast") + .arg("-hls_time") + .arg("3") + .arg("-hls_list_size") + .arg("100") + .arg("-vf") + .arg("scale=1080:-2,setsar=1:1") + .arg(output_file) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e)) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) + .await; + + if let Ok(ref res) = ffmpeg_result { + debug!("ffmpeg output: {:?}", res); + } + + ffmpeg_result.map(|_| output_file.to_string()) + } + + async fn get_video_duration(&self, input_file: &str) -> Result { + Command::new("ffprobe") + .args(&["-i", input_file]) + .args(&["-show_entries", "format=duration"]) + .args(&["-v", "quiet"]) + .args(&["-of", "csv=p=0"]) + .output() + .await + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .inspect(|duration| debug!("Found video duration: {:?}", duration)) + } + + pub async fn generate_video_gif( + &self, + input_file: &str, + output_file: &str, + duration_sec: u32, + ) -> Result { + info!("Creating gif for: '{}'", input_file); + + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir + .path() + .to_str() + .expect("Unable to make temp_dir a string"); + + match self + .get_video_duration(input_file) + .and_then(|duration| { + debug!("Creating gif frames for '{}'", input_file); + + Command::new("ffmpeg") + .args(&["-i", input_file]) + .args(&["-vf", &format!("fps=20/{}", duration)]) + .args(&["-q:v", "2"]) + .stderr(Stdio::null()) + .arg(format!("{}/frame_%03d.jpg", temp_path)) + .status() + }) + .and_then(|_| { + debug!("Generating palette"); + + Command::new("ffmpeg") + .args(&["-i", &format!("{}/frame_%03d.jpg", temp_path)]) + .args(&["-vf", "palettegen"]) + .arg(&format!("{}/palette.png", temp_path)) + .stderr(Stdio::null()) + .status() + }) + .and_then(|_| { + debug!("Creating gif for: '{}'", input_file); + self.create_gif_from_frames(temp_path, output_file) + }) + .await + { + Ok(exit_code) => { + if exit_code == 0 { + info!("Created gif for '{}' -> '{}'", input_file, output_file); + } else { + warn!( + "Failed to create gif for '{}' with exit code: {}", + input_file, exit_code + ); + } + } + Err(e) => { + error!("Error creating gif for '{}': {:?}", input_file, e); + } + } + + Ok(output_file.to_string()) + } + + async fn create_gif_from_frames(&self, frame_base_dir: &str, output_file: &str) -> Result { + Command::new("ffmpeg") + .arg("-y") + .args(&["-framerate", "4"]) + .args(&["-i", &format!("{}/frame_%03d.jpg", frame_base_dir)]) + .args(&["-i", &format!("{}/palette.png", frame_base_dir)]) + .args(&[ + "-filter_complex", + // Scale to 480x480 with a center crop + "[0:v]scale=480:-1:flags=lanczos,crop='min(in_w,in_h)':'min(in_w,in_h)':(in_w-out_w)/2:(in_h-out_h)/2, paletteuse", + ]) + .args(&["-loop", "0"]) // loop forever + .arg(output_file) + .stderr(Stdio::null()) + .status() + .map_ok(|out| out.code().unwrap_or( -1)) + .inspect_ok(|output| debug!("ffmpeg gif create exit code: {:?}", output)) + .await + } +} diff --git a/src/video/mod.rs b/src/video/mod.rs new file mode 100644 index 0000000..cbbc7b6 --- /dev/null +++ b/src/video/mod.rs @@ -0,0 +1,2 @@ +pub mod actors; +pub mod ffmpeg; \ No newline at end of file