feat(imaging): add pure-Rust Skia backend (#112)
This commit is contained in:
@@ -10,10 +10,13 @@ description = "Optional Skia image decoder for the MetaCrate LibreMetaverse rewr
|
||||
[features]
|
||||
default = []
|
||||
skia = ["dep:skia-safe"]
|
||||
rust-skia = ["dep:image-webp", "dep:skia-rs-codec", "skia-rs-codec/gif", "skia-rs-codec/jpeg", "skia-rs-codec/png"]
|
||||
|
||||
[dependencies]
|
||||
libremetaverse-imaging = { version = "0.0.1", path = "../libremetaverse-imaging" }
|
||||
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
||||
image-webp = { version = "=0.2.4", optional = true }
|
||||
skia-rs-codec = { version = "=0.3.0", default-features = false, optional = true }
|
||||
|
||||
# rust-skia publishes WebP-capable binary caches with Vulkan on Linux/Windows
|
||||
# and without Vulkan on macOS. Matching those sets avoids an hours-long Skia
|
||||
|
||||
@@ -6,10 +6,43 @@ exposes a `skia-safe` type and never discovers or links Skia.
|
||||
|
||||
## Features and formats
|
||||
|
||||
The default feature set has no Skia dependency. `SkiaTextureCodec::decode`
|
||||
returns a typed `InvalidOperation` error in that configuration, while conversion
|
||||
from the checked project-owned `backend::SKBitmap` remains available for tests
|
||||
and callers that already own decoded pixels.
|
||||
The default feature set has no Skia dependency. `SkiaTextureCodec::decode` and
|
||||
`RustSkiaTextureCodec::decode` return a typed `InvalidOperation` error when
|
||||
their respective feature is disabled, while conversion from the checked
|
||||
project-owned `backend::SKBitmap` remains available for tests and callers that
|
||||
already own decoded pixels.
|
||||
|
||||
For a fully Rust implementation, enable:
|
||||
|
||||
```sh
|
||||
cargo test -p libremetaverse-imaging-skia --no-default-features --features rust-skia
|
||||
```
|
||||
|
||||
`rust-skia` pins [`skia-rs-codec` 0.3.0](https://github.com/rust-skia/skia-rs)
|
||||
with default features disabled and only
|
||||
its pure-Rust PNG, JPEG, and GIF codecs selected. Its `webp` feature is
|
||||
deliberately disabled because that feature selects `libwebp-sys`; WebP is
|
||||
instead decoded by the separately pinned, safe-Rust
|
||||
[`image-webp` 0.2.4](https://github.com/image-rs/image-webp) crate.
|
||||
BMP, ICO, and WBMP are built into `skia-rs-codec`. The resulting normal/build
|
||||
graph has no codec `-sys` crate, C/C++ compilation, CMake, system-library
|
||||
lookup, or binary download.
|
||||
|
||||
The locked transitive codec lines are `png` 0.17.16, `jpeg-decoder` 0.3.2,
|
||||
`jpeg-encoder` 0.6.1 (the upstream JPEG feature couples decode and encode), and
|
||||
`gif` 0.13.3. `skia-rs-codec` also brings its ordinary Rust core, paint, and
|
||||
path crates; their build helpers only select Rust cfgs and compile no native
|
||||
code. A source audit of the locked `skia-rs-*` crates found no unsafe blocks,
|
||||
and `image-webp` declares `forbid(unsafe_code)`. MetaCrate itself continues to
|
||||
compile with workspace `unsafe_code = "forbid"`.
|
||||
|
||||
`RustSkiaTextureCodec` implements the same `ITextureCodec` abstraction and
|
||||
decodes BMP, GIF, ICO, baseline/progressive JPEG, interlaced PNG, WBMP, and
|
||||
lossless/lossy/animated WebP. GIF and WebP return frame one composited on the
|
||||
logical canvas; ICO chooses its largest valid entry. Both Skia features may be
|
||||
enabled together, and each codec remains independently constructible.
|
||||
The graph builds on the workspace Rust 1.96 MSRV; both selected direct codecs
|
||||
declare older compatible Rust floors.
|
||||
|
||||
Enable native decoding with:
|
||||
|
||||
@@ -17,7 +50,7 @@ Enable native decoding with:
|
||||
cargo test -p libremetaverse-imaging-skia --features skia
|
||||
```
|
||||
|
||||
The `skia` feature decodes the CPU codec formats supported by the pinned
|
||||
The native `skia` feature decodes the CPU codec formats supported by the pinned
|
||||
rust-skia release: BMP, GIF, ICO, JPEG, PNG, WBMP, and WebP. Input is buffered
|
||||
to at most 64 MiB. Dimensions are rejected before native pixel allocation when
|
||||
they exceed the core 16,777,216-pixel limit, and decoded storage uses checked
|
||||
@@ -31,6 +64,25 @@ alpha at this boundary. RGB565, BGRA8888, RGBA8888, RGBA/BGRA1010102, Gray8,
|
||||
Alpha8, row padding, and the reference byte-width fallback retain the pinned
|
||||
C# channel and rounding rules.
|
||||
|
||||
Both paths accept any `ReadWrite + Send` stream and stop buffering after 64
|
||||
MiB plus one sentinel byte. The pure-Rust path probes encoded dimensions before
|
||||
codec pixel allocation and enforces the shared 16,777,216-pixel ceiling; WebP
|
||||
also receives an explicit decoder memory limit. Checked dimensions, strides,
|
||||
buffer sizes, and fallible project-owned allocations turn malformed,
|
||||
truncated, oversized, and allocation failures into typed errors. Decoded rows
|
||||
have a top-left origin and premultiplied native samples are normalized to
|
||||
straight alpha at the shared `ManagedImage` boundary.
|
||||
|
||||
The pure-Rust graph has identical code on Linux, Windows, macOS, and WASM and
|
||||
does not call platform APIs. Checked static fixtures cover top-down/bottom-up
|
||||
BMP, palette/offset GIF, largest-entry ICO, baseline/progressive JPEG,
|
||||
interlaced palette PNG, multibyte WBMP, lossless-alpha/lossy WebP, malformed and
|
||||
oversized inputs, and concurrent decode/drop. Gitea runs the `rust-skia` suite
|
||||
in a dedicated `ubuntu-latest` job that installs no native codec prerequisites.
|
||||
`image-webp` 0.2's animated-canvas blend fast path rounds fully opaque nonzero
|
||||
samples one low; the bounded adapter corrects that single upstream edge so the
|
||||
checked first-frame planes remain byte-identical to native Skia.
|
||||
|
||||
## Binary cache and source builds
|
||||
|
||||
`skia-safe` 0.99.0 downloads an official prebuilt Skia archive when the target
|
||||
@@ -66,3 +118,8 @@ explicitly.
|
||||
is BSD-3-Clause licensed. Official binary-cache archives contain compiled Skia.
|
||||
Products that redistribute the resulting native artifacts must preserve the
|
||||
applicable MIT and BSD notices and audit the exact archive they ship.
|
||||
|
||||
`skia-rs-codec` is MIT OR Apache-2.0 and `image-webp` is MIT OR Apache-2.0.
|
||||
They are ordinary Rust source dependencies recorded in `Cargo.lock`; generated
|
||||
dependency notices and the distribution manifest bind their crates.io checksums
|
||||
and license texts for source and binary redistribution.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Optional Skia imaging adapter corresponding to `LibreMetaverse.Imaging.Skia`.
|
||||
//!
|
||||
//! Enable `skia` for bounded `BMP`, `GIF`, `ICO`, `JPEG`, `PNG`, `WBMP`, and
|
||||
//! `WebP` decoding.
|
||||
//! Enable `skia` or the fully native-Rust `rust-skia` alternative for bounded
|
||||
//! `BMP`, `GIF`, `ICO`, `JPEG`, `PNG`, `WBMP`, and `WebP` decoding.
|
||||
//! The default build remains inert. See the crate README for native cache,
|
||||
//! source-build, licensing, and cross-platform prerequisite details.
|
||||
|
||||
@@ -15,6 +15,7 @@ mod skia_codec;
|
||||
pub use generated::*;
|
||||
pub use libremetaverse_imaging as imaging;
|
||||
pub use libremetaverse_types::Error;
|
||||
pub use skia_codec::RustSkiaTextureCodec;
|
||||
|
||||
impl libremetaverse_imaging::ITextureCodec for SkiaTextureCodec {
|
||||
fn decode(
|
||||
@@ -24,3 +25,12 @@ impl libremetaverse_imaging::ITextureCodec for SkiaTextureCodec {
|
||||
Self::decode(self, stream)
|
||||
}
|
||||
}
|
||||
|
||||
impl libremetaverse_imaging::ITextureCodec for RustSkiaTextureCodec {
|
||||
fn decode(
|
||||
&self,
|
||||
stream: Box<dyn libremetaverse_types::compat::ReadWrite + Send>,
|
||||
) -> Result<libremetaverse_imaging::ManagedImage, Error> {
|
||||
Self::decode(self, stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
use crate::Error;
|
||||
use crate::backend::{SKAlphaType, SKBitmap, SKColorType};
|
||||
#[cfg(feature = "skia")]
|
||||
#[cfg(any(feature = "rust-skia", feature = "skia"))]
|
||||
use libremetaverse_imaging::{DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS};
|
||||
use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels};
|
||||
use libremetaverse_types::compat::ReadWrite;
|
||||
#[cfg(feature = "skia")]
|
||||
#[cfg(any(feature = "rust-skia", feature = "skia"))]
|
||||
use std::io::Read;
|
||||
|
||||
/// Optional Skia-backed decoder and format-neutral bitmap converter.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SkiaTextureCodec;
|
||||
|
||||
/// Pure-Rust alternative to [`SkiaTextureCodec`] with the same image boundary.
|
||||
///
|
||||
/// Enable the `rust-skia` feature to decode the common Skia codec formats
|
||||
/// without a native library, build script, system package lookup, or download.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct RustSkiaTextureCodec;
|
||||
|
||||
impl SkiaTextureCodec {
|
||||
/// Creates the stateless codec adapter.
|
||||
///
|
||||
@@ -33,17 +40,7 @@ impl SkiaTextureCodec {
|
||||
pub fn decode(&self, mut stream: Box<dyn ReadWrite + Send>) -> Result<ManagedImage, Error> {
|
||||
#[cfg(feature = "skia")]
|
||||
{
|
||||
let mut encoded = Vec::new();
|
||||
Read::by_ref(&mut stream)
|
||||
.take((DEFAULT_MAX_ENCODED_BYTES + 1) as u64)
|
||||
.read_to_end(&mut encoded)
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
if encoded.is_empty() {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
if encoded.len() > DEFAULT_MAX_ENCODED_BYTES {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let encoded = read_bounded(&mut stream)?;
|
||||
decode_with_skia(&encoded)
|
||||
}
|
||||
#[cfg(not(feature = "skia"))]
|
||||
@@ -65,6 +62,55 @@ impl SkiaTextureCodec {
|
||||
}
|
||||
}
|
||||
|
||||
impl RustSkiaTextureCodec {
|
||||
/// Creates the stateless pure-Rust codec adapter.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This fixed compatibility constructor cannot fail.
|
||||
pub const fn new() -> Result<Self, Error> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
/// Decodes a bounded stream with Rust-only BMP, GIF, ICO, JPEG, PNG,
|
||||
/// WBMP, and WebP codecs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// With `rust-skia`, returns [`Error::Argument`] for an input or decoded
|
||||
/// canvas above the documented limits and [`Error::InvalidOperation`] for
|
||||
/// malformed or unsupported images. Without the feature it returns
|
||||
/// [`Error::InvalidOperation`].
|
||||
pub fn decode(&self, mut stream: Box<dyn ReadWrite + Send>) -> Result<ManagedImage, Error> {
|
||||
#[cfg(feature = "rust-skia")]
|
||||
{
|
||||
let encoded = read_bounded(&mut stream)?;
|
||||
decode_with_rust_skia(&encoded)
|
||||
}
|
||||
#[cfg(not(feature = "rust-skia"))]
|
||||
{
|
||||
let _ = &mut stream;
|
||||
Err(Error::InvalidOperation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "rust-skia", feature = "skia"))]
|
||||
fn read_bounded(stream: &mut (dyn ReadWrite + Send)) -> Result<Vec<u8>, Error> {
|
||||
let mut encoded = Vec::new();
|
||||
stream
|
||||
.take((DEFAULT_MAX_ENCODED_BYTES + 1) as u64)
|
||||
.read_to_end(&mut encoded)
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
if encoded.is_empty() {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
if encoded.len() > DEFAULT_MAX_ENCODED_BYTES {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
// Keeping the format branches together makes comparison with the pinned C#
|
||||
// color switch auditable and prevents subtly different indexing paths.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -238,6 +284,404 @@ fn normalize_10bit_alpha(
|
||||
(normalize(red), normalize(green), normalize(blue))
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn decode_with_rust_skia(encoded: &[u8]) -> Result<ManagedImage, Error> {
|
||||
use skia_rs_codec::{ImageDecoder, ImageFormat};
|
||||
|
||||
let format = if wbmp_dimensions(encoded).is_ok() {
|
||||
ImageFormat::Wbmp
|
||||
} else {
|
||||
ImageFormat::from_magic(encoded)
|
||||
};
|
||||
if format == ImageFormat::WebP {
|
||||
return decode_webp_with_rust(encoded);
|
||||
}
|
||||
if !matches!(
|
||||
format,
|
||||
ImageFormat::Bmp
|
||||
| ImageFormat::Gif
|
||||
| ImageFormat::Ico
|
||||
| ImageFormat::Jpeg
|
||||
| ImageFormat::Png
|
||||
| ImageFormat::Wbmp
|
||||
) {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
|
||||
let (width, height) = match format {
|
||||
ImageFormat::Gif => gif_dimensions(encoded)?,
|
||||
ImageFormat::Ico => ico_dimensions(encoded)?,
|
||||
ImageFormat::Wbmp => wbmp_dimensions(encoded)?,
|
||||
_ => skia_rs_codec::get_image_dimensions(encoded).map_err(|_| Error::InvalidOperation)?,
|
||||
};
|
||||
validate_dimensions(width, height)?;
|
||||
|
||||
let decoded = if format == ImageFormat::Wbmp {
|
||||
skia_rs_codec::WbmpDecoder::new().decode_bytes(encoded)
|
||||
} else {
|
||||
skia_rs_codec::decode_image(encoded)
|
||||
}
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
validate_dimensions(decoded.width(), decoded.height())?;
|
||||
if decoded.width() != width || decoded.height() != height {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let width_usize = usize::try_from(width).map_err(|_| Error::Argument)?;
|
||||
if decoded.row_bytes() != width_usize.checked_mul(4).ok_or(Error::Argument)? {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let source_pixels = decoded.peek_pixels().ok_or(Error::InvalidOperation)?;
|
||||
let grayscale = match format {
|
||||
ImageFormat::Jpeg => jpeg_is_grayscale(encoded)?,
|
||||
ImageFormat::Png => encoded.get(25) == Some(&0),
|
||||
ImageFormat::Wbmp => true,
|
||||
_ => false,
|
||||
};
|
||||
let (row_bytes, color_type, alpha_type, pixels) = if grayscale {
|
||||
let pixel_count = width_usize
|
||||
.checked_mul(usize::try_from(height).map_err(|_| Error::Argument)?)
|
||||
.ok_or(Error::Argument)?;
|
||||
let mut gray = Vec::new();
|
||||
gray.try_reserve_exact(pixel_count)
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
gray.extend(source_pixels.chunks_exact(4).map(|rgba| rgba[0]));
|
||||
if gray.len() != pixel_count {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
(width_usize, SKColorType::Gray8, SKAlphaType::Opaque, gray)
|
||||
} else {
|
||||
let mut rgba = Vec::new();
|
||||
rgba.try_reserve_exact(source_pixels.len())
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
rgba.extend_from_slice(source_pixels);
|
||||
(
|
||||
decoded.row_bytes(),
|
||||
SKColorType::Rgba8888,
|
||||
if decoded.is_opaque() {
|
||||
SKAlphaType::Opaque
|
||||
} else {
|
||||
SKAlphaType::Unpremul
|
||||
},
|
||||
rgba,
|
||||
)
|
||||
};
|
||||
let bitmap = SKBitmap::new(width, height, row_bytes, color_type, alpha_type, pixels)?;
|
||||
bitmap_to_managed(&bitmap)
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn jpeg_is_grayscale(encoded: &[u8]) -> Result<bool, Error> {
|
||||
let mut offset = 2;
|
||||
while offset + 1 < encoded.len() {
|
||||
if encoded[offset] != 0xff {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let mut marker_offset = offset + 1;
|
||||
while encoded.get(marker_offset) == Some(&0xff) {
|
||||
marker_offset += 1;
|
||||
}
|
||||
let marker = *encoded.get(marker_offset).ok_or(Error::InvalidOperation)?;
|
||||
if marker == 0 || marker == 1 || (0xd0..=0xd9).contains(&marker) {
|
||||
offset = marker_offset + 1;
|
||||
continue;
|
||||
}
|
||||
let length_bytes = encoded
|
||||
.get(marker_offset + 1..marker_offset + 3)
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
let length = usize::from(u16::from_be_bytes(
|
||||
length_bytes
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidOperation)?,
|
||||
));
|
||||
if matches!(
|
||||
marker,
|
||||
0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf
|
||||
) {
|
||||
let components = *encoded
|
||||
.get(marker_offset + 8)
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
return match components {
|
||||
1 => Ok(true),
|
||||
3 => Ok(false),
|
||||
_ => Err(Error::InvalidOperation),
|
||||
};
|
||||
}
|
||||
if length < 2 {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
offset = marker_offset
|
||||
.checked_add(1)
|
||||
.and_then(|offset| offset.checked_add(length))
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
}
|
||||
Err(Error::InvalidOperation)
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn gif_dimensions(encoded: &[u8]) -> Result<(i32, i32), Error> {
|
||||
if encoded.len() < 13 || !(encoded.starts_with(b"GIF87a") || encoded.starts_with(b"GIF89a")) {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let canvas = (
|
||||
i32::from(u16::from_le_bytes([encoded[6], encoded[7]])),
|
||||
i32::from(u16::from_le_bytes([encoded[8], encoded[9]])),
|
||||
);
|
||||
validate_dimensions(canvas.0, canvas.1)?;
|
||||
|
||||
let global_table_bytes = if encoded[10] & 0x80 == 0 {
|
||||
0
|
||||
} else {
|
||||
3_usize
|
||||
.checked_mul(1_usize << (usize::from(encoded[10] & 0x07) + 1))
|
||||
.ok_or(Error::InvalidOperation)?
|
||||
};
|
||||
let mut offset = 13_usize
|
||||
.checked_add(global_table_bytes)
|
||||
.filter(|offset| *offset <= encoded.len())
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
loop {
|
||||
let marker = *encoded.get(offset).ok_or(Error::InvalidOperation)?;
|
||||
offset += 1;
|
||||
match marker {
|
||||
0x2c => {
|
||||
let end = offset.checked_add(9).ok_or(Error::InvalidOperation)?;
|
||||
let descriptor = encoded.get(offset..end).ok_or(Error::InvalidOperation)?;
|
||||
let frame_width = i32::from(u16::from_le_bytes([descriptor[4], descriptor[5]]));
|
||||
let frame_height = i32::from(u16::from_le_bytes([descriptor[6], descriptor[7]]));
|
||||
validate_dimensions(frame_width, frame_height)?;
|
||||
return Ok(canvas);
|
||||
}
|
||||
0x21 => {
|
||||
offset = offset.checked_add(1).ok_or(Error::InvalidOperation)?;
|
||||
loop {
|
||||
let length = *encoded.get(offset).ok_or(Error::InvalidOperation)?;
|
||||
offset += 1;
|
||||
if length == 0 {
|
||||
break;
|
||||
}
|
||||
offset = offset
|
||||
.checked_add(usize::from(length))
|
||||
.filter(|offset| *offset <= encoded.len())
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::InvalidOperation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn wbmp_dimensions(encoded: &[u8]) -> Result<(i32, i32), Error> {
|
||||
if encoded.get(..2) != Some(&[0, 0]) {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let mut offset = 2;
|
||||
let width =
|
||||
i32::try_from(read_wbmp_integer(encoded, &mut offset)?).map_err(|_| Error::Argument)?;
|
||||
let height =
|
||||
i32::try_from(read_wbmp_integer(encoded, &mut offset)?).map_err(|_| Error::Argument)?;
|
||||
validate_dimensions(width, height)?;
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn read_wbmp_integer(encoded: &[u8], offset: &mut usize) -> Result<u32, Error> {
|
||||
let mut value = 0_u32;
|
||||
for _ in 0..5 {
|
||||
let byte = *encoded.get(*offset).ok_or(Error::InvalidOperation)?;
|
||||
*offset += 1;
|
||||
value = value
|
||||
.checked_shl(7)
|
||||
.and_then(|value| value.checked_add(u32::from(byte & 0x7f)))
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
if byte & 0x80 == 0 {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(Error::InvalidOperation)
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn ico_dimensions(encoded: &[u8]) -> Result<(i32, i32), Error> {
|
||||
if encoded.len() < 6 || encoded.get(..4) != Some(&[0, 0, 1, 0]) {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let count = usize::from(u16::from_le_bytes([encoded[4], encoded[5]]));
|
||||
let mut best: Option<(u32, usize, usize)> = None;
|
||||
for index in 0..count {
|
||||
let offset = 6_usize
|
||||
.checked_add(index.checked_mul(16).ok_or(Error::InvalidOperation)?)
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
let end = offset.checked_add(16).ok_or(Error::InvalidOperation)?;
|
||||
let entry = encoded.get(offset..end).ok_or(Error::InvalidOperation)?;
|
||||
let width = if entry[0] == 0 {
|
||||
256
|
||||
} else {
|
||||
u32::from(entry[0])
|
||||
};
|
||||
let height = if entry[1] == 0 {
|
||||
256
|
||||
} else {
|
||||
u32::from(entry[1])
|
||||
};
|
||||
let area = width.checked_mul(height).ok_or(Error::Argument)?;
|
||||
let length = usize::try_from(u32::from_le_bytes(
|
||||
entry[8..12]
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidOperation)?,
|
||||
))
|
||||
.map_err(|_| Error::Argument)?;
|
||||
let image_offset = usize::try_from(u32::from_le_bytes(
|
||||
entry[12..16]
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidOperation)?,
|
||||
))
|
||||
.map_err(|_| Error::Argument)?;
|
||||
if best.is_none_or(|(best_area, _, _)| area > best_area) {
|
||||
best = Some((area, image_offset, length));
|
||||
}
|
||||
}
|
||||
let (_, offset, length) = best.ok_or(Error::InvalidOperation)?;
|
||||
let end = offset.checked_add(length).ok_or(Error::Argument)?;
|
||||
let image = encoded
|
||||
.get(offset..end)
|
||||
.filter(|image| !image.is_empty())
|
||||
.ok_or(Error::InvalidOperation)?;
|
||||
if image.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
return skia_rs_codec::get_image_dimensions(image).map_err(|_| Error::InvalidOperation);
|
||||
}
|
||||
if image.len() < 40 {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let header_size = u32::from_le_bytes(
|
||||
image[0..4]
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidOperation)?,
|
||||
);
|
||||
if header_size < 40 {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let width = i32::from_le_bytes(
|
||||
image[4..8]
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidOperation)?,
|
||||
);
|
||||
let stored_height = i32::from_le_bytes(
|
||||
image[8..12]
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidOperation)?,
|
||||
);
|
||||
if stored_height <= 0 || stored_height % 2 != 0 {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
Ok((width, stored_height / 2))
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn validate_dimensions(width: i32, height: i32) -> Result<(), Error> {
|
||||
let width = usize::try_from(width).map_err(|_| Error::Argument)?;
|
||||
let height = usize::try_from(height).map_err(|_| Error::Argument)?;
|
||||
width
|
||||
.checked_mul(height)
|
||||
.filter(|pixels| *pixels > 0 && *pixels <= DEFAULT_MAX_PIXELS)
|
||||
.map(|_| ())
|
||||
.ok_or(Error::Argument)
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn decode_webp_with_rust(encoded: &[u8]) -> Result<ManagedImage, Error> {
|
||||
use std::io::{BufReader, Cursor};
|
||||
|
||||
let reader = BufReader::new(Cursor::new(encoded));
|
||||
let mut decoder = image_webp::WebPDecoder::new(reader).map_err(|_| Error::InvalidOperation)?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
let animated = decoder.is_animated();
|
||||
let width_i32 = i32::try_from(width).map_err(|_| Error::Argument)?;
|
||||
let height_i32 = i32::try_from(height).map_err(|_| Error::Argument)?;
|
||||
validate_dimensions(width_i32, height_i32)?;
|
||||
decoder.set_memory_limit(DEFAULT_MAX_PIXELS * 16);
|
||||
|
||||
let output_size = decoder.output_buffer_size().ok_or(Error::Argument)?;
|
||||
if output_size > DEFAULT_MAX_PIXELS * 4 {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let mut packed_pixels = Vec::new();
|
||||
packed_pixels
|
||||
.try_reserve_exact(output_size)
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
packed_pixels.resize(output_size, 0);
|
||||
decoder
|
||||
.read_image(&mut packed_pixels)
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
|
||||
let has_alpha = decoder.has_alpha();
|
||||
// image-webp 0.2 uses a divide-by-256 blend fast path for animated
|
||||
// canvases. Its fully opaque nonzero samples are consequently one below
|
||||
// Skia/libwebp's divide-by-255 result. Correct that bounded first-frame
|
||||
// canvas here; partially transparent samples already use the same rounding
|
||||
// as the compatibility backend.
|
||||
if animated {
|
||||
if has_alpha {
|
||||
for rgba in packed_pixels.chunks_exact_mut(4) {
|
||||
if rgba[3] == u8::MAX {
|
||||
for channel in &mut rgba[..3] {
|
||||
if *channel != 0 {
|
||||
*channel = channel.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for channel in &mut packed_pixels {
|
||||
if *channel != 0 {
|
||||
*channel = channel.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let pixels = if has_alpha {
|
||||
packed_pixels
|
||||
} else {
|
||||
let pixel_count = usize::try_from(width)
|
||||
.ok()
|
||||
.and_then(|width| {
|
||||
usize::try_from(height)
|
||||
.ok()
|
||||
.and_then(|height| width.checked_mul(height))
|
||||
})
|
||||
.ok_or(Error::Argument)?;
|
||||
let rgba_size = pixel_count.checked_mul(4).ok_or(Error::Argument)?;
|
||||
let mut rgba = Vec::new();
|
||||
rgba.try_reserve_exact(rgba_size)
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
for rgb in packed_pixels.chunks_exact(3) {
|
||||
rgba.extend_from_slice(&[rgb[0], rgb[1], rgb[2], u8::MAX]);
|
||||
}
|
||||
if rgba.len() != rgba_size {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
rgba
|
||||
};
|
||||
let row_bytes = usize::try_from(width)
|
||||
.map_err(|_| Error::Argument)?
|
||||
.checked_mul(4)
|
||||
.ok_or(Error::Argument)?;
|
||||
let bitmap = SKBitmap::new(
|
||||
width_i32,
|
||||
height_i32,
|
||||
row_bytes,
|
||||
SKColorType::Rgba8888,
|
||||
if has_alpha {
|
||||
SKAlphaType::Unpremul
|
||||
} else {
|
||||
SKAlphaType::Opaque
|
||||
},
|
||||
pixels,
|
||||
)?;
|
||||
bitmap_to_managed(&bitmap)
|
||||
}
|
||||
|
||||
#[cfg(feature = "skia")]
|
||||
fn decode_with_skia(encoded: &[u8]) -> Result<ManagedImage, Error> {
|
||||
use skia_safe::Data;
|
||||
@@ -528,6 +972,394 @@ mod tests {
|
||||
SkiaTextureCodec.decode(Box::new(std::io::Cursor::new(Vec::new()))),
|
||||
Err(Error::InvalidOperation)
|
||||
);
|
||||
#[cfg(not(feature = "rust-skia"))]
|
||||
assert_eq!(
|
||||
RustSkiaTextureCodec.decode(Box::new(std::io::Cursor::new(Vec::new()))),
|
||||
Err(Error::InvalidOperation)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "rust-skia", feature = "skia"))]
|
||||
fn hex_fixture(source: &str) -> Vec<u8> {
|
||||
let digits: Vec<_> = source.bytes().filter(u8::is_ascii_hexdigit).collect();
|
||||
assert_eq!(digits.len() % 2, 0, "fixture contains a partial byte");
|
||||
digits
|
||||
.chunks_exact(2)
|
||||
.map(|pair| {
|
||||
let high = (pair[0] as char).to_digit(16).expect("hex digit");
|
||||
let low = (pair[1] as char).to_digit(16).expect("hex digit");
|
||||
u8::try_from(high * 16 + low).expect("hex byte")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
fn rust_decode(encoded: Vec<u8>) -> Result<ManagedImage, Error> {
|
||||
RustSkiaTextureCodec.decode(Box::new(std::io::Cursor::new(encoded)))
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "rust-skia", feature = "skia"))]
|
||||
fn checked_format_matrix() -> [(&'static str, &'static str, (i32, i32)); 19] {
|
||||
[
|
||||
(
|
||||
"BMP 16-bit RGB555",
|
||||
include_str!("../tests/fixtures/rgb555.bmp.hex"),
|
||||
(2, 1),
|
||||
),
|
||||
(
|
||||
"BMP 24-bit bottom-up",
|
||||
include_str!("../tests/fixtures/bottom-up.bmp.hex"),
|
||||
(8, 8),
|
||||
),
|
||||
(
|
||||
"BMP 32-bit BGRX",
|
||||
include_str!("../tests/fixtures/bgrx32.bmp.hex"),
|
||||
(2, 1),
|
||||
),
|
||||
(
|
||||
"GIF palette and frame offset",
|
||||
include_str!("../tests/fixtures/palette-offset.gif.hex"),
|
||||
(4, 2),
|
||||
),
|
||||
(
|
||||
"ICO largest entry",
|
||||
include_str!("../tests/fixtures/multi.ico.hex"),
|
||||
(2, 2),
|
||||
),
|
||||
(
|
||||
"JPEG baseline RGB",
|
||||
include_str!("../tests/fixtures/baseline.jpg.hex"),
|
||||
(8, 8),
|
||||
),
|
||||
(
|
||||
"JPEG progressive grayscale",
|
||||
include_str!("../tests/fixtures/progressive.jpg.hex"),
|
||||
(128, 128),
|
||||
),
|
||||
(
|
||||
"PNG grayscale",
|
||||
include_str!("../tests/fixtures/gray.png.hex"),
|
||||
(32, 32),
|
||||
),
|
||||
(
|
||||
"PNG grayscale alpha",
|
||||
include_str!("../tests/fixtures/gray-alpha.png.hex"),
|
||||
(32, 32),
|
||||
),
|
||||
(
|
||||
"PNG RGB",
|
||||
include_str!("../tests/fixtures/rgb.png.hex"),
|
||||
(32, 32),
|
||||
),
|
||||
(
|
||||
"PNG RGBA",
|
||||
include_str!("../tests/fixtures/rgba.png.hex"),
|
||||
(32, 32),
|
||||
),
|
||||
(
|
||||
"PNG straight alpha",
|
||||
include_str!("../tests/fixtures/straight-alpha.png.hex"),
|
||||
(1, 1),
|
||||
),
|
||||
(
|
||||
"PNG interlaced palette",
|
||||
include_str!("../tests/fixtures/interlaced-palette.png.hex"),
|
||||
(32, 32),
|
||||
),
|
||||
(
|
||||
"WBMP multibyte width",
|
||||
include_str!("../tests/fixtures/multibyte.wbmp.hex"),
|
||||
(130, 1),
|
||||
),
|
||||
(
|
||||
"WBMP minimum-length image",
|
||||
include_str!("../tests/fixtures/small.wbmp.hex"),
|
||||
(2, 2),
|
||||
),
|
||||
(
|
||||
"WebP lossless RGB",
|
||||
include_str!("../tests/fixtures/rgb-lossless.webp.hex"),
|
||||
(2, 2),
|
||||
),
|
||||
(
|
||||
"WebP lossless alpha",
|
||||
include_str!("../tests/fixtures/alpha.webp.hex"),
|
||||
(1, 1),
|
||||
),
|
||||
(
|
||||
"WebP lossy",
|
||||
include_str!("../tests/fixtures/lossy-red.webp.hex"),
|
||||
(8, 8),
|
||||
),
|
||||
(
|
||||
"WebP animated first frame",
|
||||
include_str!("../tests/fixtures/animated.webp.hex"),
|
||||
(11, 29),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "rust-skia", feature = "skia"))]
|
||||
fn assert_checked_format_matrix(codec: &dyn libremetaverse_imaging::ITextureCodec) {
|
||||
for (name, fixture, dimensions) in checked_format_matrix() {
|
||||
let image = codec
|
||||
.decode(Box::new(std::io::Cursor::new(hex_fixture(fixture))))
|
||||
.unwrap_or_else(|error| panic!("{name} failed to decode: {error:?}"));
|
||||
assert_eq!((image.width, image.height), dimensions, "{name}");
|
||||
assert_eq!(
|
||||
image.channels,
|
||||
if matches!(
|
||||
name,
|
||||
"JPEG progressive grayscale"
|
||||
| "PNG grayscale"
|
||||
| "WBMP multibyte width"
|
||||
| "WBMP minimum-length image"
|
||||
) {
|
||||
ManagedImageImageChannels::GRAY
|
||||
} else {
|
||||
ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA
|
||||
},
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
#[test]
|
||||
fn rust_feature_decodes_the_checked_format_matrix() {
|
||||
assert_checked_format_matrix(&RustSkiaTextureCodec);
|
||||
}
|
||||
|
||||
#[cfg(feature = "skia")]
|
||||
#[test]
|
||||
fn native_feature_decodes_the_checked_format_matrix() {
|
||||
assert_checked_format_matrix(&SkiaTextureCodec);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "rust-skia", feature = "skia"))]
|
||||
#[test]
|
||||
fn native_and_rust_backends_match_the_shared_contract_matrix() {
|
||||
fn assert_plane(name: &str, plane: &str, native: &[u8], rust: &[u8], tolerance: u8) {
|
||||
assert_eq!(native.len(), rust.len(), "{name} {plane} length");
|
||||
for (index, (&native, &rust)) in native.iter().zip(rust).enumerate() {
|
||||
assert!(
|
||||
native.abs_diff(rust) <= tolerance,
|
||||
"{name} {plane}[{index}]: native={native}, rust={rust}, tolerance={tolerance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, fixture, _) in checked_format_matrix() {
|
||||
let bytes = hex_fixture(fixture);
|
||||
let native = SkiaTextureCodec
|
||||
.decode(Box::new(std::io::Cursor::new(bytes.clone())))
|
||||
.unwrap_or_else(|error| panic!("native {name}: {error:?}"));
|
||||
let rust = RustSkiaTextureCodec
|
||||
.decode(Box::new(std::io::Cursor::new(bytes)))
|
||||
.unwrap_or_else(|error| panic!("Rust {name}: {error:?}"));
|
||||
assert_eq!(native.width, rust.width, "{name} width");
|
||||
assert_eq!(native.height, rust.height, "{name} height");
|
||||
assert_eq!(native.channels, rust.channels, "{name} channels");
|
||||
let tolerance = if name.starts_with("JPEG") {
|
||||
8
|
||||
} else if name == "WebP lossy" {
|
||||
10
|
||||
} else {
|
||||
0
|
||||
};
|
||||
assert_plane(name, "red", &native.red, &rust.red, tolerance);
|
||||
assert_plane(name, "green", &native.green, &rust.green, tolerance);
|
||||
assert_plane(name, "blue", &native.blue, &rust.blue, tolerance);
|
||||
assert_plane(name, "alpha", &native.alpha, &rust.alpha, tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
#[test]
|
||||
fn rust_feature_preserves_canvas_orientation_selection_and_alpha() {
|
||||
for fixture in [
|
||||
include_str!("../tests/fixtures/two-by-two-bottom-up.bmp.hex"),
|
||||
include_str!("../tests/fixtures/two-by-two-top-down.bmp.hex"),
|
||||
] {
|
||||
let bitmap = rust_decode(hex_fixture(fixture)).expect("oriented BMP");
|
||||
assert_eq!(bitmap.red, [255, 0, 0, 255]);
|
||||
assert_eq!(bitmap.green, [0, 255, 0, 255]);
|
||||
assert_eq!(bitmap.blue, [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
let gif = rust_decode(hex_fixture(include_str!(
|
||||
"../tests/fixtures/palette-offset.gif.hex"
|
||||
)))
|
||||
.expect("GIF logical canvas");
|
||||
assert_eq!((gif.width, gif.height), (4, 2));
|
||||
|
||||
let icon = rust_decode(hex_fixture(include_str!("../tests/fixtures/multi.ico.hex")))
|
||||
.expect("largest ICO entry");
|
||||
assert_eq!(icon.red, [255, 0, 0, 255]);
|
||||
assert_eq!(icon.green, [0, 255, 0, 255]);
|
||||
assert_eq!(icon.blue, [0, 0, 255, 255]);
|
||||
|
||||
let alpha = rust_decode(hex_fixture(include_str!(
|
||||
"../tests/fixtures/alpha.webp.hex"
|
||||
)))
|
||||
.expect("alpha WebP");
|
||||
assert_eq!(
|
||||
(alpha.red[0], alpha.green[0], alpha.blue[0]),
|
||||
(255, 255, 255)
|
||||
);
|
||||
assert!((127..=128).contains(&alpha.alpha[0]));
|
||||
|
||||
let lossy = rust_decode(hex_fixture(include_str!(
|
||||
"../tests/fixtures/lossy-red.webp.hex"
|
||||
)))
|
||||
.expect("lossy WebP");
|
||||
assert!(lossy.red.iter().all(|red| *red >= 245));
|
||||
assert!(lossy.green.iter().all(|green| *green <= 10));
|
||||
assert!(lossy.blue.iter().all(|blue| *blue <= 10));
|
||||
assert!(lossy.alpha.iter().all(|alpha| *alpha == u8::MAX));
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
#[test]
|
||||
fn rust_feature_rejects_invalid_truncated_and_oversized_input_without_panicking() {
|
||||
for malformed in [Vec::new(), b"not an image".to_vec(), b"GIF89a".to_vec()] {
|
||||
let result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rust_decode(malformed)));
|
||||
assert!(result.is_ok(), "malformed input panicked");
|
||||
assert_eq!(result.expect("checked above"), Err(Error::InvalidOperation));
|
||||
}
|
||||
|
||||
let mut pixel_bomb =
|
||||
hex_fixture(include_str!("../tests/fixtures/interlaced-palette.png.hex"));
|
||||
pixel_bomb[16..20].copy_from_slice(&4097_u32.to_be_bytes());
|
||||
pixel_bomb[20..24].copy_from_slice(&4097_u32.to_be_bytes());
|
||||
assert_eq!(rust_decode(pixel_bomb), Err(Error::Argument));
|
||||
|
||||
let mut gif_frame_bomb =
|
||||
hex_fixture(include_str!("../tests/fixtures/palette-offset.gif.hex"));
|
||||
let descriptor = gif_frame_bomb[109..]
|
||||
.iter()
|
||||
.position(|byte| *byte == 0x2c)
|
||||
.map(|offset| offset + 109)
|
||||
.expect("first GIF image descriptor");
|
||||
gif_frame_bomb[descriptor + 5..descriptor + 7].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
gif_frame_bomb[descriptor + 7..descriptor + 9].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert_eq!(rust_decode(gif_frame_bomb), Err(Error::Argument));
|
||||
|
||||
let mut malformed_gif_palette =
|
||||
hex_fixture(include_str!("../tests/fixtures/palette-offset.gif.hex"));
|
||||
// Advertise a 256-entry global palette while retaining the fixture's
|
||||
// much smaller checked table. Preflight must reject the missing table
|
||||
// before the decoder can allocate or index palette storage.
|
||||
malformed_gif_palette[10] = (malformed_gif_palette[10] & 0xf8) | 0x07;
|
||||
assert_eq!(
|
||||
rust_decode(malformed_gif_palette),
|
||||
Err(Error::InvalidOperation)
|
||||
);
|
||||
|
||||
let mut icon_embedded_bomb = hex_fixture(include_str!("../tests/fixtures/multi.ico.hex"));
|
||||
icon_embedded_bomb[86..90].copy_from_slice(&4097_i32.to_le_bytes());
|
||||
icon_embedded_bomb[90..94].copy_from_slice(&8194_i32.to_le_bytes());
|
||||
assert_eq!(rust_decode(icon_embedded_bomb), Err(Error::Argument));
|
||||
|
||||
let over_limit = vec![0_u8; DEFAULT_MAX_ENCODED_BYTES + 1];
|
||||
assert_eq!(rust_decode(over_limit), Err(Error::Argument));
|
||||
|
||||
for fixture in [
|
||||
include_str!("../tests/fixtures/bottom-up.bmp.hex"),
|
||||
include_str!("../tests/fixtures/palette-offset.gif.hex"),
|
||||
include_str!("../tests/fixtures/multi.ico.hex"),
|
||||
include_str!("../tests/fixtures/interlaced-palette.png.hex"),
|
||||
include_str!("../tests/fixtures/alpha.webp.hex"),
|
||||
] {
|
||||
let mut bytes = hex_fixture(fixture);
|
||||
bytes.truncate(bytes.len() / 2);
|
||||
let result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rust_decode(bytes)));
|
||||
assert!(result.is_ok(), "truncated input panicked");
|
||||
assert!(result.expect("checked above").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
struct DropStream {
|
||||
cursor: std::io::Cursor<Vec<u8>>,
|
||||
drops: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
impl std::io::Read for DropStream {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
std::io::Read::read(&mut self.cursor, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
impl std::io::Write for DropStream {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
std::io::Write::write(&mut self.cursor, buffer)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
impl std::io::Seek for DropStream {
|
||||
fn seek(&mut self, position: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
std::io::Seek::seek(&mut self.cursor, position)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
impl Drop for DropStream {
|
||||
fn drop(&mut self) {
|
||||
self.drops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-skia")]
|
||||
#[test]
|
||||
fn rust_feature_is_thread_safe_across_repeated_decode_and_drop() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn assert_texture_codec<T: libremetaverse_imaging::ITextureCodec + Send + Sync>() {}
|
||||
assert_texture_codec::<RustSkiaTextureCodec>();
|
||||
|
||||
let codec = Arc::new(RustSkiaTextureCodec);
|
||||
let png = Arc::new(hex_fixture(include_str!(
|
||||
"../tests/fixtures/interlaced-palette.png.hex"
|
||||
)));
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let codec = Arc::clone(&codec);
|
||||
let png = Arc::clone(&png);
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for _ in 0..16 {
|
||||
let image = codec
|
||||
.decode(Box::new(std::io::Cursor::new((*png).clone())))
|
||||
.expect("concurrent PNG decode");
|
||||
assert_eq!((image.width, image.height), (32, 32));
|
||||
let repeated = codec
|
||||
.decode(Box::new(std::io::Cursor::new((*png).clone())))
|
||||
.expect("repeated concurrent PNG decode");
|
||||
assert_eq!(image, repeated);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("decoder worker did not panic");
|
||||
}
|
||||
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let stream = DropStream {
|
||||
cursor: std::io::Cursor::new((*png).clone()),
|
||||
drops: Arc::clone(&drops),
|
||||
};
|
||||
codec.decode(Box::new(stream)).expect("drop-probe decode");
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "skia")]
|
||||
|
||||
29
crates/libremetaverse-imaging-skia/tests/fixtures/README.md
vendored
Normal file
29
crates/libremetaverse-imaging-skia/tests/fixtures/README.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Rust codec fixtures
|
||||
|
||||
The tests decode these hexadecimal files into their exact checked-in bytes; no
|
||||
image is generated while tests run. Unless noted below, fixtures come from the
|
||||
BSD-3-Clause Skia test resources vendored by `skia-bindings 0.99.0`.
|
||||
|
||||
| Fixture | SHA-256 |
|
||||
| --- | --- |
|
||||
| `alpha.webp` | `8b09993d8ffc9a9ec4e7f5f26a1c126cf265c37b0d63c0c5be2506e20dbc6fea` |
|
||||
| `animated.webp` | `292753f066add623af1e30bbeeca58f15b3d6d1052e12b13e30f21f8d3c14505` |
|
||||
| `baseline.jpg` | `4e45048dbe3078943eb364221a9757df9b9845b80e795ccdc944f1c18b5e221b` |
|
||||
| `bottom-up.bmp` | `d9cc5fff9e8e351727ad4519236ae46eaf37f8007dad5cdb8619fcd67ec68903` |
|
||||
| `gray-alpha.png` | `377a67510b4ccc74cdcce14847b02035e75ccab36e9d8e00586c4c24d1ae385a` |
|
||||
| `gray.png` | `85b19fcda452d6cbf02f570c353cfa9e47c29237f2fc8f0114deb6cd0b23f97a` |
|
||||
| `interlaced-palette.png` | `6283a61b1f7344c0cfa741076d6aed4a16a6bc5582e81db8dd7374a34230b0df` |
|
||||
| `palette-offset.gif` | `32d3409a79b75614ef385354d54f148f08856f8e9ce0d55054582ff7f40f4461` |
|
||||
| `progressive.jpg` | `d07a9815dc792543c16b796ec7729c19d6a29e7d9b4d0bbab50f4ef210be7767` |
|
||||
| `rgb.png` | `ecea1ef4a001a999ce9b82d18abf47401eb92998708b306b6c9a8ac3a6a9a2c8` |
|
||||
| `rgba.png` | `756a03364c02e3c9f85d6f4029eb3cef2488c081dc537d46e102bebcb9e02732` |
|
||||
| `straight-alpha.png` | `5a647404fa5289da777b1bb9e1dcfe20027262f992a214f38efaecc3da0e08a4` |
|
||||
|
||||
`lossy-red.webp` was produced once with FFmpeg 5.1.8/libwebp from a solid
|
||||
8x8 red source (`-c:v libwebp -lossless 0 -quality 80`) and has SHA-256
|
||||
`7e3aaf1a442b6e69f7706ee5afe0aeacb2d096e384dbb63e792421fe2449cfe8`.
|
||||
|
||||
The remaining tiny BMP, ICO, WBMP, and lossless RGB WebP files are original
|
||||
MetaCrate byte-level fixtures. Their reviewed bytes are intentionally readable
|
||||
in the `.hex` files and exercise header fields, row layout, masks, multi-byte
|
||||
integers, and multi-entry selection directly.
|
||||
3
crates/libremetaverse-imaging-skia/tests/fixtures/alpha.webp.hex
vendored
Normal file
3
crates/libremetaverse-imaging-skia/tests/fixtures/alpha.webp.hex
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
524946461e000000574542505650384c110000002f0000001007d0fffef7
|
||||
bf7f8188e87f0000
|
||||
|
||||
12
crates/libremetaverse-imaging-skia/tests/fixtures/animated.webp.hex
vendored
Normal file
12
crates/libremetaverse-imaging-skia/tests/fixtures/animated.webp.hex
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
524946464c01000057454250565038580a000000120000000a00001c0000
|
||||
414e494d060000000000ffff0000414e4d465a0000000000000000000a00
|
||||
001c0000e80300005650384c420000002f0a0007001720164ce6ff849cc6
|
||||
388ee3fc078e0645b1ad54573b50821444f0db8728aec9f9562688e8bf80
|
||||
a0c8013bd09c2c5e759505ce8f25232ee33615170c0d414e4d465a000000
|
||||
010000050000060000100000f40100005650384c420000002f0600040017
|
||||
201048525e647e814092f222330b049298079979fe037f15288a6ca749a3
|
||||
de31819aee2fc70c32898988fe4f008092d5d79d5525f8079e709b3b6120
|
||||
414e4d465c0000000100000100000600000f0000e80300005650384c4400
|
||||
00002f06c0030017201048621e647e814092f222330b049298079979fe03
|
||||
7f15286a1bc9b9defe4362d05ce5b7cfd5c23c1411fd4f093a8c46a30e09
|
||||
ee4f7ff3c3c3c3cdd303
|
||||
11
crates/libremetaverse-imaging-skia/tests/fixtures/baseline.jpg.hex
vendored
Normal file
11
crates/libremetaverse-imaging-skia/tests/fixtures/baseline.jpg.hex
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
ffd8ffe000104a46494600010101004800480000ffdb004300100b0c0e0c
|
||||
0a100e0d0e1211101318281a181616183123251d283a333d3c3933383740
|
||||
485c4e404457453738506d51575f626768673e4d71797064785c656763ff
|
||||
db0043011112121815182f1a1a2f63423842636363636363636363636363
|
||||
636363636363636363636363636363636363636363636363636363636363
|
||||
6363636363636363ffc00011080008000803012200021101031101ffc400
|
||||
1500010100000000000000000000000000000003ffc4001e100002010403
|
||||
01000000000000000000000102000311213112224171ffc4001501010100
|
||||
000000000000000000000000000304ffc4001a1101000203010000000000
|
||||
00000000000001000203213141ffda000c03010002110311003f009d4e6a
|
||||
8c15b354b2d455eda6b922c32367dd7db2222d6c9a2558c7c5393fffd9
|
||||
2
crates/libremetaverse-imaging-skia/tests/fixtures/bgrx32.bmp.hex
vendored
Normal file
2
crates/libremetaverse-imaging-skia/tests/fixtures/bgrx32.bmp.hex
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
424d3e000000000000003600000028000000020000000100000001002000000000000800000000000000000000000000000000000000
|
||||
0000ff0000ff0000
|
||||
10
crates/libremetaverse-imaging-skia/tests/fixtures/bottom-up.bmp.hex
vendored
Normal file
10
crates/libremetaverse-imaging-skia/tests/fixtures/bottom-up.bmp.hex
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
424df6000000000000003600000028000000080000000800000001001800
|
||||
00000000c0000000130b0000130b000000000000000000002272b0f8232e
|
||||
d989f03857b32260a8fe403371fe95df716a63fea76b954ce022bce472b2
|
||||
4a9f121345e342373d0a19bd98ac6f271d9411e45f83b2fb996ed8629116
|
||||
8c1271b4ca39f93d27831ab4c312f867abcc184203a7b97d481082b44afe
|
||||
5221c5d6cc78abb4dc7dd209f300ca475d60b26f44466e5742f73ba8028f
|
||||
df09557e02c78aaa24966c888154628936400ea6bdb61dc088d62bd46293
|
||||
b241c6decea5ba6e60f34b8f70a5bb5d5f39395ce266761927ba3cb0fcde
|
||||
7438c19300fa
|
||||
|
||||
5
crates/libremetaverse-imaging-skia/tests/fixtures/gray-alpha.png.hex
vendored
Normal file
5
crates/libremetaverse-imaging-skia/tests/fixtures/gray-alpha.png.hex
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
89504e470d0a1a0a0000000d4948445200000020000000200804000000d9
|
||||
73b27f0000000467414d41000186a031e8965f0000003549444154488963
|
||||
fccfc0c001859c683431229c2cdf1928032c3f86be01a361301a06543160
|
||||
341047c3802a068c06e2681850c30000df2a207da0bf7158000000004945
|
||||
4e44ae426082
|
||||
5
crates/libremetaverse-imaging-skia/tests/fixtures/gray.png.hex
vendored
Normal file
5
crates/libremetaverse-imaging-skia/tests/fixtures/gray.png.hex
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
89504e470d0a1a0a0000000d494844520000002000000020080000000056
|
||||
1125280000000467414d41000186a031e8965f0000004149444154388d63
|
||||
646024001408c8b30c05058c0f0829f8f71f3f6079301c1430ca11906764
|
||||
a2795c0c06058c8ff0cafeffcff887e67131181430cae0956564040050e5
|
||||
fe7171a90b240000000049454e44ae426082
|
||||
5
crates/libremetaverse-imaging-skia/tests/fixtures/interlaced-palette.png.hex
vendored
Normal file
5
crates/libremetaverse-imaging-skia/tests/fixtures/interlaced-palette.png.hex
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
89504e470d0a1a0a0000000d49484452000000200000002001030000013e
|
||||
b3d82100000006504c5445eeff222266ff6c1ad226000000294944415408
|
||||
d7636080820f50b80a0a43d1b0b131c39933d849088348363f1040880f40
|
||||
00212810030009ba422d1cbe5f9a0000000049454e44ae426082
|
||||
|
||||
4
crates/libremetaverse-imaging-skia/tests/fixtures/lossy-red.webp.hex
vendored
Normal file
4
crates/libremetaverse-imaging-skia/tests/fixtures/lossy-red.webp.hex
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
524946463c000000574542505650382030000000d001009d012a08000800
|
||||
01402625a00274ba01f80003b000fef36997fe6c0ae6b9f7fbffe9707e97
|
||||
07e9707fe9480000
|
||||
|
||||
8
crates/libremetaverse-imaging-skia/tests/fixtures/multi.ico.hex
vendored
Normal file
8
crates/libremetaverse-imaging-skia/tests/fixtures/multi.ico.hex
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
000001000200
|
||||
01010000010020002c00000026000000
|
||||
02020000010020003800000052000000
|
||||
28000000010000000200000001002000000000000400000000000000000000000000000000000000
|
||||
0000ffff
|
||||
28000000020000000400000001002000000000001000000000000000000000000000000000000000
|
||||
ff0000ffffffffff0000ffff00ff00ff
|
||||
|
||||
3
crates/libremetaverse-imaging-skia/tests/fixtures/multibyte.wbmp.hex
vendored
Normal file
3
crates/libremetaverse-imaging-skia/tests/fixtures/multibyte.wbmp.hex
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
0000810201
|
||||
ffffffffffffffffffffffffffffffff80
|
||||
|
||||
7
crates/libremetaverse-imaging-skia/tests/fixtures/palette-offset.gif.hex
vendored
Normal file
7
crates/libremetaverse-imaging-skia/tests/fixtures/palette-offset.gif.hex
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
474946383961040002008400000000001000002000003000004000005000
|
||||
00600000700000800000900000a00000b00000c00000d00000e00000f000
|
||||
000000000000110000220000330000440000550000660000770000880000
|
||||
990000aa0000bb0000cc0000dd0000ee0000ff21f904000a0000002c0000
|
||||
00000400020000070908090a0b0c0d0e0f810021f9040114001a002c0100
|
||||
0100030001000007041f1a1581003b
|
||||
|
||||
26
crates/libremetaverse-imaging-skia/tests/fixtures/progressive.jpg.hex
vendored
Normal file
26
crates/libremetaverse-imaging-skia/tests/fixtures/progressive.jpg.hex
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
ffd8ffe000104a46494600010101004800480000ffdb0043000806060706
|
||||
05080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20
|
||||
242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ff
|
||||
c2000b080080008001011100ffc400150001010000000000000000000000
|
||||
0000000306ffda0008010100000001824444444444444448744444444444
|
||||
444487444444444444444874444444444444448744444444444444487444
|
||||
444444444444874444444444444448744444444444444487444444444444
|
||||
444874444444444444448744444444444444487444444444444444874444
|
||||
444444444448744444444444444487444444444444444874444444444444
|
||||
44ffc40014100100000000000000000000000000000080ffda0008010100
|
||||
010502007fffc40014100100000000000000000000000000000080ffda00
|
||||
08010100063f02007fffc400151001010000000000000000000000000000
|
||||
0100ffda0008010100013f21082082082082082082082082082082082082
|
||||
082082082082082082082082082082082082082082082082082082082082
|
||||
082082082082082082082082082082082082082082082082082082082082
|
||||
082082082082082082082082082082082082082082082082082082082082
|
||||
082082082082082082082082082082082082082082082082082082082082
|
||||
082082082082082082082082082082082082082082082082082082082082
|
||||
082082082082082082082082082082082082082082082082ffda00080101
|
||||
000000100000000000000000000000000000000000000000000000000000
|
||||
000000000000ffc40014100100000000000000000000000000000080ffda
|
||||
0008010100013f10007fff00ff00ff00ff00ff00ff00ff00ff00ff00ff00
|
||||
ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00
|
||||
ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00
|
||||
ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00
|
||||
ff00ff00ff00ff00ff00ff00ff00ff00ff00ffd9
|
||||
2
crates/libremetaverse-imaging-skia/tests/fixtures/rgb-lossless.webp.hex
vendored
Normal file
2
crates/libremetaverse-imaging-skia/tests/fixtures/rgb-lossless.webp.hex
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
524946461c000000574542505650384c0f0000002f014000000710f58ffe
|
||||
0722a2ff0100
|
||||
5
crates/libremetaverse-imaging-skia/tests/fixtures/rgb.png.hex
vendored
Normal file
5
crates/libremetaverse-imaging-skia/tests/fixtures/rgb.png.hex
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
89504e470d0a1a0a0000000d4948445200000020000000200802000000fc
|
||||
18eda30000000467414d41000186a031e8965f00000048494441544889ed
|
||||
d5c10900300c024085ec91fdb772133b442bf4a1f8cee12bb40d043b800a
|
||||
14f81ca0ede47d4c784081020f4a871fc284071428f0a0743823a94081bb
|
||||
7077a3c00182b1f95e3ed793980000000049454e44ae426082
|
||||
2
crates/libremetaverse-imaging-skia/tests/fixtures/rgb555.bmp.hex
vendored
Normal file
2
crates/libremetaverse-imaging-skia/tests/fixtures/rgb555.bmp.hex
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
424d3a000000000000003600000028000000020000000100000001001000000000000400000000000000000000000000000000000000
|
||||
007ce003
|
||||
7
crates/libremetaverse-imaging-skia/tests/fixtures/rgba.png.hex
vendored
Normal file
7
crates/libremetaverse-imaging-skia/tests/fixtures/rgba.png.hex
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
89504e470d0a1a0a0000000d494844520000002000000020080600000073
|
||||
7a7af40000000467414d41000186a031e8965f0000006f494441545885ed
|
||||
d6310a80300c46e12764684fa1f73f55048f21c4ddc545781d52e85028fc
|
||||
1f4d28d98a01305e7b7e9cffba33831d75054703ca06a8f90d58a0074e35
|
||||
1e227d805c8254e31bb0420f5cdc2e0079208892ffe2a00136a07b400794
|
||||
3c1004d900195036407f011bf00052201a9cd68fe9670000000049454e44
|
||||
ae426082
|
||||
1
crates/libremetaverse-imaging-skia/tests/fixtures/small.wbmp.hex
vendored
Normal file
1
crates/libremetaverse-imaging-skia/tests/fixtures/small.wbmp.hex
vendored
Normal file
@@ -0,0 +1 @@
|
||||
000002028040
|
||||
6
crates/libremetaverse-imaging-skia/tests/fixtures/straight-alpha.png.hex
vendored
Normal file
6
crates/libremetaverse-imaging-skia/tests/fixtures/straight-alpha.png.hex
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
89504e470d0a1a0a0000000d49484452000000010000000108060000001f
|
||||
15c489000000017352474200aece1ce900000006624b474400ff00ff00ff
|
||||
a0bda793000000097048597300000b1300000b1301009a9c180000000774
|
||||
494d4507de040313163ac721af9d0000001974455874436f6d6d656e7400
|
||||
4372656174656420776974682047494d5057810e170000000d4944415408
|
||||
d763f8ffff7f3d00097b037d178855c50000000049454e44ae426082
|
||||
3
crates/libremetaverse-imaging-skia/tests/fixtures/two-by-two-bottom-up.bmp.hex
vendored
Normal file
3
crates/libremetaverse-imaging-skia/tests/fixtures/two-by-two-bottom-up.bmp.hex
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
424d46000000000000003600000028000000020000000200000001001800000000001000000000000000000000000000000000000000
|
||||
ff0000ffffff00000000ff00ff000000
|
||||
|
||||
3
crates/libremetaverse-imaging-skia/tests/fixtures/two-by-two-top-down.bmp.hex
vendored
Normal file
3
crates/libremetaverse-imaging-skia/tests/fixtures/two-by-two-top-down.bmp.hex
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
424d4600000000000000360000002800000002000000feffffff01001800000000001000000000000000000000000000000000000000
|
||||
0000ff00ff000000ff0000ffffff0000
|
||||
|
||||
@@ -18,6 +18,12 @@ sha1 = "0.10"
|
||||
sha2 = "0.11"
|
||||
uuid = { version = "1.18", features = ["v4"] }
|
||||
|
||||
# Browser and JavaScript WASM targets have no operating-system entropy syscall.
|
||||
# Select getrandom's supported Web Crypto adapter only for that target family.
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
uuid = { version = "1.18", features = ["v4", "rng-getrandom"] }
|
||||
|
||||
[dev-dependencies]
|
||||
stats_alloc = "0.1.10"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ they use those APIs directly.
|
||||
| `libremetaverse-types` | UUIDs, vectors, matrices, colors, cancellation, compatibility collections, and boundary types | Pure Rust |
|
||||
| `libremetaverse-structured-data` | LLSD/OSD XML, JSON, binary, and notation | Pure Rust |
|
||||
| `libremetaverse-imaging` | Managed images, TGA/DDS, codec traits | Optional `jpeg2000` or pure-Rust `rust-j2k` |
|
||||
| `libremetaverse-imaging-skia` | Common raster formats through Skia | Optional `skia`; native Skia build/cache |
|
||||
| `libremetaverse-imaging-skia` | Common raster formats through native Skia or pure Rust | Optional `skia` native build/cache; optional `rust-skia` has no native prerequisites |
|
||||
| `libremetaverse-prim-mesher` | Legacy prim and sculpt geometry | Pure Rust |
|
||||
| `libremetaverse-rendering-simple` | Deterministic reference geometry | Pure Rust |
|
||||
| `libremetaverse-rendering-mesh-foundry` | Prim, terrain, sculpt, and mesh-asset rendering | Pure Rust |
|
||||
|
||||
Reference in New Issue
Block a user