From 53f78d9588f676b0c210eab1ef87671fa6aaced4 Mon Sep 17 00:00:00 2001 From: electria Date: Thu, 2 Jul 2026 10:15:46 -0700 Subject: [PATCH 1/7] refactor: deduplicate into fn load_image --- src/main.rs | 58 +++++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/main.rs b/src/main.rs index 663d849..4a7180e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::{env, ffi::OsStr, fs}; +use std::{env, ffi::OsStr, fs, path::Path}; use iced::{ Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, color, event, @@ -15,7 +15,7 @@ enum Message { Event(iced::Event), } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] struct State { image: Option, image_display: Option, @@ -24,20 +24,9 @@ struct State { } impl State { fn new() -> (Self, Task) { - let state = State { - image_display: None, - image: env::args().nth(1).and_then(|path| { - Some( - ImageReader::open(path) - .unwrap() - .decode() - .unwrap() - .into_rgba8(), - ) - }), + let mut state = State::default(); - error: None, - }; + env::args().nth(1).and_then(|path| state.load_image(path)); let allocate_image = state.allocate_image(); (state, allocate_image) @@ -124,6 +113,31 @@ impl State { Task::none() } + fn load_image(&mut self, path: impl AsRef) -> Option { + let reader = match ImageReader::open(&path) { + Ok(r) => r, + Err(e) => { + return Some(format!( + "failed to create reader for '{}': {e}", + path.as_ref().display() + )); + } + }; + + let decoded_image = match reader.decode() { + Ok(i) => i, + Err(e) => { + return Some(format!( + "failed to decode image '{}': {e}", + path.as_ref().display() + )); + } + }; + + self.image = Some(decoded_image.into_rgba8()); + + None + } fn open_image(&mut self) -> Option { let Some(path) = FileDialog::new() .add_filter( @@ -138,19 +152,7 @@ impl State { return Some("no path to open provided".into()); }; - let reader = match ImageReader::open(path) { - Ok(r) => r, - Err(e) => return Some(format!("failed to create reader: {e}")), - }; - - let decoded_image = match reader.decode() { - Ok(i) => i, - Err(e) => return Some(format!("failed to decode image: {e}")), - }; - - self.image = Some(decoded_image.into_rgba8()); - - None + self.load_image(path) } fn save_image(&self) -> Option { From 831def32a42f7e4f412b0b98b1795a994ffee8b3 Mon Sep 17 00:00:00 2001 From: electria Date: Thu, 2 Jul 2026 15:14:10 -0700 Subject: [PATCH 2/7] fix: use exif oreintation https://alexwlchan.net/2025/create-thumbnail-is-exif-aware/ --- src/main.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4a7180e..c02ff95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,10 @@ use iced::{ keyboard::{self, Key, key}, theme, widget, window, }; -use image::{EncodableLayout, ImageReader, RgbaImage, codecs::webp::WebPEncoder, imageops}; +use image::{ + DynamicImage, EncodableLayout, ImageDecoder, ImageReader, RgbaImage, codecs::webp::WebPEncoder, + imageops, +}; use rfd::FileDialog; #[derive(Clone, Debug)] @@ -124,7 +127,27 @@ impl State { } }; - let decoded_image = match reader.decode() { + let mut decoder = match reader.into_decoder() { + Ok(d) => d, + Err(e) => { + return Some(format!( + "failed to create decoder for '{}': {e}", + path.as_ref().display() + )); + } + }; + + let oreintation = match decoder.orientation() { + Ok(o) => o, + Err(e) => { + return Some(format!( + "failed to get oreintation of '{}': {e}", + path.as_ref().display() + )); + } + }; + + let mut decoded_image = match DynamicImage::from_decoder(decoder) { Ok(i) => i, Err(e) => { return Some(format!( @@ -134,6 +157,8 @@ impl State { } }; + decoded_image.apply_orientation(oreintation); + self.image = Some(decoded_image.into_rgba8()); None From 02e470b317b6c154d9a0225722b16bf59dd414c4 Mon Sep 17 00:00:00 2001 From: electria Date: Thu, 2 Jul 2026 15:15:26 -0700 Subject: [PATCH 3/7] fix: display any errors that occur on start --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index c02ff95..1407b5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ impl State { fn new() -> (Self, Task) { let mut state = State::default(); - env::args().nth(1).and_then(|path| state.load_image(path)); + state.error = env::args().nth(1).and_then(|path| state.load_image(path)); let allocate_image = state.allocate_image(); (state, allocate_image) From 770bdc76d36dff8bfd1df676d3cf1aabc10537ab Mon Sep 17 00:00:00 2001 From: electria Date: Thu, 2 Jul 2026 15:57:29 -0700 Subject: [PATCH 4/7] feat: start work on jxl support --- Cargo.lock | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + flake.nix | 3 ++ src/main.rs | 42 ++++++++++---------- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c6791e..9f04b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -422,6 +422,31 @@ dependencies = [ "piper", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "built" version = "0.8.1" @@ -454,6 +479,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -778,6 +809,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1608,6 +1673,12 @@ dependencies = [ "winit", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "image" version = "0.25.10" @@ -1649,6 +1720,7 @@ dependencies = [ "dirs", "iced", "image", + "jpegxl-rs", "rfd", ] @@ -1756,6 +1828,29 @@ dependencies = [ "libc", ] +[[package]] +name = "jpegxl-rs" +version = "0.14.0+libjxl-0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7d16fe73fb6d2e16b986392b716409c6ae6fabd35769d66bea391b016cdbc9" +dependencies = [ + "bon", + "byteorder", + "half", + "image", + "jpegxl-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "jpegxl-sys" +version = "0.12.1+libjxl-0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00cb4f7ffb45ee4327e9ec6ca1a732f3abef470ee008f7d4be3afb414075cd7d" +dependencies = [ + "pkg-config", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -2758,6 +2853,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3425,6 +3530,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svg_fmt" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 668d6f6..5951fbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,4 @@ iced = { version = "0.14.0", features = [ "image" ] } dirs = "6.0.0" image = "0.25.10" rfd = "0.17.2" +jpegxl-rs = "0.14.0" diff --git a/flake.nix b/flake.nix index b86a384..8f401aa 100644 --- a/flake.nix +++ b/flake.nix @@ -36,8 +36,10 @@ commonArgs = { # all that's needed for artifacts and checks nativeBuildInputs = with pkgs; [ + pkg-config ]; buildInputs = with pkgs; [ + libjxl ]; src = ./.; @@ -64,6 +66,7 @@ "image/gif" "image/webp" "image/avif" + "image/jxl" "image/tiff" "image/bmp" "image/vnd.microsoft.icon" # .ico diff --git a/src/main.rs b/src/main.rs index 1407b5f..11edf89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::{env, ffi::OsStr, fs, path::Path}; +use std::{env, ffi::OsStr, fs, io::Read, path::Path}; use iced::{ Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, color, event, @@ -6,9 +6,10 @@ use iced::{ theme, widget, window, }; use image::{ - DynamicImage, EncodableLayout, ImageDecoder, ImageReader, RgbaImage, codecs::webp::WebPEncoder, - imageops, + DynamicImage, EncodableLayout, ImageDecoder, ImageReader, RgbImage, RgbaImage, + codecs::webp::WebPEncoder, imageops, }; +use jpegxl_rs::decode::JxlDecoder; use rfd::FileDialog; #[derive(Clone, Debug)] @@ -168,8 +169,8 @@ impl State { .add_filter( "image", &[ - "png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "avif", "bmp", "exr", "ff", "gif", - "hdr", "ico", "pnm", "qoi", "tga", "tiff", "webp", + "png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "avif", "jxl", "bmp", "exr", "ff", + "gif", "hdr", "ico", "pnm", "qoi", "tga", "tiff", "webp", ], ) .pick_file() @@ -189,24 +190,21 @@ impl State { return Some("no path to save provided".into()); }; - match fs::File::create(&path) { - Err(e) => return Some(format!("failed to create file: {e}")), - Ok(file) => { - let maybe_encoder = match path.extension().map(OsStr::to_string_lossy).as_deref() { - Some("webp") => Some(WebPEncoder::new_lossless(file)), + let mut file = match fs::File::create(&path) { + Ok(f) => f, + Err(e) => return Some(format!("failed to create file '{}': {e}", path.display())), + }; - None | Some(_) => None, - }; - - let result = if let Some(encoder) = maybe_encoder { - image.write_with_encoder(encoder) - } else { - image.save(path) - }; - - if let Err(e) = result { - return Some(format!("failed to save image: {e}")); - } + match path.extension().map(OsStr::to_string_lossy).as_deref() { + Some("jxl") => { + jpegxl_rs::encoder_builder().build().unwrap().encode( + RgbImage::from(DynamicImage::from(*image)).as_bytes(), + image.width(), + image.height(), + ); + } + None | Some(_) => { + image.save(path); } }; From 30b066273155dc30b1a048cbe4a5631ee510f160 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 3 Jul 2026 14:02:39 -0700 Subject: [PATCH 5/7] feat: saving as jxl --- src/main.rs | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 11edf89..057c0c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,10 @@ -use std::{env, ffi::OsStr, fs, io::Read, path::Path}; +use std::{ + env, + ffi::OsStr, + fs, + io::{Read, Write}, + path::Path, +}; use iced::{ Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, color, event, @@ -6,8 +12,7 @@ use iced::{ theme, widget, window, }; use image::{ - DynamicImage, EncodableLayout, ImageDecoder, ImageReader, RgbImage, RgbaImage, - codecs::webp::WebPEncoder, imageops, + DynamicImage, EncodableLayout, ImageDecoder, ImageReader, RgbImage, RgbaImage, imageops, }; use jpegxl_rs::decode::JxlDecoder; use rfd::FileDialog; @@ -197,14 +202,27 @@ impl State { match path.extension().map(OsStr::to_string_lossy).as_deref() { Some("jxl") => { - jpegxl_rs::encoder_builder().build().unwrap().encode( - RgbImage::from(DynamicImage::from(*image)).as_bytes(), - image.width(), - image.height(), - ); + let mut encoder = jpegxl_rs::encoder_builder().build().unwrap(); + let rgb_image = DynamicImage::from(image.to_owned()).into_rgb8(); + let jxl = match encoder.encode::( + &rgb_image, + rgb_image.width(), + rgb_image.height(), + ) { + Ok(j) => j, + Err(e) => { + return Some(format!("failed to encode jxl '{}': {e}", path.display())); + } + }; + + if let Err(e) = file.write(&jxl.data) { + return Some(format!("failed to write jxl to '{}': {e}", path.display())); + }; } None | Some(_) => { - image.save(path); + if let Err(e) = image.save(&path) { + return Some(format!("failed to save '{}': {e}", path.display())); + }; } }; From 9b9c04c470497d124036aa9a3a04f90cd7834b12 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 3 Jul 2026 14:27:02 -0700 Subject: [PATCH 6/7] feat: opening jxls --- src/main.rs | 114 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 73 insertions(+), 41 deletions(-) diff --git a/src/main.rs b/src/main.rs index 057c0c3..5b794af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use iced::{ use image::{ DynamicImage, EncodableLayout, ImageDecoder, ImageReader, RgbImage, RgbaImage, imageops, }; -use jpegxl_rs::decode::JxlDecoder; +use jpegxl_rs::image::ToDynamic; use rfd::FileDialog; #[derive(Clone, Debug)] @@ -123,48 +123,80 @@ impl State { } fn load_image(&mut self, path: impl AsRef) -> Option { - let reader = match ImageReader::open(&path) { - Ok(r) => r, - Err(e) => { - return Some(format!( - "failed to create reader for '{}': {e}", - path.as_ref().display() - )); + let path = path.as_ref(); + + let decoded_image = match path.extension().map(OsStr::to_string_lossy).as_deref() { + Some("jxl") => { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(e) => { + return Some(format!( + "failed to read data from '{}': {e}", + path.display() + )); + } + }; + + match jpegxl_rs::decoder_builder() + .build() + .unwrap() + .decode_to_image(&data) + { + Ok(Some(decoded_image)) => decoded_image, + Ok(None) => { + return Some(format!( + "failed to convert jxl '{}' to DynamicImage", + path.display() + )); + } + Err(e) => return Some(format!("faled to decode '{}' {e}", path.display())), + } + } + + _ => { + let reader = match ImageReader::open(&path) { + Ok(r) => r, + Err(e) => { + return Some(format!( + "failed to create reader for '{}': {e}", + path.display() + )); + } + }; + + let mut decoder = match reader.into_decoder() { + Ok(d) => d, + Err(e) => { + return Some(format!( + "failed to create decoder for '{}': {e}", + path.display() + )); + } + }; + + let oreintation = match decoder.orientation() { + Ok(o) => o, + Err(e) => { + return Some(format!( + "failed to get oreintation of '{}': {e}", + path.display() + )); + } + }; + + let mut decoded_image = match DynamicImage::from_decoder(decoder) { + Ok(i) => i, + Err(e) => { + return Some(format!("failed to decode image '{}': {e}", path.display())); + } + }; + + decoded_image.apply_orientation(oreintation); + + decoded_image } }; - let mut decoder = match reader.into_decoder() { - Ok(d) => d, - Err(e) => { - return Some(format!( - "failed to create decoder for '{}': {e}", - path.as_ref().display() - )); - } - }; - - let oreintation = match decoder.orientation() { - Ok(o) => o, - Err(e) => { - return Some(format!( - "failed to get oreintation of '{}': {e}", - path.as_ref().display() - )); - } - }; - - let mut decoded_image = match DynamicImage::from_decoder(decoder) { - Ok(i) => i, - Err(e) => { - return Some(format!( - "failed to decode image '{}': {e}", - path.as_ref().display() - )); - } - }; - - decoded_image.apply_orientation(oreintation); - self.image = Some(decoded_image.into_rgba8()); None @@ -219,7 +251,7 @@ impl State { return Some(format!("failed to write jxl to '{}': {e}", path.display())); }; } - None | Some(_) => { + _ => { if let Err(e) = image.save(&path) { return Some(format!("failed to save '{}': {e}", path.display())); }; From 8627ec026f6fcbae3bebcf9e2e55ff6fb24fafa5 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 3 Jul 2026 14:46:28 -0700 Subject: [PATCH 7/7] feat: set defaults for jxl encoding explicitly at least while there's no way to select options; this at least works for some usecases --- src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 5b794af..53e7ac9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -234,7 +234,11 @@ impl State { match path.extension().map(OsStr::to_string_lossy).as_deref() { Some("jxl") => { - let mut encoder = jpegxl_rs::encoder_builder().build().unwrap(); + let mut encoder = jpegxl_rs::encoder_builder() + .speed(jpegxl_rs::encode::EncoderSpeed::Glacier) + .lossless(true) + .build() + .unwrap(); let rgb_image = DynamicImage::from(image.to_owned()).into_rgb8(); let jxl = match encoder.encode::( &rgb_image,