158 lines
4.6 KiB
Rust
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]")));
|
|
}
|
|
}
|
|
}
|