image: add on-demand size=large preview tier (~2048px JPEG q85)

Adds a third PhotoSize between Thumb (200px) and Full (original). The
viewer placeholder and map callout previously upscaled a 200px thumb
into a full-screen / full-width view, which looked visibly blocky on
3× devices. The new tier is generated on-demand, disk-cached, and
served via the existing /image endpoint.

Storage layout mirrors the Thumb branch's lookup chain:
  1. hash-keyed: <thumbs>/_large/<hash[..2]>/<hash>.jpg (shared across
     libraries when content_hash is known)
  2. library-scoped legacy: <thumbs>/_large/<lib_id>/<rel_path>

Generation pipeline mirrors generate_image_thumbnail:
  - RAW: decode the embedded JPEG preview, apply EXIF orientation,
         resize to 2048-long-edge, encode JPEG q85
  - HEIC/HEIF: ffmpeg with scale + q:v 5 (≈ q85)
  - everything else: image crate decode + thumbnail() + JpegEncoder
Never upscales — sources below the 2048 cap re-encode at native size.

Handler offloads decode/resize to web::block to keep the actix worker
free (a 24MP source takes 100–500ms). Writes via tempfile+rename so
concurrent readers can't observe a half-written JPEG. On any
generation failure, falls through to the Full branch (which itself
serves the RAW embedded preview for unrenderable RAW containers).

Video requests for size=large fall back to the existing thumb pipeline
since there's no useful 2048px video tier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-17 21:11:11 -04:00
committed by Cameron
parent c3c6cd03db
commit 19798184f0
4 changed files with 246 additions and 0 deletions

View File

@@ -8,7 +8,10 @@
//! skip them silently.
use std::path::{Path, PathBuf};
use std::process::Command;
use image::GenericImageView;
use image::codecs::jpeg::JpegEncoder;
use lazy_static::lazy_static;
use log::{debug, error, info, warn};
use opentelemetry::{
@@ -26,6 +29,19 @@ use crate::libraries;
use crate::otel::global_tracer;
use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail};
/// Maximum long-edge size (px) for the large preview tier. Tuned to look
/// crisp full-screen on a 3× phone (≈1290×2796 native) and to hold up
/// through a few stops of pinch-zoom before the original streams in.
/// Bigger doesn't help: callers that need true full resolution request
/// `size=full` and the handler streams the original bytes.
pub const LARGE_PREVIEW_MAX_DIM: u32 = 2048;
/// JPEG quality for the large preview tier. 85 is the conventional
/// "indistinguishable from source at viewing size" point — well above the
/// `image` crate's default ~75, but well below quality-90+ territory where
/// file size doubles for no perceptible win.
const LARGE_PREVIEW_JPEG_QUALITY: u8 = 85;
lazy_static! {
pub static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
"imageserver_image_total",
@@ -89,6 +105,106 @@ pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Resul
Ok(())
}
/// Generate the on-demand large-preview tier (≈2048 long edge JPEG).
///
/// Mirrors [`generate_image_thumbnail`]'s decode waterfall — embedded RAW
/// preview, then ffmpeg for HEIC/HEIF, then the `image` crate — but
/// resizes to [`LARGE_PREVIEW_MAX_DIM`] instead of 200 and encodes at
/// quality 85 rather than the crate default. Caller is expected to have
/// already created the destination's parent dir.
///
/// Does not upscale: if the source's long edge is already below the cap,
/// the file is encoded at its native size (still re-saved as JPEG so the
/// served bytes match for callers that key off `Content-Length`).
pub fn generate_large_preview(src: &Path, dest: &Path) -> std::io::Result<()> {
let orientation = exif::read_orientation(src).unwrap_or(1);
// RAW: prefer the in-file embedded JPEG preview over raw-sensor decode.
// The preview is typically already 12 MP and avoids RAW codec quirks.
if let Some(preview) = exif::extract_embedded_jpeg_preview(src) {
let img = image::load_from_memory(&preview).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("decode embedded preview {:?}: {}", src, e),
)
})?;
let img = exif::apply_orientation(img, orientation);
return encode_large_jpeg(img, dest);
}
if file_types::needs_ffmpeg_thumbnail(src) {
return generate_large_preview_ffmpeg(src, dest);
}
let img = image::open(src).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
})?;
let img = exif::apply_orientation(img, orientation);
encode_large_jpeg(img, dest)
}
/// Resize-if-needed + JPEG-encode at q85. Used by both the embedded-preview
/// and image-crate-decode branches of `generate_large_preview`.
fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<()> {
let (w, h) = img.dimensions();
let max_dim = w.max(h);
// Avoid upscaling tiny sources — pointless work and adds nothing for
// the viewer. `thumbnail` would scale up freely; explicit guard.
let scaled = if max_dim > LARGE_PREVIEW_MAX_DIM {
img.thumbnail(LARGE_PREVIEW_MAX_DIM, LARGE_PREVIEW_MAX_DIM)
} else {
img
};
let file = std::fs::File::create(dest)
.map_err(|e| std::io::Error::other(format!("create {:?}: {}", dest, e)))?;
let mut writer = std::io::BufWriter::new(file);
let mut encoder = JpegEncoder::new_with_quality(&mut writer, LARGE_PREVIEW_JPEG_QUALITY);
encoder
.encode_image(&scaled)
.map_err(|e| std::io::Error::other(format!("encode {:?}: {}", dest, e)))?;
Ok(())
}
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
/// to the large-preview cap instead of 200.
fn generate_large_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> {
// scale=W:-1 with force_original_aspect_ratio=decrease + the min(iw,W)
// trick caps the long edge regardless of orientation, mirroring what
// image::thumbnail does for the non-ffmpeg branch.
let vf = format!(
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
cap = LARGE_PREVIEW_MAX_DIM
);
let output = Command::new("ffmpeg")
.arg("-y")
.arg("-i")
.arg(src)
.arg("-vframes")
.arg("1")
.arg("-vf")
.arg(&vf)
.arg("-q:v")
// ffmpeg's mjpeg qscale: 2 ≈ ~q95, 5 ≈ ~q85, 10 ≈ ~q70. We pick
// 5 to match the non-ffmpeg branch's q85 target.
.arg("5")
.arg("-f")
.arg("image2")
.arg("-c:v")
.arg("mjpeg")
.arg(dest)
.output()?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"ffmpeg failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) {
let tracer = global_tracer();
let span = tracer.start("creating thumbnails");