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>
479 lines
19 KiB
Rust
479 lines
19 KiB
Rust
//! Thumbnail generation + the media-count Prometheus gauges.
|
||
//!
|
||
//! Startup and per-tick scans walk each library and produce a 200×200
|
||
//! thumbnail under `THUMBNAILS/<library_id>/<rel_path>`, falling through
|
||
//! a fast path (`image` crate), a RAW-preview path (`exif::extract_embedded_jpeg_preview`),
|
||
//! and ffmpeg for video / HEIF / NEF / ARW. Files that fail every
|
||
//! decoder get a sibling `.unsupported` sentinel so subsequent scans
|
||
//! skip them silently.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Command;
|
||
|
||
use image::GenericImageView;
|
||
use image::codecs::jpeg::JpegEncoder;
|
||
use lazy_static::lazy_static;
|
||
use log::{debug, error, info, warn};
|
||
use opentelemetry::{
|
||
KeyValue,
|
||
trace::{Span, TraceContextExt, Tracer},
|
||
};
|
||
use prometheus::IntGauge;
|
||
use rayon::prelude::*;
|
||
use walkdir::DirEntry;
|
||
|
||
use crate::content_hash;
|
||
use crate::exif;
|
||
use crate::file_types;
|
||
use crate::libraries;
|
||
use crate::otel::global_tracer;
|
||
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 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",
|
||
"Count of the images on the server"
|
||
)
|
||
.unwrap();
|
||
pub static ref VIDEO_GAUGE: IntGauge = IntGauge::new(
|
||
"imageserver_video_total",
|
||
"Count of the videos on the server"
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
/// Sentinel path written next to a would-be thumbnail when a file cannot be
|
||
/// decoded by either the `image` crate or ffmpeg. Its presence causes future
|
||
/// scans to skip the file instead of re-logging the failure.
|
||
pub fn unsupported_thumbnail_sentinel(thumb_path: &Path) -> PathBuf {
|
||
let mut s = thumb_path.as_os_str().to_owned();
|
||
s.push(".unsupported");
|
||
PathBuf::from(s)
|
||
}
|
||
|
||
pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()> {
|
||
// The `image` crate doesn't auto-apply EXIF Orientation on load, and
|
||
// saving back out as JPEG drops EXIF entirely — so without baking the
|
||
// rotation into the pixels here, browsers see the raw landscape buffer
|
||
// of a portrait phone shot and render it sideways. Read once up front
|
||
// and apply to whichever decode branch we end up taking.
|
||
let orientation = exif::read_orientation(src).unwrap_or(1);
|
||
|
||
// RAW formats (ARW/NEF/CR2/etc): try the file's embedded JPEG preview
|
||
// first. Avoids ffmpeg choking on proprietary RAW compression (Sony ARW
|
||
// in particular), and is faster than decoding RAW pixels anyway.
|
||
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);
|
||
let scaled = img.thumbnail(200, u32::MAX);
|
||
scaled
|
||
.save_with_format(thumb_path, image::ImageFormat::Jpeg)
|
||
.map_err(|e| std::io::Error::other(format!("save {:?}: {}", thumb_path, e)))?;
|
||
return Ok(());
|
||
}
|
||
|
||
if file_types::needs_ffmpeg_thumbnail(src) {
|
||
return generate_image_thumbnail_ffmpeg(src, thumb_path);
|
||
}
|
||
|
||
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);
|
||
let scaled = img.thumbnail(200, u32::MAX);
|
||
scaled
|
||
.save(thumb_path)
|
||
.map_err(|e| std::io::Error::other(format!("save {:?}: {}", thumb_path, e)))?;
|
||
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(())
|
||
}
|
||
|
||
/// 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");
|
||
|
||
let thumbs = &dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
|
||
let thumbnail_directory: &Path = Path::new(thumbs);
|
||
|
||
for lib in libs {
|
||
info!(
|
||
"Scanning thumbnails for library '{}' at {}",
|
||
lib.name, lib.root_path
|
||
);
|
||
let images = PathBuf::from(&lib.root_path);
|
||
// Effective excludes = global env-var excludes ∪ library row's
|
||
// excluded_dirs. Lets a parent-library mount skip the subtree
|
||
// already covered by a child library.
|
||
let effective_excludes = lib.effective_excluded_dirs(excluded_dirs);
|
||
|
||
// Prune EXCLUDED_DIRS so we don't generate thumbnails-of-thumbnails
|
||
// for Synology @eaDir trees. file_scan handles filter_entry pruning.
|
||
crate::file_scan::walk_library_files(&images, &effective_excludes)
|
||
.into_par_iter()
|
||
.for_each(|entry| {
|
||
let src = entry.path();
|
||
let Ok(relative_path) = src.strip_prefix(&images) else {
|
||
return;
|
||
};
|
||
// Library-scoped legacy path: prevents two libraries with
|
||
// the same rel_path from clobbering each other's thumbs.
|
||
// Hash-keyed promotion happens lazily on first hash-aware
|
||
// request — keeping this loop ExifDao-free preserves the
|
||
// current "cargo build && go" startup story.
|
||
let thumb_path = content_hash::library_scoped_legacy_path(
|
||
thumbnail_directory,
|
||
lib.id,
|
||
relative_path,
|
||
);
|
||
let bare_legacy = thumbnail_directory.join(relative_path);
|
||
|
||
// Backwards-compat check: if a single-library install has a
|
||
// bare-legacy thumb here already, accept it as present.
|
||
// Same for the sentinel. Means we don't redo work after
|
||
// upgrade and we don't leave stale duplicates around.
|
||
if thumb_path.exists()
|
||
|| bare_legacy.exists()
|
||
|| unsupported_thumbnail_sentinel(&thumb_path).exists()
|
||
|| unsupported_thumbnail_sentinel(&bare_legacy).exists()
|
||
{
|
||
return;
|
||
}
|
||
|
||
let Some(parent) = thumb_path.parent() else {
|
||
return;
|
||
};
|
||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||
error!("Failed to create thumbnail dir {:?}: {}", parent, e);
|
||
return;
|
||
}
|
||
|
||
if is_video(&entry) {
|
||
let mut video_span = tracer.start_with_context(
|
||
"generate_video_thumbnail",
|
||
&opentelemetry::Context::new()
|
||
.with_remote_span_context(span.span_context().clone()),
|
||
);
|
||
video_span.set_attributes(vec![
|
||
KeyValue::new("type", "video"),
|
||
KeyValue::new("file-name", thumb_path.display().to_string()),
|
||
KeyValue::new("library", lib.name.clone()),
|
||
]);
|
||
|
||
debug!("Generating video thumbnail: {:?}", thumb_path);
|
||
if let Err(e) = generate_video_thumbnail(src, &thumb_path) {
|
||
let sentinel = unsupported_thumbnail_sentinel(&thumb_path);
|
||
error!(
|
||
"Unable to thumbnail video {:?}: {}. Writing sentinel {:?}",
|
||
src, e, sentinel
|
||
);
|
||
if let Err(se) = std::fs::write(&sentinel, b"") {
|
||
warn!("Failed to write sentinel {:?}: {}", sentinel, se);
|
||
}
|
||
}
|
||
video_span.end();
|
||
} else if is_image(&entry) {
|
||
match generate_image_thumbnail(src, &thumb_path) {
|
||
Ok(_) => info!("Saved thumbnail: {:?}", thumb_path),
|
||
Err(e) => {
|
||
let sentinel = unsupported_thumbnail_sentinel(&thumb_path);
|
||
error!(
|
||
"Unable to thumbnail {:?}: {}. Writing sentinel {:?}",
|
||
src, e, sentinel
|
||
);
|
||
if let Err(se) = std::fs::write(&sentinel, b"") {
|
||
warn!("Failed to write sentinel {:?}: {}", sentinel, se);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
debug!("Finished making thumbnails");
|
||
|
||
for lib in libs {
|
||
let effective_excludes = lib.effective_excluded_dirs(excluded_dirs);
|
||
update_media_counts(Path::new(&lib.root_path), &effective_excludes);
|
||
}
|
||
}
|
||
|
||
pub fn update_media_counts(media_dir: &Path, excluded_dirs: &[String]) {
|
||
let mut image_count = 0;
|
||
let mut video_count = 0;
|
||
for entry in crate::file_scan::walk_library_files(media_dir, excluded_dirs) {
|
||
if is_image(&entry) {
|
||
image_count += 1;
|
||
} else if is_video(&entry) {
|
||
video_count += 1;
|
||
}
|
||
}
|
||
|
||
IMAGE_GAUGE.set(image_count);
|
||
VIDEO_GAUGE.set(video_count);
|
||
}
|
||
|
||
pub fn is_image(entry: &DirEntry) -> bool {
|
||
file_types::direntry_is_image(entry)
|
||
}
|
||
|
||
pub fn is_video(entry: &DirEntry) -> bool {
|
||
file_types::direntry_is_video(entry)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::fs;
|
||
use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn unsupported_thumbnail_sentinel_appends_suffix() {
|
||
let p = Path::new("/thumbs/lib1/photo.jpg");
|
||
let s = unsupported_thumbnail_sentinel(p);
|
||
assert_eq!(s, PathBuf::from("/thumbs/lib1/photo.jpg.unsupported"));
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_thumbnail_sentinel_preserves_extension_so_existing_thumb_is_distinct() {
|
||
// A future scan checks both `thumb.exists()` and
|
||
// `sentinel.exists()` — they must be distinct paths.
|
||
let p = Path::new("foo.jpeg");
|
||
let s = unsupported_thumbnail_sentinel(p);
|
||
assert_ne!(s, PathBuf::from("foo.jpeg"));
|
||
assert!(s.to_string_lossy().ends_with(".unsupported"));
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_thumbnail_sentinel_handles_paths_without_extension() {
|
||
let p = Path::new("/thumbs/notes");
|
||
let s = unsupported_thumbnail_sentinel(p);
|
||
assert_eq!(s, PathBuf::from("/thumbs/notes.unsupported"));
|
||
}
|
||
|
||
/// Smoke-test update_media_counts: build a tempdir with two images
|
||
/// and one video, run the walker, and assert the gauges line up.
|
||
/// Exercises the is_image / is_video classifier on real DirEntry
|
||
/// values without needing a Prometheus registry.
|
||
#[test]
|
||
fn update_media_counts_counts_images_and_videos_in_tempdir() {
|
||
let tmp = TempDir::new().expect("tempdir");
|
||
fs::write(tmp.path().join("a.jpg"), b"").unwrap();
|
||
fs::write(tmp.path().join("b.png"), b"").unwrap();
|
||
fs::write(tmp.path().join("c.mp4"), b"").unwrap();
|
||
fs::write(tmp.path().join("notes.txt"), b"").unwrap();
|
||
// Reset gauges first in case another test mutated them — the
|
||
// gauges are process-global statics.
|
||
IMAGE_GAUGE.set(0);
|
||
VIDEO_GAUGE.set(0);
|
||
|
||
update_media_counts(tmp.path(), &[]);
|
||
|
||
assert_eq!(IMAGE_GAUGE.get(), 2, "jpg + png");
|
||
assert_eq!(VIDEO_GAUGE.get(), 1, "mp4");
|
||
}
|
||
}
|