Video Gifs #34

Merged
cameron merged 9 commits from feature/video-gifs into master 2025-08-08 18:55:15 +00:00
Showing only changes of commit 399f5f2336 - Show all commits

View File

@@ -5,13 +5,19 @@ use log::{debug, error, info, trace, warn};
use std::future::Future; use std::future::Future;
use std::io::{Result, Stdout}; use std::io::{Result, Stdout};
use std::process::{Output, Stdio}; use std::process::{Output, Stdio};
use std::time::Instant;
use tokio::process::Command; use tokio::process::Command;
pub struct Ffmpeg; pub struct Ffmpeg;
pub enum GifType {
Overview,
OverviewVideo { duration: u32 },
}
impl Ffmpeg { impl Ffmpeg {
async fn _generate_playlist(&self, input_file: &str, output_file: &str) -> Result<String> { async fn _generate_playlist(&self, input_file: &str, output_file: &str) -> Result<String> {
let ffmpeg_result: Result<Output> = tokio::process::Command::new("ffmpeg") let ffmpeg_result: Result<Output> = Command::new("ffmpeg")
.arg("-i") .arg("-i")
.arg(input_file) .arg(input_file)
.arg("-c:v") .arg("-c:v")
@@ -41,7 +47,7 @@ impl Ffmpeg {
ffmpeg_result.map(|_| output_file.to_string()) ffmpeg_result.map(|_| output_file.to_string())
} }
async fn get_video_duration(&self, input_file: &str) -> Result<String> { async fn get_video_duration(&self, input_file: &str) -> Result<u32> {
Command::new("ffprobe") Command::new("ffprobe")
.args(["-i", input_file]) .args(["-i", input_file])
.args(["-show_entries", "format=duration"]) .args(["-show_entries", "format=duration"])
@@ -51,66 +57,101 @@ impl Ffmpeg {
.await .await
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.inspect(|duration| debug!("Found video duration: {:?}", duration)) .inspect(|duration| debug!("Found video duration: {:?}", duration))
.and_then(|duration| {
duration
.parse::<f32>()
.map(|duration| duration as u32)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
})
.inspect(|duration| debug!("Found video duration: {:?}", duration))
} }
pub async fn generate_video_gif( pub async fn generate_video_gif(
&self, &self,
input_file: &str, input_file: &str,
output_file: &str, output_file: &str,
duration_sec: u32, gif_type: GifType,
) -> Result<String> { ) -> Result<String> {
info!("Creating gif for: '{}'", input_file); info!("Creating gif for: '{}'", input_file);
let temp_dir = tempfile::tempdir()?; match gif_type {
let temp_path = temp_dir GifType::Overview => {
.path() let temp_dir = tempfile::tempdir()?;
.to_str() let temp_path = temp_dir
.expect("Unable to make temp_dir a string"); .path()
.to_str()
.expect("Unable to make temp_dir a string");
match self match self
.get_video_duration(input_file) .get_video_duration(input_file)
.and_then(|duration| { .and_then(|duration| {
debug!("Creating gif frames for '{}'", input_file); debug!("Creating gif frames for '{}'", input_file);
Command::new("ffmpeg") Command::new("ffmpeg")
.args(["-i", input_file]) .args(["-i", input_file])
.args(["-vf", &format!("fps=20/{}", duration)]) .args(["-vf", &format!("fps=20/{}", duration)])
.args(["-q:v", "2"]) .args(["-q:v", "2"])
.stderr(Stdio::null()) .stderr(Stdio::null())
.arg(format!("{}/frame_%03d.jpg", temp_path)) .arg(format!("{}/frame_%03d.jpg", temp_path))
.status() .status()
}) })
.and_then(|_| { .and_then(|_| {
debug!("Generating palette"); debug!("Generating palette");
Command::new("ffmpeg") Command::new("ffmpeg")
.args(["-i", &format!("{}/frame_%03d.jpg", temp_path)]) .args(["-i", &format!("{}/frame_%03d.jpg", temp_path)])
.args(["-vf", "palettegen"]) .args(["-vf", "palettegen"])
.arg(format!("{}/palette.png", temp_path)) .arg(format!("{}/palette.png", temp_path))
.stderr(Stdio::null()) .stderr(Stdio::null())
.status() .status()
}) })
.and_then(|_| { .and_then(|_| {
debug!("Creating gif for: '{}'", input_file); debug!("Creating gif for: '{}'", input_file);
self.create_gif_from_frames(temp_path, output_file) self.create_gif_from_frames(temp_path, output_file)
}) })
.await .await
{ {
Ok(exit_code) => { Ok(exit_code) => {
if exit_code == 0 { if exit_code == 0 {
info!("Created gif for '{}' -> '{}'", input_file, output_file); info!("Created gif for '{}' -> '{}'", input_file, output_file);
} else { } else {
warn!( warn!(
"Failed to create gif for '{}' with exit code: {}", "Failed to create gif for '{}' with exit code: {}",
input_file, exit_code input_file, exit_code
); );
}
}
Err(e) => {
error!("Error creating gif for '{}': {:?}", input_file, e);
}
} }
} }
Err(e) => { GifType::OverviewVideo { duration } => {
error!("Error creating gif for '{}': {:?}", input_file, e); let start = Instant::now();
match self
.get_video_duration(input_file)
.and_then(|input_duration| {
Command::new("ffmpeg")
.args(["-i", input_file])
.args([
"-vf",
// Grab 1 second of frames equally spaced to create a 'duration' second long video scaled to 720px on longest side
&format!(
"select='lt(mod(t,{}),1)',setpts=N/FRAME_RATE/TB,scale='if(gt(iw,ih),720,-2)':'if(gt(ih,iw),720,-2)",
input_duration / duration
),
])
.arg("-an")
.arg(output_file)
.status()
})
.await
{
Ok(out) => info!("Finished clip '{}' with code {:?} in {:?}", output_file, out.code(), start.elapsed()),
Err(e) => error!("Error creating video overview: {}", e),
}
} }
} }
Ok(output_file.to_string()) Ok(output_file.to_string())
} }
@@ -126,10 +167,11 @@ impl Ffmpeg {
"[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", "[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 .args(["-loop", "0"]) // loop forever
.args(["-final_delay", "75"])
.arg(output_file) .arg(output_file)
.stderr(Stdio::null()) .stderr(Stdio::null())
.status() .status()
.map_ok(|out| out.code().unwrap_or( -1)) .map_ok(|out| out.code().unwrap_or(-1))
.inspect_ok(|output| debug!("ffmpeg gif create exit code: {:?}", output)) .inspect_ok(|output| debug!("ffmpeg gif create exit code: {:?}", output))
.await .await
} }