diff --git a/CLAUDE.md b/CLAUDE.md index daf3575..390f26f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -511,7 +511,7 @@ All protected endpoints extract `Claims` via `FromRequest` trait implementation. **Key Endpoint Patterns:** ```rust -// Image serving & upload +GET /image?path=...&size=...&format=... // size: thumb (200px) | medium (1024) | large (2048) | xlarge (4096) | full GET /image?path=...&size=...&format=... POST /image (multipart file upload) diff --git a/src/content_hash.rs b/src/content_hash.rs index a2a4632..76bd1d7 100644 --- a/src/content_hash.rs +++ b/src/content_hash.rs @@ -50,6 +50,15 @@ pub fn thumbnail_path(thumbs_dir: &Path, hash: &str) -> PathBuf { thumbs_dir.join(shard).join(format!("{}.jpg", hash)) } +/// Hash-keyed medium-preview path: `/_medium//.jpg`. +pub fn medium_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf { + let shard = shard_prefix(hash); + thumbs_dir + .join("_medium") + .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 diff --git a/src/data/mod.rs b/src/data/mod.rs index 34a9424..1f79a1c 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -198,6 +198,7 @@ pub enum PhotoSize { Full, XLarge, Large, + Medium, Thumb, } diff --git a/src/handlers/image.rs b/src/handlers/image.rs index dbc8b91..6525080 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -79,27 +79,25 @@ pub async fn get_image( }); if let Some((library, path)) = resolved { - let image_size = req.size.unwrap_or(PhotoSize::Full); + let mut image_size = req.size.unwrap_or(PhotoSize::Full); + let mut tier = match image_size { + PhotoSize::Medium => Some(PreviewTier::Medium), + PhotoSize::Large => Some(PreviewTier::Large), + PhotoSize::XLarge => Some(PreviewTier::XLarge), + PhotoSize::Full | PhotoSize::Thumb => None, + }; - // `size=large|xlarge` is only meaningful for stills — there's no + // The preview tiers are only meaningful for stills — there's no // useful "resized video preview" tier. Videos fall back to the // existing thumb pipeline (which already handles gif/static // selection). `mut` so preview branches can downgrade to `Full` // after a generation failure. - let mut image_size = if (image_size == PhotoSize::Large || image_size == PhotoSize::XLarge) - && file_types::is_video_file(&path) - { - PhotoSize::Thumb - } else { - image_size - }; + if tier.is_some() && file_types::is_video_file(&path) { + tier = None; + image_size = PhotoSize::Thumb; + } - if image_size == PhotoSize::Large || image_size == PhotoSize::XLarge { - let tier = if image_size == PhotoSize::Large { - PreviewTier::Large - } else { - PreviewTier::XLarge - }; + if let Some(tier) = tier { if let Some(resp) = serve_preview( tier, library, @@ -277,6 +275,7 @@ pub async fn get_image( /// On-demand resized tiers served by [`serve_preview`]. #[derive(Clone, Copy)] enum PreviewTier { + Medium, Large, XLarge, } @@ -284,6 +283,7 @@ enum PreviewTier { impl PreviewTier { fn name(self) -> &'static str { match self { + PreviewTier::Medium => "Medium", PreviewTier::Large => "Large", PreviewTier::XLarge => "XLarge", } @@ -291,6 +291,7 @@ impl PreviewTier { fn legacy_dir(self) -> &'static str { match self { + PreviewTier::Medium => "_medium", PreviewTier::Large => "_large", PreviewTier::XLarge => "_xlarge", } @@ -298,17 +299,24 @@ impl PreviewTier { fn hash_path(self, thumbs: &Path, hash: &str) -> PathBuf { match self { + PreviewTier::Medium => content_hash::medium_preview_path(thumbs, hash), PreviewTier::Large => content_hash::large_preview_path(thumbs, hash), PreviewTier::XLarge => content_hash::xlarge_preview_path(thumbs, hash), } } - fn generate(self, src: &Path, dest: &Path) -> std::io::Result<()> { + fn max_dim(self) -> u32 { + use crate::thumbnails::*; match self { - PreviewTier::Large => crate::thumbnails::generate_large_preview(src, dest), - PreviewTier::XLarge => crate::thumbnails::generate_xlarge_preview(src, dest), + PreviewTier::Medium => MEDIUM_PREVIEW_MAX_DIM, + PreviewTier::Large => LARGE_PREVIEW_MAX_DIM, + PreviewTier::XLarge => XLARGE_PREVIEW_MAX_DIM, } } + + fn generate(self, src: &Path, dest: &Path) -> std::io::Result<()> { + crate::thumbnails::generate_preview(src, dest, self.max_dim()) + } } /// Serve the cached `tier` preview of `path`, generating it on a miss. @@ -1384,31 +1392,34 @@ mod tests { } #[actix_rt::test] - async fn large_preview_caps_a_panorama_at_2048() { + async fn preview_tiers_cap_a_panorama_at_their_size() { let root = tempfile::tempdir().unwrap(); let thumbs = tempfile::tempdir().unwrap(); let lib = library(root.path()); let src = write_jpeg(root.path(), "pano.jpg", 6000, 1600); let req = TestRequest::default().to_http_request(); - serve_preview( - PreviewTier::Large, - &lib, - &src, - thumbs.path(), - &exif_dao(), - &req, - ) - .await - .expect("preview served"); + for (tier, dir, dims) in [ + (PreviewTier::Medium, "_medium", (1024, 273)), + (PreviewTier::Large, "_large", (2048, 546)), + ] { + serve_preview(tier, &lib, &src, thumbs.path(), &exif_dao(), &req) + .await + .expect("preview served"); - // Unhashed row → library-scoped legacy path under `_large/`. - let dest = content_hash::library_scoped_legacy_path( - &thumbs.path().join("_large"), - lib.id, - Path::new("pano.jpg"), - ); - let (w, h) = image::open(dest).unwrap().dimensions(); - assert_eq!((w, h), (2048, 546)); + // Unhashed row → library-scoped legacy path under the tier's dir. + let dest = content_hash::library_scoped_legacy_path( + &thumbs.path().join(dir), + lib.id, + Path::new("pano.jpg"), + ); + assert_eq!(image::open(dest).unwrap().dimensions(), dims, "{dir}"); + } + } + + #[test] + fn size_param_accepts_medium() { + let q = web::Query::::from_query("path=a.jpg&size=medium").unwrap(); + assert_eq!(q.size, Some(PhotoSize::Medium)); } } diff --git a/src/thumbnails.rs b/src/thumbnails.rs index 11e4f5c..507c2b1 100644 --- a/src/thumbnails.rs +++ b/src/thumbnails.rs @@ -25,6 +25,13 @@ use crate::file_types; use crate::libraries; use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail}; +/// Maximum long-edge size (px) for the medium preview tier: grid and +/// memories tiles. A full-width single-column tile on a 3× phone is +/// ≈1180px and a two-column one ≈590px, so 1024 is crisp for both at a +/// quarter of `large`'s pixels. Tiles used to pull `large`, downloading +/// 10–15× what they displayed. +pub const MEDIUM_PREVIEW_MAX_DIM: u32 = 1024; + /// 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. @@ -32,11 +39,11 @@ use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumb /// `size=full` and the handler streams the original bytes. pub const LARGE_PREVIEW_MAX_DIM: u32 = 2048; -/// JPEG quality for the large and xlarge preview tiers. 85 is the +/// JPEG quality for the preview tiers. 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; +const PREVIEW_JPEG_QUALITY: u8 = 85; /// Maximum long-edge size (px) for the xlarge preview tier. Bridges the /// gap between `large` (2048px, ~16MB decoded) and the original bytes @@ -108,18 +115,20 @@ 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). +/// Generate an on-demand preview tier: a JPEG whose long edge is capped +/// at `max_dim` ([`MEDIUM_PREVIEW_MAX_DIM`], [`LARGE_PREVIEW_MAX_DIM`] or +/// [`XLARGE_PREVIEW_MAX_DIM`]). /// /// 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. +/// resizes to `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<()> { +pub fn generate_preview(src: &Path, dest: &Path, max_dim: u32) -> std::io::Result<()> { let orientation = exif::read_orientation(src).unwrap_or(1); // RAW: prefer the in-file embedded JPEG preview over raw-sensor decode. @@ -132,36 +141,35 @@ pub fn generate_large_preview(src: &Path, dest: &Path) -> std::io::Result<()> { ) })?; let img = exif::apply_orientation(img, orientation); - return encode_large_jpeg(img, dest); + return encode_preview_jpeg(img, dest, max_dim); } if file_types::needs_ffmpeg_thumbnail(src) { - return generate_large_preview_ffmpeg(src, dest, orientation); + return generate_preview_ffmpeg(src, dest, orientation, max_dim); } 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) + encode_preview_jpeg(img, dest, max_dim) } /// 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<()> { +/// and image-crate-decode branches of [`generate_preview`]. +fn encode_preview_jpeg(img: image::DynamicImage, dest: &Path, max_dim: u32) -> std::io::Result<()> { let (w, h) = img.dimensions(); - let max_dim = w.max(h); - // Avoid upscaling tiny sources — pointless work and adds nothing for + // Avoid upscaling small 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) + let scaled = if w.max(h) > max_dim { + img.thumbnail(max_dim, 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); + let mut encoder = JpegEncoder::new_with_quality(&mut writer, PREVIEW_JPEG_QUALITY); encoder .encode_image(&scaled) .map_err(|e| std::io::Error::other(format!("encode {:?}: {}", dest, e)))?; @@ -170,8 +178,13 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<( /// 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, orientation: i32) -> std::io::Result<()> { +/// to `max_dim` instead of 200. +fn generate_preview_ffmpeg( + src: &Path, + dest: &Path, + orientation: i32, + max_dim: u32, +) -> std::io::Result<()> { // Rotation + scale + colorspace. HEIC sources use Display P3; without // colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing // warm/oversaturated output. The min(iw,cap) trick caps the long edge @@ -188,7 +201,7 @@ fn generate_large_preview_ffmpeg(src: &Path, dest: &Path, orientation: i32) -> s }; let scale_expr = format!( "scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'", - cap = LARGE_PREVIEW_MAX_DIM + cap = max_dim ); let vf = if rotation.is_empty() { format!("{},colorspace=bt709", scale_expr) @@ -224,105 +237,6 @@ fn generate_large_preview_ffmpeg(src: &Path, dest: &Path, orientation: i32) -> s Ok(()) } -/// Generate the on-demand xlarge-preview tier (≈4096 long edge JPEG). -/// -/// Same waterfall as [`generate_large_preview`] but targeting -/// [`XLARGE_PREVIEW_MAX_DIM`]. Sources whose long edge is already below -/// the cap are encoded at native size (no upscale). -pub fn generate_xlarge_preview(src: &Path, dest: &Path) -> std::io::Result<()> { - let orientation = exif::read_orientation(src).unwrap_or(1); - - 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_xlarge_jpeg(img, dest); - } - - if file_types::needs_ffmpeg_thumbnail(src) { - return generate_xlarge_preview_ffmpeg(src, dest, orientation); - } - - 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_xlarge_jpeg(img, dest) -} - -fn encode_xlarge_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<()> { - let (w, h) = img.dimensions(); - let max_dim = w.max(h); - let scaled = if max_dim > XLARGE_PREVIEW_MAX_DIM { - img.thumbnail(XLARGE_PREVIEW_MAX_DIM, XLARGE_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(()) -} - -fn generate_xlarge_preview_ffmpeg( - src: &Path, - dest: &Path, - orientation: i32, -) -> std::io::Result<()> { - let rotation = match orientation { - 2 => "hflip", - 3 => "transpose=2", - 4 => "vflip", - 5 => "transpose=0,hflip", - 6 => "transpose=0", - 7 => "transpose=1,hflip", - 8 => "transpose=1", - _ => "", - }; - let scale_expr = format!( - "scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'", - cap = XLARGE_PREVIEW_MAX_DIM - ); - let vf = if rotation.is_empty() { - format!("{},colorspace=bt709", scale_expr) - } else { - format!("{},{},colorspace=bt709", rotation, scale_expr) - }; - let output = Command::new("ffmpeg") - .arg("-y") - .arg("-i") - .arg(src) - .arg("-vframes") - .arg("1") - .arg("-vf") - .arg(&vf) - .arg("-q:v") - .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(()) -} - #[tracing::instrument(name = "creating thumbnails", skip_all)] pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) { let span = tracing::Span::current();