//! Animated grid previews for videos and GIFs.
//!
//! The grid's thumbnails are single JPEG frames, so nothing in a listing
//! moves. This job asks ffmpeg for a short, small, silent animated WebP —
//! the first few seconds, scaled down — and stores it as
//! `<stem>.preview.webp` beside the original. Browsers animate that in a
//! plain `<img>` and Android decodes it natively, so neither client needs a
//! video player per tile. Clients fall back to the still when it's absent.
//!
//! The original is only ever read. Sound is dropped from the preview alone;
//! the upload itself keeps its audio track untouched.
use futures_util::future::BoxFuture;
use sea_orm::*;
use std::process::Stdio;
use std::time::Duration;
use tracing::{error, info, warn};
use crate::entities::uploads;
use crate::jobs;
use crate::queue::{JobKind, JobMessage, JobOutcome};
use crate::storage::Storage;
use crate::workers::WorkerContext;
/// `<stem>.preview.webp` beside the original.
pub const PREVIEW_SUFFIX: &str = "preview";
pub const PREVIEW_EXT: &str = "webp";
/// Long enough to show what a clip is, short enough that a grid of them stays
/// a few hundred KB each. At 320px wide a 4s loop comes out around 100–150KB.
const PREVIEW_SECONDS: &str = "4";
const PREVIEW_FPS: u32 = 10;
const PREVIEW_MAX_WIDTH: u32 = 320;
/// Which uploads get a preview: anything that moves. A single-frame GIF gets
/// a single-frame preview, which costs little and saves probing first.
pub fn wants_preview(upload: &uploads::Model) -> bool {
matches!(
upload.content_type.as_deref(),
Some(ct) if ct.starts_with("video/") || ct == "image/gif"
)
}
/// Same split as the poster job: whether retrying could possibly help.
enum PreviewError {
Spawn(String),
Failed(String),
}
pub fn run(ctx: WorkerContext, msg: JobMessage) -> BoxFuture<'static, JobOutcome> {
Box::pin(async move {
if matches!(
jobs::is_complete(ctx.db.as_ref(), msg.upload_id, JobKind::Preview).await,
Ok(true)
) {
info!(upload_id = %msg.upload_id, "preview already complete, skipping");
return JobOutcome::Ack;
}
let _ = jobs::mark_processing(ctx.db.as_ref(), msg.upload_id, JobKind::Preview).await;
let upload = match uploads::Entity::find_by_id(msg.upload_id)
.one(ctx.db.as_ref())
.await
{
Ok(Some(u)) => u,
Ok(None) => {
warn!(upload_id = %msg.upload_id, "upload not found, dropping");
let _ = jobs::mark_failed(ctx.db.as_ref(), msg.upload_id, JobKind::Preview, "upload not found").await;
return JobOutcome::Reject;
}
Err(e) => {
error!(error = %e, "db error loading upload");
return JobOutcome::Retry;
}
};
let abs = ctx.storage_abs(&upload.item_path);
let webp = match encode_preview(&ctx, &abs).await {
Ok(bytes) => bytes,
Err(PreviewError::Spawn(e)) => {
error!(upload_id = %upload.id, error = %e, "ffmpeg unavailable");
let _ = jobs::mark_failed(ctx.db.as_ref(), upload.id, JobKind::Preview, &e).await;
return JobOutcome::Retry;
}
Err(PreviewError::Failed(e)) => {
error!(upload_id = %upload.id, error = %e, "preview encoding failed");
let _ = jobs::mark_failed(ctx.db.as_ref(), upload.id, JobKind::Preview, &e).await;
return JobOutcome::Reject;
}
};
let stored = match ctx
.storage
.put_derived_as(&upload.item_path, PREVIEW_SUFFIX, PREVIEW_EXT, &webp)
.await
{
Ok(rel) => rel,
Err(e) => {
error!(error = %e, "write preview failed");
return JobOutcome::Retry;
}
};
let _ = jobs::mark_complete(ctx.db.as_ref(), upload.id, JobKind::Preview).await;
info!(upload_id = %upload.id, preview = %stored, bytes = webp.len(), "preview encoded");
JobOutcome::Ack
})
}
/// The first few seconds as a looping animated WebP, straight to stdout.
async fn encode_preview(ctx: &WorkerContext, path: &std::path::Path) -> Result<Vec<u8>, PreviewError> {
let bin = &ctx.config.ffmpeg_bin;
let mut cmd = tokio::process::Command::new(bin);
cmd.arg("-v")
.arg("error")
.arg("-i")
.arg(path)
.arg("-t")
.arg(PREVIEW_SECONDS)
// A preview is a picture. The original keeps its sound.
.arg("-an")
// min(): never upscale a clip that is already small. -2 keeps the
// height even, which the encoder wants.
.arg("-vf")
.arg(format!(
"fps={PREVIEW_FPS},scale='min({PREVIEW_MAX_WIDTH},iw)':-2:flags=lanczos"
))
.arg("-c:v")
.arg("libwebp_anim")
.arg("-loop")
.arg("0")
.arg("-quality")
.arg("60")
.arg("-compression_level")
.arg("4")
// The webp muxer writes the whole file in one go, so a pipe is fine.
.arg("-f")
.arg("webp")
.arg("-")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let timeout = Duration::from_secs(ctx.config.preview_timeout_secs);
let output = match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(PreviewError::Spawn(format!("could not run {bin}: {e}"))),
Err(_) => {
return Err(PreviewError::Failed(format!(
"ffmpeg timed out after {}s",
ctx.config.preview_timeout_secs
)));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PreviewError::Failed(format!(
"ffmpeg exited {}: {}",
output.status,
stderr.trim().chars().take(400).collect::<String>()
)));
}
if output.stdout.is_empty() {
return Err(PreviewError::Failed(
"ffmpeg produced no preview — no decodable video stream?".to_string(),
));
}
Ok(output.stdout)
}
⭐
Log in to mark this repository.
6.1 KiB · text
History
c20d4f7