From 923c2ae04fb2999a7c868ef6a3d807dbc66ed452 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:05:09 -0400 Subject: [PATCH 1/7] feat(image)!: tag decoded images with the container's colour space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decode_standard_image` and `decode_standard_image_with` ran every result through `tag_srgb`, which overwrote the colour description with sRGB unconditionally. A Display P3 HEIC and an sRGB PNG came back indistinguishable, and `ImageProbe::color_space` was a hardcoded constant that never looked at the file at all. Callers had no way to learn a decoded image's actual colour space short of calling `read_standard_image_metadata` separately and parsing the ICC blob themselves. `rawshift-image-core::color_resolve` now maps what a container declares — a CICP code-point pair, an embedded ICC profile, or neither — onto a `ColorDescription`, and the five decoders that already hold that information (PNG, JPEG, WebP, AVIF, HEIC) tag with it. The remaining formats have no colour path in rawshift and keep the sRGB default, which `default_to_srgb` now applies by format rather than blanket. Resolution precedence is CICP, then the profile, then sRGB. A profile that is recognisably sRGB (or linear sRGB) is tagged as such; anything else resolves to `UNSPECIFIED`. That is not a shortfall — `ColorDescription` is a CICP pair and Adobe RGB / ProPhoto have no faithful CICP expression, so `UNSPECIFIED` is what that type already documents for them, with the profile preserved verbatim in `ImageMetadata::icc_profile`. Two details worth review attention: - The colorant tolerance is 4e-3, not something tighter. The sRGB profiles in circulation genuinely disagree: the ICC's own releases put the green colorant's Z at 0.09708, the HP/Microsoft "sRGB IEC61966-2.1" profile that rawshift itself embeds puts it at 0.09500. A tolerance that split them would make rawshift's own encode output round-trip as `UNSPECIFIED`; `rawshift_own_srgb_profile_is_recognised` pins this. Display P3, the nearest confusable space, is 0.079 away — twenty times the tolerance. - A single-entry `curveType` is matched by exponent range (2.1..=2.3) rather than by sampling against the sRGB EOTF. A 2.2 power law and the sRGB curve differ by more than the sampling tolerance in the toe, so sampling would reject the gamma-2.2 approximation that most v2 profiles — rawshift's included — use. JPEG's CMYK branch deliberately does not use the embedded profile: it describes the CMYK samples, not the RGB the Blinn approximation synthesises from them. `probe_standard_image` now reports the real colour space for JPEG, WebP, AVIF, and HEIC, all of which are a marker/chunk/box walk. PNG reports `UNSPECIFIED` — its `iCCP` chunk is DEFLATE-compressed and gamut-png exposes ancillary chunks only from a full decode, so reading it in a probe would decode every pixel. Marked `!` because decode output that was always `SRGB` can now be `DISPLAY_P3`, `REC2020`, `LINEAR_SRGB`, or `UNSPECIFIED`. Nothing inside rawshift reads `RgbImage::color()` on the decode path — `convert_to_srgb` has no internal callers — so this changes no behaviour within the crate. 644 workspace tests pass with `--features rawshift-image/full`, including the 32 fixture-backed standard decode tests. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- Cargo.lock | 2 + crates/rawshift-image-avif/src/decoder.rs | 62 ++- crates/rawshift-image-core/Cargo.toml | 2 + .../rawshift-image-core/src/color_resolve.rs | 452 ++++++++++++++++++ crates/rawshift-image-core/src/lib.rs | 2 + crates/rawshift-image-heic/src/decoder.rs | 59 ++- crates/rawshift-image-jpeg/src/lib.rs | 13 +- crates/rawshift-image-png/src/lib.rs | 15 +- crates/rawshift-image-webp/src/lib.rs | 9 +- crates/rawshift-image/src/formats/standard.rs | 92 +++- 10 files changed, 693 insertions(+), 15 deletions(-) create mode 100644 crates/rawshift-image-core/src/color_resolve.rs diff --git a/Cargo.lock b/Cargo.lock index 88c66fd..72713d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1338,7 +1338,9 @@ dependencies = [ name = "rawshift-image-core" version = "0.1.1" dependencies = [ + "gamut-color", "gamut-core", + "gamut-icc", "rawshift-core", "rawshift-hwdec", "serde", diff --git a/crates/rawshift-image-avif/src/decoder.rs b/crates/rawshift-image-avif/src/decoder.rs index bf38981..0d193c3 100644 --- a/crates/rawshift-image-avif/src/decoder.rs +++ b/crates/rawshift-image-avif/src/decoder.rs @@ -178,6 +178,19 @@ impl AvifFile { } } + /// The colour space the container declared for one item. + /// + /// A `colr` box holds either CICP code points (`nclx`) or an ICC profile; + /// AVIF permits a `colr` per item, so each decoded item is tagged from its + /// own box, falling back to the primary item's when it has none — a grid + /// tile or auxiliary typically inherits the primary's colour. + #[cfg(feature = "hw")] + fn item_color(&self, id: u32) -> rawshift_image_core::ColorDescription { + let own = self.image.item(id).as_ref().and_then(declared_color); + let color = own.or_else(|| declared_color(&self.image.primary_item())); + rawshift_image_core::resolve_color(color.unwrap_or_default()) + } + /// Decode one item through the hardware AV1 decoder. #[cfg(feature = "hw")] fn decode_item(&self, id: u32) -> RawResult { @@ -189,7 +202,11 @@ impl AvifFile { }; let mut adapter = hw::HwAv1Adapter::new(decoder); match self.image.decode_item_rgba8(id, &mut adapter) { - Ok(rgba) => rawshift_image_core::hw_planes::rgba8_to_rgb_image(&rgba), + Ok(rgba) => { + let mut image = rawshift_image_core::hw_planes::rgba8_to_rgb_image(&rgba)?; + image.set_color(self.item_color(id)); + Ok(image) + } Err(source) => Err(adapter.into_raw_error(source)), } } @@ -205,6 +222,49 @@ impl AvifFile { } } +/// What one item's `colr` box declares about its colour space. +/// +/// A `colr` holds either CICP code points (`nclx`) or an ICC profile. The +/// returned borrow is of the file data, not of `item`, which is why this is a +/// free function with an explicit lifetime rather than a closure. +fn declared_color<'a>( + item: &gamut_avif::AvifItem<'a>, +) -> Option> { + match item.colour()? { + ColourInformation::Nclx(nclx) => Some(rawshift_image_core::ContainerColor { + cicp: Some((nclx.colour_primaries, nclx.transfer_characteristics)), + icc: None, + }), + ColourInformation::RestrictedIcc(bytes) | ColourInformation::UnrestrictedIcc(bytes) => { + Some(rawshift_image_core::ContainerColor { + cicp: None, + icc: Some(bytes.as_slice()), + }) + } + _ => None, + } +} + +/// The colour space a AVIF file declares for its primary item, without +/// decoding pixels. +/// +/// Parses the container only — no hardware decoder is involved, so this works +/// in every build. Returns [`ColorDescription::SRGB`] when the file carries no +/// `colr` box, and [`ColorDescription::UNSPECIFIED`] when it cannot be parsed +/// at all or carries a profile with no CICP expression. +/// +/// [`ColorDescription::SRGB`]: rawshift_image_core::ColorDescription::SRGB +/// [`ColorDescription::UNSPECIFIED`]: rawshift_image_core::ColorDescription::UNSPECIFIED +#[must_use] +pub fn probe_avif_color(data: &[u8]) -> rawshift_image_core::ColorDescription { + match AvifFile::open(data.to_vec()) { + Ok(file) => rawshift_image_core::resolve_color( + declared_color(&file.image.primary_item()).unwrap_or_default(), + ), + Err(_) => rawshift_image_core::ColorDescription::UNSPECIFIED, + } +} + /// Whether AVIF pixel decode can work in this build on this machine: a /// hardware AV1 decoder is compiled in (`hw`/`hw-*` feature) **and** usable /// at runtime. diff --git a/crates/rawshift-image-core/Cargo.toml b/crates/rawshift-image-core/Cargo.toml index de76537..082d1f6 100644 --- a/crates/rawshift-image-core/Cargo.toml +++ b/crates/rawshift-image-core/Cargo.toml @@ -14,6 +14,8 @@ categories = ["multimedia::images"] [dependencies] rawshift-core = { workspace = true } gamut-core = { workspace = true } +gamut-color = { workspace = true } +gamut-icc = { workspace = true } thiserror = { workspace = true } serde = { workspace = true, optional = true } rawshift-hwdec = { workspace = true, optional = true } diff --git a/crates/rawshift-image-core/src/color_resolve.rs b/crates/rawshift-image-core/src/color_resolve.rs new file mode 100644 index 0000000..67ee38f --- /dev/null +++ b/crates/rawshift-image-core/src/color_resolve.rs @@ -0,0 +1,452 @@ +//! Resolving the colour space a container declared for a decoded image. +//! +//! Every standard decoder produces display-referred RGB, but *which* RGB is a +//! property of the source container, not of the decoder. This module turns the +//! two things a container can say — a CICP code-point pair, and/or an embedded +//! ICC profile — into the [`ColorDescription`] tag carried by +//! [`RgbImage`](crate::RgbImage). +//! +//! # Precedence +//! +//! 1. An explicit CICP code-point pair from the container (an AVIF/HEIC `colr` +//! `nclx` box, a PNG `cICP` chunk) is authoritative and used directly. +//! 2. Otherwise an embedded ICC profile is inspected: +//! - a `cicp` tag inside the profile (ICC.1:2022 §10.3) is used directly; +//! - a matrix/TRC profile whose colorants and curves are sRGB resolves to +//! [`ColorDescription::SRGB`], or to +//! [`ColorDescription::LINEAR_SRGB`] when its curves are linear; +//! - anything else resolves to [`ColorDescription::UNSPECIFIED`]. +//! 3. With neither, the result is [`ColorDescription::SRGB`] — the documented +//! default for standard delivery formats. +//! +//! Step 2's final case is deliberate, not a shortfall. [`ColorDescription`] is +//! a CICP pair, and ICC-authoritative spaces such as Adobe RGB and ProPhoto RGB +//! have no faithful CICP expression; inventing a code-point pair for them would +//! misdescribe the samples. `UNSPECIFIED` is what that type documents for +//! exactly this case, and the profile itself is preserved verbatim in +//! [`ImageMetadata::icc_profile`](rawshift_core::metadata::ImageMetadata). + +use rawshift_core::{ColorDescription, ColourPrimaries, TransferCharacteristics}; + +/// The sRGB colorants, chromatically adapted to the ICC PCS illuminant (D50) — +/// the `rXYZ` / `gXYZ` / `bXYZ` triplets a conforming sRGB matrix/TRC profile +/// carries. +const SRGB_COLORANTS_D50: [[f64; 3]; 3] = [ + [0.436_07, 0.222_49, 0.013_92], + [0.385_15, 0.716_86, 0.096_04], + [0.143_07, 0.060_61, 0.714_10], +]; + +/// Tolerance for a colorant match. +/// +/// Wide enough to accept every sRGB profile in circulation, which disagree by +/// more than rounding: the ICC's own releases put the green colorant's Z at +/// 0.09708, while the HP/Microsoft "sRGB IEC61966-2.1" profile that most +/// encoders (rawshift's own included) embed puts it at 0.09500. The midpoint +/// above is 0.00104 from each, so 4e-3 clears both with room to spare. +/// +/// It stays far from a false positive: the nearest other common space is +/// Display P3, whose red colorant X differs from sRGB's by 0.079 — twenty +/// times this tolerance. Adobe RGB differs by 0.17. +const COLORANT_TOLERANCE: f64 = 4e-3; + +/// Tolerance for a sampled or parametric tone curve against the sRGB EOTF. +/// +/// sRGB profiles express the curve as the ICC parametric type-3 function or as +/// a sampled 1024- or 4096-entry table, and those disagree by a few parts in a +/// thousand at the sample points. Pure-gamma curves do not go through this +/// path — see [`is_srgb_curve`]. +const CURVE_TOLERANCE: f64 = 5e-3; + +/// The gamma range accepted as an sRGB approximation. +/// +/// A `curveType` with a single entry is a pure power law, which is how most v2 +/// profiles approximate sRGB — rawshift's own embedded profile uses the +/// `u8Fixed8` value 0x0238 (2.21875). The true sRGB curve is not a power law, +/// so this is matched by exponent range rather than by sampling: at the toe a +/// 2.2 power law and the sRGB EOTF differ by more than [`CURVE_TOLERANCE`]. +const SRGB_GAMMA_RANGE: std::ops::RangeInclusive = 2.1..=2.3; + +/// The gamma range accepted as linear. +const LINEAR_GAMMA_RANGE: std::ops::RangeInclusive = 0.99..=1.01; + +/// Points at which a curve is compared against the sRGB EOTF. +/// +/// Both ends are included even though nearly every curve matches there — they +/// cheaply reject a curve that is not normalised — along with four interior +/// points spread across the range. +const CURVE_SAMPLES: [f64; 6] = [0.0, 0.04, 0.25, 0.5, 0.75, 1.0]; + +/// What a container declared about an image's colour space. +/// +/// Both fields record what the *container* said, not what the decoder +/// produced. Build one per decode and pass it to [`resolve_color`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct ContainerColor<'a> { + /// A CICP `(colour_primaries, transfer_characteristics)` pair, when the + /// container carried one explicitly. + pub cicp: Option<(u16, u16)>, + /// The embedded ICC profile, unparsed. + pub icc: Option<&'a [u8]>, +} + +impl<'a> ContainerColor<'a> { + /// A container that declared nothing about colour. + pub fn none() -> Self { + Self::default() + } + + /// A container that declared only an ICC profile. + pub fn icc(profile: &'a [u8]) -> Self { + Self { + cicp: None, + icc: Some(profile), + } + } + + /// A container that declared only a CICP code-point pair. + pub fn cicp(primaries: u16, transfer: u16) -> Self { + Self { + cicp: Some((primaries, transfer)), + icc: None, + } + } + + /// Attach an ICC profile to an existing declaration. + #[must_use] + pub fn with_icc(mut self, profile: Option<&'a [u8]>) -> Self { + self.icc = profile; + self + } + + /// Attach a CICP pair to an existing declaration. + #[must_use] + pub fn with_cicp(mut self, cicp: Option<(u16, u16)>) -> Self { + self.cicp = cicp; + self + } +} + +/// Resolve the [`ColorDescription`] to tag a decoded image with. +/// +/// See the [module documentation](self) for the precedence rules. This never +/// fails: a malformed or unclassifiable ICC profile resolves to +/// [`ColorDescription::UNSPECIFIED`], because a profile rawshift cannot +/// classify is no reason to reject an otherwise good decode. +pub fn resolve_color(declared: ContainerColor<'_>) -> ColorDescription { + if let Some(color) = declared.cicp.and_then(from_code_points) { + return color; + } + + let Some(bytes) = declared.icc else { + // Nothing declared: standard delivery formats are sRGB by convention. + return ColorDescription::SRGB; + }; + + let Ok(profile) = gamut_icc::IccProfile::parse(bytes) else { + return ColorDescription::UNSPECIFIED; + }; + + if let Some(color) = cicp_tag(&profile) { + return color; + } + classify_matrix_trc(&profile).unwrap_or(ColorDescription::UNSPECIFIED) +} + +/// Build a `ColorDescription` from a code-point pair, rejecting the pair that +/// says nothing. +/// +/// `(2, 2)` is CICP's "unspecified/unspecified", which a container writes when +/// it has no information. Treating that as a declaration would shadow an ICC +/// profile that does carry the answer, so it is rejected and the caller falls +/// through to the profile. +fn from_code_points((primaries, transfer): (u16, u16)) -> Option { + let color = ColorDescription::from_code_points(primaries, transfer)?; + (color != ColorDescription::UNSPECIFIED).then_some(color) +} + +/// Read a `cicp` tag out of a profile, if it carries one. +/// +/// ICC.1:2022 §10.3 lets a profile state its colour space as CICP code points +/// directly, which is both cheaper and less ambiguous than inferring it from +/// the colorants. +fn cicp_tag(profile: &gamut_icc::IccProfile) -> Option { + let gamut_icc::TagData::Cicp(cicp) = profile.get(gamut_icc::KnownTag::Cicp)? else { + return None; + }; + from_code_points(( + u16::from(cicp.colour_primaries), + u16::from(cicp.transfer_characteristics), + )) +} + +/// Classify a matrix/TRC profile whose colorants are sRGB's. +/// +/// Returns `None` when the profile is not matrix/TRC at all (it is LUT-based, +/// or not RGB), when its colorants are some other space, or when its curves +/// are neither sRGB nor linear. +fn classify_matrix_trc(profile: &gamut_icc::IccProfile) -> Option { + use gamut_icc::KnownTag::{ + BlueColorant, BlueTrc, GreenColorant, GreenTrc, RedColorant, RedTrc, + }; + + let colorants = [ + (RedColorant, SRGB_COLORANTS_D50[0]), + (GreenColorant, SRGB_COLORANTS_D50[1]), + (BlueColorant, SRGB_COLORANTS_D50[2]), + ]; + for (tag, expected) in colorants { + let gamut_icc::TagData::Xyz(values) = profile.get(tag)? else { + return None; + }; + let actual = values.first()?.to_f64(); + if !close(actual, expected, COLORANT_TOLERANCE) { + return None; + } + } + + let curves = [RedTrc, GreenTrc, BlueTrc]; + if curves.iter().all(|&tag| is_srgb_curve(profile, tag)) { + Some(ColorDescription::SRGB) + } else if curves.iter().all(|&tag| is_linear_curve(profile, tag)) { + Some(ColorDescription::LINEAR_SRGB) + } else { + None + } +} + +/// Whether one TRC tag is the sRGB transfer curve, or the pure-gamma curve +/// commonly used to approximate it. +fn is_srgb_curve(profile: &gamut_icc::IccProfile, tag: gamut_icc::KnownTag) -> bool { + match profile.get(tag) { + // A single-entry `curveType` is a power law. The sRGB EOTF is not one, + // so match it by exponent rather than by sampling. + Some(gamut_icc::TagData::Curve(gamut_icc::Curve::Gamma(g))) => { + SRGB_GAMMA_RANGE.contains(&g.to_f64()) + } + Some(gamut_icc::TagData::Curve(curve)) => samples_match(|x| curve.eval(x)), + Some(gamut_icc::TagData::ParametricCurve(curve)) => samples_match(|x| curve.eval(x)), + _ => false, + } +} + +/// Whether one TRC tag is the identity (a linear transfer). +fn is_linear_curve(profile: &gamut_icc::IccProfile, tag: gamut_icc::KnownTag) -> bool { + match profile.get(tag) { + Some(gamut_icc::TagData::Curve(gamut_icc::Curve::Identity)) => true, + Some(gamut_icc::TagData::Curve(gamut_icc::Curve::Gamma(g))) => { + LINEAR_GAMMA_RANGE.contains(&g.to_f64()) + } + _ => false, + } +} + +/// Whether a curve tracks the sRGB EOTF across [`CURVE_SAMPLES`]. +fn samples_match(eval: impl Fn(f64) -> f64) -> bool { + CURVE_SAMPLES + .into_iter() + .all(|x| (eval(x) - gamut_color::transfer::srgb_eotf(x)).abs() <= CURVE_TOLERANCE) +} + +/// Component-wise comparison within `tolerance`. +fn close(a: [f64; 3], b: [f64; 3], tolerance: f64) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tolerance) +} + +/// Whether rawshift can currently convert samples in `color` to sRGB. +/// +/// [`convert_to_srgb`] handles the sRGB and linear transfers on BT.709 +/// primaries. Wide-gamut primaries need a colour-management engine, which is +/// not wired into the library yet — see the crate documentation. +/// +/// [`convert_to_srgb`]: https://docs.rs/rawshift-image/latest/rawshift_image/transforms/fn.convert_to_srgb.html +pub fn is_convertible_to_srgb(color: ColorDescription) -> bool { + matches!( + color.primaries, + ColourPrimaries::Bt709 | ColourPrimaries::Unspecified + ) && matches!( + color.transfer, + TransferCharacteristics::Srgb + | TransferCharacteristics::Linear + | TransferCharacteristics::Unspecified + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use gamut_icc::{ + ColorSpace, Curve, DeviceClass, IccProfile, ProfileHeader, S15Fixed16, Signature, TagData, + U8Fixed8, XyzNumber, + }; + + /// Build a matrix/TRC profile from explicit colorants and one shared TRC, + /// so each test states exactly the profile shape it is about. + fn matrix_trc_profile(colorants: [[f64; 3]; 3], trc: TagData) -> Vec { + let xyz = |v: [f64; 3]| TagData::Xyz(vec![XyzNumber::from_f64(v)]); + IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![ + (Signature(*b"rXYZ"), xyz(colorants[0])), + (Signature(*b"gXYZ"), xyz(colorants[1])), + (Signature(*b"bXYZ"), xyz(colorants[2])), + (Signature(*b"rTRC"), trc.clone()), + (Signature(*b"gTRC"), trc.clone()), + (Signature(*b"bTRC"), trc), + ], + } + .to_bytes() + .expect("hand-built matrix/TRC fixture serialises") + } + + /// Gamma 2.21875 — the `u8Fixed8` value rawshift's own embedded sRGB + /// profile uses. + fn gamma_22() -> TagData { + TagData::Curve(Curve::Gamma(U8Fixed8(0x0238))) + } + + /// The true sRGB curve as ICC parametric function type 3. + fn srgb_parametric() -> TagData { + let p = |v: f64| S15Fixed16((v * 65536.0).round() as i32); + TagData::ParametricCurve(gamut_icc::ParametricCurve { + function_type: 3, + params: vec![ + p(2.4), + p(1.0 / 1.055), + p(0.055 / 1.055), + p(1.0 / 12.92), + p(0.04045), + ], + }) + } + + /// The HP/Microsoft "sRGB IEC61966-2.1" colorants — the variant rawshift + /// itself embeds, whose green Z is 0.09500 rather than the ICC's 0.09708. + const HP_SRGB_COLORANTS: [[f64; 3]; 3] = [ + [0.436_07, 0.222_49, 0.013_92], + [0.385_15, 0.716_86, 0.095_00], + [0.143_07, 0.060_61, 0.714_07], + ]; + + /// The ICC's own sRGB colorants. + const ICC_SRGB_COLORANTS: [[f64; 3]; 3] = [ + [0.436_07, 0.222_49, 0.013_92], + [0.385_15, 0.716_87, 0.097_08], + [0.143_07, 0.060_61, 0.714_10], + ]; + + /// Display P3, D50-adapted — the nearest space that must NOT be mistaken + /// for sRGB. + const DISPLAY_P3_COLORANTS: [[f64; 3]; 3] = [ + [0.515_12, 0.241_20, -0.001_05], + [0.291_98, 0.692_24, 0.041_89], + [0.157_10, 0.066_57, 0.784_07], + ]; + + /// Adobe RGB (1998), D50-adapted. + const ADOBE_RGB_COLORANTS: [[f64; 3]; 3] = [ + [0.609_74, 0.311_11, 0.019_47], + [0.205_28, 0.625_67, 0.060_87], + [0.149_19, 0.063_22, 0.744_57], + ]; + + #[test] + fn nothing_declared_defaults_to_srgb() { + assert_eq!( + resolve_color(ContainerColor::none()), + ColorDescription::SRGB + ); + } + + #[test] + fn explicit_cicp_wins() { + let (p, t) = ColorDescription::DISPLAY_P3.code_points(); + assert_eq!( + resolve_color(ContainerColor::cicp(p, t)), + ColorDescription::DISPLAY_P3 + ); + } + + #[test] + fn unspecified_cicp_falls_through_to_the_profile() { + let profile = matrix_trc_profile(ICC_SRGB_COLORANTS, srgb_parametric()); + // (2, 2) is CICP "unspecified": it must not shadow the profile. + let declared = ContainerColor::cicp(2, 2).with_icc(Some(&profile)); + assert_eq!(resolve_color(declared), ColorDescription::SRGB); + } + + #[test] + fn unassigned_cicp_falls_through() { + // 250 is not an assigned CICP primaries code point. + assert_eq!( + resolve_color(ContainerColor::cicp(250, 250)), + ColorDescription::SRGB + ); + } + + #[test] + fn icc_srgb_profile_is_recognised() { + let profile = matrix_trc_profile(ICC_SRGB_COLORANTS, srgb_parametric()); + assert_eq!( + resolve_color(ContainerColor::icc(&profile)), + ColorDescription::SRGB + ); + } + + /// The regression that motivated [`COLORANT_TOLERANCE`]: rawshift embeds + /// the HP/Microsoft sRGB variant with a gamma-2.2 curve, so failing to + /// recognise it would make rawshift's own encode output round-trip as + /// `UNSPECIFIED`. + #[test] + fn rawshift_own_srgb_profile_is_recognised() { + let profile = matrix_trc_profile(HP_SRGB_COLORANTS, gamma_22()); + assert_eq!( + resolve_color(ContainerColor::icc(&profile)), + ColorDescription::SRGB + ); + } + + #[test] + fn linear_srgb_profile_is_recognised() { + let profile = matrix_trc_profile(ICC_SRGB_COLORANTS, TagData::Curve(Curve::Identity)); + assert_eq!( + resolve_color(ContainerColor::icc(&profile)), + ColorDescription::LINEAR_SRGB + ); + } + + #[test] + fn display_p3_profile_is_not_mistaken_for_srgb() { + let profile = matrix_trc_profile(DISPLAY_P3_COLORANTS, srgb_parametric()); + assert_eq!( + resolve_color(ContainerColor::icc(&profile)), + ColorDescription::UNSPECIFIED + ); + } + + #[test] + fn adobe_rgb_profile_is_unspecified() { + let profile = matrix_trc_profile(ADOBE_RGB_COLORANTS, gamma_22()); + assert_eq!( + resolve_color(ContainerColor::icc(&profile)), + ColorDescription::UNSPECIFIED + ); + } + + #[test] + fn malformed_profile_is_unspecified_not_an_error() { + assert_eq!( + resolve_color(ContainerColor::icc(b"not a profile")), + ColorDescription::UNSPECIFIED + ); + } + + #[test] + fn srgb_and_linear_srgb_are_convertible() { + assert!(is_convertible_to_srgb(ColorDescription::SRGB)); + assert!(is_convertible_to_srgb(ColorDescription::LINEAR_SRGB)); + assert!(is_convertible_to_srgb(ColorDescription::UNSPECIFIED)); + assert!(!is_convertible_to_srgb(ColorDescription::DISPLAY_P3)); + assert!(!is_convertible_to_srgb(ColorDescription::REC2020)); + } +} diff --git a/crates/rawshift-image-core/src/lib.rs b/crates/rawshift-image-core/src/lib.rs index becadbb..88848ce 100644 --- a/crates/rawshift-image-core/src/lib.rs +++ b/crates/rawshift-image-core/src/lib.rs @@ -1,6 +1,7 @@ //! Shared still-image contracts for rawshift's per-format crates. #![forbid(unsafe_code)] +pub mod color_resolve; pub mod error; #[cfg(feature = "hw-planes")] #[doc(hidden)] @@ -9,6 +10,7 @@ mod rgb_image; use std::io::Write; +pub use color_resolve::{ContainerColor, resolve_color}; pub use error::{EncodeError, FormatError, ParseError, ProcessingError, RawError, RawResult}; pub use rawshift_core::*; pub use rgb_image::RgbImage; diff --git a/crates/rawshift-image-heic/src/decoder.rs b/crates/rawshift-image-heic/src/decoder.rs index 894d503..90f29c0 100644 --- a/crates/rawshift-image-heic/src/decoder.rs +++ b/crates/rawshift-image-heic/src/decoder.rs @@ -164,6 +164,18 @@ impl HeicFile { } } + /// The colour space the container declared for one item. + /// + /// HEIF permits a `colr` per item, so each decoded item is tagged from its + /// own box, falling back to the primary item's when it has none — a grid + /// tile or auxiliary typically inherits the primary's colour. + #[cfg(feature = "hw")] + fn item_color(&self, id: u32) -> rawshift_image_core::ColorDescription { + let own = self.image.item(id).as_ref().and_then(declared_color); + let color = own.or_else(|| declared_color(&self.image.primary_item())); + rawshift_image_core::resolve_color(color.unwrap_or_default()) + } + /// Decode one item through the hardware HEVC decoder. #[cfg(feature = "hw")] fn decode_item(&self, id: u32) -> RawResult { @@ -175,7 +187,11 @@ impl HeicFile { }; let mut adapter = hw::HwHevcAdapter::new(decoder); match self.image.decode_item_rgba8(id, &mut adapter) { - Ok(rgba) => rgba8_to_rgb_image(&rgba), + Ok(rgba) => { + let mut image = rgba8_to_rgb_image(&rgba)?; + image.set_color(self.item_color(id)); + Ok(image) + } Err(source) => Err(adapter.into_raw_error(source)), } } @@ -191,6 +207,47 @@ impl HeicFile { } } +/// What one item's `colr` box declares about its colour space. +/// +/// A `colr` holds either CICP code points (`nclx`) or an ICC profile. The +/// returned borrow is of the file data, not of `item`, which is why this is a +/// free function with an explicit lifetime rather than a closure. +fn declared_color<'a>(item: &HeifItem<'a>) -> Option> { + match item.colour()? { + ColourInformation::Nclx(nclx) => Some(rawshift_image_core::ContainerColor { + cicp: Some((nclx.colour_primaries, nclx.transfer_characteristics)), + icc: None, + }), + ColourInformation::RestrictedIcc(bytes) | ColourInformation::UnrestrictedIcc(bytes) => { + Some(rawshift_image_core::ContainerColor { + cicp: None, + icc: Some(bytes.as_slice()), + }) + } + _ => None, + } +} + +/// The colour space a HEIC file declares for its primary item, without +/// decoding pixels. +/// +/// Parses the container only — no hardware decoder is involved, so this works +/// in every build. Returns [`ColorDescription::SRGB`] when the file carries no +/// `colr` box, and [`ColorDescription::UNSPECIFIED`] when it cannot be parsed +/// at all or carries a profile with no CICP expression. +/// +/// [`ColorDescription::SRGB`]: rawshift_image_core::ColorDescription::SRGB +/// [`ColorDescription::UNSPECIFIED`]: rawshift_image_core::ColorDescription::UNSPECIFIED +#[must_use] +pub fn probe_heic_color(data: &[u8]) -> rawshift_image_core::ColorDescription { + match HeicFile::open(data.to_vec()) { + Ok(file) => rawshift_image_core::resolve_color( + declared_color(&file.image.primary_item()).unwrap_or_default(), + ), + Err(_) => rawshift_image_core::ColorDescription::UNSPECIFIED, + } +} + /// Whether HEIC pixel decode can work in this build on this machine: a /// hardware HEVC decoder is compiled in (`hw`/`hw-*` feature) **and** usable /// at runtime. diff --git a/crates/rawshift-image-jpeg/src/lib.rs b/crates/rawshift-image-jpeg/src/lib.rs index 960bfb4..67cdf9a 100644 --- a/crates/rawshift-image-jpeg/src/lib.rs +++ b/crates/rawshift-image-jpeg/src/lib.rs @@ -44,6 +44,9 @@ pub fn decode(data: &[u8], _config: &JpegDecodeConfig) -> RawResult { }; let info = gamut_jpeg::info(data).map_err(jpeg_err)?; if info.components == 4 { + // CMYK. The embedded profile describes the *CMYK* samples, not the RGB + // this branch synthesises with the Blinn approximation below, so it + // must not be used to tag the result. Left at the sRGB default. let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; let dims = decoded.dimensions(); let samples = decoded @@ -61,10 +64,18 @@ pub fn decode(data: &[u8], _config: &JpegDecodeConfig) -> RawResult { } let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; let dims = decoded.dimensions(); - RgbImage::new( + // A second pass over the APP segments: a marker walk, not an entropy + // decode, so it costs a scan of the headers rather than of the image. + let icc = gamut_jpeg::metadata(data).ok().and_then(|meta| meta.icc); + let color = rawshift_image_core::resolve_color(rawshift_image_core::ContainerColor { + cicp: None, + icc: icc.as_deref(), + }); + RgbImage::with_color( dims.width, dims.height, decoded.as_samples().iter().map(|&v| scale(v)).collect(), + color, ) } diff --git a/crates/rawshift-image-png/src/lib.rs b/crates/rawshift-image-png/src/lib.rs index 31a0e77..f0e12f9 100644 --- a/crates/rawshift-image-png/src/lib.rs +++ b/crates/rawshift-image-png/src/lib.rs @@ -46,6 +46,19 @@ pub fn decode(data: &[u8], config: &PngDecodeConfig) -> RawResult { }) })?; let (width, height) = (decoded.header.width, decoded.header.height); + // Tag from what the container declared, before `decoded.image` is consumed + // below. cICP is authoritative when present; otherwise the iCCP profile + // decides. A bare sRGB chunk needs no special case — with no iCCP, + // `resolve_color` already defaults to sRGB. + let color = rawshift_image_core::resolve_color(rawshift_image_core::ContainerColor { + cicp: decoded + .cicp + .map(|c| (u16::from(c.color_primaries), u16::from(c.transfer_function))), + icc: decoded + .icc_profile + .as_ref() + .map(|icc| icc.profile.as_slice()), + }); let samples = match decoded.image { PngImage::Gray8(image) => image .as_samples() @@ -92,7 +105,7 @@ pub fn decode(data: &[u8], config: &PngDecodeConfig) -> RawResult { .collect() } }; - RgbImage::new(width, height, samples) + RgbImage::with_color(width, height, samples, color) } #[cfg(feature = "decode")] diff --git a/crates/rawshift-image-webp/src/lib.rs b/crates/rawshift-image-webp/src/lib.rs index 69114e8..0c106d6 100644 --- a/crates/rawshift-image-webp/src/lib.rs +++ b/crates/rawshift-image-webp/src/lib.rs @@ -50,7 +50,14 @@ pub fn decode(data: &[u8], _config: &WebpDecodeConfig) -> RawResult { .iter() .map(|&value| u16::from(value) * 257) .collect(); - RgbImage::new(dimensions.width, dimensions.height, samples) + // `gamut_webp::metadata` walks the RIFF chunk list; it does not re-decode + // the VP8/VP8L payload. + let icc = gamut_webp::metadata(data).ok().and_then(|meta| meta.icc); + let color = rawshift_image_core::resolve_color(rawshift_image_core::ContainerColor { + cicp: None, + icc: icc.as_deref(), + }); + RgbImage::with_color(dimensions.width, dimensions.height, samples, color) } #[cfg(feature = "encode")] diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index ca3d058..048c454 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -583,7 +583,7 @@ pub fn decode_standard_image(data: &[u8], format: StandardFormat) -> RawResult RawRe #[allow(unreachable_patterns)] _ => unreachable!(), }; - decoded.map(tag_srgb) + decoded.map(|image| default_to_srgb(options.format(), image)) } -/// Tag a freshly-decoded standard image with its color description. +/// Whether `format`'s decoder resolves the container's colour space itself. /// -/// Every standard decoder produces display-referred, sRGB-encoded RGB, so the -/// result is tagged [`ColorDescription::SRGB`](crate::core::ColorDescription::SRGB). -/// When the source carried a non-sRGB ICC profile the pixels are *not* -/// converted — the precise profile is preserved in +/// These decoders already hold the `colr` box / `iCCP` chunk / APP2 segment +/// they need, so they tag the image with +/// [`resolve_color`](rawshift_image_core::resolve_color) at no extra parsing +/// cost, and [`default_to_srgb`] must leave their answer alone. +/// +/// Everything else — GIF, JXL, TIFF, SVG, PPM, APV — has no colour path in +/// rawshift today and falls back to the sRGB default. +const fn decoder_resolves_color(format: StandardFormat) -> bool { + matches!( + format, + StandardFormat::Png + | StandardFormat::Jpeg + | StandardFormat::WebP + | StandardFormat::Avif + | StandardFormat::Heic + ) +} + +/// Apply the sRGB default to a decoder that does not resolve colour itself. +/// +/// For the formats in [`decoder_resolves_color`] this is the identity: their +/// tag is authoritative, including +/// [`UNSPECIFIED`](crate::core::ColorDescription::UNSPECIFIED), which means +/// "the source carried an ICC-authoritative profile that has no CICP +/// expression" — overwriting that with sRGB is precisely the bug this +/// distinction exists to avoid. Those pixels are *not* converted; the profile +/// is preserved in /// [`ImageMetadata::icc_profile`](crate::core::ImageMetadata) by /// [`read_standard_image_metadata`], and a caller wanting true sRGB pixels can /// apply [`convert_to_srgb`](crate::transforms::convert_to_srgb). -fn tag_srgb(mut image: RgbImage) -> RgbImage { - image.set_color(crate::core::ColorDescription::SRGB); +fn default_to_srgb(format: StandardFormat, mut image: RgbImage) -> RgbImage { + if !decoder_resolves_color(format) { + image.set_color(crate::core::ColorDescription::SRGB); + } image } @@ -718,10 +743,57 @@ pub fn probe_standard_image(data: &[u8]) -> RawResult { format, size, bit_depth, - color_space: crate::core::ColorDescription::SRGB, + color_space: probe_color(data, format), }) } +/// The colour space a probe can determine without decoding pixels. +/// +/// JPEG, WebP, AVIF, and HEIC expose their colour declaration in a marker, +/// chunk, or box walk, which is what a probe is allowed to cost. +/// +/// PNG is the gap: its `iCCP` chunk is DEFLATE-compressed, and gamut-png +/// exposes ancillary chunks only from its full `PngDecoder::decode`, so +/// reading it here would decode every pixel — exactly what a probe promises +/// not to do. PNG therefore reports +/// [`UNSPECIFIED`](crate::core::ColorDescription::UNSPECIFIED), meaning "not +/// determined by the probe" rather than "no profile"; a PNG cICP chunk is +/// uncompressed but is rare enough not to be worth a bespoke chunk walk here. +/// [`decode_standard_image`] resolves PNG's colour authoritatively. +/// +/// Formats with no colour path at all report +/// [`SRGB`](crate::core::ColorDescription::SRGB), the documented default. +fn probe_color(data: &[u8], format: StandardFormat) -> crate::core::ColorDescription { + use crate::core::ColorDescription; + + let _ = data; + match format { + #[cfg(any(feature = "jpeg-decode", feature = "jpeg-encode"))] + StandardFormat::Jpeg => { + let icc = gamut_jpeg::metadata(data).ok().and_then(|meta| meta.icc); + rawshift_image_core::resolve_color(rawshift_image_core::ContainerColor { + cicp: None, + icc: icc.as_deref(), + }) + } + #[cfg(any(feature = "webp-decode", feature = "webp-encode"))] + StandardFormat::WebP => { + let icc = gamut_webp::metadata(data).ok().and_then(|meta| meta.icc); + rawshift_image_core::resolve_color(rawshift_image_core::ContainerColor { + cicp: None, + icc: icc.as_deref(), + }) + } + #[cfg(feature = "avif-decode")] + StandardFormat::Avif => crate::formats::avif::probe_avif_color(data), + #[cfg(feature = "heic-decode")] + StandardFormat::Heic => crate::formats::heic::probe_heic_color(data), + // Not determined by a header-only probe; see the doc comment. + StandardFormat::Png => ColorDescription::UNSPECIFIED, + _ => ColorDescription::SRGB, + } +} + /// PNG: dimensions and bit depth live in the fixed-offset IHDR chunk. fn probe_png(data: &[u8]) -> RawResult<(Dimensions, Option)> { if data.len() < 26 || &data[12..16] != b"IHDR" { From 9fda2e1b6930df5c7e07fd68586a7388bf846463 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:08:12 -0400 Subject: [PATCH 2/7] feat(image): preserve the source ICC profile through encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `embed_icc` branch — PNG, JPEG, WebP, AVIF, JXL — wrote `IccProfile::srgb()`, so encoding discarded whatever profile the source carried and replaced it with a synthesised sRGB one. An Adobe RGB or ProPhoto RGB image survived decode with its pixels intact and its profile preserved in `ImageMetadata`, then lost the profile on the way out: the samples were still wide-gamut but the file now claimed to be sRGB. `profile_to_embed` picks the profile using the image's `ColorDescription` as the arbiter, which is exactly the distinction the previous commit made available: - `UNSPECIFIED` means the decoder found an ICC-authoritative profile with no faithful CICP expression, so the profile *is* the colour space and is embedded verbatim. - Any named space means the samples are in that space whatever `metadata` happens to carry, and the synthesised sRGB profile is used. That second arm is load-bearing rather than a fallback. `RawFile::process` tags its output `SRGB` while the source file's metadata may carry a *camera* profile describing the sensor, not the developed image; embedding it would mis-tag every RAW export. `named_colour_space_does_not_adopt_an_unrelated_profile` pins this. Known gap, documented on the helper: an image tagged `DISPLAY_P3` or `REC2020` from a container's CICP box still gets an sRGB profile, because rawshift can only synthesise sRGB and the source carried code points rather than a profile. The right fix is to write the code points through to the output container (a PNG `cICP` chunk, an AVIF `colr nclx` box), which is tracked separately. 646 workspace tests pass with `--features rawshift-image/full`. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- crates/rawshift-image/src/formats/encode.rs | 67 ++++++++++++-- .../tests/export_format_tests.rs | 89 +++++++++++++++++++ 2 files changed, 147 insertions(+), 9 deletions(-) diff --git a/crates/rawshift-image/src/formats/encode.rs b/crates/rawshift-image/src/formats/encode.rs index 8ed3e1e..f218b3c 100644 --- a/crates/rawshift-image/src/formats/encode.rs +++ b/crates/rawshift-image/src/formats/encode.rs @@ -21,6 +21,50 @@ use super::export::EncodeOptions; #[cfg(feature = "webp-encode")] use super::export::WebPMode; +/// The ICC profile bytes to embed for an image. +/// +/// Prefers the source's own profile so a wide-gamut image survives a +/// round-trip, but only when that profile is what actually describes these +/// samples. The image's [`ColorDescription`] is the arbiter: +/// +/// - [`UNSPECIFIED`] means the decoder found an ICC-authoritative profile with +/// no faithful CICP expression — Adobe RGB, ProPhoto RGB — and deliberately +/// declined to invent code points for it. The profile *is* the colour space, +/// so it is embedded verbatim and the round-trip is lossless. +/// - Any named space means the samples are in that space regardless of what +/// `metadata` happens to carry, and a synthesised sRGB profile is used. +/// +/// That second case is what keeps RAW export correct: `RawFile::process` tags +/// its output `SRGB`, while the file's metadata may carry a *camera* profile +/// describing the sensor rather than the developed image. Embedding that would +/// mis-tag the export. +/// +/// # Known gap +/// +/// An image tagged `DISPLAY_P3` or `REC2020` from a container's CICP box gets +/// an sRGB profile, because rawshift can only synthesise sRGB and the source +/// carried code points rather than a profile. Writing the code points through +/// to the output container (a PNG `cICP` chunk, an AVIF `colr nclx` box) is the +/// correct fix and is tracked separately. +/// +/// [`ColorDescription`]: crate::core::ColorDescription +/// [`UNSPECIFIED`]: crate::core::ColorDescription::UNSPECIFIED +#[cfg(any_standard_encode)] +fn profile_to_embed<'a>( + image: &RgbImage, + metadata: &'a ImageMetadata, +) -> std::borrow::Cow<'a, [u8]> { + use crate::core::ColorDescription; + use crate::metadata::icc::IccProfile; + + if image.color() == ColorDescription::UNSPECIFIED + && let Some(profile) = metadata.icc_profile.as_deref() + { + return std::borrow::Cow::Borrowed(profile); + } + std::borrow::Cow::Owned(IccProfile::srgb().as_bytes().to_vec()) +} + /// Encode a linear RGB image to an in-memory byte buffer. /// /// This is the core encode entry point — every output format (PNG, JPEG, WebP, @@ -121,7 +165,6 @@ fn encode_png( ) -> RawResult> { use super::export::{PngCompressionLevel, PngFilterStrategy, PngFilterType}; use crate::metadata::exif::ExifBuilder; - use crate::metadata::icc::IccProfile; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgb16}; use gamut_png::{FilterStrategy, FilterType, Level, PngEncoder}; @@ -161,9 +204,12 @@ fn encode_png( // Metadata is embedded by the encoder itself (eXIf / iCCP / XMP iTXt // chunks), so it is configured up front — no post-hoc chunk muxing. let m = &cfg.common.metadata; + // Bound outside the `if` so the borrow the encoder takes outlives it. + let icc; if m.embed_icc { // "ICC Profile" is the conventional iCCP profile name. - encoder = encoder.with_icc_profile("ICC Profile", IccProfile::srgb().as_bytes()); + icc = profile_to_embed(image, metadata); + encoder = encoder.with_icc_profile("ICC Profile", &icc); } if m.embed_exif { match ExifBuilder::new(metadata).build_bytes() { @@ -218,7 +264,6 @@ fn encode_jpeg( ) -> RawResult> { use super::export::{JpegDensityUnit, JpegSubsampling}; use crate::metadata::exif::ExifBuilder; - use crate::metadata::icc::IccProfile; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; use gamut_jpeg::{ChromaSubsampling, DensityUnit, JpegEncoder}; @@ -264,8 +309,11 @@ fn encode_jpeg( Err(e) => tracing::warn!("Failed to embed EXIF in JPEG: {e}"), } } + // Bound outside the `if` so the borrow the encoder takes outlives it. + let icc; if m.embed_icc { - encoder = encoder.with_icc_profile(IccProfile::srgb().as_bytes()); + icc = profile_to_embed(image, metadata); + encoder = encoder.with_icc_profile(&icc); } if m.embed_xmp && let Some(xmp_data) = &metadata.xmp @@ -295,7 +343,6 @@ fn encode_webp( cfg: &super::export::WebpEncodeConfig, ) -> RawResult> { use crate::metadata::exif::ExifBuilder; - use crate::metadata::icc::IccProfile; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; use gamut_webp::WebpEncoder; @@ -312,7 +359,7 @@ fn encode_webp( } model.icc = if m.embed_icc { Some( - gamut_icc::IccProfile::parse(IccProfile::srgb().as_bytes()).map_err(|e| { + gamut_icc::IccProfile::parse(&profile_to_embed(image, metadata)).map_err(|e| { RawError::Encode(EncodeError::Encoding { format: "WebP", message: format!("WebP ICC encoding error: {e}"), @@ -413,7 +460,8 @@ fn encode_avif( // items by rawshift's own muxer (`metadata::isobmff::insert_item`). let m = &cfg.common.metadata; if m.embed_icc { - match IccProfile::srgb().append_to_avif(avif_bytes.clone()) { + let profile = IccProfile::from_bytes(profile_to_embed(image, metadata).into_owned()); + match profile.append_to_avif(avif_bytes.clone()) { Ok(data) => avif_bytes = data, Err(e) => tracing::warn!("Failed to embed ICC in AVIF: {e}"), } @@ -445,7 +493,6 @@ fn encode_jxl( cfg: &super::export::JxlEncodeConfig, ) -> RawResult> { use crate::metadata::exif::ExifBuilder; - use crate::metadata::icc::IccProfile; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgb16}; use gamut_jxl::{ColorSpec, Container, Distance, Effort, JxlEncoder}; @@ -480,7 +527,9 @@ fn encode_jxl( // colour metadata rather than a sidecar box. let m = &cfg.common.metadata; if m.embed_icc { - encoder = encoder.with_color(ColorSpec::Icc(IccProfile::srgb().as_bytes().to_vec())); + encoder = encoder.with_color(ColorSpec::Icc( + profile_to_embed(image, metadata).into_owned(), + )); } let mut needs_container = cfg.use_container; if m.embed_exif { diff --git a/crates/rawshift-image/tests/export_format_tests.rs b/crates/rawshift-image/tests/export_format_tests.rs index 45749c1..286ccd1 100644 --- a/crates/rawshift-image/tests/export_format_tests.rs +++ b/crates/rawshift-image/tests/export_format_tests.rs @@ -1046,6 +1046,95 @@ mod in_memory_tests { assert_eq!(decoded.color(), ColorDescription::SRGB); } + /// An ICC-authoritative source profile — one with no faithful CICP + /// expression — must survive encode → decode, not be replaced by a + /// synthesised sRGB profile. Adobe RGB is the canonical case. + #[test] + fn adobe_rgb_profile_survives_a_png_round_trip() { + use rawshift_image::core::ColorDescription; + use rawshift_image::formats::read_standard_image_metadata; + + let profile = adobe_rgb_profile(); + // Tagging the image UNSPECIFIED is what a decode of an Adobe RGB + // source produces: the profile is the colour space. + let mut image = synthetic_image(); + image.set_color(ColorDescription::UNSPECIFIED); + let metadata = ImageMetadata { + icc_profile: Some(profile.clone()), + ..ImageMetadata::default() + }; + + let bytes = + encode_rgb_image_to_vec(&image, &metadata, &EncodeOptions::png()).expect("encode PNG"); + + let embedded = read_standard_image_metadata(&bytes, StandardFormat::Png) + .icc_profile + .expect("PNG carries an ICC profile"); + assert_eq!( + embedded, profile, + "the source profile must be embedded verbatim, not replaced by sRGB" + ); + + // And the decode side must not claim it is sRGB. + let decoded = decode_standard_image(&bytes, StandardFormat::Png).expect("decode PNG"); + assert_eq!(decoded.color(), ColorDescription::UNSPECIFIED); + } + + /// An image tagged with a named space keeps the synthesised sRGB profile + /// even when the metadata carries some other profile — this is what stops + /// a RAW export from embedding the camera's sensor profile, since + /// `RawFile::process` tags its output `SRGB`. + #[test] + fn named_colour_space_does_not_adopt_an_unrelated_profile() { + use rawshift_image::core::ColorDescription; + use rawshift_image::formats::read_standard_image_metadata; + + let mut image = synthetic_image(); + image.set_color(ColorDescription::SRGB); + let metadata = ImageMetadata { + icc_profile: Some(adobe_rgb_profile()), + ..ImageMetadata::default() + }; + + let bytes = + encode_rgb_image_to_vec(&image, &metadata, &EncodeOptions::png()).expect("encode PNG"); + let embedded = read_standard_image_metadata(&bytes, StandardFormat::Png) + .icc_profile + .expect("PNG carries an ICC profile"); + assert_ne!( + embedded, + adobe_rgb_profile(), + "an sRGB-tagged image must not adopt the metadata's camera profile" + ); + let decoded = decode_standard_image(&bytes, StandardFormat::Png).expect("decode PNG"); + assert_eq!(decoded.color(), ColorDescription::SRGB); + } + + /// A minimal Adobe RGB (1998) matrix/TRC profile: D50-adapted colorants + /// and a gamma-2.2 curve. Distinct enough from sRGB that + /// `resolve_color` must classify it `UNSPECIFIED`. + fn adobe_rgb_profile() -> Vec { + use gamut_icc::{ + ColorSpace, Curve, DeviceClass, IccProfile, ProfileHeader, Signature, TagData, + U8Fixed8, XyzNumber, + }; + let xyz = |v: [f64; 3]| TagData::Xyz(vec![XyzNumber::from_f64(v)]); + let trc = TagData::Curve(Curve::Gamma(U8Fixed8(0x0238))); + IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![ + (Signature(*b"rXYZ"), xyz([0.609_74, 0.311_11, 0.019_47])), + (Signature(*b"gXYZ"), xyz([0.205_28, 0.625_67, 0.060_87])), + (Signature(*b"bXYZ"), xyz([0.149_19, 0.063_22, 0.744_57])), + (Signature(*b"rTRC"), trc.clone()), + (Signature(*b"gTRC"), trc.clone()), + (Signature(*b"bTRC"), trc), + ], + } + .to_bytes() + .expect("Adobe RGB fixture serialises") + } + #[test] fn probe_reports_dimensions_without_decoding() { let bytes = encode_rgb_image_to_vec( From 0247882dd0dd1dd94d43fec88df3be350bb5170b Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:24:05 -0400 Subject: [PATCH 3/7] feat(gallery): colour-managed decode/encode round-trip GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rawshift had eight CLI examples but nothing showing the decode → encode → decode loop end to end, and nothing making hardware decode visible. The VideoToolbox backend's only proof was a device-gated test suite. `examples/gallery` is a cross-platform iced GUI: pick images, and each is decoded, re-encoded through every selected format with every parameter exposed, written to the system temp directory, decoded back, and shown side by side against the source with PSNR / max-delta / bit-exact verdicts. A stage that fails puts its error text in an empty placeholder rather than dropping the tile. `gamut-cmm` is a git dependency, and the gamut repository's aom/dav1d submodules make that a ~1.4 GB checkout. As a workspace member that cost would land on every CI job — including ones naming it in `--exclude`, since Cargo still reads every member manifest to build the resolve graph. Standalone, only `just gallery*` and the dedicated `gallery` CI job pay it. It also keeps iced/wgpu/winit out of `cargo test -p rawshift-image`, which an `[[example]]` could not have done: Cargo has no optional dev-dependencies. The git dependency itself is a scoped carve-out to the Upstream-First Policy, now recorded in AGENTS.md. The no-git rule exists because git deps prevent publishing; that does not reach a crate which is `publish = false` and outside the workspace. gamut-cmm is the only way to apply an ICC transform to pixels — gamut-icc parses profiles but explicitly does not transform them — and it is not on crates.io yet. Note that gamut-cmm pulls gamut-core/color/icc from the *git* checkout, a different Cargo source from the crates.io copies rawshift uses, so this crate links two `gamut-icc` crates whose types are not interchangeable. ICC data crosses that boundary as bytes and is re-parsed; `src/color.rs` documents it. `pipeline`, `color`, `settings`, and `render` hold no iced types, so `--headless` and the 35 unit tests drive exactly the code the window does — there is no second implementation to drift. Work runs on a blocking worker one source at a time, so a large RAW cannot freeze the window. On this M3 Pro, rawshift cannot decode its own AVIF output: gamut-avif encodes identity-matrix 4:4:4 (AV1 Profile 1) and VideoToolbox's still-image path decodes Main / Profile 0 only. Both halves work in isolation, and nothing in the test suite crosses them. The gallery shows the decode error rather than hiding the column, and the hardware-absence heuristic is deliberately narrow so a decoder that *rejects* a bitstream is counted as a failure rather than excused as a missing backend. Run from the repo root on macOS 26.6, M3 Pro: | Command | Result | | --- | --- | | `cargo fmt --all -- --check` | clean | | `cargo clippy --workspace --all-targets -- -D warnings` | clean | | `cargo clippy -p rawshift-image --all-targets --features full -- -D warnings` | clean | | `cargo test --workspace --features rawshift-image/full` | 646 passed, 0 failed | | `cargo check --workspace` (MSRV job shape) | clean; does not build the gallery | | `cargo fmt --manifest-path examples/gallery/Cargo.toml -- --check` | clean | | `cargo clippy --manifest-path examples/gallery/Cargo.toml --all-targets -- -D warnings` | clean | | `cargo test --manifest-path examples/gallery/Cargo.toml` | 35 passed | | `just gallery-headless` on PNG/JPEG/AVIF/HEIC fixtures | table below | The whole tree — iced 0.14, wgpu, git gamut-cmm, libjxl — builds on the pinned 1.92.0 toolchain. Headless run against the generated fixtures reports the VideoToolbox backend with HEVC + AV1, decodes the HEIC through hardware, reads its colour from the `colr` box (CICP 1/13), and round-trips PNG and JPEG XL bit-exact with JPEG at 31.88 dB and WebP at 28.68 dB. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- .github/workflows/ci.yml | 32 + .gitignore | 3 + AGENTS.md | 7 + CHANGELOG.md | 44 + Cargo.toml | 8 + crates/rawshift-image/src/core/mod.rs | 5 + examples/gallery/Cargo.lock | 5280 +++++++++++++++++++++++++ examples/gallery/Cargo.toml | 74 + examples/gallery/README.md | 102 + examples/gallery/src/app.rs | 879 ++++ examples/gallery/src/color.rs | 412 ++ examples/gallery/src/headless.rs | 184 + examples/gallery/src/main.rs | 115 + examples/gallery/src/pipeline.rs | 422 ++ examples/gallery/src/render.rs | 134 + examples/gallery/src/settings.rs | 269 ++ justfile | 21 + 17 files changed, 7991 insertions(+) create mode 100644 examples/gallery/Cargo.lock create mode 100644 examples/gallery/Cargo.toml create mode 100644 examples/gallery/README.md create mode 100644 examples/gallery/src/app.rs create mode 100644 examples/gallery/src/color.rs create mode 100644 examples/gallery/src/headless.rs create mode 100644 examples/gallery/src/main.rs create mode 100644 examples/gallery/src/pipeline.rs create mode 100644 examples/gallery/src/render.rs create mode 100644 examples/gallery/src/settings.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51357f4..34b1ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,6 +258,38 @@ jobs: name: coverage-report path: coverage.json + # The demonstration GUI (examples/gallery). A standalone package outside the + # workspace, so no other job builds it: its gamut-cmm git dependency pulls the + # gamut repository's aom/dav1d submodules, a ~1.4 GB checkout that a workspace + # member would impose on every job that resolves the workspace. + # + # Builds and unit-tests only. The window needs a display server, but the + # round-trip logic it drives does not — `--headless` runs the same matrix, and + # the unit tests cover decode/encode/compare and the colour transform. + gallery: + name: Gallery (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.92.0" + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: examples/gallery + # libjxl (via gamut-jxl, pulled by rawshift-image/full) builds from source. + - name: Install build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y cmake clang libclang-dev + - run: cargo fmt --manifest-path examples/gallery/Cargo.toml -- --check + - run: cargo clippy --manifest-path examples/gallery/Cargo.toml --all-targets -- -D warnings + - run: cargo test --manifest-path examples/gallery/Cargo.toml + msrv: name: MSRV (1.92) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e4cc6ec..a4a587e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /target +# examples/gallery is a standalone package outside the workspace, so it has a +# build directory of its own that the anchored rule above does not cover. +/examples/gallery/target .DS_Store diff --git a/AGENTS.md b/AGENTS.md index d2368c9..d58987c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,13 @@ dependencies are not permitted because they prevent publishing rawshift. link to the gamut issue. - Permanent exceptions (stay on current deps; do not migrate, do not file upstream issues): GIF (`gif`), SVG (`resvg`), PPM (`zune-ppm`). +- One scoped carve-out to the no-git-dependencies rule: `examples/gallery`, the + demonstration GUI, takes `gamut-cmm` from gamut `master`. The rule exists + because git dependencies prevent publishing; that does not reach a crate + which is `publish = false` **and** outside the workspace, so no published + crate's dependency tree contains it. The carve-out is limited to that one + package and expires when gamut-cmm reaches crates.io. Do not widen it: the + workspace `[workspace.dependencies]` gamut pins stay crates.io-only. - Supported compilation targets and hardware decode APIs are fixed in `docs/SUPPORT.md` (with justifications for exclusions) — do not add or remove targets/APIs; they were decided once at v1. diff --git a/CHANGELOG.md b/CHANGELOG.md index 48808fa..15d9630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,54 @@ from the published [gamut](https://github.com/visualcommons/gamut) crates. dependency is a registry package. 0.x source compatibility is a non-goal (see `docs/V1_DESIGN.md`). +### Added + +#### Colour management + +- `rawshift-image-core::color_resolve` maps what a container declares — a CICP + code-point pair, an embedded ICC profile, or neither — onto a + `ColorDescription`. Surfaced from `rawshift_image::core` as `resolve_color`, + `ContainerColor`, and `is_convertible_to_srgb`. +- `probe_avif_color` / `probe_heic_color`: the primary item's declared colour + space from container parsing alone, with no hardware decoder involved. + +#### Examples + +- `examples/gallery`, a cross-platform GUI (iced) demonstrating the decode and + encode paths end to end: sources are re-encoded through every selected format + with every parameter exposed, written to the temp directory, decoded back, + and shown side by side with PSNR / max-delta / bit-exact verdicts. Run it + with `just gallery`, or `just gallery-headless` for the same matrix without a + window. It is a standalone package outside the workspace and is never + published; see its README for why, and for the scoped `gamut-cmm` git + carve-out recorded in `AGENTS.md`. + ### Changed All entries below are **breaking**, grouped by area. +#### Colour handling on decode and encode + +- **Decode now reports the container's real colour space.** `decode_standard_image` + and `decode_standard_image_with` previously forced every result to + `ColorDescription::SRGB`. PNG, JPEG, WebP, AVIF, and HEIC now tag from the + `cICP` chunk / `colr` box / embedded profile they already parse; the formats + with no colour path keep the sRGB default. A profile with no faithful CICP + expression (Adobe RGB, ProPhoto RGB) resolves to `UNSPECIFIED`, which is what + that value documents, with the profile preserved in + `ImageMetadata::icc_profile`. Callers that assumed decode always yields + `SRGB` must handle `DISPLAY_P3`, `REC2020`, `LINEAR_SRGB`, and `UNSPECIFIED`. +- **`ImageProbe::color_space` reads the file** for JPEG, WebP, AVIF, and HEIC + instead of returning a hardcoded `SRGB`. PNG reports `UNSPECIFIED` — its + `iCCP` chunk is DEFLATE-compressed and gamut-png exposes ancillary chunks + only from a full decode, which a header-only probe must not do. +- **Encode preserves the source ICC profile.** Every `embed_icc` path wrote a + synthesised sRGB profile, discarding the source's. An image tagged + `UNSPECIFIED` now carries its own profile through to the output. Images + tagged with a named space still get sRGB, which is what keeps RAW export + correct — `RawFile::process` tags its output `SRGB` while the file's metadata + may hold a camera profile describing the sensor, not the developed image. + #### Package boundaries - Image formats now live in 17 independently publishable diff --git a/Cargo.toml b/Cargo.toml index dd400d3..98b9d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,14 @@ members = [ "crates/rawshift-image-webp", "crates/rawshift-video", ] +# `examples/gallery` is deliberately NOT a member. It is a `publish = false` +# demonstration GUI whose only git dependency (gamut-cmm, see its manifest) +# drags in the gamut repository's `aom`/`dav1d` submodules — a ~1.4 GB +# checkout. A workspace member would impose that fetch on every job that +# resolves the workspace, including ones excluding it by name, because Cargo +# still reads every member manifest to build the resolve graph. Standalone, it +# is built only by `just gallery*` and the dedicated `gallery` CI job. +exclude = ["examples/gallery"] [workspace.package] version = "0.1.1" diff --git a/crates/rawshift-image/src/core/mod.rs b/crates/rawshift-image/src/core/mod.rs index dfbcee3..224afcf 100644 --- a/crates/rawshift-image/src/core/mod.rs +++ b/crates/rawshift-image/src/core/mod.rs @@ -9,6 +9,11 @@ pub use rawshift_core::*; pub use rawshift_image_core::RgbImage; +// The container-colour resolution used by the per-format decoders, surfaced +// so callers can classify a profile the same way a decode does. +pub use rawshift_image_core::color_resolve::{ + ContainerColor, is_convertible_to_srgb, resolve_color, +}; // Re-export IccProfile from the internal metadata module so it remains // publicly accessible under `core` as before the workspace split. diff --git a/examples/gallery/Cargo.lock b/examples/gallery/Cargo.lock new file mode 100644 index 0000000..827d598 --- /dev/null +++ b/examples/gallery/Cargo.lock @@ -0,0 +1,5280 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.13.1", + "fontdb 0.23.0", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.24.1", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gamut-av1" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281f68cb437b34726d4eb788c7a9fab10724c166c4ae5cfda6ff65f7155ae94" +dependencies = [ + "gamut-bitstream", + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-dsp", +] + +[[package]] +name = "gamut-avif" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a6df2615bd17e126f3a0b6716430206b97fcf2f9fa0acc2725a5052e9e06b09" +dependencies = [ + "gamut-av1", + "gamut-codec-abi", + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-isobmff", +] + +[[package]] +name = "gamut-bitstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706ef7c7342d2cf3d2737690a6127b1c91f908add9d18c72e5d17c3931f2fe6a" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "gamut-cmm" +version = "0.1.0" +source = "git+https://github.com/visualcommons/gamut?branch=master#5e847484dcac644214bce165383f05fd892a54df" +dependencies = [ + "gamut-color 2.0.0 (git+https://github.com/visualcommons/gamut?branch=master)", + "gamut-core 2.0.1 (git+https://github.com/visualcommons/gamut?branch=master)", + "gamut-icc 1.0.0 (git+https://github.com/visualcommons/gamut?branch=master)", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-codec-abi" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38704051ac2980276478f6eb9c55a26fb608151f8e42c355f5a699a2f3afde03" + +[[package]] +name = "gamut-color" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde55ae76aee646990fd4f353574fbcfaf9f37a5f3e54ba0907c7d384936e56a" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "gamut-color" +version = "2.0.0" +source = "git+https://github.com/visualcommons/gamut?branch=master#5e847484dcac644214bce165383f05fd892a54df" +dependencies = [ + "gamut-core 2.0.1 (git+https://github.com/visualcommons/gamut?branch=master)", +] + +[[package]] +name = "gamut-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17c79c8672b675d538dceffda1dd5d00ba279cbcca4f75c7b4fa847741d5d31" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-core" +version = "2.0.1" +source = "git+https://github.com/visualcommons/gamut?branch=master#5e847484dcac644214bce165383f05fd892a54df" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-deflate" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b128070e10042ed241459f2a6542c9bd3639e3115b8a445732bf099f929ceec1" + +[[package]] +name = "gamut-dng" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab77ef628afcec0c690536c44040f484e8c95761a977efc183dfe03e77beee64" +dependencies = [ + "gamut-bitstream", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-ifd", + "gamut-jxl 0.3.0", + "miniz_oxide", +] + +[[package]] +name = "gamut-dsp" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed494150ad7376cd7e5f8775d175c5a22e497604f31ebc0ce47d94554d7861fb" + +[[package]] +name = "gamut-exif" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ce8735a1fc71d77b5361f4f126676abab8811cdd3797a6bacdb920cd20f85d" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-ifd", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-heic" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4d36811d22489afe2be15572022d29ed0ab2d9ac5ab00a89355f0122d9632d" +dependencies = [ + "gamut-codec-abi", + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-isobmff", +] + +[[package]] +name = "gamut-icc" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09916f0aea3f9dbe1c5ac67e0e29956b9af43c8c86a0fbe6b47c597b63fd8cee" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "md-5", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-icc" +version = "1.0.0" +source = "git+https://github.com/visualcommons/gamut?branch=master#5e847484dcac644214bce165383f05fd892a54df" +dependencies = [ + "gamut-core 2.0.1 (git+https://github.com/visualcommons/gamut?branch=master)", + "md-5", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-ifd" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3bd47b53d4007db880394ed226de033d992646cfa394768237fed0e429806e" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "gamut-iptc" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b7bb8412adf69f48657d207ff5358750ebe4efe81c2dd7b32091034f354d6b" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-xmp", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-isobmff" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d60a05bc5adea2f820e285b0b85dc3e2e66a45c42e10f552415f53bf85060d" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "gamut-jpeg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f8fc170fc64c37d8a17c2c5c2ab7e23de375f01c99cf816e1f3eae7362b3c7" +dependencies = [ + "gamut-codec-abi", + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-dsp", +] + +[[package]] +name = "gamut-jxl" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb902868a80069b5896495a66fc3a4ce7e0b85beb8acb008ac5c21f3279b073" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "jxl", +] + +[[package]] +name = "gamut-jxl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8449465a694deb302f468edee6b5ed0c49587b03add54298bebebbedd7d02622" +dependencies = [ + "gamut-codec-abi", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-jxl-sys", + "jxl", +] + +[[package]] +name = "gamut-jxl-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b11d4b466b3f17ab6c7424d9051cff24d03cfd4d00e3170f7311aa7c30ed0d" +dependencies = [ + "cmake", + "jpegxl-src", +] + +[[package]] +name = "gamut-metadata" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502d8badb1ddfd9bca06fdec9a8e756dbf3eccc5be8274612bb89a20950b19f3" +dependencies = [ + "gamut-exif", + "gamut-icc 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-iptc", + "gamut-xmp", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-png" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d427c3d8d1850296d31e41812fefbd352ba65bd70794e8a849aca7fa76dbf" +dependencies = [ + "gamut-codec-abi", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-deflate", + "miniz_oxide", +] + +[[package]] +name = "gamut-riff" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14bd0da4d6d163461120b8b71bd01195dd85750cfe341d0a12a43cc155ee8627" +dependencies = [ + "gamut-bitstream", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "gamut-webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2a3f801c048a65edfb6282bdadd8221faf148a08cc1d88513066fb926f91090" +dependencies = [ + "gamut-codec-abi", + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-dsp", + "gamut-riff", +] + +[[package]] +name = "gamut-xmp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd39a4ebd0d14800751e3ae64a0c86a07e00b77f3ac564570e0e8987d41945bb" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "quick-xml 0.40.1", + "thiserror 2.0.20", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "image", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.20", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "image", + "kamadak-exif", + "log", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo 0.10.4", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "image-webp" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpegxl-src" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06f56a51e36f688517deebb0b7c10a11c598476936838938495c93764696180a" +dependencies = [ + "cmake", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jxl" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d1c706ad4b79469f7e713886c1d62b1cef365f38978c5e78d6e44258b18b99" +dependencies = [ + "array-init", + "byteorder", + "jxl_macros", + "jxl_simd", + "jxl_transforms", + "num-derive", + "num-traits", + "thiserror 2.0.20", +] + +[[package]] +name = "jxl_macros" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67c8b0a177004f4b05f73513c4be845845d2b5b1f93b6db71b5c72a1c9ad748" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jxl_simd" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d37f283bbcd785c0a1b128a9c1249feb8bdca899fffb0f281a6b183d3c3761" + +[[package]] +name = "jxl_transforms" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcc4291b69be45f42167d1722b286fec0ff8644e75e9e74d4c8db24e7e90bdf" +dependencies = [ + "jxl_simd", +] + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-video-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05bf9a3c14831a7d9641b0d81d87dd913ee238a012b2fde27db5a84b56f5df3e" +dependencies = [ + "bitflags 2.13.1", + "objc2-core-foundation", + "objc2-core-media", + "objc2-core-video", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawshift-core" +version = "0.1.1" +dependencies = [ + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "serde", +] + +[[package]] +name = "rawshift-gallery" +version = "0.1.1" +dependencies = [ + "clap", + "gamut-cmm", + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-icc 1.0.0 (git+https://github.com/visualcommons/gamut?branch=master)", + "iced", + "rawshift-core", + "rawshift-hwdec", + "rawshift-image", + "rayon", + "rfd", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "rawshift-hwdec" +version = "0.1.1" +dependencies = [ + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libloading", + "objc2-core-foundation", + "objc2-core-media", + "objc2-core-video", + "objc2-video-toolbox", + "thiserror 2.0.20", +] + +[[package]] +name = "rawshift-image" +version = "0.1.1" +dependencies = [ + "gamut-avif", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-dng", + "gamut-exif", + "gamut-heic", + "gamut-icc 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-ifd", + "gamut-isobmff", + "gamut-jpeg", + "gamut-jxl 0.4.0", + "gamut-metadata", + "gamut-png", + "gamut-webp", + "gamut-xmp", + "gif", + "rawshift-core", + "rawshift-hwdec", + "rawshift-image-arw", + "rawshift-image-avif", + "rawshift-image-core", + "rawshift-image-cr2", + "rawshift-image-cr3", + "rawshift-image-crw", + "rawshift-image-dng", + "rawshift-image-gif", + "rawshift-image-heic", + "rawshift-image-ifd", + "rawshift-image-jpeg", + "rawshift-image-jxl", + "rawshift-image-ljpeg", + "rawshift-image-metadata", + "rawshift-image-nef", + "rawshift-image-png", + "rawshift-image-ppm", + "rawshift-image-raf", + "rawshift-image-svg", + "rawshift-image-tiff", + "rawshift-image-webp", + "rayon", + "resvg", + "serde", + "thiserror 2.0.20", + "tiff", + "tracing", + "zune-core 0.5.3", + "zune-ppm", +] + +[[package]] +name = "rawshift-image-arw" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "rawshift-image-ljpeg", + "tracing", +] + +[[package]] +name = "rawshift-image-avif" +version = "0.1.1" +dependencies = [ + "gamut-avif", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-isobmff", + "rawshift-hwdec", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-core" +version = "0.1.1" +dependencies = [ + "gamut-color 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-icc 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rawshift-core", + "rawshift-hwdec", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "rawshift-image-cr2" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "rawshift-image-ljpeg", +] + +[[package]] +name = "rawshift-image-cr3" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "tracing", +] + +[[package]] +name = "rawshift-image-crw" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", +] + +[[package]] +name = "rawshift-image-dng" +version = "0.1.1" +dependencies = [ + "gamut-dng", + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "serde", + "tracing", +] + +[[package]] +name = "rawshift-image-gif" +version = "0.1.1" +dependencies = [ + "gif", + "rawshift-image-core", + "serde", +] + +[[package]] +name = "rawshift-image-heic" +version = "0.1.1" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-heic", + "gamut-isobmff", + "rawshift-hwdec", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-ifd" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-core", + "rawshift-image-core", + "tracing", +] + +[[package]] +name = "rawshift-image-jpeg" +version = "0.1.1" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-jpeg", + "gamut-xmp", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-jxl" +version = "0.1.1" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-jxl 0.4.0", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-ljpeg" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", +] + +[[package]] +name = "rawshift-image-metadata" +version = "0.1.1" +dependencies = [ + "gamut-exif", + "gamut-icc 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-ifd", + "gamut-metadata", + "gamut-xmp", + "rawshift-core", +] + +[[package]] +name = "rawshift-image-nef" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "rawshift-image-ljpeg", +] + +[[package]] +name = "rawshift-image-png" +version = "0.1.1" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-png", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-ppm" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "serde", + "zune-core 0.5.3", + "zune-ppm", +] + +[[package]] +name = "rawshift-image-raf" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "tracing", +] + +[[package]] +name = "rawshift-image-svg" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "resvg", + "serde", +] + +[[package]] +name = "rawshift-image-tiff" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "serde", + "tiff", +] + +[[package]] +name = "rawshift-image-webp" +version = "0.1.1" +dependencies = [ + "gamut-core 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "gamut-metadata", + "gamut-webp", + "gamut-xmp", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.24.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +dependencies = [ + "core_maths", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f" + +[[package]] +name = "unicode-ccc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb 0.22.0", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.20", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.3", +] + +[[package]] +name = "zune-ppm" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa3b8ca5bfe13b58735c3c8e56442390946e8e0e0ff50e12b9215afd278d422" +dependencies = [ + "zune-core 0.5.3", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml new file mode 100644 index 0000000..04f3430 --- /dev/null +++ b/examples/gallery/Cargo.toml @@ -0,0 +1,74 @@ +# Standalone package — deliberately outside the rawshift workspace. +# +# The `gamut-cmm` git dependency below pulls the gamut repository, whose +# `aom` / `dav1d` submodules make for a ~1.4 GB checkout. As a workspace member +# that cost would land on every job that resolves the workspace; standalone, it +# lands only here. This also keeps iced/wgpu/winit out of +# `cargo test -p rawshift-image`. +[workspace] + +[package] +name = "rawshift-gallery" +description = "Colour-managed decode/encode round-trip gallery — a demonstration GUI for rawshift." +version = "0.1.1" +edition = "2024" +# Not MSRV-bound: this crate is never published and is excluded from the MSRV +# job. It is still expected to build on the pinned toolchain in +# rust-toolchain.toml. +rust-version = "1.92.0" +license = "MPL-2.0" +repository = "https://github.com/visualcommons/rawshift" +homepage = "https://github.com/visualcommons/rawshift" +publish = false + +[dependencies] +rawshift-image = { path = "../../crates/rawshift-image", features = [ + "full", + "experimental", +] } +rawshift-core = { path = "../../crates/rawshift-core" } +rawshift-hwdec = { path = "../../crates/rawshift-hwdec", features = ["hw"] } + +# ── The one git dependency in this repository ──────────────────────────────── +# AGENTS.md forbids git dependencies on gamut because they prevent publishing +# rawshift. That reason does not reach this crate: `rawshift-gallery` is +# `publish = false` and outside the workspace, so no published crate's +# dependency tree contains it and `cargo publish -p rawshift-image` never sees +# it. The published crates stay crates.io-only. +# +# gamut-cmm is the ICC colour-management module (gamut#323). It is not on +# crates.io, and it is the only way to apply an ICC transform to pixels — +# gamut-icc parses and serialises profiles but explicitly does not transform. +# Retire this carve-out for a crates.io version once gamut-cmm ships. +# +# NOTE: gamut-cmm depends on gamut-core/gamut-color/gamut-icc by path inside +# the gamut workspace, so over git those resolve to the git checkout — a +# different Cargo *source* from crates.io even at identical version numbers. +# This crate therefore links two copies of gamut-icc whose `IccProfile` types +# are NOT interchangeable. ICC data crosses that boundary as bytes and is +# re-parsed; never pass a typed profile value across it. +gamut-cmm = { git = "https://github.com/visualcommons/gamut", branch = "master" } +gamut-icc-git = { package = "gamut-icc", git = "https://github.com/visualcommons/gamut", branch = "master" } + +# `image-without-codecs` gives the image widget without the `image` crate's +# decoders — the gallery feeds it rawshift's own buffers, so pulling a second +# set of codecs in would be both wasteful and dishonest about what decoded +# the picture. `tokio` is iced's futures executor; `rfd` shares it. +iced = { version = "0.14", default-features = false, features = [ + "wgpu", + "tiny-skia", + "image-without-codecs", + "advanced", + "tokio", +] } +rfd = "0.15" +tokio = { version = "1.50", features = ["rt-multi-thread"] } + +# The crates.io gamut-color, matching rawshift's own tree — used for the sRGB +# transfer function. Distinct from the git copy gamut-cmm links; see src/color.rs. +gamut-color = "2.0.0" + +rayon = "1.11.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4.5", features = ["derive"] } diff --git a/examples/gallery/README.md b/examples/gallery/README.md new file mode 100644 index 0000000..dd33bae --- /dev/null +++ b/examples/gallery/README.md @@ -0,0 +1,102 @@ +# rawshift gallery + +A cross-platform GUI that demonstrates rawshift's decode and encode paths end +to end, including hardware-accelerated HEIC/AVIF decode. + +Pick images from the filesystem. Each one is decoded, re-encoded through every +selected output format with every parameter exposed, written to the system temp +directory, and decoded back. Results are shown side by side per source with a +numeric comparison against the original — or, where a stage failed, the error +text inside an empty placeholder. + +```sh +just gallery # the window +just gallery-headless path/to/*.jpg # the same matrix, no window +just gallery-check # fmt + clippy + tests +``` + +## Why this is not a workspace member + +`examples/gallery` sits in the root `Cargo.toml`'s `exclude` list and carries +its own `[workspace]` table. + +Its `gamut-cmm` dependency comes from git, and the gamut repository has `aom` +and `dav1d` submodules that Cargo fetches with it — a checkout of roughly +1.4 GB. As a workspace member that cost would land on *every* CI job, including +ones that named it in `--exclude`, because Cargo still reads every member +manifest to build the resolve graph. Standing outside the workspace, it is +built only by the `just gallery*` recipes and the dedicated `gallery` CI job. + +It also keeps iced, wgpu, and winit out of `cargo test -p rawshift-image`: +Cargo has no optional dev-dependencies, so an `[[example]]` with +`required-features` would not have avoided that. + +## The gamut-cmm carve-out + +`AGENTS.md` forbids git dependencies on gamut because they prevent publishing +rawshift. That reason does not reach this crate — it is `publish = false` and +outside the workspace, so no published crate's dependency tree contains it. + +`gamut-cmm` is the ICC colour-management module ([gamut#323]). It is not on +crates.io yet, and it is the only way to apply an ICC transform to pixels: +`gamut-icc` parses and serialises profiles but explicitly does not transform +them. Retire the carve-out for a crates.io version once gamut-cmm ships. + +**Two gamut trees.** `gamut-cmm` depends on `gamut-core` / `gamut-color` / +`gamut-icc` by path inside the gamut workspace, so over git those resolve to +the git checkout — a different Cargo *source* from crates.io even at identical +version numbers. This crate therefore links two copies of `gamut-icc` whose +`IccProfile` types are not interchangeable. ICC data crosses that boundary as +**bytes** and is re-parsed; see `src/color.rs`. + +[gamut#323]: https://github.com/visualcommons/gamut/issues/323 + +## Layout + +| Module | Role | +| --- | --- | +| `pipeline.rs` | decode → encode → temp file → decode → compare. No `iced` types. | +| `color.rs` | container colour resolution and the display transform via gamut-cmm. | +| `settings.rs` | the encode-settings model, holding the library's own config structs. | +| `render.rs` | decoded image to texture, with preview downsampling. | +| `headless.rs` | the same matrix with no window. | +| `app.rs` | the window: state, messages, update, view. | + +`pipeline`, `color`, and `settings` are free of `iced` types on purpose: the +headless runner and the unit tests drive exactly the code the window does, so +there is no second implementation to drift. + +## What the numbers mean + +- **bit-exact** — every sample matched. Asserted for the lossless + configurations (PNG, lossless WebP/AVIF/JXL); a lossless configuration that + does *not* come back bit-exact is reported as `LOSSLESS VIOLATED`, which is a + defect rather than expected loss. +- **PSNR / max Δ** — for lossy configurations, computed on the + full-resolution buffers (the on-screen previews are downsampled; the numbers + are not). +- **no hardware decoder** — expected on a machine with no backend, and not + counted as a failure. A decoder that exists but *rejects* the bitstream is + counted: that is an interoperability gap, not an absent backend. + +## Known interoperability gap + +On Apple hardware, rawshift cannot currently decode its own AVIF output. +`gamut-avif` encodes identity-matrix 4:4:4, which is AV1 Profile 1, and +VideoToolbox's still-image path decodes Main / Profile 0 only. The AVIF column +therefore shows a decode error on macOS even though both halves work in +isolation. The gallery surfaces this rather than hiding it — see the tracking +issue linked from the pull request that added this crate. + +## Colour management + +Two layers, addressed separately: + +- **Container** — what the file declared. rawshift resolves this now: + `RgbImage::color()` carries the CICP description, and the raw profile is in + `ImageMetadata::icc_profile`. The panel under each source shows both. +- **Display** — getting the samples into the viewer's space. An + ICC-authoritative source (one tagged `UNSPECIFIED`, meaning its profile has + no faithful CICP expression) goes through `gamut-cmm`. Anything gamut-cmm + cannot link — LUT-based profiles, since its phase P5 is unimplemented — falls + back to sRGB with a visible warning rather than refusing to draw. diff --git a/examples/gallery/src/app.rs b/examples/gallery/src/app.rs new file mode 100644 index 0000000..d08b637 --- /dev/null +++ b/examples/gallery/src/app.rs @@ -0,0 +1,879 @@ +//! The window: state, messages, update, view. +//! +//! Deliberately thin. Everything that decides anything lives in +//! [`crate::pipeline`], [`crate::color`], and [`crate::settings`]; this module +//! moves their results onto the screen and routes user input back. That split +//! is what lets `--headless` exercise the same behaviour with no display. +//! +//! Work runs off the UI thread via [`iced::Task::perform`], one source at a +//! time, so a large RAW cannot freeze the window. + +use std::path::PathBuf; +use std::sync::Arc; + +use iced::widget::{ + Space, button, checkbox, column, container, image, pick_list, row, rule, scrollable, slider, + text, +}; +use iced::{Alignment, Element, Fill, Length, Task, Theme}; + +use rawshift_image::formats::export::{ + BitDepth, JpegSubsampling, PngCompressionLevel, PngFilterStrategy, PngFilterType, WebPMode, +}; + +use crate::pipeline::{self, RawSettings, Source, Variant}; +use crate::render::{self, Preview}; +use crate::settings::{EncodeSettings, FormatConfig}; + +/// Launch the window. +pub fn run(initial: Vec) -> iced::Result { + iced::application( + move || { + let state = State::default(); + let task = if initial.is_empty() { + Task::none() + } else { + Task::done(Message::FilesChosen(initial.clone())) + }; + (state, task) + }, + State::update, + State::view, + ) + .title("rawshift gallery") + // Annotated so the closure infers as higher-ranked over the borrow. + .theme(|_state: &State| Theme::Dark) + .run() +} + +/// One loaded source and its round-trip results. +struct Row { + source: Source, + preview: Preview, + variants: Vec, + /// Previews for the round-tripped variants, indexed alongside `variants`. + variant_previews: Vec>, +} + +#[derive(Default)] +pub struct State { + settings: EncodeSettings, + raw: RawSettings, + rows: Vec, + /// Paths still waiting to be processed, so the UI can show progress and + /// stay responsive between them. + queue: Vec, + /// Files that could not even be decoded, shown as their own placeholders. + rejected: Vec<(PathBuf, String)>, + busy: bool, + banner: String, +} + +/// The outcome of processing one source off-thread. +/// +/// `Arc` because `Message` must be `Clone` while `Source` holds a full decoded +/// image — cloning that per message would copy the pixels. +type Processed = Arc), (PathBuf, String)>>; + +#[derive(Debug, Clone)] +pub enum Message { + Browse, + FilesChosen(Vec), + /// One source finished processing off-thread. + SourceDone(Processed), + Clear, + Rerun, + ToggleFormat(usize, bool), + ToggleExpanded(usize), + ConfigChanged(usize, ConfigEdit), + BitDepthChanged(BitDepth), + EmbedExif(bool), + EmbedIcc(bool), + EmbedXmp(bool), + RawGamma(f32), +} + +/// A single parameter edit, one variant per control in the settings panel. +#[derive(Debug, Clone)] +pub enum ConfigEdit { + PngCompression(PngCompressionLevel), + PngFilter(PngFilterStrategy), + PngAutoReduce(bool), + JpegQuality(u8), + JpegSubsampling(JpegSubsampling), + JpegProgressive(bool), + JpegRestart(u16), + WebpMode(WebPMode), + WebpQuality(u8), + AvifLossless(bool), + AvifQuality(u8), + JxlLossless(bool), + JxlDistance(f32), + JxlEffort(u8), + JxlContainer(bool), +} + +impl State { + fn update(&mut self, message: Message) -> Task { + match message { + Message::Browse => { + return Task::perform(pick_files(), Message::FilesChosen); + } + Message::FilesChosen(paths) => { + if paths.is_empty() { + return Task::none(); + } + self.queue.extend(paths); + return self.pump(); + } + Message::SourceDone(result) => { + self.busy = false; + match Arc::try_unwrap(result) { + Ok(Ok((source, variants))) => self.push_row(source, variants), + Ok(Err((path, error))) => self.rejected.push((path, error)), + // Another handle exists only if this message were cloned, + // which the runtime does not do; recover rather than panic. + Err(shared) => match &*shared { + Ok((source, variants)) => { + self.push_row(source.clone(), variants.clone()); + } + Err((path, error)) => self.rejected.push((path.clone(), error.clone())), + }, + } + return self.pump(); + } + Message::Clear => { + self.rows.clear(); + self.rejected.clear(); + self.queue.clear(); + pipeline::clean_scratch(); + } + Message::Rerun => { + let paths: Vec = self + .rows + .iter() + .map(|r| r.source.path.clone()) + .chain(self.rejected.iter().map(|(p, _)| p.clone())) + .collect(); + self.rows.clear(); + self.rejected.clear(); + self.queue = paths; + return self.pump(); + } + Message::ToggleFormat(index, enabled) => { + if let Some(format) = self.settings.formats.get_mut(index) { + format.enabled = enabled; + if enabled { + format.expanded = true; + } + } + } + Message::ToggleExpanded(index) => { + if let Some(format) = self.settings.formats.get_mut(index) { + format.expanded = !format.expanded; + } + } + Message::ConfigChanged(index, edit) => { + if let Some(format) = self.settings.formats.get_mut(index) { + apply_edit(&mut format.config, edit); + } + } + Message::BitDepthChanged(depth) => self.settings.common.bit_depth = depth, + Message::EmbedExif(v) => self.settings.common.metadata.embed_exif = v, + Message::EmbedIcc(v) => self.settings.common.metadata.embed_icc = v, + Message::EmbedXmp(v) => self.settings.common.metadata.embed_xmp = v, + Message::RawGamma(gamma) => self.raw.gamma = gamma, + } + Task::none() + } + + fn push_row(&mut self, source: Source, variants: Vec) { + let preview = render::preview(&source.image, &source.metadata); + let variant_previews = variants + .iter() + .map(|v| { + v.outcome + .as_ref() + .ok() + .map(|ok| render::preview(&ok.image, &source.metadata)) + }) + .collect(); + self.rows.push(Row { + source, + preview, + variants, + variant_previews, + }); + } + + /// Start the next queued file, if the worker is free. + /// + /// One at a time rather than a fan-out: the encoders are already + /// internally parallel (rayon), and processing several large sources at + /// once would compete for the same cores while making the UI's progress + /// report meaningless. + fn pump(&mut self) -> Task { + if self.busy || self.queue.is_empty() { + return Task::none(); + } + let path = self.queue.remove(0); + let configs = self.settings.selected(); + let raw = self.raw; + self.busy = true; + Task::perform( + async move { + Arc::new( + tokio::task::spawn_blocking(move || process(path.clone(), &configs, raw)) + .await + .unwrap_or_else(|e| { + Err((PathBuf::from(""), format!("worker panicked: {e}"))) + }), + ) + }, + Message::SourceDone, + ) + } + + fn view(&self) -> Element<'_, Message> { + let banner = if self.banner.is_empty() { + crate::backend_banner() + } else { + self.banner.clone() + }; + + let header = column![ + row![ + text("rawshift gallery").size(22), + Space::new().width(Fill), + button("Add images…").on_press(Message::Browse), + button("Re-run").on_press(Message::Rerun), + button("Clear").on_press(Message::Clear), + ] + .spacing(8) + .align_y(Alignment::Center), + text(banner).size(12), + text(format!( + "intermediates: {}", + pipeline::scratch_dir().display() + )) + .size(11), + ] + .spacing(4); + + let status = if self.busy || !self.queue.is_empty() { + text(format!("working… {} queued", self.queue.len())).size(12) + } else if self.rows.is_empty() && self.rejected.is_empty() { + text("No images loaded. Use “Add images…” to pick some.").size(13) + } else { + text(format!( + "{} image(s), {} rejected", + self.rows.len(), + self.rejected.len() + )) + .size(12) + }; + + let body = row![ + container(scrollable(self.settings_panel()).height(Fill)) + .width(Length::Fixed(320.0)) + .padding(8), + rule::vertical(1), + container(scrollable(self.results()).height(Fill).width(Fill)).padding(8), + ]; + + column![ + container(header).padding(10), + rule::horizontal(1), + container(status).padding([4, 10]), + body, + ] + .into() + } + + fn settings_panel(&self) -> Element<'_, Message> { + let mut panel = column![ + text("Shared").size(16), + row![text("bit depth").size(12), Space::new().width(Fill), { + let (options, selected) = choices(BIT_DEPTHS, &self.settings.common.bit_depth); + pick_list(options, selected, |c: Choice| { + Message::BitDepthChanged(c.value) + }) + .text_size(12) + },] + .align_y(Alignment::Center), + checkbox(self.settings.common.metadata.embed_exif) + .label("embed EXIF") + .on_toggle(Message::EmbedExif) + .size(14) + .text_size(12), + checkbox(self.settings.common.metadata.embed_icc) + .label("embed ICC") + .on_toggle(Message::EmbedIcc) + .size(14) + .text_size(12), + checkbox(self.settings.common.metadata.embed_xmp) + .label("embed XMP") + .on_toggle(Message::EmbedXmp) + .size(14) + .text_size(12), + rule::horizontal(1), + text("RAW development").size(16), + row![ + text(format!("gamma {:.2}", self.raw.gamma)).size(12), + Space::new().width(Fill), + slider(1.0..=3.0, self.raw.gamma, Message::RawGamma).step(0.05), + ] + .spacing(8) + .align_y(Alignment::Center), + rule::horizontal(1), + text("Output formats").size(16), + ] + .spacing(6); + + for (index, format) in self.settings.formats.iter().enumerate() { + panel = panel.push(rule::horizontal(1)); + panel = panel.push( + row![ + checkbox(format.enabled) + .label(format.config.format().name()) + .on_toggle(move |v| Message::ToggleFormat(index, v)) + .size(15), + Space::new().width(Fill), + button(text(if format.expanded { "−" } else { "+" }).size(12)) + .on_press(Message::ToggleExpanded(index)) + .padding([0, 6]), + ] + .align_y(Alignment::Center), + ); + if format.expanded { + panel = + panel.push(container(config_controls(index, &format.config)).padding([0, 12])); + } + } + + panel.spacing(6).into() + } + + fn results(&self) -> Element<'_, Message> { + let mut out = column![].spacing(16); + + for (path, error) in &self.rejected { + out = out.push(placeholder_card( + &format!("{}", path.display()), + error, + 240.0, + )); + } + + for row_data in &self.rows { + out = out.push(self.result_row(row_data)); + } + + out.into() + } + + fn result_row<'a>(&'a self, row_data: &'a Row) -> Element<'a, Message> { + let source = &row_data.source; + let mut colour = column![].spacing(1); + for (key, value) in crate::color::describe(&row_data.preview.report) { + colour = colour.push(text(format!("{key}: {value}")).size(10)); + } + + let mut strip = row![ + column![ + text("source").size(12), + image(row_data.preview.handle.clone()) + .width(Length::Fixed(220.0)) + .height(Length::Fixed(180.0)), + text(crate::headless::summarise(source)).size(10), + text(format!( + "{}×{}", + row_data.preview.source_size.0, row_data.preview.source_size.1 + )) + .size(10), + colour, + ] + .spacing(3) + .width(Length::Fixed(230.0)) + ] + .spacing(10); + + for (variant, preview) in row_data.variants.iter().zip(&row_data.variant_previews) { + strip = strip.push(variant_cell(variant, preview.as_ref())); + } + + column![ + text(source.name()).size(15), + scrollable(strip).direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new() + )), + ] + .spacing(4) + .into() + } +} + +/// One results-grid cell: the round-tripped image, or the error in its place. +fn variant_cell<'a>(variant: &'a Variant, preview: Option<&'a Preview>) -> Element<'a, Message> { + let title = text(format!("{} — {}", variant.format.name(), variant.summary)).size(11); + + match (&variant.outcome, preview) { + (Ok(ok), Some(preview)) => { + let verdict = match ok.comparison { + Some(c) if c.bit_exact => text("bit-exact ✓").size(11), + Some(c) => text(format!( + "PSNR {:.2} dB · max Δ {}{}", + c.psnr_db.unwrap_or(f64::NAN), + c.max_delta, + // Naming the expectation makes a lossy result legible as + // "as designed" rather than "close enough". + if variant.lossless { + "" + } else { + " (lossy by design)" + } + )) + .size(11), + None => text("dimensions differ").size(11), + }; + let mut cell = column![ + title, + image(preview.handle.clone()) + .width(Length::Fixed(220.0)) + .height(Length::Fixed(180.0)), + text(format!( + "{} · encode {:.1} ms · decode {:.1} ms", + human_bytes(ok.encoded_bytes), + ok.encode_ms, + ok.decode_ms + )) + .size(10), + // The intermediate really is a file; naming it lets the + // viewer open it in another tool and check independently. + text(format!( + "wrote {}", + ok.temp_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + )) + .size(9), + verdict, + ] + .spacing(3); + if ok.lossless_violated { + cell = cell.push( + text("LOSSLESS VIOLATED — this configuration promised an exact round trip") + .size(10), + ); + } + container(cell).width(Length::Fixed(230.0)).into() + } + (Err(error), _) => container(placeholder_card( + &format!("{} — {}", variant.format.name(), variant.summary), + error, + 180.0, + )) + .width(Length::Fixed(230.0)) + .into(), + // A success with no preview cannot occur (they are built together), + // but rendering something is better than unwrapping. + (Ok(_), None) => container(column![title, text("no preview").size(10)]) + .width(Length::Fixed(230.0)) + .into(), + } +} + +/// The empty image placeholder carrying an error message. +fn placeholder_card<'a>(title: &str, error: &str, height: f32) -> Element<'a, Message> { + column![ + text(title.to_owned()).size(11), + container( + container(text(error.to_owned()).size(11)) + .padding(8) + .center_x(Fill) + .center_y(Fill) + ) + .width(Length::Fixed(220.0)) + .height(Length::Fixed(height)) + .style(container::bordered_box), + ] + .spacing(3) + .into() +} + +/// A pick-list entry: a library value plus the label to show for it. +/// +/// `pick_list` requires `Display`, and the library's configuration enums are +/// plain data that deliberately do not implement it — a human-readable name is +/// a presentation concern, and adding one upstream would put UI wording in a +/// public API. Wrapping keeps the label in the view where it belongs. +#[derive(Debug, Clone, PartialEq)] +struct Choice { + value: T, + label: &'static str, +} + +impl std::fmt::Display for Choice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label) + } +} + +/// Build a pick list from a value/label table, selecting the current value. +/// +/// Returns `None` for the selection when `current` is not in the table, which +/// leaves the control blank rather than silently showing the wrong entry — +/// that would happen only if a `#[non_exhaustive]` library enum grew a variant +/// the panel has not been taught about, and a blank control is the honest +/// signal for it. +fn choices( + table: &[(T, &'static str)], + current: &T, +) -> (Vec>, Option>) { + let options: Vec> = table + .iter() + .map(|(value, label)| Choice { + value: value.clone(), + label, + }) + .collect(); + let selected = options.iter().find(|c| &c.value == current).cloned(); + (options, selected) +} + +/// The parameter controls for one format — every field the config exposes. +fn config_controls(index: usize, config: &FormatConfig) -> Element<'_, Message> { + let edit = move |e: ConfigEdit| Message::ConfigChanged(index, e); + match config { + FormatConfig::Png(cfg) => column![ + labelled("compression", { + let (options, selected) = choices(PNG_COMPRESSION, &cfg.compression); + pick_list(options, selected, move |c: Choice| { + edit(ConfigEdit::PngCompression(c.value)) + }) + .text_size(11) + .into() + },), + labelled("filter", { + let (options, selected) = choices(PNG_FILTERS, &cfg.filter); + pick_list(options, selected, move |c: Choice| { + edit(ConfigEdit::PngFilter(c.value)) + }) + .text_size(11) + .into() + },), + checkbox(cfg.auto_reduce) + .label("auto-reduce colour type") + .on_toggle(move |v| edit(ConfigEdit::PngAutoReduce(v))) + .size(13) + .text_size(11), + ] + .spacing(4) + .into(), + + FormatConfig::Jpeg(cfg) => column![ + labelled( + &format!("quality {}", cfg.quality), + slider(1..=100u8, cfg.quality, move |v| edit( + ConfigEdit::JpegQuality(v) + )) + .into(), + ), + labelled("subsampling", { + let (options, selected) = choices(JPEG_SUBSAMPLING, &cfg.subsampling); + pick_list(options, selected, move |c: Choice| { + edit(ConfigEdit::JpegSubsampling(c.value)) + }) + .text_size(11) + .into() + },), + checkbox(cfg.progressive) + .label("progressive (SOF2)") + .on_toggle(move |v| edit(ConfigEdit::JpegProgressive(v))) + .size(13) + .text_size(11), + labelled( + &format!("restart interval {}", cfg.restart_interval), + slider(0..=64u16, cfg.restart_interval, move |v| edit( + ConfigEdit::JpegRestart(v) + )) + .into(), + ), + ] + .spacing(4) + .into(), + + FormatConfig::WebP(cfg) => { + let lossless = matches!(cfg.mode, WebPMode::Lossless); + let mut controls = column![ + checkbox(lossless) + .label("lossless") + .on_toggle(move |v| edit(ConfigEdit::WebpMode(if v { + WebPMode::Lossless + } else { + WebPMode::Lossy + }))) + .size(13) + .text_size(11), + ] + .spacing(4); + // Quality is ignored in lossless mode, so it is not offered there + // rather than shown as a control with no effect. + if !lossless { + controls = controls.push(labelled( + &format!("quality {}", cfg.quality), + slider(0..=100u8, cfg.quality, move |v| { + edit(ConfigEdit::WebpQuality(v)) + }) + .into(), + )); + } + controls.into() + } + + FormatConfig::Avif(cfg) => { + let mut controls = column![ + checkbox(cfg.lossless) + .label("lossless") + .on_toggle(move |v| edit(ConfigEdit::AvifLossless(v))) + .size(13) + .text_size(11), + ] + .spacing(4); + if !cfg.lossless { + controls = controls.push(labelled( + &format!("quality {}", cfg.quality), + slider(0..=100u8, cfg.quality, move |v| { + edit(ConfigEdit::AvifQuality(v)) + }) + .into(), + )); + } + controls.into() + } + + FormatConfig::Jxl(cfg) => { + let mut controls = column![ + checkbox(cfg.lossless) + .label("lossless") + .on_toggle(move |v| edit(ConfigEdit::JxlLossless(v))) + .size(13) + .text_size(11), + ] + .spacing(4); + if !cfg.lossless { + controls = controls.push(labelled( + &format!("distance {:.2}", cfg.distance), + slider(0.1..=25.0f32, cfg.distance, move |v| { + edit(ConfigEdit::JxlDistance(v)) + }) + .step(0.1) + .into(), + )); + } + controls = controls.push(labelled( + &format!("effort {}", cfg.effort), + slider(1..=10u8, cfg.effort, move |v| { + edit(ConfigEdit::JxlEffort(v)) + }) + .into(), + )); + controls = controls.push( + checkbox(cfg.use_container) + .label("force ISO BMFF container") + .on_toggle(move |v| edit(ConfigEdit::JxlContainer(v))) + .size(13) + .text_size(11), + ); + controls.into() + } + } +} + +/// Every PNG filter strategy, including each fixed filter type, so the panel +/// exposes the whole enum rather than only its simple arms. +const PNG_FILTERS: &[(PngFilterStrategy, &str)] = &[ + (PngFilterStrategy::None, "none (fastest)"), + (PngFilterStrategy::Fixed(PngFilterType::None), "fixed: none"), + (PngFilterStrategy::Fixed(PngFilterType::Sub), "fixed: sub"), + (PngFilterStrategy::Fixed(PngFilterType::Up), "fixed: up"), + ( + PngFilterStrategy::Fixed(PngFilterType::Average), + "fixed: average", + ), + ( + PngFilterStrategy::Fixed(PngFilterType::Paeth), + "fixed: paeth", + ), + (PngFilterStrategy::MinSumAbs, "min sum of absolutes"), + (PngFilterStrategy::BruteForce, "brute force (smallest)"), +]; + +const PNG_COMPRESSION: &[(PngCompressionLevel, &str)] = &[ + (PngCompressionLevel::Store, "store (uncompressed)"), + (PngCompressionLevel::Fast, "fast"), + (PngCompressionLevel::Default, "default"), + (PngCompressionLevel::Best, "best (zopfli)"), +]; + +const JPEG_SUBSAMPLING: &[(JpegSubsampling, &str)] = &[ + (JpegSubsampling::Yuv420, "4:2:0"), + (JpegSubsampling::Yuv422, "4:2:2"), + (JpegSubsampling::Yuv444, "4:4:4"), +]; + +/// Output bit depths. Ten and Twelve are offered even though several encoders +/// reject them — the resulting `UnsupportedBitDepth` error appearing in that +/// format's placeholder is exactly the kind of encoder boundary this gallery +/// exists to make visible. +const BIT_DEPTHS: &[(BitDepth, &str)] = &[ + (BitDepth::Eight, "8-bit"), + (BitDepth::Ten, "10-bit"), + (BitDepth::Twelve, "12-bit"), + (BitDepth::Sixteen, "16-bit"), +]; + +fn labelled<'a>(label: &str, control: Element<'a, Message>) -> Element<'a, Message> { + column![text(label.to_owned()).size(11), control] + .spacing(2) + .into() +} + +fn apply_edit(config: &mut FormatConfig, edit: ConfigEdit) { + match (config, edit) { + (FormatConfig::Png(cfg), ConfigEdit::PngCompression(v)) => cfg.compression = v, + (FormatConfig::Png(cfg), ConfigEdit::PngFilter(v)) => cfg.filter = v, + (FormatConfig::Png(cfg), ConfigEdit::PngAutoReduce(v)) => cfg.auto_reduce = v, + (FormatConfig::Jpeg(cfg), ConfigEdit::JpegQuality(v)) => cfg.quality = v, + (FormatConfig::Jpeg(cfg), ConfigEdit::JpegSubsampling(v)) => cfg.subsampling = v, + (FormatConfig::Jpeg(cfg), ConfigEdit::JpegProgressive(v)) => cfg.progressive = v, + (FormatConfig::Jpeg(cfg), ConfigEdit::JpegRestart(v)) => cfg.restart_interval = v, + (FormatConfig::WebP(cfg), ConfigEdit::WebpMode(v)) => cfg.mode = v, + (FormatConfig::WebP(cfg), ConfigEdit::WebpQuality(v)) => cfg.quality = v, + (FormatConfig::Avif(cfg), ConfigEdit::AvifLossless(v)) => cfg.lossless = v, + (FormatConfig::Avif(cfg), ConfigEdit::AvifQuality(v)) => cfg.quality = v, + (FormatConfig::Jxl(cfg), ConfigEdit::JxlLossless(v)) => cfg.lossless = v, + (FormatConfig::Jxl(cfg), ConfigEdit::JxlDistance(v)) => cfg.distance = v, + (FormatConfig::Jxl(cfg), ConfigEdit::JxlEffort(v)) => cfg.effort = v, + (FormatConfig::Jxl(cfg), ConfigEdit::JxlContainer(v)) => cfg.use_container = v, + // A control can only be built from its own config variant, so a + // mismatch would mean the panel and the model disagreed. + (config, edit) => { + tracing::warn!(?edit, "edit did not match {:?}", config.format()); + } + } +} + +/// Decode one source and round-trip it through every selected format. +/// +/// Runs on a blocking worker; returns everything the UI needs so the update +/// handler does no image work of its own. +fn process( + path: PathBuf, + configs: &[FormatConfig], + raw: RawSettings, +) -> Result<(Source, Vec), (PathBuf, String)> { + let source = pipeline::load_source(&path, raw).map_err(|e| (path.clone(), e))?; + let variants = configs + .iter() + .map(|config| pipeline::round_trip(&source, config)) + .collect(); + Ok((source, variants)) +} + +/// The native file picker. +async fn pick_files() -> Vec { + rfd::AsyncFileDialog::new() + .set_title("Choose images") + .pick_files() + .await + .map(|files| files.into_iter().map(|f| f.path().to_path_buf()).collect()) + .unwrap_or_default() +} + +fn human_bytes(bytes: usize) -> String { + const KIB: f64 = 1024.0; + let bytes = bytes as f64; + if bytes < KIB { + format!("{bytes:.0} B") + } else if bytes < KIB * KIB { + format!("{:.1} KiB", bytes / KIB) + } else { + format!("{:.1} MiB", bytes / (KIB * KIB)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rawshift_image::formats::export::{JpegEncodeConfig, PngEncodeConfig}; + + #[test] + fn edits_reach_their_own_format() { + let mut config = FormatConfig::Jpeg(JpegEncodeConfig::default()); + apply_edit(&mut config, ConfigEdit::JpegQuality(42)); + let FormatConfig::Jpeg(cfg) = &config else { + panic!("variant changed"); + }; + assert_eq!(cfg.quality, 42); + } + + #[test] + fn a_mismatched_edit_leaves_the_config_untouched() { + let mut config = FormatConfig::Png(PngEncodeConfig::default()); + let before = config.clone(); + apply_edit(&mut config, ConfigEdit::JpegQuality(1)); + assert_eq!(config, before); + } + + #[test] + fn png_filter_list_covers_every_fixed_filter() { + // Five fixed filters plus None, MinSumAbs, BruteForce. + assert_eq!(PNG_FILTERS.len(), 8); + for filter in [ + PngFilterType::None, + PngFilterType::Sub, + PngFilterType::Up, + PngFilterType::Average, + PngFilterType::Paeth, + ] { + assert!( + PNG_FILTERS + .iter() + .any(|(v, _)| *v == PngFilterStrategy::Fixed(filter)), + "{filter:?} is not offered in the settings panel" + ); + } + } + + #[test] + fn toggling_a_format_expands_it() { + let mut state = State::default(); + state.settings.formats[3].enabled = false; + state.settings.formats[3].expanded = false; + let _ = state.update(Message::ToggleFormat(3, true)); + assert!(state.settings.formats[3].enabled); + assert!(state.settings.formats[3].expanded); + } + + #[test] + fn shared_settings_reach_the_model() { + let mut state = State::default(); + let _ = state.update(Message::BitDepthChanged(BitDepth::Eight)); + let _ = state.update(Message::EmbedIcc(false)); + assert_eq!(state.settings.common.bit_depth, BitDepth::Eight); + assert!(!state.settings.common.metadata.embed_icc); + } + + #[test] + fn clear_empties_every_collection() { + let mut state = State::default(); + state.rejected.push((PathBuf::from("a"), "bad".into())); + state.queue.push(PathBuf::from("b")); + let _ = state.update(Message::Clear); + assert!(state.rejected.is_empty()); + assert!(state.queue.is_empty()); + assert!(state.rows.is_empty()); + } +} diff --git a/examples/gallery/src/color.rs b/examples/gallery/src/color.rs new file mode 100644 index 0000000..ae18227 --- /dev/null +++ b/examples/gallery/src/color.rs @@ -0,0 +1,412 @@ +//! Colour management for display. +//! +//! Two layers of the problem, addressed separately: +//! +//! **Container level.** What did the file actually say its colour space was? +//! rawshift answers this — [`RgbImage::color`] carries the resolved +//! [`ColorDescription`] and the raw profile is in +//! [`ImageMetadata::icc_profile`]. [`describe`] renders both. +//! +//! **Display level.** Getting the samples into the viewer's space. rawshift's +//! own `transforms::convert_to_srgb` handles only the sRGB and linear +//! transfers on BT.709 primaries and errors on anything wider, so the gallery +//! goes through `gamut-cmm` — the ICC colour-management module — for a real +//! transform. +//! +//! # The two gamut trees +//! +//! `gamut-cmm` is a git dependency (see this crate's `Cargo.toml`), so it +//! links its *own* copies of `gamut-core` / `gamut-color` / `gamut-icc` from +//! the git checkout. Those are a different Cargo source from the crates.io +//! copies rawshift uses, which makes `gamut_icc::IccProfile` two unrelated +//! types. ICC data therefore crosses the boundary as **bytes**: the profile in +//! `ImageMetadata::icc_profile` is re-parsed here with the git `gamut-icc` +//! (aliased `gamut_icc_git`) before it reaches `gamut-cmm`. +//! +//! # Cost +//! +//! `Pipeline::eval` is scalar `f64` over interleaved samples, so it runs once +//! per image when the display buffer is built, via rayon — never per frame. + +use gamut_icc_git::{IccProfile, RenderingIntent}; +use rawshift_core::ColorDescription; +use rawshift_image::core::RgbImage; +use rawshift_image::core::metadata::ImageMetadata; +use rayon::prelude::*; + +/// How an image's colour was determined, and what was done to display it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColorReport { + /// The tag rawshift resolved from the container. + pub tag: ColorDescription, + /// The size of the embedded ICC profile, when the container carried one. + pub icc_bytes: Option, + /// What was actually applied to the samples. + pub transform: Transform, + /// Set when the display path could not honour the source colour and fell + /// back to treating it as sRGB. + pub warning: Option, +} + +/// The transform applied on the way to the display buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transform { + /// Already display sRGB; only bit-depth reduction ran. + None, + /// The sRGB opto-electronic transfer function (a linear source). + SrgbEncode, + /// A full ICC transform through gamut-cmm, source profile → sRGB. + Icc, +} + +impl Transform { + pub fn label(self) -> &'static str { + match self { + Transform::None => "none (already sRGB)", + Transform::SrgbEncode => "sRGB OETF (linear source)", + Transform::Icc => "ICC to sRGB (gamut-cmm)", + } + } +} + +/// Convert a decoded image to an 8-bit RGBA buffer in display sRGB. +/// +/// Returns the buffer alongside a [`ColorReport`] describing what was done, so +/// the UI can state the provenance rather than silently producing pixels. +/// +/// Never fails. A profile gamut-cmm cannot link — LUT-based ones, since its +/// phase P5 is unimplemented — falls back to treating the samples as sRGB and +/// records a warning: drawing the image with a caveat beats refusing to draw +/// it. +pub fn to_display_rgba(image: &RgbImage, metadata: &ImageMetadata) -> (Vec, ColorReport) { + let tag = image.color(); + let icc = metadata.icc_profile.as_deref(); + let mut warning = None; + + // An ICC-authoritative source: the profile *is* the colour space, so a + // real transform is both possible and necessary. + if tag == ColorDescription::UNSPECIFIED + && let Some(bytes) = icc + { + match icc_to_srgb(image, bytes) { + Ok(rgba) => { + return ( + rgba, + ColorReport { + tag, + icc_bytes: Some(bytes.len()), + transform: Transform::Icc, + warning: None, + }, + ); + } + Err(e) => warning = Some(format!("ICC transform unavailable ({e}); shown as sRGB")), + } + } + + // Wide-gamut code points with no profile to link. gamut-cmm needs a + // profile, and synthesising one from CICP primaries needs a chromaticity + // accessor gamut-color does not expose yet. + if warning.is_none() && !rawshift_image::core::is_convertible_to_srgb(tag) { + warning = Some(format!( + "{} carries no embedded profile to transform from; shown as sRGB", + tag.name() + )); + } + + let transform = if tag == ColorDescription::LINEAR_SRGB { + Transform::SrgbEncode + } else { + Transform::None + }; + let rgba = match transform { + Transform::SrgbEncode => encode_linear(image), + _ => pack_rgba(image), + }; + ( + rgba, + ColorReport { + tag, + icc_bytes: icc.map(<[u8]>::len), + transform, + warning, + }, + ) +} + +/// Transform through gamut-cmm: source profile → PCS → sRGB. +fn icc_to_srgb(image: &RgbImage, profile_bytes: &[u8]) -> Result, String> { + let source = + IccProfile::parse(profile_bytes).map_err(|e| format!("unparsable profile: {e}"))?; + let destination = + IccProfile::parse(&srgb_profile_bytes()).map_err(|e| format!("sRGB profile: {e}"))?; + + let intent = RenderingIntent::MediaRelativeColorimetric; + let to_pcs = gamut_cmm::link::device_to_pcs(&source, intent).map_err(|e| e.to_string())?; + let from_pcs = + gamut_cmm::link::pcs_to_device(&destination, intent).map_err(|e| e.to_string())?; + let pipeline = to_pcs.compose(from_pcs).map_err(|e| e.to_string())?; + + if pipeline.input_channels() != 3 || pipeline.output_channels() != 3 { + return Err(format!( + "expected a 3-to-3 transform, got {}-to-{}", + pipeline.input_channels(), + pipeline.output_channels() + )); + } + + let scale = f64::from(u16::MAX); + let rgba = image + .data() + .par_chunks_exact(3) + .flat_map_iter(|px| { + let input = [ + f64::from(px[0]) / scale, + f64::from(px[1]) / scale, + f64::from(px[2]) / scale, + ]; + let mut output = [0.0f64; 3]; + // A per-pixel failure cannot be reported from inside the map, and + // a pipeline that evaluates for one pixel evaluates for all: the + // only failure modes are channel-count and buffer-length + // mismatches, both checked once above. Passing the input through + // on the impossible branch keeps the image readable. + if pipeline.eval(&input, &mut output).is_err() { + output = input; + } + [ + to_u8(output[0]), + to_u8(output[1]), + to_u8(output[2]), + u8::MAX, + ] + }) + .collect(); + Ok(rgba) +} + +/// The sRGB profile to convert *to*. +/// +/// Built with the git `gamut-icc` so it is the type gamut-cmm expects. +/// Deliberately not borrowed from rawshift's `IccProfile::srgb()`: those bytes +/// come from the crates.io copy, and constructing locally keeps the two trees +/// visibly separate. +fn srgb_profile_bytes() -> Vec { + use gamut_icc_git::{ + ColorSpace, Curve, DeviceClass, ProfileHeader, Signature, TagData, U8Fixed8, XyzNumber, + }; + + let xyz = |v: [f64; 3]| TagData::Xyz(vec![XyzNumber::from_f64(v)]); + // Gamma 2.2, matching the approximation rawshift itself embeds. + let trc = TagData::Curve(Curve::Gamma(U8Fixed8(0x0238))); + IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![ + (Signature(*b"rXYZ"), xyz([0.436_07, 0.222_49, 0.013_92])), + (Signature(*b"gXYZ"), xyz([0.385_15, 0.716_86, 0.095_00])), + (Signature(*b"bXYZ"), xyz([0.143_07, 0.060_61, 0.714_07])), + (Signature(*b"rTRC"), trc.clone()), + (Signature(*b"gTRC"), trc.clone()), + (Signature(*b"bTRC"), trc), + ], + } + .to_bytes() + .expect("the static sRGB profile serialises") +} + +/// Apply the sRGB OETF to linear samples on the way to 8 bits. +fn encode_linear(image: &RgbImage) -> Vec { + let scale = f64::from(u16::MAX); + image + .data() + .par_chunks_exact(3) + .flat_map_iter(|px| { + let encode = |v: u16| to_u8(gamut_color::transfer::srgb_oetf(f64::from(v) / scale)); + [encode(px[0]), encode(px[1]), encode(px[2]), u8::MAX] + }) + .collect() +} + +/// Pack 16-bit RGB into 8-bit RGBA, taking the high byte of each sample. +fn pack_rgba(image: &RgbImage) -> Vec { + image + .data() + .par_chunks_exact(3) + .flat_map_iter(|px| { + [ + (px[0] >> 8) as u8, + (px[1] >> 8) as u8, + (px[2] >> 8) as u8, + u8::MAX, + ] + }) + .collect() +} + +/// Clamp and quantise a `[0, 1]` value to 8 bits. +fn to_u8(v: f64) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// A human-readable account of an image's colour, for the detail panel. +pub fn describe(report: &ColorReport) -> Vec<(String, String)> { + let (primaries, transfer) = report.tag.code_points(); + let mut rows = vec![ + ("rawshift tag".to_owned(), report.tag.name().to_owned()), + ( + "CICP code points".to_owned(), + format!("primaries {primaries}, transfer {transfer}"), + ), + ( + "embedded ICC".to_owned(), + match report.icc_bytes { + Some(n) => format!("{n} bytes"), + None => "none".to_owned(), + }, + ), + ( + "display transform".to_owned(), + report.transform.label().to_owned(), + ), + ]; + if let Some(warning) = &report.warning { + rows.push(("warning".to_owned(), warning.clone())); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(fill: u16, color: ColorDescription) -> RgbImage { + RgbImage::with_color(2, 2, vec![fill; 2 * 2 * 3], color).expect("valid buffer") + } + + fn profile(colorants: [[f64; 3]; 3]) -> Vec { + use gamut_icc_git::{ + ColorSpace, Curve, DeviceClass, ProfileHeader, Signature, TagData, U8Fixed8, XyzNumber, + }; + let xyz = |v: [f64; 3]| TagData::Xyz(vec![XyzNumber::from_f64(v)]); + let trc = TagData::Curve(Curve::Gamma(U8Fixed8(0x0238))); + IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![ + (Signature(*b"rXYZ"), xyz(colorants[0])), + (Signature(*b"gXYZ"), xyz(colorants[1])), + (Signature(*b"bXYZ"), xyz(colorants[2])), + (Signature(*b"rTRC"), trc.clone()), + (Signature(*b"gTRC"), trc.clone()), + (Signature(*b"bTRC"), trc), + ], + } + .to_bytes() + .expect("fixture serialises") + } + + /// Adobe RGB (1998), D50-adapted. + const ADOBE_RGB: [[f64; 3]; 3] = [ + [0.609_74, 0.311_11, 0.019_47], + [0.205_28, 0.625_67, 0.060_87], + [0.149_19, 0.063_22, 0.744_57], + ]; + + #[test] + fn srgb_source_needs_no_transform() { + let (rgba, report) = to_display_rgba( + &image(0xFFFF, ColorDescription::SRGB), + &ImageMetadata::default(), + ); + assert_eq!(report.transform, Transform::None); + assert!(report.warning.is_none()); + assert_eq!(rgba.len(), 2 * 2 * 4); + assert_eq!(&rgba[..4], &[255, 255, 255, 255]); + } + + #[test] + fn linear_source_gets_the_srgb_oetf() { + // Linear 0.5 encodes to roughly 0.74 — that gap is the whole point. + let (rgba, report) = to_display_rgba( + &image(0x8000, ColorDescription::LINEAR_SRGB), + &ImageMetadata::default(), + ); + assert_eq!(report.transform, Transform::SrgbEncode); + assert!( + rgba[0] > 180, + "linear 0.5 should encode well above 128, got {}", + rgba[0] + ); + } + + #[test] + fn wide_gamut_without_a_profile_warns_rather_than_failing() { + let (rgba, report) = to_display_rgba( + &image(0x8000, ColorDescription::DISPLAY_P3), + &ImageMetadata::default(), + ); + assert_eq!(rgba.len(), 2 * 2 * 4, "the image is still drawn"); + let warning = report.warning.expect("a caveat must be recorded"); + assert!(warning.contains("Display P3"), "got: {warning}"); + } + + /// The load-bearing case: an ICC-authoritative source is transformed + /// through gamut-cmm rather than shown as-is. + #[test] + fn icc_authoritative_source_goes_through_gamut_cmm() { + let metadata = ImageMetadata { + icc_profile: Some(profile(ADOBE_RGB)), + ..ImageMetadata::default() + }; + let (rgba, report) = + to_display_rgba(&image(0x8000, ColorDescription::UNSPECIFIED), &metadata); + assert_eq!( + report.transform, + Transform::Icc, + "warning was: {:?}", + report.warning + ); + assert!(report.warning.is_none()); + assert_eq!(rgba.len(), 2 * 2 * 4); + // Adobe RGB's wider primaries put a mid grey at a different sRGB value + // than a straight pass-through would. + assert_ne!( + rgba[0], 0x80, + "a real transform must change the sample, not pass it through" + ); + } + + #[test] + fn unparsable_profile_falls_back_with_a_warning() { + let metadata = ImageMetadata { + icc_profile: Some(b"not a profile".to_vec()), + ..ImageMetadata::default() + }; + let (rgba, report) = + to_display_rgba(&image(0x8000, ColorDescription::UNSPECIFIED), &metadata); + assert_eq!(rgba.len(), 2 * 2 * 4); + assert_eq!(report.transform, Transform::None); + assert!(report.warning.is_some()); + } + + #[test] + fn the_destination_srgb_profile_links() { + let bytes = srgb_profile_bytes(); + let parsed = IccProfile::parse(&bytes).expect("parses"); + gamut_cmm::link::pcs_to_device(&parsed, RenderingIntent::MediaRelativeColorimetric) + .expect("the destination profile must link, or nothing can be displayed"); + } + + #[test] + fn describe_reports_the_warning_when_there_is_one() { + let (_, report) = to_display_rgba( + &image(0, ColorDescription::REC2020), + &ImageMetadata::default(), + ); + let rows = describe(&report); + assert!(rows.iter().any(|(k, _)| k == "warning")); + assert!( + rows.iter() + .any(|(k, v)| k == "rawshift tag" && v == "Rec. 2020") + ); + } +} diff --git a/examples/gallery/src/headless.rs b/examples/gallery/src/headless.rs new file mode 100644 index 0000000..792d917 --- /dev/null +++ b/examples/gallery/src/headless.rs @@ -0,0 +1,184 @@ +//! The same round-trip matrix, without a window. +//! +//! Exists so CI can exercise the decode/encode/compare logic on a machine with +//! no display server, and so a failure can be reproduced from a terminal. It +//! drives [`crate::pipeline`] directly — the window has no logic of its own to +//! diverge from. + +use std::path::PathBuf; + +use crate::pipeline::{self, RawSettings, Source}; +use crate::settings::EncodeSettings; + +/// Run every selected format against every input and print a table. +/// +/// Returns the number of failures: a source that would not decode, a variant +/// that errored, or a lossless configuration that did not come back bit-exact. +/// A hardware decoder that is simply absent is reported but **not** counted — +/// on a machine with no backend that is the honest result, not a regression. +pub fn run(paths: &[PathBuf], settings: &EncodeSettings, raw: RawSettings) -> usize { + let configs = settings.selected(); + println!("{}", crate::backend_banner()); + println!(); + + if configs.is_empty() { + println!("No output formats selected."); + return 0; + } + + let mut failures = 0; + for path in paths { + println!("── {}", path.display()); + let source = match pipeline::load_source(path, raw) { + Ok(source) => source, + Err(e) => { + println!(" decode failed: {e}"); + failures += 1; + continue; + } + }; + + let (_, report) = crate::color::to_display_rgba(&source.image, &source.metadata); + println!( + " {} {}x{}, {} on disk", + source.kind.label(), + source.image.width(), + source.image.height(), + human_bytes(source.file_bytes as usize), + ); + for (key, value) in crate::color::describe(&report) { + println!(" {key:>18}: {value}"); + } + + println!( + " {:<8} {:>10} {:>9} {:>9} {:>10} {:>9} settings", + "format", "bytes", "encode", "decode", "PSNR", "max delta" + ); + for config in &configs { + let variant = pipeline::round_trip(&source, config); + match &variant.outcome { + Ok(ok) => { + let (psnr, delta) = match ok.comparison { + Some(c) if c.bit_exact => ("bit-exact".to_owned(), "0".to_owned()), + Some(c) => ( + format!("{:.2} dB", c.psnr_db.unwrap_or(f64::NAN)), + c.max_delta.to_string(), + ), + None => ("size differs".to_owned(), "-".to_owned()), + }; + println!( + " {:<8} {:>10} {:>8.1}ms {:>8.1}ms {:>10} {:>9} {}", + variant.format.name(), + human_bytes(ok.encoded_bytes), + ok.encode_ms, + ok.decode_ms, + psnr, + delta, + variant.summary, + ); + if ok.lossless_violated { + println!( + " ^ LOSSLESS VIOLATED: a lossless configuration must \ + round-trip bit-exact" + ); + failures += 1; + } + } + Err(e) => { + println!(" {:<8} {e}", variant.format.name()); + if is_hardware_absence(e) { + println!( + " (expected on a machine with no hardware decoder; \ + not counted as a failure)" + ); + } else { + failures += 1; + } + } + } + } + println!(); + } + + pipeline::clean_scratch(); + failures +} + +/// Whether an error is "this machine has no hardware decoder" rather than a +/// defect. +/// +/// Matched on the error text because the round trip flattens every stage's +/// error to a string for display. The pattern is `RawError::HwDecoderUnavailable`'s +/// own `Display`: `"no hardware decoder available for {codec}: {reason}"`. +/// +/// Deliberately narrow. A decoder that exists but *rejects the bitstream* — +/// VideoToolbox refusing AV1 Profile 1, say — also mentions hardware, and is a +/// real interoperability failure that must be counted rather than excused. +fn is_hardware_absence(message: &str) -> bool { + message.contains("no hardware decoder available for") +} + +/// Byte counts at a glance. +fn human_bytes(bytes: usize) -> String { + const KIB: f64 = 1024.0; + let bytes = bytes as f64; + if bytes < KIB { + format!("{bytes:.0} B") + } else if bytes < KIB * KIB { + format!("{:.1} KiB", bytes / KIB) + } else { + format!("{:.1} MiB", bytes / (KIB * KIB)) + } +} + +/// Describe a decoded source in one line, for the UI's file list. +pub fn summarise(source: &Source) -> String { + format!( + "{} · {}x{} · {}", + source.kind.label(), + source.image.width(), + source.image.height(), + human_bytes(source.file_bytes as usize) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hardware_absence_is_recognised() { + assert!(is_hardware_absence( + "decode failed: no hardware decoder available for AV1: no hardware AV1 decode \ + backend is compiled in or usable at runtime on this target" + )); + assert!(!is_hardware_absence("encode failed: unsupported bit depth")); + } + + /// A hardware decoder that exists but rejects the bitstream is a real + /// failure, not an absent backend — VideoToolbox declining AV1 Profile 1 + /// is a genuine interoperability gap and must be counted. + #[test] + fn a_rejected_bitstream_is_not_treated_as_missing_hardware() { + assert!(!is_hardware_absence( + "decode failed: hardware AV1 decode failed: AV1 profile 1 is outside the \ + still-picture scope (VideoToolbox decodes Main / Profile 0)" + )); + } + + #[test] + fn byte_counts_scale() { + assert_eq!(human_bytes(512), "512 B"); + assert_eq!(human_bytes(2048), "2.0 KiB"); + assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MiB"); + } + + #[test] + fn no_selected_formats_is_not_a_failure() { + let settings = super::EncodeSettings { + common: Default::default(), + formats: Vec::new(), + }; + assert_eq!(run(&[], &settings, RawSettings::default()), 0); + } +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs new file mode 100644 index 0000000..c44fe28 --- /dev/null +++ b/examples/gallery/src/main.rs @@ -0,0 +1,115 @@ +//! # rawshift gallery +//! +//! A cross-platform GUI that demonstrates rawshift's decode and encode paths +//! end to end, including hardware-accelerated HEIC/AVIF decode. +//! +//! Pick images from the filesystem; each one is decoded, re-encoded through +//! every selected output format with every parameter exposed, written to the +//! system temp directory, and decoded back. The results are shown side by side +//! per source, with a numeric comparison against the original — or, where a +//! stage failed, the error text inside an empty placeholder. +//! +//! Usage: +//! +//! ```text +//! cargo run --release # the window +//! cargo run --release -- --headless a.jpg # the same matrix, no window +//! cargo run --release -- --headless --all-formats a.jpg +//! ``` +//! +//! Run from `examples/gallery`: this is a standalone package, outside the +//! rawshift workspace. See its `Cargo.toml` for why. +//! +//! ## Layout +//! +//! - [`pipeline`] — decode, encode, round trip, compare. No `iced` types. +//! - [`color`] — container colour resolution and the display transform. +//! - [`settings`] — the encode-settings model. +//! - [`render`] — decoded image to texture. +//! - [`headless`] — the same matrix with no window. +//! - [`app`] — the window. + +mod app; +mod color; +mod headless; +mod pipeline; +mod render; +mod settings; + +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Images to load at startup. Required with `--headless`. + inputs: Vec, + + /// Run the round-trip matrix and print a table instead of opening a + /// window. Exits non-zero if any round trip failed. + #[arg(long)] + headless: bool, + + /// Enable every output format rather than the default PNG + JPEG pair. + #[arg(long)] + all_formats: bool, +} + +/// One line describing what this build can decode on this machine. +/// +/// Shown in the window header and at the top of the headless report, because +/// "the AVIF column is empty" means something entirely different depending on +/// whether a hardware decoder exists. +pub fn backend_banner() -> String { + let backend = rawshift_hwdec::backend() + .map_or_else(|| "none".to_owned(), |backend| backend.name().to_owned()); + let codecs = rawshift_hwdec::available_codecs(); + let codecs = if codecs.is_empty() { + "no codecs".to_owned() + } else { + codecs + .iter() + .map(|c| c.name()) + .collect::>() + .join(", ") + }; + let decoders = rawshift_image::formats::available_decoders().len(); + let encoders = rawshift_image::formats::available_encoders().len(); + format!( + "hardware backend: {backend} ({codecs}) · {decoders} decoders, {encoders} encoders \ + compiled in" + ) +} + +fn main() -> Result<(), Box> { + use tracing_subscriber::prelude::*; + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let args = Args::parse(); + + if args.headless { + if args.inputs.is_empty() { + eprintln!("--headless needs at least one input path"); + std::process::exit(2); + } + let mut encode = settings::EncodeSettings::default(); + if args.all_formats { + for format in &mut encode.formats { + format.enabled = true; + } + } + let failures = headless::run(&args.inputs, &encode, pipeline::RawSettings::default()); + if failures > 0 { + eprintln!("{failures} round trip(s) failed"); + std::process::exit(1); + } + return Ok(()); + } + + app::run(args.inputs)?; + Ok(()) +} diff --git a/examples/gallery/src/pipeline.rs b/examples/gallery/src/pipeline.rs new file mode 100644 index 0000000..4bedc2a --- /dev/null +++ b/examples/gallery/src/pipeline.rs @@ -0,0 +1,422 @@ +//! The decode → encode → temp file → decode → compare round trip. +//! +//! Pure compute and file IO, with no `iced` types anywhere, so the headless +//! runner and the unit tests drive exactly the same code the window does. +//! +//! Every stage returns a `Result` whose error is a displayable string rather +//! than a typed error: the gallery's job is to *render* the failure next to +//! the successes, not to react to its variant. A missing hardware decoder, a +//! truncated file, and an encoder that refuses a bit depth all reach the grid +//! the same way. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use rawshift_image::core::RgbImage; +use rawshift_image::core::metadata::ImageMetadata; +use rawshift_image::formats::export::OutputFormat; +use rawshift_image::formats::{ + StandardFormat, decode_standard_image, detect_standard_format, encode_rgb_image_to_vec, + read_standard_image_metadata, +}; + +use crate::settings::FormatConfig; + +/// How a source file was decoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + /// A standard delivery format, through `decode_standard_image`. + Standard(StandardFormat), + /// A camera RAW file, developed through `RawFile::process`. + Raw, +} + +impl SourceKind { + pub fn label(self) -> String { + match self { + SourceKind::Standard(f) => f.name().to_owned(), + SourceKind::Raw => "RAW".to_owned(), + } + } +} + +/// A decoded source image and everything the UI needs to describe it. +#[derive(Debug, Clone)] +pub struct Source { + pub path: PathBuf, + pub kind: SourceKind, + pub image: RgbImage, + pub metadata: ImageMetadata, + /// Bytes on disk, for the compression ratios in the results grid. + pub file_bytes: u64, +} + +impl Source { + /// The file's stem, for labelling a row. + pub fn name(&self) -> String { + self.path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.path.display().to_string()) + } +} + +/// Controls for the RAW development pipeline, used only for RAW sources. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RawSettings { + pub gamma: f32, +} + +impl Default for RawSettings { + fn default() -> Self { + // 2.2 matches `ProcessingOptions`' own sRGB default. + Self { gamma: 2.2 } + } +} + +/// Decode one source file. +/// +/// RAW is tried first and deliberately so: DNG, ARW, NEF, and CR2 are all +/// TIFF-based, so `detect_standard_format` reports them as +/// [`StandardFormat::Tiff`] and the standard decoder would return the embedded +/// preview or fail, rather than developing the sensor data. +pub fn load_source(path: &Path, raw: RawSettings) -> Result { + let file_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + if let Some(source) = try_load_raw(path, raw, file_bytes) { + return source; + } + + let bytes = std::fs::read(path).map_err(|e| format!("read failed: {e}"))?; + let format = detect_standard_format(&bytes) + .ok_or_else(|| "unrecognised image format (no magic-byte match)".to_owned())?; + let image = decode_standard_image(&bytes, format) + .map_err(|e| format!("{format} decode failed: {e}"))?; + let metadata = read_standard_image_metadata(&bytes, format); + Ok(Source { + path: path.to_path_buf(), + kind: SourceKind::Standard(format), + image, + metadata, + file_bytes, + }) +} + +/// Attempt a RAW decode. +/// +/// `None` means "not a RAW file, try the standard path"; `Some(Err(..))` means +/// it *was* RAW and developing it failed, which must surface rather than be +/// retried as a standard image. +fn try_load_raw(path: &Path, raw: RawSettings, file_bytes: u64) -> Option> { + use rawshift_image::formats::RawFile; + use rawshift_image::processing::ProcessingOptions; + + let file = std::fs::File::open(path).ok()?; + let mut raw_file = RawFile::open(std::io::BufReader::new(file)).ok()?; + + let options = ProcessingOptions::new().gamma(raw.gamma); + let metadata = raw_file.metadata(); + Some( + raw_file + .process(&options) + .map(|image| Source { + path: path.to_path_buf(), + kind: SourceKind::Raw, + image, + metadata, + file_bytes, + }) + .map_err(|e| format!("RAW development failed: {e}")), + ) +} + +/// How a round-tripped image compares to its source. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Comparison { + /// Peak signal-to-noise ratio in dB over all three channels, or `None` + /// when the images are identical (PSNR is infinite there, and printing + /// `inf` reads as a bug rather than as success). + pub psnr_db: Option, + /// The largest absolute per-channel difference, in 16-bit sample units. + pub max_delta: u16, + /// Whether every sample matched exactly. + pub bit_exact: bool, +} + +/// The result of encoding and re-decoding one source through one format. +#[derive(Debug, Clone)] +pub struct VariantOk { + pub encoded_bytes: usize, + pub temp_path: PathBuf, + pub encode_ms: f64, + pub decode_ms: f64, + pub image: RgbImage, + /// `None` when the re-decoded image has different dimensions from the + /// source, which makes a per-sample comparison meaningless. + pub comparison: Option, + /// Set when the round trip claimed to be lossless but was not — the one + /// outcome that is a real defect rather than expected loss. + pub lossless_violated: bool, +} + +/// One cell of the results grid. +#[derive(Debug, Clone)] +pub struct Variant { + pub format: OutputFormat, + /// The settings actually used, for the cell caption. + pub summary: String, + pub lossless: bool, + pub outcome: Result, +} + +/// The directory this process writes its intermediate files to. +/// +/// `std::env::temp_dir()` rather than a hardcoded `/tmp`: on macOS it is a +/// per-user directory and on Windows there is no `/tmp` at all. The PID keeps +/// concurrent runs from overwriting each other. +pub fn scratch_dir() -> PathBuf { + std::env::temp_dir().join(format!("rawshift-gallery-{}", std::process::id())) +} + +/// Encode one source through one format, write it out, and decode it back. +/// +/// The intermediate really does go through the filesystem rather than staying +/// in a buffer: reading it back is what exercises the decoder's own sniffing +/// and container parsing on bytes it did not produce in-process. +pub fn round_trip(source: &Source, config: &FormatConfig) -> Variant { + let format = config.format(); + let variant = Variant { + format, + summary: config.summary(), + lossless: config.is_lossless(), + outcome: Err(String::new()), + }; + Variant { + outcome: run_round_trip(source, config), + ..variant + } +} + +fn run_round_trip(source: &Source, config: &FormatConfig) -> Result { + let format = config.format(); + let options = config.to_encode_options(); + + let started = Instant::now(); + let encoded = encode_rgb_image_to_vec(&source.image, &source.metadata, &options) + .map_err(|e| format!("encode failed: {e}"))?; + let encode_ms = started.elapsed().as_secs_f64() * 1000.0; + + let dir = scratch_dir(); + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let stem = source + .path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_owned()); + let temp_path = dir.join(format!("{stem}.{}", format.extension())); + std::fs::write(&temp_path, &encoded) + .map_err(|e| format!("cannot write {}: {e}", temp_path.display()))?; + + let read_back = std::fs::read(&temp_path) + .map_err(|e| format!("cannot read back {}: {e}", temp_path.display()))?; + + let started = Instant::now(); + let image = decode_encoded(&read_back, format)?; + let decode_ms = started.elapsed().as_secs_f64() * 1000.0; + + let comparison = compare(&source.image, &image); + // A lossless configuration that did not come back bit-exact is the one + // result worth flagging as a defect rather than as expected loss. + let lossless_violated = config.is_lossless() && comparison.is_some_and(|c| !c.bit_exact); + + Ok(VariantOk { + encoded_bytes: encoded.len(), + temp_path, + encode_ms, + decode_ms, + image, + comparison, + lossless_violated, + }) +} + +/// Decode bytes the gallery just wrote. +/// +/// The format is known, but detection runs anyway and must agree: a decoder +/// that cannot recognise its own encoder's output is a defect this +/// demonstration should surface, not paper over. +fn decode_encoded(bytes: &[u8], format: OutputFormat) -> Result { + let standard = match format { + OutputFormat::Png => StandardFormat::Png, + OutputFormat::Jpeg => StandardFormat::Jpeg, + OutputFormat::WebP => StandardFormat::WebP, + OutputFormat::Avif => StandardFormat::Avif, + OutputFormat::Jxl => StandardFormat::Jxl, + OutputFormat::Dng => { + return Err("DNG re-decode goes through the RAW pipeline, not implemented here".into()); + } + }; + match detect_standard_format(bytes) { + Some(detected) if detected == standard => {} + Some(detected) => { + return Err(format!( + "sniffed as {detected} but {} was encoded", + standard.name() + )); + } + None => return Err(format!("{} output was not recognised", standard.name())), + } + decode_standard_image(bytes, standard).map_err(|e| format!("decode failed: {e}")) +} + +/// Compare a round-tripped image against its source. +/// +/// `None` when the dimensions differ, which makes a per-sample comparison +/// meaningless — better to say so than to report a delta computed over +/// mismatched pixels. +pub fn compare(source: &RgbImage, actual: &RgbImage) -> Option { + if source.width() != actual.width() || source.height() != actual.height() { + return None; + } + let (a, b) = (source.data(), actual.data()); + if a.len() != b.len() { + return None; + } + + let mut max_delta = 0u16; + let mut sum_squared = 0f64; + for (&x, &y) in a.iter().zip(b) { + let delta = x.abs_diff(y); + max_delta = max_delta.max(delta); + sum_squared += f64::from(delta) * f64::from(delta); + } + + let bit_exact = max_delta == 0; + let psnr_db = (!bit_exact).then(|| { + let mse = sum_squared / a.len() as f64; + let peak = f64::from(u16::MAX); + 20.0 * peak.log10() - 10.0 * mse.log10() + }); + + Some(Comparison { + psnr_db, + max_delta, + bit_exact, + }) +} + +/// Remove this process's scratch directory. +/// +/// Best-effort: a leftover directory in the system temp area is not worth +/// failing a run over, and the OS reclaims it. +pub fn clean_scratch() { + let _ = std::fs::remove_dir_all(scratch_dir()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(width: u32, height: u32, fill: u16) -> RgbImage { + RgbImage::new(width, height, vec![fill; (width * height * 3) as usize]) + .expect("valid buffer") + } + + #[test] + fn identical_images_are_bit_exact_with_no_psnr() { + let comparison = compare(&image(4, 4, 30000), &image(4, 4, 30000)).expect("same size"); + assert!(comparison.bit_exact); + assert_eq!(comparison.max_delta, 0); + assert_eq!( + comparison.psnr_db, None, + "an exact match has infinite PSNR; reporting a number would be misleading" + ); + } + + #[test] + fn differing_images_report_delta_and_finite_psnr() { + let comparison = compare(&image(4, 4, 30000), &image(4, 4, 30100)).expect("same size"); + assert!(!comparison.bit_exact); + assert_eq!(comparison.max_delta, 100); + let psnr = comparison.psnr_db.expect("finite PSNR"); + assert!(psnr.is_finite() && psnr > 0.0, "got {psnr}"); + } + + #[test] + fn psnr_falls_as_error_grows() { + let near = compare(&image(4, 4, 30000), &image(4, 4, 30010)) + .and_then(|c| c.psnr_db) + .expect("finite"); + let far = compare(&image(4, 4, 30000), &image(4, 4, 40000)) + .and_then(|c| c.psnr_db) + .expect("finite"); + assert!(near > far, "near={near} should beat far={far}"); + } + + #[test] + fn mismatched_dimensions_have_no_comparison() { + assert!(compare(&image(4, 4, 0), &image(8, 8, 0)).is_none()); + } + + #[test] + fn scratch_dir_is_process_scoped() { + let dir = scratch_dir(); + assert!( + dir.to_string_lossy() + .contains(&std::process::id().to_string()), + "concurrent runs must not share a scratch directory: {}", + dir.display() + ); + assert!(dir.starts_with(std::env::temp_dir())); + } + + #[test] + fn unrecognised_bytes_report_an_error_rather_than_panicking() { + let err = decode_encoded(b"definitely not an image", OutputFormat::Png) + .expect_err("garbage must not decode"); + assert!(err.contains("not recognised"), "got: {err}"); + } + + /// The round trip must go through a real file, and a lossless PNG must + /// come back bit-exact. + #[test] + fn png_round_trip_is_bit_exact_through_the_filesystem() { + use rawshift_image::formats::export::PngEncodeConfig; + + let source = Source { + path: PathBuf::from("synthetic.png"), + kind: SourceKind::Standard(StandardFormat::Png), + image: image(8, 8, 4096), + metadata: ImageMetadata::default(), + file_bytes: 0, + }; + let config = FormatConfig::Png(PngEncodeConfig::default()); + let variant = round_trip(&source, &config); + + let ok = variant.outcome.expect("PNG round trip succeeds"); + assert!(ok.temp_path.exists(), "the intermediate must reach disk"); + assert!(ok.encoded_bytes > 0); + let comparison = ok.comparison.expect("same dimensions"); + assert!(comparison.bit_exact, "max delta {}", comparison.max_delta); + assert!(!ok.lossless_violated); + clean_scratch(); + } + + /// A lossy configuration must not be reported as a lossless violation. + #[test] + fn lossy_jpeg_is_not_flagged_as_a_lossless_violation() { + use rawshift_image::formats::export::JpegEncodeConfig; + + let source = Source { + path: PathBuf::from("synthetic.jpg"), + kind: SourceKind::Standard(StandardFormat::Jpeg), + image: image(16, 16, 20000), + metadata: ImageMetadata::default(), + file_bytes: 0, + }; + let variant = round_trip(&source, &FormatConfig::Jpeg(JpegEncodeConfig::default())); + let ok = variant.outcome.expect("JPEG round trip succeeds"); + assert!(!ok.lossless_violated); + clean_scratch(); + } +} diff --git a/examples/gallery/src/render.rs b/examples/gallery/src/render.rs new file mode 100644 index 0000000..ccbdd71 --- /dev/null +++ b/examples/gallery/src/render.rs @@ -0,0 +1,134 @@ +//! Turning decoded images into textures the window can draw. +//! +//! Kept apart from [`crate::color`] so the colour decisions stay testable +//! without `iced` in scope: this module only downsizes and wraps. + +use iced::widget::image::Handle; +use rawshift_image::core::RgbImage; +use rawshift_image::core::metadata::ImageMetadata; + +use crate::color::{self, ColorReport}; + +/// The longest edge, in pixels, a preview texture is reduced to. +/// +/// The colour transform is scalar `f64` per pixel, so a 60-megapixel RAW would +/// spend seconds in `Pipeline::eval` at full size for a thumbnail nobody can +/// see the detail of. Reducing first bounds that work; the numeric comparison +/// in [`crate::pipeline`] always runs on the full-resolution buffers, so +/// nothing that is *measured* is measured on a downscaled image. +pub const PREVIEW_MAX_EDGE: u32 = 512; + +/// A texture plus the account of how its colour was handled. +pub struct Preview { + pub handle: Handle, + pub report: ColorReport, + /// The full-resolution dimensions, which the preview may not have. + pub source_size: (u32, u32), +} + +/// Build a colour-managed preview texture. +pub fn preview(image: &RgbImage, metadata: &ImageMetadata) -> Preview { + let source_size = (image.width(), image.height()); + let reduced = downsample(image, PREVIEW_MAX_EDGE); + let subject = reduced.as_ref().unwrap_or(image); + let (rgba, report) = color::to_display_rgba(subject, metadata); + Preview { + handle: Handle::from_rgba(subject.width(), subject.height(), rgba), + report, + source_size, + } +} + +/// Nearest-neighbour reduction to fit `max_edge`, or `None` when the image +/// already fits. +/// +/// Nearest-neighbour rather than a filtered resample on purpose: this is a +/// codec demonstration, and a smoothing filter would hide exactly the blocking +/// and ringing artefacts a lossy encoder produces, which is the thing worth +/// looking at. +fn downsample(image: &RgbImage, max_edge: u32) -> Option { + let (width, height) = (image.width(), image.height()); + let longest = width.max(height); + if longest <= max_edge { + return None; + } + + // Integer step keeps the mapping exact and cheap. + let step = longest.div_ceil(max_edge); + let (new_width, new_height) = (width.div_ceil(step), height.div_ceil(step)); + let src = image.data(); + let mut out = Vec::with_capacity((new_width * new_height * 3) as usize); + for y in 0..new_height { + let sy = (y * step).min(height - 1); + for x in 0..new_width { + let sx = (x * step).min(width - 1); + let i = ((sy * width + sx) * 3) as usize; + out.extend_from_slice(&src[i..i + 3]); + } + } + // The arithmetic above guarantees the invariant; a failure here would be a + // bug in this function rather than bad input, so fall back to the original + // rather than losing the image. + RgbImage::with_color(new_width, new_height, out, image.color()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use rawshift_core::ColorDescription; + + fn image(width: u32, height: u32) -> RgbImage { + RgbImage::with_color( + width, + height, + vec![0x8000; (width * height * 3) as usize], + ColorDescription::SRGB, + ) + .expect("valid buffer") + } + + #[test] + fn small_images_are_not_resampled() { + assert!(downsample(&image(64, 64), PREVIEW_MAX_EDGE).is_none()); + } + + #[test] + fn large_images_fit_within_the_preview_bound() { + let reduced = downsample(&image(2000, 1000), 512).expect("resampled"); + assert!(reduced.width().max(reduced.height()) <= 512); + assert_eq!( + reduced.data().len(), + (reduced.width() * reduced.height() * 3) as usize + ); + } + + #[test] + fn downsampling_preserves_the_colour_tag() { + let source = RgbImage::with_color( + 1000, + 1000, + vec![0u16; 1000 * 1000 * 3], + ColorDescription::DISPLAY_P3, + ) + .expect("valid buffer"); + let reduced = downsample(&source, 100).expect("resampled"); + assert_eq!( + reduced.color(), + ColorDescription::DISPLAY_P3, + "dropping the tag here would make the preview silently mis-managed" + ); + } + + #[test] + fn non_square_images_keep_both_edges_bounded() { + let reduced = downsample(&image(4000, 37), 512).expect("resampled"); + assert!(reduced.width() <= 512); + assert!(reduced.height() >= 1); + } + + #[test] + fn preview_reports_the_full_resolution_size() { + let preview = preview(&image(1024, 768), &ImageMetadata::default()); + assert_eq!(preview.source_size, (1024, 768)); + } +} diff --git a/examples/gallery/src/settings.rs b/examples/gallery/src/settings.rs new file mode 100644 index 0000000..5aa6e50 --- /dev/null +++ b/examples/gallery/src/settings.rs @@ -0,0 +1,269 @@ +//! The encode-settings model. +//! +//! One entry per output format rawshift can encode, each holding that format's +//! real configuration struct from +//! [`rawshift_image::formats::export`] — not a parallel copy of it. The UI +//! mutates those structs directly, so every parameter the library exposes is +//! reachable from the window and none can silently drift out of sync with the +//! library's defaults. +//! +//! Deliberately free of `iced` types: the headless runner and the unit tests +//! drive this module directly. + +use rawshift_image::formats::export::{ + AvifEncodeConfig, CommonEncodeOptions, EncodeOptions, JpegEncodeConfig, JxlEncodeConfig, + OutputFormat, PngEncodeConfig, WebpEncodeConfig, +}; + +/// One format's configuration, as the library models it. +/// +/// Mirrors the [`EncodeOptions`] variants rather than wrapping them, so the +/// enum arms track the library's own feature gating. +#[derive(Debug, Clone, PartialEq)] +pub enum FormatConfig { + Png(PngEncodeConfig), + Jpeg(JpegEncodeConfig), + WebP(WebpEncodeConfig), + Avif(AvifEncodeConfig), + Jxl(JxlEncodeConfig), +} + +impl FormatConfig { + /// The output format this configuration produces. + pub fn format(&self) -> OutputFormat { + match self { + FormatConfig::Png(_) => OutputFormat::Png, + FormatConfig::Jpeg(_) => OutputFormat::Jpeg, + FormatConfig::WebP(_) => OutputFormat::WebP, + FormatConfig::Avif(_) => OutputFormat::Avif, + FormatConfig::Jxl(_) => OutputFormat::Jxl, + } + } + + /// Whether this configuration is a mathematically lossless one, so the + /// round trip can be asserted bit-exact rather than merely close. + /// + /// PNG is lossless by construction. The others carry an explicit mode. + pub fn is_lossless(&self) -> bool { + match self { + FormatConfig::Png(_) => true, + FormatConfig::Jpeg(_) => false, + FormatConfig::WebP(cfg) => { + matches!( + cfg.mode, + rawshift_image::formats::export::WebPMode::Lossless + ) + } + FormatConfig::Avif(cfg) => cfg.lossless, + FormatConfig::Jxl(cfg) => cfg.lossless, + } + } + + /// The encoder-agnostic options in effect, for the UI to edit. + pub fn common_mut(&mut self) -> &mut CommonEncodeOptions { + match self { + FormatConfig::Png(cfg) => &mut cfg.common, + FormatConfig::Jpeg(cfg) => &mut cfg.common, + FormatConfig::WebP(cfg) => &mut cfg.common, + FormatConfig::Avif(cfg) => &mut cfg.common, + FormatConfig::Jxl(cfg) => &mut cfg.common, + } + } + + /// Build the library's own options value for an encode. + pub fn to_encode_options(&self) -> EncodeOptions { + match self.clone() { + FormatConfig::Png(cfg) => EncodeOptions::Png(cfg), + FormatConfig::Jpeg(cfg) => EncodeOptions::Jpeg(cfg), + FormatConfig::WebP(cfg) => EncodeOptions::WebP(cfg), + FormatConfig::Avif(cfg) => EncodeOptions::Avif(cfg), + FormatConfig::Jxl(cfg) => EncodeOptions::Jxl(cfg), + } + } + + /// A one-line summary of the settings actually in force, for the results + /// grid and the headless table. + /// + /// Only parameters the encoder will honour in the current mode appear — + /// quoting a JXL distance next to `lossless` would misrepresent what ran. + pub fn summary(&self) -> String { + match self { + FormatConfig::Png(cfg) => format!( + "{:?} compression, {:?} filter{}", + cfg.compression, + cfg.filter, + if cfg.auto_reduce { ", auto-reduce" } else { "" } + ), + FormatConfig::Jpeg(cfg) => format!( + "q{} {:?}{}{}", + cfg.quality, + cfg.subsampling, + if cfg.progressive { " progressive" } else { "" }, + if cfg.restart_interval > 0 { + format!(" restart={}", cfg.restart_interval) + } else { + String::new() + } + ), + FormatConfig::WebP(cfg) => match cfg.mode { + rawshift_image::formats::export::WebPMode::Lossless => "lossless".to_owned(), + rawshift_image::formats::export::WebPMode::Lossy => { + format!("lossy q{}", cfg.quality) + } + }, + FormatConfig::Avif(cfg) => { + if cfg.lossless { + "lossless".to_owned() + } else { + format!("lossy q{}", cfg.quality) + } + } + FormatConfig::Jxl(cfg) => { + let mode = if cfg.lossless { + "lossless".to_owned() + } else { + format!("distance {:.2}", cfg.distance) + }; + format!( + "{mode}, effort {}{}", + cfg.effort, + if cfg.use_container { ", container" } else { "" } + ) + } + } + } +} + +/// One row of the settings panel: a format, whether it is selected, and its +/// parameters. +#[derive(Debug, Clone, PartialEq)] +pub struct FormatSettings { + pub enabled: bool, + pub config: FormatConfig, + /// Whether the panel section is expanded in the UI. + pub expanded: bool, +} + +/// Every encodable format, with the shared options applied across all of them. +#[derive(Debug, Clone, PartialEq)] +pub struct EncodeSettings { + /// Applied to every format before encoding, so bit depth and metadata + /// embedding are set once rather than per format. + pub common: CommonEncodeOptions, + pub formats: Vec, +} + +impl Default for EncodeSettings { + /// Every format the build can encode, at the library's own defaults. + /// + /// PNG and JPEG start enabled: one lossless path and one lossy path, so a + /// first run shows both a bit-exact result and a PSNR without the user + /// having to choose anything. + fn default() -> Self { + let mut formats = Vec::new(); + let mut push = |config: FormatConfig, enabled: bool| { + formats.push(FormatSettings { + enabled, + config, + expanded: enabled, + }); + }; + push(FormatConfig::Png(PngEncodeConfig::default()), true); + push(FormatConfig::Jpeg(JpegEncodeConfig::default()), true); + push(FormatConfig::WebP(WebpEncodeConfig::lossy()), false); + push(FormatConfig::Avif(AvifEncodeConfig::default()), false); + push(FormatConfig::Jxl(JxlEncodeConfig::default()), false); + Self { + common: CommonEncodeOptions::default(), + formats, + } + } +} + +impl EncodeSettings { + /// The selected formats, with [`Self::common`] applied to each. + /// + /// Returned in panel order so the results grid columns are stable across + /// runs — a column that moved between runs would make the side-by-side + /// comparison useless. + pub fn selected(&self) -> Vec { + self.formats + .iter() + .filter(|f| f.enabled) + .map(|f| { + let mut config = f.config.clone(); + *config.common_mut() = self.common; + config + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rawshift_image::formats::export::{BitDepth, WebPMode}; + + #[test] + fn defaults_enable_one_lossless_and_one_lossy_format() { + let settings = EncodeSettings::default(); + let selected = settings.selected(); + assert_eq!(selected.len(), 2); + assert!(selected.iter().any(|c| c.is_lossless())); + assert!(selected.iter().any(|c| !c.is_lossless())); + } + + #[test] + fn common_options_override_each_format() { + let mut settings = EncodeSettings::default(); + settings.common.bit_depth = BitDepth::Eight; + for config in settings.selected() { + let mut config = config; + assert_eq!(config.common_mut().bit_depth, BitDepth::Eight); + } + } + + #[test] + fn selected_preserves_panel_order() { + let mut settings = EncodeSettings::default(); + for f in &mut settings.formats { + f.enabled = true; + } + let order: Vec<_> = settings.selected().iter().map(|c| c.format()).collect(); + assert_eq!( + order, + vec![ + OutputFormat::Png, + OutputFormat::Jpeg, + OutputFormat::WebP, + OutputFormat::Avif, + OutputFormat::Jxl, + ] + ); + } + + #[test] + fn losslessness_tracks_the_mode_flag() { + assert!(FormatConfig::Png(PngEncodeConfig::default()).is_lossless()); + assert!(!FormatConfig::Jpeg(JpegEncodeConfig::default()).is_lossless()); + assert!(FormatConfig::WebP(WebpEncodeConfig::lossless()).is_lossless()); + assert!(!FormatConfig::WebP(WebpEncodeConfig::lossy()).is_lossless()); + } + + #[test] + fn summary_omits_parameters_the_mode_ignores() { + let lossless = FormatConfig::Jxl(JxlEncodeConfig::default()); + assert!(lossless.summary().contains("lossless")); + assert!( + !lossless.summary().contains("distance"), + "a lossless JXL must not quote a Butteraugli distance" + ); + + let lossy = FormatConfig::WebP(WebpEncodeConfig { + mode: WebPMode::Lossy, + quality: 42, + ..WebpEncodeConfig::lossy() + }); + assert!(lossy.summary().contains("q42")); + } +} diff --git a/justfile b/justfile index b84b0d9..7c654bf 100644 --- a/justfile +++ b/justfile @@ -69,6 +69,27 @@ test-hw: cargo test -p rawshift-image --features full \ --test heic_hw_decode --test avif_hw_decode -- --nocapture +# ── Gallery (examples/gallery) ──────────────────────────────────────────────── +# The demonstration GUI is a standalone package outside the workspace: its +# gamut-cmm git dependency drags in the gamut repository's aom/dav1d submodules +# (~1.4 GB). Every recipe below addresses it by manifest path, so `just clippy` +# and `just test` never build it and the pre-commit hook never fetches it. + +# Run the gallery GUI. Extra args go to the binary (e.g. `just gallery a.jpg`). +gallery *args: + cargo run --release --manifest-path examples/gallery/Cargo.toml -- {{args}} + +# Run the gallery's round-trip matrix with no window — what CI exercises, and +# how to reproduce a decode/encode failure from a terminal. +gallery-headless *args: + cargo run --manifest-path examples/gallery/Cargo.toml -- --headless --all-formats {{args}} + +# Lint, format-check, and test the gallery +gallery-check: + cargo fmt --manifest-path examples/gallery/Cargo.toml -- --check + cargo clippy --manifest-path examples/gallery/Cargo.toml --all-targets -- -D warnings + cargo test --manifest-path examples/gallery/Cargo.toml + # Generate docs for the whole workspace doc: cargo doc --workspace --no-deps --open From 971f91613ddb126ab1a46174d3d169996cdbf266 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:26:32 -0400 Subject: [PATCH 4/7] docs: link the filed upstream issues and add the gallery to the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-references the issues opened while building the gallery, so the gaps documented in code comments point at something trackable rather than saying "tracked separately": - gamut#375 — gamut-avif encodes AV1 Profile 1, which VideoToolbox's still-image path cannot decode. Found by the gallery: rawshift cannot read back its own AVIF output on Apple hardware. - gamut#376 — `ColourPrimaries` chromaticities and an `oetf_for` inverse in gamut-color, the two pieces missing before wide-gamut conversion can live in the library. - gamut#377 — publish gamut-cmm, which retires the git carve-out. - gamut#378 — mark the aom/dav1d submodules `update = none`; the ~1.4 GB fetch is why the gallery sits outside the workspace. - gamut#379 — a metadata-only entry point for gamut-png, which is why a PNG probe cannot report a colour space. - rawshift#66 — extend `convert_to_srgb` to Display P3 and Rec. 2020 (blocked on gamut#376), plus the unblocked encode-side half: writing CICP code points through to the output container. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- CHANGELOG.md | 3 ++- README.md | 20 +++++++++++++++++++ crates/rawshift-image/src/formats/encode.rs | 3 ++- crates/rawshift-image/src/formats/standard.rs | 4 +++- examples/gallery/README.md | 18 ++++++++++++++--- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d9630..3a25c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,8 @@ All entries below are **breaking**, grouped by area. - **`ImageProbe::color_space` reads the file** for JPEG, WebP, AVIF, and HEIC instead of returning a hardcoded `SRGB`. PNG reports `UNSPECIFIED` — its `iCCP` chunk is DEFLATE-compressed and gamut-png exposes ancillary chunks - only from a full decode, which a header-only probe must not do. + only from a full decode, which a header-only probe must not do + ([gamut#379](https://github.com/visualcommons/gamut/issues/379)). - **Encode preserves the source ICC profile.** Every `embed_icc` path wrote a synthesised sRGB profile, discarding the source's. An image tagged `UNSPECIFIED` now carries its own profile through to the output. Images diff --git a/README.md b/README.md index 8595071..bc6e07d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,26 @@ The key priorities in order are: +### Seeing it work + +`crates/rawshift-image/examples/` holds small CLI examples (decode, encode, +develop a RAW, dump metadata, inspect an AVIF/HEIC container). + +For an end-to-end view there is a GUI, [`examples/gallery`](examples/gallery): +pick images and each is decoded, re-encoded through every selected output +format with every parameter exposed, written to the temp directory, decoded +back, and shown side by side against the source with PSNR / max-delta / +bit-exact verdicts. It also reports which hardware decode backend is active. + +```sh +just gallery # the window +just gallery-headless path/to/*.jpg # the same matrix, no window +``` + +It is a standalone package outside the workspace and is never published — see +its README for why, and for the one scoped `gamut-cmm` git carve-out to the +upstream-first policy. + ## Format Support Rawshift targets both still image and video formats. Image decoding is the diff --git a/crates/rawshift-image/src/formats/encode.rs b/crates/rawshift-image/src/formats/encode.rs index f218b3c..e12a1dc 100644 --- a/crates/rawshift-image/src/formats/encode.rs +++ b/crates/rawshift-image/src/formats/encode.rs @@ -45,7 +45,8 @@ use super::export::WebPMode; /// an sRGB profile, because rawshift can only synthesise sRGB and the source /// carried code points rather than a profile. Writing the code points through /// to the output container (a PNG `cICP` chunk, an AVIF `colr nclx` box) is the -/// correct fix and is tracked separately. +/// correct fix — tracked in +/// [rawshift#66](https://github.com/visualcommons/rawshift/issues/66). /// /// [`ColorDescription`]: crate::core::ColorDescription /// [`UNSPECIFIED`]: crate::core::ColorDescription::UNSPECIFIED diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index 048c454..ad41225 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -759,7 +759,9 @@ pub fn probe_standard_image(data: &[u8]) -> RawResult { /// [`UNSPECIFIED`](crate::core::ColorDescription::UNSPECIFIED), meaning "not /// determined by the probe" rather than "no profile"; a PNG cICP chunk is /// uncompressed but is rare enough not to be worth a bespoke chunk walk here. -/// [`decode_standard_image`] resolves PNG's colour authoritatively. +/// [`decode_standard_image`] resolves PNG's colour authoritatively. A +/// metadata-only entry point is asked for in +/// [gamut#379](https://github.com/visualcommons/gamut/issues/379). /// /// Formats with no colour path at all report /// [`SRGB`](crate::core::ColorDescription::SRGB), the documented default. diff --git a/examples/gallery/README.md b/examples/gallery/README.md index dd33bae..27a18e8 100644 --- a/examples/gallery/README.md +++ b/examples/gallery/README.md @@ -40,7 +40,8 @@ outside the workspace, so no published crate's dependency tree contains it. `gamut-cmm` is the ICC colour-management module ([gamut#323]). It is not on crates.io yet, and it is the only way to apply an ICC transform to pixels: `gamut-icc` parses and serialises profiles but explicitly does not transform -them. Retire the carve-out for a crates.io version once gamut-cmm ships. +them. Retire the carve-out for a crates.io version once gamut-cmm ships +([gamut#377]); the ~1.4 GB fetch is [gamut#378]. **Two gamut trees.** `gamut-cmm` depends on `gamut-core` / `gamut-color` / `gamut-icc` by path inside the gamut workspace, so over git those resolve to @@ -50,6 +51,8 @@ version numbers. This crate therefore links two copies of `gamut-icc` whose **bytes** and is re-parsed; see `src/color.rs`. [gamut#323]: https://github.com/visualcommons/gamut/issues/323 +[gamut#377]: https://github.com/visualcommons/gamut/issues/377 +[gamut#378]: https://github.com/visualcommons/gamut/issues/378 ## Layout @@ -85,8 +88,9 @@ On Apple hardware, rawshift cannot currently decode its own AVIF output. `gamut-avif` encodes identity-matrix 4:4:4, which is AV1 Profile 1, and VideoToolbox's still-image path decodes Main / Profile 0 only. The AVIF column therefore shows a decode error on macOS even though both halves work in -isolation. The gallery surfaces this rather than hiding it — see the tracking -issue linked from the pull request that added this crate. +isolation. The gallery surfaces this rather than hiding it: [gamut#375]. + +[gamut#375]: https://github.com/visualcommons/gamut/issues/375 ## Colour management @@ -100,3 +104,11 @@ Two layers, addressed separately: no faithful CICP expression) goes through `gamut-cmm`. Anything gamut-cmm cannot link — LUT-based profiles, since its phase P5 is unimplemented — falls back to sRGB with a visible warning rather than refusing to draw. + + A wide-gamut image that carried CICP code points rather than a profile has + nothing for gamut-cmm to link from, so it is shown as sRGB with a warning. + Closing that needs a `ColourPrimaries` → chromaticities accessor upstream + ([gamut#376]); the matching library-side conversion is [rawshift#66]. + +[gamut#376]: https://github.com/visualcommons/gamut/issues/376 +[rawshift#66]: https://github.com/visualcommons/rawshift/issues/66 From ff2614495572b3c91d9924bec789105dea06f938 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:41:41 -0400 Subject: [PATCH 5/7] ci: run CI on pull requests targeting any branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pull_request: branches: [master]` meant a stacked PR — one feature branch based on another, to be retargeted to master once the parent lands — got **no** checks. Not pending ones: zero. #67 sat MERGEABLE with an empty status rollup, which reads as "nothing to run" rather than "nothing has been verified". That is worst for exactly the changes a stack introduces. #67 adds a three-OS `gallery` job, and without this the iced/wgpu/winit tree and the git gamut-cmm dependency would never have been built on Linux or Windows before review. Dropping the filter costs CI minutes on stacked PRs. The alternative — leaving them unverified until the parent merges — hides platform failures until the moment the branch is retargeted, which is the worst time to discover them. `push` keeps its `branches: [master]` filter, so this does not double up on every feature-branch push. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34b1ecf..135dbc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,11 @@ name: CI on: push: branches: [master] + # No branch filter: a pull request targeting any branch runs CI. Stacked PRs + # (one feature branch based on another, retargeted to master once the parent + # lands) would otherwise get no checks at all — not pending ones, none — and + # sit unverified for as long as the parent is open. pull_request: - branches: [master] env: CARGO_TERM_COLOR: always From ee852665100533a738ff6a59ea6592cb4639d422 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:42:51 -0400 Subject: [PATCH 6/7] chore(gallery): drop rfd's async-std runtime in favour of iced's tokio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rfd = "0.15"` took the crate's default features, which are `xdg-portal` + `async-std`. That pulled a whole second async runtime alongside the tokio executor iced is already configured with, and made the comment above the iced dependency — claiming rfd shares that executor — untrue. `default-features = false` with an explicit `xdg-portal` + `tokio` keeps rfd's own default Linux backend while dropping async-std entirely (`cargo tree -i async-std` now finds no such package). Worth recording why `xdg-portal` and not rfd's `gtk3` alternative: the portal backend needs no system development packages, so the Linux CI job installs nothing on rfd's behalf. `gtk3` would have required libgtk-3-dev on every runner. 35 gallery tests pass; fmt and clippy clean. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- examples/gallery/Cargo.lock | 187 +++++++----------------------------- examples/gallery/Cargo.toml | 13 ++- 2 files changed, 46 insertions(+), 154 deletions(-) diff --git a/examples/gallery/Cargo.lock b/examples/gallery/Cargo.lock index 827d598..c6e44df 100644 --- a/examples/gallery/Cargo.lock +++ b/examples/gallery/Cargo.lock @@ -134,8 +134,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ - "async-fs", - "async-net", "enumflags2", "futures-channel", "futures-util", @@ -143,6 +141,7 @@ dependencies = [ "raw-window-handle", "serde", "serde_repr", + "tokio", "url", "wayland-backend", "wayland-client", @@ -162,101 +161,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - [[package]] name = "async-recursion" version = "1.1.1" @@ -268,30 +172,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.92" @@ -381,19 +261,6 @@ dependencies = [ "objc2 0.6.4", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -2357,6 +2224,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2924,17 +2802,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkg-config" version = "0.3.34" @@ -3881,6 +3748,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4150,7 +4027,14 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -4430,6 +4314,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5055,14 +4945,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", "async-recursion", - "async-task", "async-trait", - "blocking", "enumflags2", "event-listener", "futures-core", @@ -5073,6 +4957,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index 04f3430..05357e9 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -52,8 +52,8 @@ gamut-icc-git = { package = "gamut-icc", git = "https://github.com/visualcommons # `image-without-codecs` gives the image widget without the `image` crate's # decoders — the gallery feeds it rawshift's own buffers, so pulling a second -# set of codecs in would be both wasteful and dishonest about what decoded -# the picture. `tokio` is iced's futures executor; `rfd` shares it. +# set of codecs in would be both wasteful and dishonest about what decoded the +# picture. `tokio` selects iced's futures executor. iced = { version = "0.14", default-features = false, features = [ "wgpu", "tiny-skia", @@ -61,7 +61,14 @@ iced = { version = "0.14", default-features = false, features = [ "advanced", "tokio", ] } -rfd = "0.15" +# default-features = false to drop rfd's default `async-std`: it would pull a +# second async runtime alongside iced's tokio for no benefit. `xdg-portal` is +# rfd's own default Linux backend and, unlike its `gtk3` alternative, needs no +# system development packages — so the Linux CI job installs nothing for it. +rfd = { version = "0.15", default-features = false, features = [ + "xdg-portal", + "tokio", +] } tokio = { version = "1.50", features = ["rt-multi-thread"] } # The crates.io gamut-color, matching rawshift's own tree — used for the sRGB From b45081feed91ff249ee54edf7ba92226f8a46739 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:48:09 -0400 Subject: [PATCH 7/7] fix(gallery): enable iced's x11 and wayland features so Linux builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux CI leg failed in `wayland-sys`, whose build script requires `wayland-client` through pkg-config: The system library `wayland-client` required by crate `wayland-sys` was not found. `default-features = false` on iced dropped `iced_winit`'s default `x11` and `wayland` features. Those are not optional extras on Linux — they are the only windowing backends there, so the crate had no way to open a window on Linux at all, and something in the tree still pulled winit's wayland support down the non-dlopen path. Selecting them explicitly fixes both problems at once: `iced_winit/wayland` brings `winit/wayland-dlopen`, which loads libwayland at runtime instead of linking it, so neither the CI job nor a Linux user needs libwayland-dev. This also corrects a claim I had made in the CI comment and the gallery README — that winit dlopens X11/Wayland so no packages are needed. That was only true with these features enabled, which they were not. Both now say what actually makes it true, and why removing the features breaks it. macOS and Windows were unaffected (both legs were still building when this landed), which is exactly why the Linux leg was worth having. 35 gallery tests pass; fmt and clippy clean. Claude-Session: https://claude.ai/code/session_019wHgpYYx1hJJ5NkMh6XiDB --- .github/workflows/ci.yml | 7 +- examples/gallery/Cargo.lock | 353 +++++++++++++++++++++++++++++++++++- examples/gallery/Cargo.toml | 8 + examples/gallery/README.md | 9 + 4 files changed, 375 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 135dbc2..54a997a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,7 +285,12 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: examples/gallery - # libjxl (via gamut-jxl, pulled by rawshift-image/full) builds from source. + # libjxl (via gamut-jxl, pulled by rawshift-image/full) builds from + # source, hence cmake/clang. Nothing is installed for windowing: the + # gallery selects iced's `x11` + `wayland` features, and the latter + # brings `winit/wayland-dlopen`, so libwayland is loaded at runtime + # rather than linked. Dropping those features sends the build into + # `wayland-sys`, which needs libwayland-dev via pkg-config. - name: Install build dependencies if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y cmake clang libclang-dev diff --git a/examples/gallery/Cargo.lock b/examples/gallery/Cargo.lock index c6e44df..b250ae4 100644 --- a/examples/gallery/Cargo.lock +++ b/examples/gallery/Cargo.lock @@ -2,12 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -119,6 +148,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -319,6 +354,43 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.4.4" @@ -403,6 +475,25 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.20", + "x11rb", +] + [[package]] name = "cmake" version = "0.1.58" @@ -611,6 +702,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -692,6 +792,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + [[package]] name = "either" version = "1.18.0" @@ -1329,6 +1435,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2723,6 +2839,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -3583,6 +3708,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3739,6 +3877,69 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -3764,8 +3965,11 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ + "as-raw-xcb-connection", "bytemuck", + "fastrand", "js-sys", + "memmap2", "ndk", "objc2 0.6.4", "objc2-core-foundation", @@ -3774,10 +3978,16 @@ dependencies = [ "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", "tracing", "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", "web-sys", "windows-sys 0.61.2", + "x11rb", ] [[package]] @@ -3996,6 +4206,19 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -4073,6 +4296,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4424,6 +4648,28 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + [[package]] name = "wayland-protocols" version = "0.32.13" @@ -4436,6 +4682,58 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-scanner" version = "0.31.11" @@ -4455,6 +4753,7 @@ checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", + "once_cell", "pkg-config", ] @@ -4652,6 +4951,8 @@ checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" dependencies = [ "clipboard-win", "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", "raw-window-handle", "thiserror 2.0.20", ] @@ -4823,11 +5124,13 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ + "ahash", "android-activity", "atomic-waker", "bitflags 2.13.1", "block2 0.5.1", - "calloop", + "bytemuck", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -4836,24 +5139,34 @@ dependencies = [ "dpi", "js-sys", "libc", + "memmap2", "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", + "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -4878,6 +5191,44 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + [[package]] name = "xkbcommon-dl" version = "0.4.2" diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index 05357e9..5be4094 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -54,12 +54,20 @@ gamut-icc-git = { package = "gamut-icc", git = "https://github.com/visualcommons # decoders — the gallery feeds it rawshift's own buffers, so pulling a second # set of codecs in would be both wasteful and dishonest about what decoded the # picture. `tokio` selects iced's futures executor. +# `x11` and `wayland` are not optional extras on Linux — they are the only +# windowing backends there, and `default-features = false` drops both. They +# also select `winit/wayland-dlopen`, which loads libwayland at runtime instead +# of linking it, so neither a Linux build nor a Linux user needs +# libwayland-dev. Without them the build reaches `wayland-sys`, whose build +# script demands `wayland-client` through pkg-config, and fails. iced = { version = "0.14", default-features = false, features = [ "wgpu", "tiny-skia", "image-without-codecs", "advanced", "tokio", + "x11", + "wayland", ] } # default-features = false to drop rfd's default `async-std`: it would pull a # second async runtime alongside iced's tokio for no benefit. `xdg-portal` is diff --git a/examples/gallery/README.md b/examples/gallery/README.md index 27a18e8..73c2c90 100644 --- a/examples/gallery/README.md +++ b/examples/gallery/README.md @@ -54,6 +54,15 @@ version numbers. This crate therefore links two copies of `gamut-icc` whose [gamut#377]: https://github.com/visualcommons/gamut/issues/377 [gamut#378]: https://github.com/visualcommons/gamut/issues/378 +## Linux + +No system development packages are needed. The crate selects iced's `x11` and +`wayland` features; `wayland` brings `winit/wayland-dlopen`, so libwayland is +loaded at runtime rather than linked. Those two features are not optional +extras — they are the only windowing backends on Linux, and building with +`default-features = false` and neither of them selected fails in `wayland-sys`, +whose build script requires `wayland-client` through pkg-config. + ## Layout | Module | Role |