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:
2026-05-25 15:33:03 -04:00
parent 9dba659d1e
commit b9175e2718
4 changed files with 196 additions and 10 deletions

View File

@@ -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");