image: add on-demand size=large preview tier (~2048px JPEG q85) #100
@@ -50,6 +50,18 @@ pub fn thumbnail_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
|||||||
thumbs_dir.join(shard).join(format!("{}.jpg", hash))
|
thumbs_dir.join(shard).join(format!("{}.jpg", hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hash-keyed large-preview path: `<thumbs_dir>/_large/<hash[..2]>/<hash>.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
|
||||||
|
/// shards don't collide with the larger derivative.
|
||||||
|
pub fn large_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
||||||
|
let shard = shard_prefix(hash);
|
||||||
|
thumbs_dir
|
||||||
|
.join("_large")
|
||||||
|
.join(shard)
|
||||||
|
.join(format!("{}.jpg", hash))
|
||||||
|
}
|
||||||
|
|
||||||
/// Hash-keyed HLS output directory: `<video_dir>/<hash[..2]>/<hash>/`.
|
/// Hash-keyed HLS output directory: `<video_dir>/<hash[..2]>/<hash>/`.
|
||||||
/// The playlist lives at `playlist.m3u8` inside this directory and its
|
/// The playlist lives at `playlist.m3u8` inside this directory and its
|
||||||
/// segments are co-located so HLS relative references Just Work. See
|
/// segments are co-located so HLS relative references Just Work. See
|
||||||
@@ -120,6 +132,9 @@ mod tests {
|
|||||||
let p = thumbnail_path(thumbs, "abcdef0123");
|
let p = thumbnail_path(thumbs, "abcdef0123");
|
||||||
assert_eq!(p, PathBuf::from("/tmp/thumbs/ab/abcdef0123.jpg"));
|
assert_eq!(p, PathBuf::from("/tmp/thumbs/ab/abcdef0123.jpg"));
|
||||||
|
|
||||||
|
let l = large_preview_path(thumbs, "abcdef0123");
|
||||||
|
assert_eq!(l, PathBuf::from("/tmp/thumbs/_large/ab/abcdef0123.jpg"));
|
||||||
|
|
||||||
let video = Path::new("/tmp/video");
|
let video = Path::new("/tmp/video");
|
||||||
let d = hls_dir(video, "1234deadbeef");
|
let d = hls_dir(video, "1234deadbeef");
|
||||||
assert_eq!(d, PathBuf::from("/tmp/video/12/1234deadbeef"));
|
assert_eq!(d, PathBuf::from("/tmp/video/12/1234deadbeef"));
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ pub enum MediaType {
|
|||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum PhotoSize {
|
pub enum PhotoSize {
|
||||||
Full,
|
Full,
|
||||||
|
Large,
|
||||||
Thumb,
|
Thumb,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,120 @@ pub async fn get_image(
|
|||||||
|
|
||||||
if let Some((library, path)) = resolved {
|
if let Some((library, path)) = resolved {
|
||||||
let image_size = req.size.unwrap_or(PhotoSize::Full);
|
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 {
|
if image_size == PhotoSize::Thumb {
|
||||||
let relative_path = path
|
let relative_path = path
|
||||||
.strip_prefix(&library.root_path)
|
.strip_prefix(&library.root_path)
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
//! skip them silently.
|
//! skip them silently.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use image::GenericImageView;
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use opentelemetry::{
|
use opentelemetry::{
|
||||||
@@ -26,6 +29,19 @@ use crate::libraries;
|
|||||||
use crate::otel::global_tracer;
|
use crate::otel::global_tracer;
|
||||||
use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail};
|
use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail};
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// Bigger doesn't help: callers that need true full resolution request
|
||||||
|
/// `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.
|
||||||
|
const LARGE_PREVIEW_JPEG_QUALITY: u8 = 85;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
|
pub static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
|
||||||
"imageserver_image_total",
|
"imageserver_image_total",
|
||||||
@@ -89,6 +105,106 @@ pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate the on-demand large-preview tier (≈2048 long edge JPEG).
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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<()> {
|
||||||
|
let orientation = exif::read_orientation(src).unwrap_or(1);
|
||||||
|
|
||||||
|
// RAW: prefer the in-file embedded JPEG preview over raw-sensor decode.
|
||||||
|
// The preview is typically already 1–2 MP and avoids RAW codec quirks.
|
||||||
|
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_large_jpeg(img, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_types::needs_ffmpeg_thumbnail(src) {
|
||||||
|
return generate_large_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_large_jpeg(img, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<()> {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let max_dim = w.max(h);
|
||||||
|
// Avoid upscaling tiny 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)
|
||||||
|
} 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) -> std::io::Result<()> {
|
||||||
|
// scale=W:-1 with force_original_aspect_ratio=decrease + the min(iw,W)
|
||||||
|
// trick caps the long edge regardless of orientation, mirroring what
|
||||||
|
// image::thumbnail does for the non-ffmpeg branch.
|
||||||
|
let vf = format!(
|
||||||
|
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
||||||
|
cap = LARGE_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")
|
||||||
|
// ffmpeg's mjpeg qscale: 2 ≈ ~q95, 5 ≈ ~q85, 10 ≈ ~q70. We pick
|
||||||
|
// 5 to match the non-ffmpeg branch's q85 target.
|
||||||
|
.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]) {
|
pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) {
|
||||||
let tracer = global_tracer();
|
let tracer = global_tracer();
|
||||||
let span = tracer.start("creating thumbnails");
|
let span = tracer.start("creating thumbnails");
|
||||||
|
|||||||
Reference in New Issue
Block a user