This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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]")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user