From 293350092cd4074a485b1d305e06ef7912794b3e Mon Sep 17 00:00:00 2001 From: Mitchel Stewart <74831516+Quackdoc@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:45:53 -0400 Subject: [PATCH] thumbnail: Support jxl and plumbing for future formats. (#1058) * add plumbing for additional thumbnailers * remove bad logging and fmt * fix bad logging message * add decoding ram limits * add configuration for thumbs * cleanups * fix rebase fails --- Cargo.lock | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/app.rs | 5 +- src/config.rs | 19 +++++ src/dialog.rs | 10 ++- src/tab.rs | 137 ++++++++++++++++++++++++--------- 6 files changed, 338 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6438ff6..fa1136f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,21 @@ dependencies = [ "equator", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "almost" version = "0.2.0" @@ -796,6 +811,16 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.0" @@ -1504,6 +1529,7 @@ dependencies = [ "ignore", "image", "io-uring", + "jxl-oxide", "libc", "libcosmic", "log", @@ -4173,6 +4199,186 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda699770a7f4ea38f8eb21d91b545eb6448be28e540acc7ce84498bcead4903" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6748ba8af69b87c68f8dcdf992de959c207962689bc28ddb7906abf4a0b786c9" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30587a9687223a602a408555db47803c907ea47700e1f28eb14cdb3bf1527a9" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335e4371396c5729ba80a42798746d198897d3b854ba4f3684efac5f4025d84f" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ba39b083a82788a17717edbcc4b08160b51fdffc9fec640deba9e8268da1a" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f526ad8af8daea0d1cccce945f18c241f95b391d34443be018de2efbf28b44e" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e45ccb25d698cdcad3a5573a7181835842711fd951c98fe38986e3cb721e775" +dependencies = [ + "brotli-decompressor", + "bytemuck", + "image", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f3fece78b2104450bd6d1bdbc48e3b6ef7442ef276be2a08e35b229eeff1a4" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "rayon", + "rayon-core", + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d48ad406543de5d6cd50aaaa8b87534f82991d684d848b3190228e8fa690fff" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "kamadak-exif" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index 0b62331..3a3b26d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ zip = "2.2.2" uzers = "0.12.1" md-5 = "0.10.6" png = "0.17.16" +jxl-oxide = { version = "0.12.2", features = ["image"] } # Completion-based IO runtime to enable io_uring / IOCP file IO support. [dependencies.compio] diff --git a/src/app.rs b/src/app.rs index 8cff8c3..f9b996c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1002,6 +1002,7 @@ impl App { let mut tab = Tab::new( location.clone(), self.config.tab, + self.config.thumb_cfg, Some(&self.state.sort_names), window_id, ); @@ -6208,7 +6209,7 @@ pub(crate) mod test_utils { use tempfile::{tempdir, TempDir}; use crate::{ - config::{IconSizes, TabConfig}, + config::{IconSizes, TabConfig, ThumbCfg}, tab::Item, }; @@ -6372,7 +6373,7 @@ pub(crate) mod test_utils { // New tab with items let location = Location::Path(path.to_owned()); let (parent_item_opt, items) = location.scan(IconSizes::default()); - let mut tab = Tab::new(location, TabConfig::default(), None); + let mut tab = Tab::new(location, TabConfig::default(), ThumbCfg::default(), None); tab.parent_item_opt = parent_item_opt; tab.set_items(items); diff --git a/src/config.rs b/src/config.rs index 5895adf..969423f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -166,6 +166,7 @@ pub struct Config { pub app_theme: AppTheme, pub dialog: DialogConfig, pub desktop: DesktopConfig, + pub thumb_cfg: ThumbCfg, pub favorites: Vec, pub show_details: bool, pub tab: TabConfig, @@ -220,6 +221,7 @@ impl Default for Config { app_theme: AppTheme::System, desktop: DesktopConfig::default(), dialog: DialogConfig::default(), + thumb_cfg: ThumbCfg::default(), favorites: vec![ Favorite::Home, Favorite::Documents, @@ -289,6 +291,23 @@ impl Default for DialogConfig { } } } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] +#[serde(default)] +pub struct ThumbCfg { + pub jobs: NonZeroU16, + pub max_mem_mb: NonZeroU16, + pub max_size_mb: NonZeroU16, +} + +impl Default for ThumbCfg { + fn default() -> Self { + Self { + jobs: 4.try_into().unwrap(), + max_mem_mb: 2000.try_into().unwrap(), + max_size_mb: 64.try_into().unwrap(), + } + } +} /// Global and local [`crate::tab::Tab`] config. /// diff --git a/src/dialog.rs b/src/dialog.rs index 1cf0789..5d5498a 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -36,7 +36,7 @@ use std::{ use crate::{ app::{Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind}, - config::{Config, DialogConfig, Favorite, TimeConfig, TIME_CONFIG_ID}, + config::{Config, DialogConfig, Favorite, TabConfig, ThumbCfg, TimeConfig, TIME_CONFIG_ID}, fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, @@ -945,7 +945,13 @@ impl Application for App { }, }); - let mut tab = Tab::new(location, flags.config.dialog_tab(), None, None); + let mut tab = Tab::new( + location, + flags.config.dialog_tab(), + ThumbCfg::default(), + None, + None, + ); tab.mode = tab::Mode::Dialog(flags.kind.clone()); tab.sort_name = tab::HeadingOptions::Modified; tab.sort_direction = false; diff --git a/src/tab.rs b/src/tab.rs index 0bf3df3..3bb4eb7 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -44,6 +44,8 @@ use icu::datetime::{ options::{components, preferences}, DateTimeFormatter, DateTimeFormatterOptions, }; +use image::ImageDecoder; +use jxl_oxide::integration::JxlDecoder; use mime_guess::{mime, Mime}; use once_cell::sync::Lazy; use ordermap::OrderMap; @@ -71,7 +73,7 @@ use walkdir::WalkDir; use crate::{ app::{Action, PreviewItem, PreviewKind}, clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, - config::{DesktopConfig, IconSizes, TabConfig, ICON_SCALE_MAX, ICON_SIZE_GRID}, + config::{DesktopConfig, IconSizes, TabConfig, ThumbCfg, ICON_SCALE_MAX, ICON_SIZE_GRID}, dialog::DialogKind, fl, localize::{LANGUAGE_SORTER, LOCALE}, @@ -1721,6 +1723,9 @@ impl ItemThumbnail { metadata: ItemMetadata, mime: mime::Mime, mut thumbnail_size: u32, + max_mem: u64, + jobs: usize, + max_size_mb: u64, ) -> Self { let thumbnail_cacher = ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size)); @@ -1763,49 +1768,92 @@ impl ItemThumbnail { }; let mut tried_supported_file = false; - - if !check_size("image", 64 * 1000 * 1000) { + if !check_size("image", max_size_mb * 1000 * 1000) { return ItemThumbnail::NotImage; } // First try built-in image thumbnailer if mime.type_() == mime::IMAGE { + log::warn!("mime is {}", mime.subtype().as_str()); tried_supported_file = true; - match image::ImageReader::open(path).and_then(|img| img.with_guessed_format()) { - Ok(reader) => match reader.decode() { - Ok(image) => { - if let Ok(cacher) = thumbnail_cacher.as_ref() { - match cacher.update_with_image(image) { - Ok(path) => { - return ItemThumbnail::Image( - widget::image::Handle::from_path(path), - None, - ); - } + let dyn_img: Option = match mime.subtype().as_str() { + "jxl" => match File::open(path) { + Ok(file) => match JxlDecoder::new(file) { + Ok(mut decoder) => { + let mut limits = image::Limits::default(); + let max_ram = max_mem * 1000 * 1000 / jobs as u64; + limits.max_alloc = Some(max_ram); + let _ = decoder.set_limits(limits); + match image::DynamicImage::from_decoder(decoder) { + Ok(img) => Some(img), Err(err) => { - log::warn!("failed to decode {:?}: {}", path, err); + log::warn!("failed to decode jxl {:?}: {}", path, err); + None } } - } else { - // Fallback for when thumbnail cacher isn't available. - let thumbnail = - image.thumbnail(thumbnail_size, thumbnail_size).into_rgba8(); - return ItemThumbnail::Image( - widget::image::Handle::from_rgba( - thumbnail.width(), - thumbnail.height(), - thumbnail.into_raw(), - ), - Some((image.width(), image.height())), - ); } - } + Err(err) => { + log::warn!("failed to create jxl decoder {:?}: {}", path, err); + None + } + }, Err(err) => { - log::warn!("failed to decode {:?}: {}", path, err); + log::warn!("failed to open path {:?}: {}", path, err); + None } }, - Err(err) => { - log::warn!("failed to read {:?}: {}", path, err); + _ => { + match image::ImageReader::open(path).and_then(|img| img.with_guessed_format()) { + Ok(mut reader) => { + let mut limits = image::Limits::default(); + let max_ram = max_mem * 1000 * 1000 / jobs as u64; + limits.max_alloc = Some(max_ram); + reader.limits(limits); + match reader.decode() { + Ok(reader) => Some(reader), + Err(err) => { + log::warn!("failed to decode {:?}: {}", path, err); + None + } + } + } + Err(err) => { + log::warn!("failed to read {:?}: {}", path, err); + None + } + } } + }; + + match dyn_img { + Some(dyn_img) => { + if let Ok(cacher) = thumbnail_cacher.as_ref() { + match cacher.update_with_image(dyn_img) { + Ok(path) => { + return ItemThumbnail::Image( + widget::image::Handle::from_path(path), + None, + ); + } + Err(err) => { + log::warn!("cacher failed to decode {:?}: {}", path, err); + } + } + } else { + // Fallback for when thumbnail cacher isn't available. + let thumbnail = dyn_img + .thumbnail(thumbnail_size, thumbnail_size) + .into_rgba8(); + return ItemThumbnail::Image( + widget::image::Handle::from_rgba( + thumbnail.width(), + thumbnail.height(), + thumbnail.into_raw(), + ), + Some((dyn_img.width(), dyn_img.height())), + ); + } + } + None => (), } } @@ -2401,6 +2449,7 @@ pub struct Tab { pub history_i: usize, pub history: Vec, pub config: TabConfig, + pub thumb_config: ThumbCfg, pub sort_name: HeadingOptions, pub sort_direction: bool, pub gallery: bool, @@ -2485,6 +2534,7 @@ impl Tab { pub fn new( location: Location, config: TabConfig, + thumb_config: ThumbCfg, sorting_options: Option<&OrderMap>, window_id: Option, ) -> Self { @@ -2515,6 +2565,7 @@ impl Tab { history_i: 0, history, config, + thumb_config, sort_name, sort_direction, gallery: false, @@ -5524,7 +5575,7 @@ impl Tab { pub fn subscription(&self, preview: bool) -> Subscription { //TODO: how many thumbnail loads should be in flight at once? - let jobs = 8; + let jobs = self.thumb_config.jobs.get().clone() as usize; let mut subscriptions = Vec::with_capacity(jobs + 3); if let Some(items) = &self.items_opt { @@ -5570,16 +5621,26 @@ impl Tab { }; if can_thumbnail { let mime = item.mime.clone(); - + let max_jobs = jobs.clone(); + let max_mb = self.thumb_config.max_mem_mb.get().clone() as u64; + let max_size = self.thumb_config.max_size_mb.get().clone() as u64; subscriptions.push(Subscription::run_with_id( ("thumbnail", path.clone()), - stream::channel(1, |mut output| async move { + stream::channel(1, move |mut output| async move { let message = { let path = path.clone(); + tokio::task::spawn_blocking(move || { let start = Instant::now(); - let thumbnail = - ItemThumbnail::new(&path, metadata, mime, THUMBNAIL_SIZE); + let thumbnail = ItemThumbnail::new( + &path, + metadata, + mime, + THUMBNAIL_SIZE, + max_mb, + max_jobs, + max_size, + ); log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed()); Message::Thumbnail(path.clone(), thumbnail) }) @@ -6081,6 +6142,7 @@ mod tests { Location::Path(path.into()), TabConfig::default(), None, + ThumbCfg::default(), None, ); @@ -6183,6 +6245,7 @@ mod tests { Location::Path(path.to_owned()), TabConfig::default(), None, + ThumbCfg::default(), None, ); debug!( @@ -6320,6 +6383,7 @@ mod tests { Location::Path(path.into()), TabConfig::default(), None, + ThumbCfg::default(), None, ); @@ -6347,6 +6411,7 @@ mod tests { Location::Path(next_dir.clone()), TabConfig::default(), None, + ThumbCfg::default(), None, ); // This will eventually yield false once root is hit