use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, process::Stdio, sync::Arc, time::Duration, }; use axum::{ body::Body, extract::{Query, State}, http::header, response::{IntoResponse, Response}, }; use tokio::process::Command; use crate::{ app::AppState, error::AppError, media::{PathQuery, path, probe}, }; const FRAME_COUNT: usize = 10; const FRAME_WIDTH: usize = 320; const FRAME_HEIGHT: usize = 180; pub async fn sprite( State(state): State>, Query(query): Query, ) -> Result { let media = path::resolve(&state.config, &query.path)?; if !media.is_file() { return Err(AppError::bad_request("path is not a media file")); } let _permit = state .preview_jobs .acquire() .await .map_err(AppError::internal)?; let metadata = probe::run(&state.config, &media).await?; let duration = metadata .format .and_then(|format| format.duration) .and_then(|value| value.parse::().ok()) .filter(|value| value.is_finite() && *value > 0.0) .ok_or_else(|| AppError::bad_request("media duration is unavailable"))?; let timestamps = preview_timestamps(&query.path, duration); let mut command = Command::new(&state.config.ffmpeg); command.args(["-v", "error"]); for timestamp in timestamps { command .args(["-ss", &format!("{timestamp:.3}"), "-i"]) .arg(&media); } let filter = sprite_filter(); command .args([ "-filter_complex", &filter, "-map", "[sprite]", "-frames:v", "1", "-c:v", "mjpeg", "-q:v", "5", "-f", "image2pipe", "pipe:1", ]) .stdin(Stdio::null()) .kill_on_drop(true); let output = tokio::time::timeout(Duration::from_secs(30), command.output()) .await .map_err(|_| AppError::bad_request("preview generation timed out"))? .map_err(AppError::internal)?; if !output.status.success() || output.stdout.is_empty() { let details = String::from_utf8_lossy(&output.stderr); return Err(AppError::bad_request(format!( "failed to create preview: {}", details.trim() ))); } Ok(( [ (header::CONTENT_TYPE, "image/jpeg"), (header::CACHE_CONTROL, "private, max-age=3600"), ], Body::from(output.stdout), ) .into_response()) } fn preview_timestamps(path: &str, duration: f64) -> [f64; FRAME_COUNT] { let mut hasher = DefaultHasher::new(); path.hash(&mut hasher); let mut seed = hasher.finish(); std::array::from_fn(|index| { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; let jitter = (seed as f64 / u64::MAX as f64 - 0.5) * 0.06; let position = ((index as f64 + 0.5) / FRAME_COUNT as f64 + jitter).clamp(0.02, 0.98); position * duration }) } fn sprite_filter() -> String { let mut filters = (0..FRAME_COUNT) .map(|index| { format!( "[{index}:v:0]setpts=PTS-STARTPTS,scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{index}]" ) }) .collect::>(); let inputs = (0..FRAME_COUNT) .map(|index| format!("[v{index}]")) .collect::(); let layout = (0..FRAME_COUNT) .map(|index| { format!( "{}_{}", (index % 5) * FRAME_WIDTH, (index / 5) * FRAME_HEIGHT ) }) .collect::>() .join("|"); filters.push(format!( "{inputs}xstack=inputs={FRAME_COUNT}:layout={layout}[sprite]" )); filters.join(";") } #[cfg(test)] mod tests { use super::{FRAME_COUNT, preview_timestamps, sprite_filter}; #[test] fn timestamps_are_stable_and_inside_the_video() { let first = preview_timestamps("series/episode.mkv", 100.0); let second = preview_timestamps("series/episode.mkv", 100.0); assert_eq!(first, second); assert!(first.iter().all(|value| *value >= 2.0 && *value <= 98.0)); } #[test] fn sprite_contains_every_input() { let filter = sprite_filter(); assert!(filter.contains(&format!("xstack=inputs={FRAME_COUNT}"))); for index in 0..FRAME_COUNT { assert!(filter.contains(&format!("[{index}:v:0]"))); } } }