90dec3b212
Add dominant-color palette extraction, storage, filtering, and startup backfill so the gallery and Timeline can be filtered from the toolbar color picker. Introduce a reusable tooltip component and migrate the color filter, update indicator, and gallery filename hover affordances to it.
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
use crate::media::MediaTools;
|
|
use anyhow::{anyhow, Result};
|
|
use fast_image_resize as fir;
|
|
use fast_image_resize::IntoImageView;
|
|
use image::codecs::jpeg::JpegEncoder;
|
|
use image::ImageDecoder;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub const THUMB_SIZE: u32 = 320;
|
|
|
|
pub struct GeneratedThumbnail {
|
|
pub path: PathBuf,
|
|
pub width: Option<i64>,
|
|
pub height: Option<i64>,
|
|
/// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty
|
|
/// when the thumbnail already existed (the color backfill handles those).
|
|
pub palette: Vec<(u8, u8, u8, f32)>,
|
|
}
|
|
|
|
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
|
|
let path_str = image_path.to_string_lossy();
|
|
let out_path = thumb_path(cache_dir, &path_str);
|
|
|
|
let original_dimensions = image::image_dimensions(image_path)
|
|
.ok()
|
|
.map(|(width, height)| (Some(width as i64), Some(height as i64)))
|
|
.unwrap_or((None, None));
|
|
|
|
if out_path.exists() {
|
|
return Ok(GeneratedThumbnail {
|
|
path: out_path,
|
|
width: original_dimensions.0,
|
|
height: original_dimensions.1,
|
|
palette: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let img = decode_for_thumbnail(image_path)?;
|
|
|
|
// RGB8 throughout: JPEG output has no alpha, so decoding/resizing RGBA
|
|
// only to flatten at encode time wastes a third of the bandwidth.
|
|
let src = image::DynamicImage::ImageRgb8(img.into_rgb8());
|
|
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
|
|
|
|
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
|
|
let mut resizer = fir::Resizer::new();
|
|
let options = fir::ResizeOptions::new()
|
|
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom));
|
|
resizer.resize(&src, &mut dst, Some(&options))?;
|
|
|
|
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
|
|
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
|
|
|
|
// Sample the dominant-color palette from the resized buffer before encoding.
|
|
let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE)
|
|
.into_iter()
|
|
.map(|color| (color.r, color.g, color.b, color.weight))
|
|
.collect();
|
|
|
|
if let Some(parent) = out_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let mut output_file = std::fs::File::create(&out_path)?;
|
|
let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82);
|
|
encoder.encode_image(&thumb)?;
|
|
Ok(GeneratedThumbnail {
|
|
path: out_path,
|
|
width: original_dimensions.0,
|
|
height: original_dimensions.1,
|
|
palette,
|
|
})
|
|
}
|
|
|
|
/// Decodes an image for thumbnailing, with EXIF orientation already applied.
|
|
fn decode_for_thumbnail(image_path: &Path) -> Result<image::DynamicImage> {
|
|
decode_image_scaled(image_path, THUMB_SIZE, false)
|
|
}
|
|
|
|
/// Decodes an image at reduced resolution, with EXIF orientation applied.
|
|
///
|
|
/// JPEGs take a fast path that decodes at reduced resolution (DCT scaling),
|
|
/// which skips most of the IDCT/upsampling work for large photos. Anything
|
|
/// that path can't handle falls back to the `image` crate at full size.
|
|
///
|
|
/// `cover` selects which side must stay at or above `target`: `false` keeps
|
|
/// the longest side (for aspect-fit consumers), `true` keeps the shortest
|
|
/// side (for fill-crop consumers like the CLIP preprocessor).
|
|
pub fn decode_image_scaled(
|
|
image_path: &Path,
|
|
target: u32,
|
|
cover: bool,
|
|
) -> Result<image::DynamicImage> {
|
|
if is_jpeg(image_path) {
|
|
if let Some(img) = decode_jpeg_scaled(image_path, target, cover) {
|
|
return Ok(img);
|
|
}
|
|
}
|
|
|
|
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
|
|
let mut decoder = reader.into_decoder()?;
|
|
let orientation = decoder.orientation()?;
|
|
let mut img = image::DynamicImage::from_decoder(decoder)?;
|
|
img.apply_orientation(orientation);
|
|
Ok(img)
|
|
}
|
|
|
|
fn is_jpeg(path: &Path) -> bool {
|
|
path.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg"))
|
|
}
|
|
|
|
/// Decodes a JPEG at the smallest DCT scale that still covers `target`.
|
|
/// Returns `None` on any failure (corrupt file, CMYK without conversion
|
|
/// support, etc.) so the caller can fall back to the generic decoder.
|
|
/// mozjpeg reports errors by panicking, hence the `catch_unwind`.
|
|
fn decode_jpeg_scaled(image_path: &Path, target: u32, cover: bool) -> Option<image::DynamicImage> {
|
|
let path = image_path.to_path_buf();
|
|
let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec<u8>, u32, u32)> {
|
|
let mut decompress = mozjpeg::Decompress::new_path(&path)?;
|
|
let (width, height) = decompress.size();
|
|
let reference = if cover {
|
|
width.min(height)
|
|
} else {
|
|
width.max(height)
|
|
};
|
|
decompress.scale(scale_numerator(reference, target));
|
|
let mut started = decompress.rgb()?;
|
|
let (out_width, out_height) = (started.width() as u32, started.height() as u32);
|
|
let pixels = started.read_scanlines::<u8>()?;
|
|
started.finish()?;
|
|
Ok((pixels, out_width, out_height))
|
|
})
|
|
.ok()?
|
|
.ok()?;
|
|
|
|
let (pixels, width, height) = decoded;
|
|
let buffer = image::RgbImage::from_raw(width, height, pixels)?;
|
|
let mut img = image::DynamicImage::ImageRgb8(buffer);
|
|
if let Some(orientation) = exif_orientation(image_path) {
|
|
img.apply_orientation(orientation);
|
|
}
|
|
Some(img)
|
|
}
|
|
|
|
/// Smallest numerator (of /8) that keeps `reference` at or above `target`,
|
|
/// so the subsequent resize never upscales.
|
|
fn scale_numerator(reference: usize, target: u32) -> u8 {
|
|
for numerator in 1..=8u8 {
|
|
if reference * numerator as usize / 8 >= target as usize {
|
|
return numerator;
|
|
}
|
|
}
|
|
8
|
|
}
|
|
|
|
fn exif_orientation(path: &Path) -> Option<image::metadata::Orientation> {
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let mut reader = std::io::BufReader::new(file);
|
|
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
|
|
let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
|
|
let value = field.value.get_uint(0)?;
|
|
image::metadata::Orientation::from_exif(value as u8)
|
|
}
|
|
|
|
pub fn generate_video_thumbnail(
|
|
tools: &MediaTools,
|
|
video_path: &Path,
|
|
cache_dir: &Path,
|
|
) -> Result<GeneratedThumbnail> {
|
|
let path_str = video_path.to_string_lossy();
|
|
let out_path = video_poster_path(cache_dir, &path_str);
|
|
|
|
if out_path.exists() {
|
|
return Ok(GeneratedThumbnail {
|
|
path: out_path,
|
|
width: None,
|
|
height: None,
|
|
palette: Vec::new(),
|
|
});
|
|
}
|
|
|
|
if let Some(parent) = out_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let output_path = out_path.to_string_lossy().into_owned();
|
|
let attempts: [&[&str]; 3] = [
|
|
&[
|
|
"-y",
|
|
"-threads",
|
|
"2",
|
|
"-ss",
|
|
"00:00:00.000",
|
|
"-i",
|
|
path_str.as_ref(),
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
|
"-q:v",
|
|
"4",
|
|
&output_path,
|
|
],
|
|
&[
|
|
"-y",
|
|
"-threads",
|
|
"2",
|
|
"-ss",
|
|
"00:00:00.250",
|
|
"-i",
|
|
path_str.as_ref(),
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
|
"-q:v",
|
|
"4",
|
|
&output_path,
|
|
],
|
|
&[
|
|
"-y",
|
|
"-threads",
|
|
"2",
|
|
"-i",
|
|
path_str.as_ref(),
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
|
"-q:v",
|
|
"4",
|
|
&output_path,
|
|
],
|
|
];
|
|
|
|
let mut last_error = String::new();
|
|
for args in attempts {
|
|
let output = tools.ffmpeg_command().args(args).output()?;
|
|
if output.status.success() && out_path.exists() {
|
|
return Ok(GeneratedThumbnail {
|
|
path: out_path,
|
|
width: None,
|
|
height: None,
|
|
palette: Vec::new(),
|
|
});
|
|
}
|
|
last_error = String::from_utf8_lossy(&output.stderr).to_string();
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"ffmpeg failed generating poster for {path_str}: {last_error}"
|
|
))
|
|
}
|
|
|
|
pub fn generate_avif_thumbnail(
|
|
tools: &MediaTools,
|
|
image_path: &Path,
|
|
cache_dir: &Path,
|
|
) -> Result<GeneratedThumbnail> {
|
|
let path_str = image_path.to_string_lossy();
|
|
let out_path = thumb_path(cache_dir, &path_str);
|
|
let original_dimensions = ffprobe_dimensions(tools, image_path);
|
|
|
|
if out_path.exists() {
|
|
return Ok(GeneratedThumbnail {
|
|
path: out_path,
|
|
width: original_dimensions.0,
|
|
height: original_dimensions.1,
|
|
palette: Vec::new(),
|
|
});
|
|
}
|
|
|
|
if let Some(parent) = out_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let output_path = out_path.to_string_lossy().into_owned();
|
|
let output = tools
|
|
.ffmpeg_command()
|
|
.args([
|
|
"-y",
|
|
"-threads",
|
|
"2",
|
|
"-i",
|
|
path_str.as_ref(),
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
|
"-q:v",
|
|
"4",
|
|
&output_path,
|
|
])
|
|
.output()?;
|
|
|
|
if output.status.success() && out_path.exists() {
|
|
// AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here;
|
|
// sample the palette by reading the JPEG thumbnail we just wrote.
|
|
let palette =
|
|
crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|color| (color.r, color.g, color.b, color.weight))
|
|
.collect();
|
|
return Ok(GeneratedThumbnail {
|
|
path: out_path,
|
|
width: original_dimensions.0,
|
|
height: original_dimensions.1,
|
|
palette,
|
|
});
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"ffmpeg failed generating AVIF thumbnail for {path_str}: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
))
|
|
}
|
|
|
|
fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option<i64>, Option<i64>) {
|
|
let output = tools
|
|
.ffprobe_command()
|
|
.args([
|
|
"-v",
|
|
"error",
|
|
"-select_streams",
|
|
"v:0",
|
|
"-show_entries",
|
|
"stream=width,height",
|
|
"-of",
|
|
"csv=p=0:s=x",
|
|
&image_path.to_string_lossy(),
|
|
])
|
|
.output();
|
|
let Ok(output) = output else {
|
|
return (None, None);
|
|
};
|
|
|
|
if !output.status.success() {
|
|
return (None, None);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let mut parts = stdout.trim().split('x');
|
|
let width = parts.next().and_then(|part| part.parse::<i64>().ok());
|
|
let height = parts.next().and_then(|part| part.parse::<i64>().ok());
|
|
(width, height)
|
|
}
|
|
|
|
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
|
thumb_path_with_ext(cache_dir, image_path, "jpg")
|
|
}
|
|
|
|
pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf {
|
|
thumb_path_with_ext(cache_dir, video_path, "jpg")
|
|
}
|
|
|
|
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
|
|
let hash = hash_path(input_path);
|
|
cache_dir.join(format!("{hash}.{extension}"))
|
|
}
|
|
|
|
fn hash_path(s: &str) -> String {
|
|
format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes()))
|
|
}
|
|
|
|
fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
|
|
if width <= max_size && height <= max_size {
|
|
return (width.max(1), height.max(1));
|
|
}
|
|
|
|
if width >= height {
|
|
let scaled_height = ((height as f64 / width as f64) * max_size as f64).round() as u32;
|
|
(max_size, scaled_height.max(1))
|
|
} else {
|
|
let scaled_width = ((width as f64 / height as f64) * max_size as f64).round() as u32;
|
|
(scaled_width.max(1), max_size)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn scale_numerator_picks_smallest_sufficient() {
|
|
assert_eq!(scale_numerator(6000, THUMB_SIZE), 1);
|
|
assert_eq!(scale_numerator(640, THUMB_SIZE), 4);
|
|
assert_eq!(scale_numerator(320, THUMB_SIZE), 8);
|
|
assert_eq!(scale_numerator(100, THUMB_SIZE), 8);
|
|
// Cover mode reference: shortest side must reach 224 for CLIP.
|
|
assert_eq!(scale_numerator(1200, 224), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn jpeg_fast_path_decodes_at_reduced_resolution() {
|
|
let dir = std::env::temp_dir().join("phokus-thumb-test");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let src_path = dir.join("large.jpg");
|
|
let img =
|
|
image::RgbImage::from_fn(1600, 1200, |x, _| image::Rgb([(x % 256) as u8, 64, 128]));
|
|
img.save(&src_path).unwrap();
|
|
|
|
// 1600 max dim -> numerator 2 -> 400x300 decode output
|
|
let decoded = decode_jpeg_scaled(&src_path, THUMB_SIZE, false)
|
|
.expect("fast path should handle plain JPEG");
|
|
assert_eq!((decoded.width(), decoded.height()), (400, 300));
|
|
|
|
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
|
|
let covered =
|
|
decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG");
|
|
assert_eq!((covered.width(), covered.height()), (400, 300));
|
|
|
|
let cache = dir.join("cache");
|
|
let _ = std::fs::remove_file(thumb_path(&cache, &src_path.to_string_lossy()));
|
|
let result = generate_image_thumbnail(&src_path, &cache).unwrap();
|
|
assert_eq!(result.width, Some(1600));
|
|
assert_eq!(result.height, Some(1200));
|
|
let (thumb_w, thumb_h) = image::image_dimensions(&result.path).unwrap();
|
|
assert_eq!((thumb_w, thumb_h), (320, 240));
|
|
}
|
|
}
|