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 CordesandClaude Opus 4.7 authored and Cameron committed 2026-05-18 13:14:49 -04:00
1 parent c3c6cd03db
commit 19798184f0
4 files changed
+246

No files matched your search

+114
View File
@@ -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<PathBuf> = {
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)