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:
1 parent
c3c6cd03db
commit
19798184f0
4 files changed
+246
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user