image: add xlarge (4096px) on-demand preview tier
New `PhotoSize::XLarge` variant sits between `Large` (2048px) and `Full` (original). On-demand generated and disk-cached at `_xlarge/<hash>.jpg`, same waterfall as `Large` (embedded RAW preview → ffmpeg → image crate). Sources below 4096px serve at native size. Reduces decoded bitmap memory from ~192MB (48MP full) to ~64MB for the mobile viewer's zoom tier. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,15 @@ pub fn large_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
||||
.join(format!("{}.jpg", hash))
|
||||
}
|
||||
|
||||
/// Hash-keyed xlarge-preview path: `<thumbs_dir>/_xlarge/<hash[..2]>/<hash>.jpg`.
|
||||
pub fn xlarge_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
||||
let shard = shard_prefix(hash);
|
||||
thumbs_dir
|
||||
.join("_xlarge")
|
||||
.join(shard)
|
||||
.join(format!("{}.jpg", hash))
|
||||
}
|
||||
|
||||
/// Hash-keyed HLS output directory: `<video_dir>/<hash[..2]>/<hash>/`.
|
||||
/// The playlist lives at `playlist.m3u8` inside this directory and its
|
||||
/// segments are co-located so HLS relative references Just Work. See
|
||||
|
||||
@@ -194,6 +194,7 @@ pub enum MediaType {
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PhotoSize {
|
||||
Full,
|
||||
XLarge,
|
||||
Large,
|
||||
Thumb,
|
||||
}
|
||||
|
||||
@@ -83,12 +83,14 @@ 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) {
|
||||
// `size=large|xlarge` is 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
|
||||
@@ -196,6 +198,93 @@ pub async fn get_image(
|
||||
image_size = PhotoSize::Full;
|
||||
}
|
||||
|
||||
if image_size == PhotoSize::XLarge {
|
||||
let relative_path = path
|
||||
.strip_prefix(&library.root_path)
|
||||
.expect("Error stripping library root prefix from xlarge preview");
|
||||
let relative_path_str = relative_path.to_string_lossy().replace('\\', "/");
|
||||
let thumbs = Path::new(&app_state.thumbnail_path);
|
||||
let xlarge_dir = thumbs.join("_xlarge");
|
||||
|
||||
let hash_xlarge_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::xlarge_preview_path(thumbs, h)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let scoped_legacy_xlarge_path =
|
||||
content_hash::library_scoped_legacy_path(&xlarge_dir, library.id, relative_path);
|
||||
|
||||
let existing = hash_xlarge_path
|
||||
.as_ref()
|
||||
.filter(|p| p.exists())
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
if scoped_legacy_xlarge_path.exists() {
|
||||
Some(scoped_legacy_xlarge_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);
|
||||
}
|
||||
}
|
||||
|
||||
let dest = hash_xlarge_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| scoped_legacy_xlarge_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)?;
|
||||
}
|
||||
let tmp = dest_for_block.with_extension("jpg.tmp");
|
||||
crate::thumbnails::generate_xlarge_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!(
|
||||
"XLarge preview generation failed for {:?}: {} — falling back to original",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"XLarge preview blocking-pool error for {:?}: {} — falling back to original",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
image_size = PhotoSize::Full;
|
||||
}
|
||||
|
||||
if image_size == PhotoSize::Thumb {
|
||||
let relative_path = path
|
||||
.strip_prefix(&library.root_path)
|
||||
|
||||
@@ -36,12 +36,19 @@ 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 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.
|
||||
/// JPEG quality for the large and xlarge 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;
|
||||
|
||||
/// Maximum long-edge size (px) for the xlarge preview tier. Bridges the
|
||||
/// gap between `large` (2048px, ~16MB decoded) and the original bytes
|
||||
/// (potentially 48+ MP / ~192MB decoded). At 4096px the decoded bitmap is
|
||||
/// ~64MB — enough for 2-3× pinch-zoom on any phone before the viewer
|
||||
/// needs to stream the true original.
|
||||
pub const XLARGE_PREVIEW_MAX_DIM: u32 = 4096;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
|
||||
"imageserver_image_total",
|
||||
@@ -205,6 +212,86 @@ fn generate_large_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()>
|
||||
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);
|
||||
}
|
||||
|
||||
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) -> std::io::Result<()> {
|
||||
let vf = format!(
|
||||
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
||||
cap = XLARGE_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")
|
||||
.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");
|
||||
|
||||
Reference in New Issue
Block a user