Files
lan-play/src/media/preview.rs
T
Ultradesu eb0d4fb1c5
Build and Publish / Build and Publish Docker Image (push) Successful in 10m8s
Init
2026-08-05 12:32:52 +01:00

158 lines
4.6 KiB
Rust

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<Arc<AppState>>,
Query(query): Query<PathQuery>,
) -> Result<Response, AppError> {
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::<f64>().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::<Vec<_>>();
let inputs = (0..FRAME_COUNT)
.map(|index| format!("[v{index}]"))
.collect::<String>();
let layout = (0..FRAME_COUNT)
.map(|index| {
format!(
"{}_{}",
(index % 5) * FRAME_WIDTH,
(index / 5) * FRAME_HEIGHT
)
})
.collect::<Vec<_>>()
.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]")));
}
}
}