6.1 KiB · text History 376a05c
//! 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)
}