use anyhow::{anyhow, Context}; use ffmpeg_next::{ format::{input, stream::Disposition}, media::Type, }; use lazy_static::lazy_static; use regex::Regex; use tempfile::tempdir; use tokio::{fs, process::Command}; use std::path::Path; lazy_static! { static ref SUBTITLE_FORBID_REGEX: Regex = Regex::new("(?i)sign|song").unwrap(); } pub struct VideoInfo { pub duration_secs: f64, // The index among the subtitle streams, not among the streams in general pub subtitle_stream_index: Option, } pub fn get_video_info>( source: &P, subtitle_lang: Option<&str>, ) -> anyhow::Result { let ctx = input(source).context("Failed to load video file")?; let duration_secs = ctx.duration() as f64 / f64::from(ffmpeg_next::ffi::AV_TIME_BASE); let subtitle_stream_index = subtitle_lang.and_then(|lang| { ctx.streams() .filter(|stream| { ffmpeg_next::codec::context::Context::from_parameters(stream.parameters()) .map(|c| c.medium()) == Ok(Type::Subtitle) }) .enumerate() .filter(|(_, stream)| { let metadata = stream.metadata(); if metadata.get("language") != Some(lang) { return false; } if metadata .get("title") .map(|t| SUBTITLE_FORBID_REGEX.is_match(t)) == Some(true) { return false; } true }) .min_by_key(|(_, stream)| stream.disposition().contains(Disposition::FORCED)) .map(|(idx, _)| idx) }); Ok(VideoInfo { duration_secs, subtitle_stream_index, }) } pub async fn take_screencap>( source: &P, timestamp_secs: f64, subtitle_stream_index: Option, ) -> anyhow::Result> { let ext = source.as_ref().extension().and_then(|s| s.to_str()); if ext != Some("mkv") && ext != Some("mp4") { return Err(anyhow!( "Video file {} had unexpected file extension", source.as_ref().display() )); } let tmp_dir = tempdir() .context("Failed to create temporary directory for ffmpeg input and output files")?; let link_path = tmp_dir.path().join(format!("in.{}", ext.unwrap())); fs::symlink(source, &link_path) .await .context("Failed to create symlink for video file")?; let dest_path = tmp_dir.path().join("out.png"); let mut cmd = Command::new("ffmpeg"); cmd.arg("-ss") .arg(format!("{:.2}", timestamp_secs)) .arg("-copyts") .arg("-i") .arg(&link_path); if let Some(idx) = subtitle_stream_index { cmd.arg("-filter_complex").arg(format!( "[0:v]subtitles={}:si={}", link_path.to_string_lossy(), idx )); } cmd.args(["-vframes", "1"]) .args(["-loglevel", "quiet"]) .arg("-y") .arg(&dest_path); let status = cmd .status() .await .with_context(|| format!("Error running ffmpeg child process {:?}", cmd))?; if !status.success() { match status.code() { Some(code) => return Err(anyhow!("ffmpeg exited with status code {code}")), None => return Err(anyhow!("ffmpeg terminated by signal")), } } fs::read(&dest_path) .await .with_context(|| format!("Failed to read ffmpeg output file {}", dest_path.display())) }