From 33269d354017023404e2cb1919baae27c4a97b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 20:24:41 +0200 Subject: [PATCH] feat(theseus): add `normalize_skin_texture` Tauri command This command lets the app frontend opt in to normalizing the texture of any skin, which may be in either the legacy 64x32 or newer 64x64 format, to the newer 64x64 format for display purposes. --- Cargo.lock | 11 +- Cargo.toml | 3 + apps/app/src/api/minecraft_skins.rs | 9 + packages/app-lib/Cargo.toml | 5 +- packages/app-lib/src/api/minecraft_skins.rs | 90 ++---- .../assets/test/MissingNo_normalized.png | Bin 0 -> 7155 bytes .../src/api/minecraft_skins/png_util.rs | 298 ++++++++++++++++++ packages/app-lib/src/error.rs | 6 + 8 files changed, 353 insertions(+), 69 deletions(-) create mode 100644 packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png create mode 100644 packages/app-lib/src/api/minecraft_skins/png_util.rs diff --git a/Cargo.lock b/Cargo.lock index 4ec513984..9dfc4ee77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7995,7 +7995,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid 1.16.0", + "uuid 1.17.0", "webpki-roots 0.26.11", ] @@ -8078,7 +8078,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", - "uuid 1.16.0", + "uuid 1.17.0", "whoami", ] @@ -8118,7 +8118,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", - "uuid 1.16.0", + "uuid 1.17.0", "whoami", ] @@ -8145,7 +8145,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", - "uuid 1.16.0", + "uuid 1.17.0", ] [[package]] @@ -8885,6 +8885,7 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", "chardetng", "chrono", @@ -8907,11 +8908,13 @@ dependencies = [ "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 51655fb4e..b37e3da69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [ async-walkdir = "2.1.0" base64 = "0.22.1" bitflags = "2.9.1" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" @@ -91,6 +92,7 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" @@ -99,6 +101,7 @@ rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 regex = "1.11.1" reqwest = { version = "0.12.19", default-features = false } +rgb = "0.8.50" rust_decimal = { version = "1.37.1", features = [ "serde-with-float", "serde-with-str", diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs index 42ff5ff34..bf0e68ca5 100644 --- a/apps/app/src/api/minecraft_skins.rs +++ b/apps/app/src/api/minecraft_skins.rs @@ -12,6 +12,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { equip_skin, remove_custom_skin, unequip_skin, + normalize_skin_texture, ]) .build() } @@ -80,3 +81,11 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> { pub async fn unequip_skin() -> Result<()> { Ok(minecraft_skins::unequip_skin().await?) } + +/// `invoke('plugin:minecraft-skins|normalize_skin_texture')` +/// +/// See also: [minecraft_skins::normalize_skin_texture] +#[tauri::command] +pub async fn normalize_skin_texture(skin: &Skin) -> Result { + Ok(minecraft_skins::normalize_skin_texture(skin).await?) +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 8efa68d84..4b53db978 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -23,6 +23,9 @@ quick-xml = { workspace = true, features = ["async-tokio"] } enumset.workspace = true chardetng.workspace = true encoding_rs.workspace = true +png.workspace = true +bytemuck.workspace = true +rgb.workspace = true chrono = { workspace = true, features = ["serde"] } daedalus.workspace = true @@ -48,7 +51,7 @@ async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rust futures = { workspace = true, features = ["async-await", "alloc"] } reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration", "multipart"] } tokio = { workspace = true, features = ["time", "io-util", "net", "sync", "fs", "macros", "process"] } -tokio-util = { workspace = true, features = ["compat"] } +tokio-util = { workspace = true, features = ["compat", "io", "io-util"] } async-recursion.workspace = true fs4 = { workspace = true, features = ["tokio"] } async-walkdir.workspace = true diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index dc1a3a922..08f0341c5 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -1,14 +1,10 @@ //! Theseus skin management interface -use std::{ - borrow::Cow, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; -use base64::Engine; pub use bytes::Bytes; use data_url::DataUrl; use futures::{Stream, StreamExt, TryStreamExt, future::Either, stream}; @@ -38,6 +34,8 @@ mod assets { pub use default::DEFAULT_SKINS; } +mod png_util; + #[derive(Deserialize, Serialize, Debug)] pub struct Cape { /// An identifier for this cape, potentially unique to the owning player. @@ -202,9 +200,16 @@ pub async fn get_available_skins() -> crate::Result> { name: None, variant: custom_skin.variant, cape_id: custom_skin.cape_id, - texture: texture_blob_to_data_url( + texture: png_util::blob_to_data_url( custom_skin.texture_blob(&state.pool).await?, - ), + ) + .or_else(|| { + // Fall back to a placeholder texture if the DB somehow contains corrupt data + png_util::blob_to_data_url(include_bytes!( + "minecraft_skins/assets/default/MissingNo.png" + )) + }) + .unwrap(), source: SkinSource::Custom, is_equipped, texture_key: custom_skin.texture_key.into(), @@ -265,7 +270,7 @@ pub async fn add_and_equip_custom_skin( variant: MinecraftSkinVariant, cape_override: Option, ) -> crate::Result<()> { - let (skin_width, skin_height) = png_dimensions(&texture_blob)?; + let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; if skin_width != 64 || ![32, 64].contains(&skin_height) { return Err(ErrorKind::InvalidSkinTexture)?; } @@ -457,6 +462,17 @@ pub async fn unequip_skin() -> crate::Result<()> { Ok(()) } +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// Returns the normalized, processed texture as a byte array in PNG format. +#[tracing::instrument] +pub async fn normalize_skin_texture(skin: &Skin) -> crate::Result { + png_util::normalize_skin_texture(skin).await +} + /// Synchronizes the equipped cape with the selected cape if necessary, taking into /// account the currently equipped cape, the default cape for the player, and if a /// cape override is provided. @@ -527,57 +543,3 @@ async fn save_current_custom_external_skin( Ok(()) } - -fn texture_blob_to_data_url(texture_blob: Vec) -> Arc { - let data = if is_png(&texture_blob) { - Cow::Owned(texture_blob) - } else { - // Fall back to a placeholder texture if the DB somehow contains corrupt data - Cow::Borrowed( - &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], - ) - }; - - Url::parse(&format!( - "data:image/png;base64,{}", - base64::engine::general_purpose::STANDARD.encode(data) - )) - .unwrap() - .into() -} - -fn is_png(data: &[u8]) -> bool { - /// The initial 8 bytes of a PNG file, used to identify it as such. - /// - /// Reference: - const PNG_SIGNATURE: &[u8] = - &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - - data.starts_with(PNG_SIGNATURE) -} - -fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> { - if !is_png(data) { - Err(ErrorKind::InvalidPng)?; - } - - // Read the width and height fields from the IHDR chunk, which the - // PNG specification mandates to be the first in the file, just after - // the 8 signature bytes. See: - // https://www.w3.org/TR/png-3/#5DataRep - // https://www.w3.org/TR/png-3/#11IHDR - let width = u32::from_be_bytes( - data.get(16..20) - .ok_or(ErrorKind::InvalidPng)? - .try_into() - .unwrap(), - ); - let height = u32::from_be_bytes( - data.get(20..24) - .ok_or(ErrorKind::InvalidPng)? - .try_into() - .unwrap(), - ); - - Ok((width, height)) -} diff --git a/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png new file mode 100644 index 0000000000000000000000000000000000000000..639b3fe15949a68ebfc83561109b4d764e5b4063 GIT binary patch literal 7155 zcma)BYlv1=6de&Y6#YAp#DG+PB!r3HpcRUlWKS|JW`vZmAtb@f$MBI75fwu(Ljord zvWGc^sHlzRpU@si;G-;^5po1&MspZvzG?iv_TKlb{kY#94SdW!_nfoWUTf`r&egY{ zSTJ|;#G5D9)zwX&_sHz0=b`liAA08XF}**G{p{97n|}PK zy1MVZ!@q1>Ise5Sul%_?{aXI+mQ9a#Y-zl6T3`9KJuk2Q{H+ICj<=PLT z_pLj3{PgsWzNR&+27mf`|H7uzomc49tczO@9BChTvg_}o!(Xh3BQ(E%*3^;t&yK#F zb{<*OFz{Gqd)x5sr4w^oKMW%jZgcgI?&d3TPA=$6t;g;u@0@wzQwAw8ibojv$kFlD z4~i1890i7jpvg8uiT9N59{OZ^o`<64G+FYZECRg7>dr$4mj)Vbd4BR0&LwefES1|Q zS8fy90S~;fE^di`td$9D1m^vc{j&z<_-BL!N)eA3`Tg3LGg$o9r3>%K*nxAWS~^LN z=XZ3T*z>}=Z;a07q>Td%X31Cg|U%n=_a3o73&X(@Yl) z!2%tuXL$y<9XMUt_Ow`oETD46&^_A5$(V z&Abp+&=edQCa?c_*U7b+S8SXkOY+P&&lqO=8<&hq2w+~ym&Q?~Gpvx-g-m6V!EI4_ zgEOqIE%&rK=58>98~zU+j3}>+unQJApTwZ~Xj4na0I;&Y27spel{aKUTpG8yu5pFb zQE6fOuuZ90xPZl8>A6gp@2Ylovuvtgu$#y?nnQd^M!1x5R0EZiIV|f#zK~if>kj4Z zj*J}JsACkU7?<~PMi%Giq3Lt1M;DTJ7#ZAf{;D>4PngOq-Fp(BB?>><*<=^Zh&vfU zt5S9;bh&;#pEW4p)Lt1Xz2wpUjNHmVXE%p5&O1092!P`nY(Y~ zBWSIPC%>YWmIfQ)cQ7D9wR)9x@H5bCf0>otuM?{Uy6-sgB z)4@j#n3rWl&sN=8A-I0e-Htr~lC3^4((~rVOi-&aJDitcNW*0ygw#@M zyoW1OCyNXk&AJ##ZDVMRNW=sI{mxXm&*`Ntv`LS1F5eMvB@_!jd}S$cj(Ye2H8 zTFmXaTE?T70l6zSZG5nywpk~vnlC4Y8C$fWN7tRm4Z(xD32<>9h$^^rMkwiIParma ztprIYZhfsqA9p@lI=5vDSdU<;sSEQ+*VUXZ+1+lp-jcEja~a7eO2-1OZNyr_WmAmUQRH#$dV zOr9KPIT%V>8keKB8>{ELtM}db z$F|+s9WfZi_z;XE`oYC~3D87bVxinNuKJt5iOZQCjtl9ar9*hZl;XRYT*IkQX8jG5 zDqH1SN8&;UNtdJc!dOo@6NJliJdNx{Sg?8up4_~TF~a`6PVM9dm6Iv-@hJ%)`97E6(`=GVfF zKcd`yK|lQ^1%EF>fS#@il}_9Wlv{bLg$>q~m1OqN&;oKH3BFC$q2OUxT48P#9OikR zJbL4_Qnx_bb8IN<#|f;ro;Cr2sI&-^N#doI)>Jyyy!6QCSg=cMYcPWKAIyVPVk^TW ze#J@S8BfZW2j3`lsA*)j;*BZ$lYo-V;wa>xo$aRvQFypwN+{2W-rxlii6`>o?j&0l z0!oQMb(xqcQ*@(1710bmWAz8mB~1Vj&6Y|e;(&8OFdBtA*C1dM8&Ushd*eBru-gv2 zNsn8Ma?E`B?x5$SgdZX;8yt$IL_QN5()vv)kaWW#^q|M&g? literal 0 HcmV?d00001 diff --git a/packages/app-lib/src/api/minecraft_skins/png_util.rs b/packages/app-lib/src/api/minecraft_skins/png_util.rs new file mode 100644 index 000000000..b4ffbac41 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/png_util.rs @@ -0,0 +1,298 @@ +//! Miscellaneous PNG utilities for Minecraft skins. + +use std::sync::Arc; + +use base64::Engine; +use bytemuck::{AnyBitPattern, NoUninit}; +use bytes::Bytes; +use futures::TryStreamExt; +use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge}; +use url::Url; + +use crate::ErrorKind; + +use super::Skin; + +pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option> { + let png_data = png_data.as_ref(); + + is_png(png_data).then(|| { + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png_data) + )) + .unwrap() + .into() + }) +} + +pub fn is_png(png_data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + png_data.starts_with(PNG_SIGNATURE) +} + +pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(png_data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + png_data + .get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + png_data + .get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// Returns the normalized, processed texture as a byte array in PNG format. +pub async fn normalize_skin_texture(skin: &Skin) -> crate::Result { + let texture_stream = SyncIoBridge::new(Box::pin( + skin.resolve_texture() + .await? + .map_err(std::io::Error::other) + .into_async_read() + .compat(), + )); + + tokio::task::spawn_blocking(|| { + let mut png_reader = { + let mut decoder = png::Decoder::new(texture_stream); + decoder.set_transformations( + png::Transformations::normalize_to_color8(), + ); + decoder.read_info() + }?; + + // The code below assumes that the skin texture has valid dimensions. + // This also serves as a way to bail out early for obviously invalid or + // adversarial textures + if png_reader.info().width != 64 + || ![64, 32].contains(&png_reader.info().height) + { + Err(ErrorKind::InvalidSkinTexture)?; + } + + let is_legacy_skin = png_reader.info().height == 32; + + let mut texture_buf = if is_legacy_skin { + // Legacy skins have half the height, so duplicate the rows to + // turn them into a 64x64 texture + vec![0; png_reader.output_buffer_size() * 2] + } else { + // Modern skins are left as-is + vec![0; png_reader.output_buffer_size()] + }; + + let texture_buf_color_type = png_reader.output_color_type().0; + png_reader.next_frame(&mut texture_buf)?; + + if is_legacy_skin { + convert_legacy_skin_texture( + &mut texture_buf, + texture_buf_color_type, + png_reader.info(), + )?; + } + + let mut encoded_png = vec![]; + + let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64); + png_encoder.set_color(texture_buf_color_type); + png_encoder.set_depth(png::BitDepth::Eight); + png_encoder.set_filter(png::FilterType::NoFilter); + png_encoder.set_compression(png::Compression::Fast); + + // Keeping color space information properly set, to handle the occasional + // strange PNG with non-sRGB chromacities and/or different grayscale spaces + // that keeps most people wondering, is what sets a carefully crafted image + // manipulation routine apart :) + if let Some(source_chromacities) = + png_reader.info().source_chromaticities.as_ref().cloned() + { + png_encoder.set_source_chromaticities(source_chromacities); + } + if let Some(source_gamma) = + png_reader.info().source_gamma.as_ref().cloned() + { + png_encoder.set_source_gamma(source_gamma); + } + if let Some(source_srgb) = png_reader.info().srgb.as_ref().cloned() { + png_encoder.set_source_srgb(source_srgb); + } + + let mut png_writer = png_encoder.write_header()?; + png_writer.write_image_data(&texture_buf)?; + png_writer.finish()?; + + Ok(encoded_png.into()) + }) + .await? +} + +/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the +/// native 64x64 format used by modern Minecraft clients. +/// +/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method. +#[inline] +fn convert_legacy_skin_texture( + texture_buf: &mut [u8], + texture_color_type: png::ColorType, + texture_info: &png::Info, +) -> crate::Result<()> { + /// The skin faces the game client copies around, in order, when converting a + /// legacy skin to the native 64x64 format. + const FACE_COPY_PARAMETERS: &[( + usize, + usize, + isize, + isize, + usize, + usize, + )] = &[ + (4, 16, 16, 32, 4, 4), + (8, 16, 16, 32, 4, 4), + (0, 20, 24, 32, 4, 12), + (4, 20, 16, 32, 4, 12), + (8, 20, 8, 32, 4, 12), + (12, 20, 16, 32, 4, 12), + (44, 16, -8, 32, 4, 4), + (48, 16, -8, 32, 4, 4), + (40, 20, 0, 32, 4, 12), + (44, 20, -8, 32, 4, 12), + (48, 20, -16, 32, 4, 12), + (52, 20, -8, 32, 4, 12), + ]; + + for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS { + macro_rules! do_copy { + ($pixel_type:ty) => { + copy_rect_mirror_horizontally::<$pixel_type>( + // This cast should never fail because all pixels have a depth of 8 bits + // after the transformations applied during decoding + ::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?, + &texture_info, + *x, + *y, + *off_x, + *off_y, + *width, + *height, + ) + }; + } + + match texture_color_type.samples() { + 1 => do_copy!(rgb::Gray), + 2 => do_copy!(rgb::GrayAlpha), + 3 => do_copy!(rgb::Rgb), + 4 => do_copy!(rgb::Rgba), + _ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations + }; + } + + Ok(()) +} + +/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf` +/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left +/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the +/// pixels horizontally. +/// +/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int, +/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`, +/// respectively. +fn copy_rect_mirror_horizontally( + texture_buf: &mut [PixelType], + texture_info: &png::Info, + x: usize, + y: usize, + off_x: isize, + off_y: isize, + width: usize, + height: usize, +) { + for row in 0..height { + for col in 0..width { + let src_x = x + col; + let src_y = y + row; + let dst_x = (x as isize + off_x) as usize + (width - 1 - col); + let dst_y = (y as isize + off_y) as usize + row; + + texture_buf[dst_x + dst_y * texture_info.width as usize] = + texture_buf[src_x + src_y * texture_info.width as usize]; + } + } +} + +#[cfg(test)] +#[tokio::test] +async fn normalize_skin_texture_works() { + use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant}; + + let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..]; + let expected_normalized_png_data = + &include_bytes!("assets/test/MissingNo_normalized.png")[..]; + + let normalized_png_data = normalize_skin_texture(&Skin { + texture_key: "missingno".into(), + name: None, + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: blob_to_data_url(legacy_png_data).unwrap(), + source: SkinSource::Default, + is_equipped: false, + }) + .await + .expect("Failed to normalize skin texture"); + + let decode_to_pixels = |png_data: &[u8]| { + let decoder = png::Decoder::new(png_data); + let mut reader = decoder.read_info().expect("Failed to read PNG info"); + let mut buffer = vec![0; reader.output_buffer_size()]; + reader + .next_frame(&mut buffer) + .expect("Failed to decode PNG"); + (buffer, reader.info().clone()) + }; + + let (normalized_pixels, normalized_info) = + decode_to_pixels(&normalized_png_data); + let (expected_pixels, expected_info) = + decode_to_pixels(expected_normalized_png_data); + + // Check that dimensions match + assert_eq!(normalized_info.width, expected_info.width); + assert_eq!(normalized_info.height, expected_info.height); + assert_eq!(normalized_info.color_type, expected_info.color_type); + + // Check that pixel data matches + assert_eq!( + normalized_pixels, expected_pixels, + "Pixel data doesn't match" + ); +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index d38c10c3f..75c144f55 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -141,6 +141,12 @@ pub enum ErrorKind { #[error("Invalid PNG")] InvalidPng, + #[error("Invalid PNG: {0}")] + PngDecodingError(#[from] png::DecodingError), + + #[error("PNG encoding error: {0}")] + PngEncodingError(#[from] png::EncodingError), + #[error( "A skin texture must have a dimension of either 64x64 or 64x32 pixels" )]