diff --git a/src/content_hash.rs b/src/content_hash.rs index a2a9e9e..d68dbef 100644 --- a/src/content_hash.rs +++ b/src/content_hash.rs @@ -50,6 +50,18 @@ pub fn thumbnail_path(thumbs_dir: &Path, hash: &str) -> PathBuf { thumbs_dir.join(shard).join(format!("{}.jpg", hash)) } +/// Hash-keyed large-preview path: `/_large//.jpg`. +/// Kept under the same root as 200px thumbs so deployments don't need a +/// second env var, but namespaced under `_large/` so the existing 200px +/// shards don't collide with the larger derivative. +pub fn large_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf { + let shard = shard_prefix(hash); + thumbs_dir + .join("_large") + .join(shard) + .join(format!("{}.jpg", hash)) +} + /// Hash-keyed HLS output directory: `///`. /// The playlist lives at `playlist.m3u8` inside this directory and its /// segments are co-located so HLS relative references Just Work. See @@ -120,6 +132,9 @@ mod tests { let p = thumbnail_path(thumbs, "abcdef0123"); assert_eq!(p, PathBuf::from("/tmp/thumbs/ab/abcdef0123.jpg")); + let l = large_preview_path(thumbs, "abcdef0123"); + assert_eq!(l, PathBuf::from("/tmp/thumbs/_large/ab/abcdef0123.jpg")); + let video = Path::new("/tmp/video"); let d = hls_dir(video, "1234deadbeef"); assert_eq!(d, PathBuf::from("/tmp/video/12/1234deadbeef")); diff --git a/src/data/mod.rs b/src/data/mod.rs index be931ba..4780d6c 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -194,6 +194,7 @@ pub enum MediaType { #[serde(rename_all = "lowercase")] pub enum PhotoSize { Full, + Large, Thumb, } diff --git a/src/handlers/image.rs b/src/handlers/image.rs index 07e977d..ca0f598 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -82,6 +82,120 @@ pub async fn get_image( if let Some((library, path)) = resolved { let image_size = req.size.unwrap_or(PhotoSize::Full); + + // `size=large` is only meaningful for stills — there's no useful + // "2048px video preview" tier. Videos fall back to the existing + // thumb pipeline (which already handles gif/static selection). + // `mut` so the Large branch can downgrade itself to `Full` after a + // generation failure (RAW-preview branch below keys off `Full`). + let mut image_size = if image_size == PhotoSize::Large && file_types::is_video_file(&path) { + PhotoSize::Thumb + } else { + image_size + }; + + if image_size == PhotoSize::Large { + let relative_path = path + .strip_prefix(&library.root_path) + .expect("Error stripping library root prefix from large preview"); + let relative_path_str = relative_path.to_string_lossy().replace('\\', "/"); + let thumbs = Path::new(&app_state.thumbnail_path); + let large_dir = thumbs.join("_large"); + + // Lookup chain mirrors the Thumb branch — hash-keyed first so + // multi-library deployments share derivative bytes across + // libraries, then library-scoped legacy as the fallback for + // rows that aren't hashed yet. + let hash_large_path: Option = { + let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); + match dao.get_exif(&context, &relative_path_str) { + Ok(Some(row)) => row + .content_hash + .as_deref() + .map(|h| content_hash::large_preview_path(thumbs, h)), + _ => None, + } + }; + let scoped_legacy_large_path = + content_hash::library_scoped_legacy_path(&large_dir, library.id, relative_path); + + let existing = hash_large_path + .as_ref() + .filter(|p| p.exists()) + .cloned() + .or_else(|| { + if scoped_legacy_large_path.exists() { + Some(scoped_legacy_large_path.clone()) + } else { + None + } + }); + + if let Some(found) = existing { + if let Ok(file) = NamedFile::open(&found) { + span.set_status(Status::Ok); + return file + .use_etag(true) + .use_last_modified(true) + .prefer_utf8(true) + .into_response(&request); + } + } + + // Cache miss — generate. Resize + JPEG-encode can take 100–500ms + // for a 24MP source (longer for RAW), so run on the blocking pool + // to keep the actix worker free. Prefer the hash-keyed + // destination when a hash is known so the result is reusable + // across libraries that hold the same bytes. + let dest = hash_large_path + .clone() + .unwrap_or_else(|| scoped_legacy_large_path.clone()); + let src = path.clone(); + let dest_for_block = dest.clone(); + let generated = web::block(move || { + if let Some(parent) = dest_for_block.parent() { + std::fs::create_dir_all(parent)?; + } + // Write to a sibling tempfile then atomically rename so a + // concurrent reader never observes a half-written JPEG. + let tmp = dest_for_block.with_extension("jpg.tmp"); + crate::thumbnails::generate_large_preview(&src, &tmp)?; + std::fs::rename(&tmp, &dest_for_block)?; + Ok::<(), std::io::Error>(()) + }) + .await; + + match generated { + Ok(Ok(())) => { + if let Ok(file) = NamedFile::open(&dest) { + span.set_status(Status::Ok); + return file + .use_etag(true) + .use_last_modified(true) + .prefer_utf8(true) + .into_response(&request); + } + } + Ok(Err(e)) => { + warn!( + "Large preview generation failed for {:?}: {} — falling back to original", + path, e + ); + } + Err(e) => { + warn!( + "Large preview blocking-pool error for {:?}: {} — falling back to original", + path, e + ); + } + } + // Fall through to the Full branch below so the caller gets + // *something* useful (the original bytes — or the RAW + // embedded preview, which is what the Full branch returns for + // unrenderable RAW containers) instead of a 404. + image_size = PhotoSize::Full; + } + if image_size == PhotoSize::Thumb { let relative_path = path .strip_prefix(&library.root_path) diff --git a/src/thumbnails.rs b/src/thumbnails.rs index c2ae23b..51b6200 100644 --- a/src/thumbnails.rs +++ b/src/thumbnails.rs @@ -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 1–2 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");