From 60ee4433b77621be7ebdb26554fc2ff7cc646e42 Mon Sep 17 00:00:00 2001 From: Cody Wyatt Neiman Date: Tue, 29 Oct 2024 18:09:00 -0400 Subject: [PATCH] Add JXL image support --- Cargo.lock | 158 +++++++++++++++++- Cargo.toml | 2 +- crates/egui_extras/Cargo.toml | 12 +- crates/egui_extras/src/image.rs | 42 ++++- crates/egui_extras/src/loaders.rs | 8 + .../egui_extras/src/loaders/image_loader.rs | 1 + crates/egui_extras/src/loaders/jxl_loader.rs | 111 ++++++++++++ crates/egui_extras/src/loaders/svg_loader.rs | 1 + 8 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 crates/egui_extras/src/loaders/jxl_loader.rs diff --git a/Cargo.lock b/Cargo.lock index 43a25113bd8..813b4a379b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -590,9 +590,9 @@ checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" [[package]] name = "bytemuck" -version = "1.14.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" dependencies = [ "bytemuck_derive", ] @@ -1352,6 +1352,7 @@ dependencies = [ "ehttp", "enum-map", "image", + "jxl-oxide", "log", "mime_guess2", "puffin", @@ -2185,9 +2186,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.2" +version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" +checksum = "bc144d44a31d753b02ce64093d532f55ff8dc4ebf2ffb8a63c0dda691385acae" dependencies = [ "bytemuck", "byteorder-lite", @@ -2320,6 +2321,151 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "0.5.0-alpha.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "0.5.0-alpha.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.9.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.11.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +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.5.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.11.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "jxl-bitstream", + "jxl-color", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.9.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.10.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "bytemuck", + "image", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "0.1.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.10.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "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 = "0.1.1" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.9.0" +source = "git+https://github.com/tirr-c/jxl-oxide.git#229c223af49de96247a4e5548470ce2753912f3e" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "keyboard_events" version = "0.1.0" @@ -5202,9 +5348,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-jpeg" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec866b44a2a1fd6133d363f073ca1b179f438f99e7e5bfb1e33f7181facfe448" +checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 47d10f9aad3..53de7e38fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ ahash = { version = "0.8.11", default-features = false, features = [ "std", ] } backtrace = "0.3" -bytemuck = "1.7.2" +bytemuck = "1.19" criterion = { version = "0.5.1", default-features = false } dify = { version = "0.7", default-features = false } document-features = " 0.2.8" diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index b13a518e8d0..b351272e10e 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -30,8 +30,8 @@ rustdoc-args = ["--generate-link-to-definition"] [features] default = ["dep:mime_guess2"] -## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). -all_loaders = ["file", "http", "image", "svg", "gif"] +## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `jxl`, `svg`, `gif`). +all_loaders = ["file", "http", "image", "jxl", "svg", "gif"] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] @@ -53,6 +53,9 @@ http = ["dep:ehttp"] ## ``` image = ["dep:image"] +## Support loading jxl images via jxl-oxide. Requires the [`image`](https://docs.rs/image) crate feature. +jxl = ["dep:jxl-oxide", "image"] + ## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate. ## ## Only enabled on native, because of the low resolution (1ms) of clocks in browsers. @@ -105,5 +108,10 @@ syntect = { version = "5", optional = true, default-features = false, features = # svg feature resvg = { version = "0.37", optional = true, default-features = false } +# jxl feature +jxl-oxide = { version = "0.10", git = "https://github.com/tirr-c/jxl-oxide.git", optional = true, default-features = false, features = [ + "image", +] } + # http feature ehttp = { version = "0.5", optional = true, default-features = false } diff --git a/crates/egui_extras/src/image.rs b/crates/egui_extras/src/image.rs index f6301aec823..d887e4494be 100644 --- a/crates/egui_extras/src/image.rs +++ b/crates/egui_extras/src/image.rs @@ -192,7 +192,22 @@ impl RetainedImage { use egui::ColorImage; -/// Load a (non-svg) image. +/// Load a `image::DynamicImage` image. +/// +/// Requires the "image" feature. You must also opt-in to the image formats you need +/// with e.g. `image = { version = "0.25", features = ["jpeg", "png"] }`. +/// +/// # Errors +/// On invalid image or unsupported image format. +#[cfg(feature = "image")] +pub fn load_dynamic_image(dynamic_image: &image::DynamicImage) -> egui::ColorImage { + let size = [dynamic_image.width() as _, dynamic_image.height() as _]; + let image_buffer = dynamic_image.to_rgba8(); + let pixels = image_buffer.as_flat_samples(); + egui::ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()) +} + +/// Load a (non-svg, non-jxl) image. /// /// Requires the "image" feature. You must also opt-in to the image formats you need /// with e.g. `image = { version = "0.25", features = ["jpeg", "png"] }`. @@ -203,13 +218,24 @@ use egui::ColorImage; pub fn load_image_bytes(image_bytes: &[u8]) -> Result { crate::profile_function!(); let image = image::load_from_memory(image_bytes).map_err(|err| err.to_string())?; - let size = [image.width() as _, image.height() as _]; - let image_buffer = image.to_rgba8(); - let pixels = image_buffer.as_flat_samples(); - Ok(egui::ColorImage::from_rgba_unmultiplied( - size, - pixels.as_slice(), - )) + Ok(load_dynamic_image(&image)) +} + +/// Load a jxl image. +/// +/// Requires the "image" and "jxl" features. You must also opt-in to any other image formats +/// you need with e.g. `image = { version = "0.25", features = ["jpeg", "png"] }`. +/// No image crate feature is required for jxl support. +/// +/// # Errors +/// On invalid image or unsupported image format. +#[cfg(all(feature = "image", feature = "jxl"))] +pub fn load_image_bytes_jxl(image_bytes: &[u8]) -> Result { + crate::profile_function!(); + let decoder = + jxl_oxide::integration::JxlDecoder::new(image_bytes).map_err(|err| err.to_string())?; + let image = image::DynamicImage::from_decoder(decoder).map_err(|err| err.to_string())?; + Ok(load_dynamic_image(&image)) } /// Load an SVG and rasterize it into an egui image. diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs index 02683e442e7..e1b102c2df2 100644 --- a/crates/egui_extras/src/loaders.rs +++ b/crates/egui_extras/src/loaders.rs @@ -78,6 +78,12 @@ pub fn install_image_loaders(ctx: &egui::Context) { log::trace!("installed ImageCrateLoader"); } + #[cfg(feature = "jxl")] + if !ctx.is_loader_installed(self::jxl_loader::JxlLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new(self::jxl_loader::JxlLoader::default())); + log::trace!("installed JxlLoader"); + } + #[cfg(feature = "gif")] if !ctx.is_loader_installed(self::gif_loader::GifLoader::ID) { ctx.add_image_loader(std::sync::Arc::new(self::gif_loader::GifLoader::default())); @@ -111,5 +117,7 @@ mod ehttp_loader; mod gif_loader; #[cfg(feature = "image")] mod image_loader; +#[cfg(feature = "jxl")] +mod jxl_loader; #[cfg(feature = "svg")] mod svg_loader; diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs index 8c2c497058a..46197e4ab33 100644 --- a/crates/egui_extras/src/loaders/image_loader.rs +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -117,5 +117,6 @@ mod tests { assert!(is_supported_uri("http://test.gif")); assert!(is_supported_uri("file://test")); assert!(!is_supported_uri("test.svg")); + assert!(!is_supported_uri("test.jxl")); } } diff --git a/crates/egui_extras/src/loaders/jxl_loader.rs b/crates/egui_extras/src/loaders/jxl_loader.rs new file mode 100644 index 00000000000..679e291ac4b --- /dev/null +++ b/crates/egui_extras/src/loaders/jxl_loader.rs @@ -0,0 +1,111 @@ +use ahash::HashMap; +use egui::{ + load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, + mutex::Mutex, + ColorImage, +}; +use std::{mem::size_of, path::Path, sync::Arc}; + +type Entry = Result, String>; + +#[derive(Default)] +pub struct JxlLoader { + cache: Mutex>, +} + +impl JxlLoader { + pub const ID: &'static str = egui::generate_loader_id!(JxlLoader); +} + +fn is_supported_uri(uri: &str) -> bool { + let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { + return false; + }; + + ext == "jxl" +} + +fn is_unsupported_mime(mime: &str) -> bool { + mime != "image/jxl" +} + +impl ImageLoader for JxlLoader { + fn id(&self) -> &str { + Self::ID + } + + fn load(&self, ctx: &egui::Context, uri: &str, _: SizeHint) -> ImageLoadResult { + // three stages of guessing if we support loading the image: + // 1. URI extension + // 2. Mime from `BytesPoll::Ready` + // 3. image::guess_format + + // (1) + if !is_supported_uri(uri) { + return Err(LoadError::NotSupported); + } + + let mut cache = self.cache.lock(); + if let Some(entry) = cache.get(uri).cloned() { + match entry { + Ok(image) => Ok(ImagePoll::Ready { image }), + Err(err) => Err(LoadError::Loading(err)), + } + } else { + match ctx.try_load_bytes(uri) { + Ok(BytesPoll::Ready { bytes, mime, .. }) => { + // (2 and 3) + if mime.as_deref().is_some_and(is_unsupported_mime) { + return Err(LoadError::NotSupported); + } + + log::trace!("started loading {uri:?}"); + let result = crate::image::load_image_bytes_jxl(&bytes).map(Arc::new); + log::trace!("finished loading {uri:?}"); + cache.insert(uri.into(), result.clone()); + match result { + Ok(image) => Ok(ImagePoll::Ready { image }), + Err(err) => Err(LoadError::Loading(err)), + } + } + Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), + Err(err) => Err(err), + } + } + } + + fn forget(&self, uri: &str) { + let _ = self.cache.lock().remove(uri); + } + + fn forget_all(&self) { + self.cache.lock().clear(); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|result| match result { + Ok(image) => image.pixels.len() * size_of::(), + Err(err) => err.len(), + }) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_support() { + assert!(!is_supported_uri("https://test.png")); + assert!(!is_supported_uri("test.jpeg")); + assert!(!is_supported_uri("http://test.gif")); + assert!(!is_supported_uri("test.webp")); + assert!(!is_supported_uri("file://test")); + assert!(!is_supported_uri("test.svg")); + assert!(is_supported_uri("test.jxl")); + } +} diff --git a/crates/egui_extras/src/loaders/svg_loader.rs b/crates/egui_extras/src/loaders/svg_loader.rs index 67b59540c69..42bf893673c 100644 --- a/crates/egui_extras/src/loaders/svg_loader.rs +++ b/crates/egui_extras/src/loaders/svg_loader.rs @@ -98,5 +98,6 @@ mod tests { assert!(!is_supported("test.webp")); assert!(!is_supported("file://test")); assert!(is_supported("test.svg")); + assert!(!is_supported("test.jxl")); } }