//! Optional JPEG 2000 codec backed by a bounded system `OpenJPEG` adapter. use crate::codec::{InterleavedComponent, InterleavedImage}; use crate::{ DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS, Error, ITextureCodec, ManagedImage, ManagedImageImageChannels, }; use libremetaverse_openjpeg as openjpeg; use libremetaverse_types::compat::ReadWrite; use std::io::Read; /// JPEG 2000 container selection. #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] pub enum J2kFormat { /// Raw JPEG 2000 codestream used by Second Life texture assets. #[default] Codestream, /// JP2 file-format container. Jp2, } /// JPEG 2000 wavelet and rate-control mode. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum J2kCompression { /// Reversible 5/3 wavelet with exact sample reconstruction. #[default] Lossless, /// Irreversible 9/7 wavelet targeting the given compression ratio. Lossy { /// Uncompressed bytes divided by target codestream bytes. Must be at /// least 1.0 and finite. compression_ratio: f32, }, } /// Bounded decode configuration. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct J2kDecodeOptions { discard_levels: u32, quality_layers: u32, strict: bool, max_encoded_bytes: usize, max_pixels: usize, } impl Default for J2kDecodeOptions { fn default() -> Self { Self { discard_levels: 0, quality_layers: 0, strict: true, max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES, max_pixels: DEFAULT_MAX_PIXELS, } } } impl J2kDecodeOptions { /// Sets the number of highest-resolution levels to discard. #[must_use] pub const fn with_discard_levels(mut self, discard_levels: u32) -> Self { self.discard_levels = discard_levels; self } /// Limits decoding to the first `quality_layers` progressive layers. Zero /// decodes every available layer. #[must_use] pub const fn with_quality_layers(mut self, quality_layers: u32) -> Self { self.quality_layers = quality_layers; self } /// Selects whether truncated codestreams are rejected. #[must_use] pub const fn with_strict_mode(mut self, strict: bool) -> Self { self.strict = strict; self } /// Replaces the encoded-byte and decoded-pixel limits. /// /// Zero limits are invalid and cause decode to return [`Error::Argument`]. #[must_use] pub const fn with_limits(mut self, max_encoded_bytes: usize, max_pixels: usize) -> Self { self.max_encoded_bytes = max_encoded_bytes; self.max_pixels = max_pixels; self } /// Configured discard level. #[must_use] pub const fn discard_levels(self) -> u32 { self.discard_levels } /// Configured quality-layer limit. #[must_use] pub const fn quality_layers(self) -> u32 { self.quality_layers } } /// Encode configuration. #[derive(Clone, Copy, Debug, PartialEq)] pub struct J2kEncodeOptions { format: J2kFormat, compression: J2kCompression, max_encoded_bytes: usize, } impl Default for J2kEncodeOptions { fn default() -> Self { Self { format: J2kFormat::Codestream, compression: J2kCompression::Lossless, max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES, } } } impl J2kEncodeOptions { /// Selects raw codestream or JP2 output. #[must_use] pub const fn with_format(mut self, format: J2kFormat) -> Self { self.format = format; self } /// Selects reversible lossless or irreversible lossy compression. #[must_use] pub const fn with_compression(mut self, compression: J2kCompression) -> Self { self.compression = compression; self } /// Caps the produced codestream size. #[must_use] pub const fn with_max_encoded_bytes(mut self, max_encoded_bytes: usize) -> Self { self.max_encoded_bytes = max_encoded_bytes; self } } /// Cross-platform JPEG 2000 adapter. /// /// The optional adapter links to BSD-2-Clause `OpenJPEG` 2.5.4 or newer. Linux /// and macOS builds discover it with `pkg-config`; Windows MSVC builds use /// vcpkg. Checked-in minimal bindings and all unsafe FFI are isolated in the private /// adapter crate and no native codec type crosses this boundary. #[derive(Clone, Copy, Debug, Default)] pub struct J2kCodec { decode_options: J2kDecodeOptions, } impl J2kCodec { /// Creates a codec with explicit bounded decode options. #[must_use] pub const fn new(decode_options: J2kDecodeOptions) -> Self { Self { decode_options } } /// Decodes a raw J2K codestream or JP2 container while retaining component /// precision, signedness, alpha metadata, and order. /// /// The encoded-byte limit is checked before buffering to a codec stream. /// `OpenJPEG` header parsing does not allocate sample planes; dimensions and /// component counts are validated before `decode` may allocate them. /// /// # Errors /// /// Returns a typed argument or parse failure for invalid limits, oversized /// input/dimensions, unsupported component layouts, or malformed data. pub fn decode_interleaved( encoded: &[u8], options: J2kDecodeOptions, ) -> Result { if options.max_encoded_bytes == 0 || options.max_pixels == 0 || encoded.is_empty() || encoded.len() > options.max_encoded_bytes { return Err(Error::Argument); } let format = detect_format(encoded)?; let decoded = openjpeg::decode( encoded, backend_format(format), openjpeg::DecodeOptions { discard_levels: options.discard_levels, quality_layers: options.quality_layers, strict: options.strict, max_pixels: options.max_pixels.min(DEFAULT_MAX_PIXELS), }, ) .map_err(map_decode_error)?; backend_image_to_interleaved(decoded, options.max_pixels) } /// Decodes into the C#-compatible planar byte representation. /// /// # Errors /// /// Returns the failures documented by [`Self::decode_interleaved`] or a /// typed error for a component conversion/allocation failure. pub fn decode_bytes(encoded: &[u8], options: J2kDecodeOptions) -> Result { interleaved_to_managed(&Self::decode_interleaved(encoded, options)?) } /// Encodes the four-component compatibility view used by `CoreJ2K`. /// /// Color images preserve RGB and optional alpha. Alpha-only images repeat /// alpha into RGB and encode an opaque alpha plane. Images without alpha /// receive opaque alpha. Bump is not a JPEG 2000 output component, matching /// the reference adapter. /// /// # Errors /// /// Returns a typed validation/operation failure for invalid image layouts, /// lossy settings, allocation/codec errors, or oversized output. pub fn encode(image: &ManagedImage, options: J2kEncodeOptions) -> Result, Error> { image.validate()?; if options.max_encoded_bytes == 0 { return Err(Error::Argument); } if let J2kCompression::Lossy { compression_ratio } = options.compression && (!compression_ratio.is_finite() || compression_ratio < 1.0) { return Err(Error::Argument); } encode_with_openjpeg(image, options) } } impl ITextureCodec for J2kCodec { fn decode(&self, mut stream: Box) -> Result { if self.decode_options.max_encoded_bytes == 0 { return Err(Error::Argument); } let limit = self .decode_options .max_encoded_bytes .checked_add(1) .ok_or(Error::Argument)?; let mut encoded = Vec::new(); Read::by_ref(&mut stream) .take(u64::try_from(limit).map_err(|_| Error::Argument)?) .read_to_end(&mut encoded) .map_err(|_| parse("JPEG 2000 input stream"))?; if encoded.len() > self.decode_options.max_encoded_bytes { return Err(Error::Argument); } Self::decode_bytes(&encoded, self.decode_options) } } fn backend_image_to_interleaved( image: openjpeg::Image, max_pixels: usize, ) -> Result { let (width, height) = checked_dimensions(image.width, image.height, max_pixels)?; let mut decoded = Vec::new(); decoded .try_reserve_exact(image.components.len()) .map_err(|_| Error::InvalidOperation)?; for component in image.components { if component.width != image.width || component.height != image.height { return Err(parse("subsampled JPEG 2000 components")); } decoded.push(InterleavedComponent::new( component.precision, component.signed, component.alpha, component.samples, )?); } InterleavedImage::new(width, height, decoded) } fn interleaved_to_managed(image: &InterleavedImage) -> Result { let channels = channels_for_components(image.number_of_components())?; let pixels = usize::try_from(image.width()) .ok() .and_then(|width| { usize::try_from(image.height()) .ok() .and_then(|height| width.checked_mul(height)) }) .ok_or(Error::Argument)?; let mut bytes = Vec::new(); let length = pixels .checked_mul(image.number_of_components()) .ok_or(Error::Argument)?; bytes .try_reserve_exact(length) .map_err(|_| Error::InvalidOperation)?; bytes.resize(length, 0); let mut plane = vec![0; pixels]; for component in 0..image.number_of_components() { image.to_component_bytes(component, &mut plane)?; for (pixel, sample) in plane.iter().enumerate() { bytes[pixel * image.number_of_components() + component] = *sample; } } managed_from_reference_interleaved(image.width(), image.height(), channels, &bytes) } fn encode_with_openjpeg(image: &ManagedImage, options: J2kEncodeOptions) -> Result, Error> { let width = u32::try_from(image.width).map_err(|_| Error::Argument)?; let height = u32::try_from(image.height).map_err(|_| Error::Argument)?; let planes = reference_encode_planes(image)?; let components = [ openjpeg::ComponentRef { precision: 8, signed: false, alpha: false, samples: &planes[0], }, openjpeg::ComponentRef { precision: 8, signed: false, alpha: false, samples: &planes[1], }, openjpeg::ComponentRef { precision: 8, signed: false, alpha: false, samples: &planes[2], }, openjpeg::ComponentRef { precision: 8, signed: false, alpha: true, samples: &planes[3], }, ]; let compression = match options.compression { J2kCompression::Lossless => openjpeg::Compression::Lossless, J2kCompression::Lossy { compression_ratio } => { openjpeg::Compression::Lossy { compression_ratio } } }; openjpeg::encode( width, height, &components, backend_format(options.format), compression, options.max_encoded_bytes, ) .map_err(map_encode_error) } fn reference_encode_planes(image: &ManagedImage) -> Result<[Vec; 4], Error> { let pixels = usize::try_from(image.width) .ok() .and_then(|width| { usize::try_from(image.height) .ok() .and_then(|height| width.checked_mul(height)) }) .ok_or(Error::Argument)?; let mut planes = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; for plane in &mut planes { plane .try_reserve_exact(pixels) .map_err(|_| Error::InvalidOperation)?; plane.resize(pixels, 0); } let has_color = image.channels.contains(ManagedImageImageChannels::COLOR); let has_alpha = image.channels.contains(ManagedImageImageChannels::ALPHA); if has_alpha && !has_color { for (pixel, sample) in image.alpha.iter().copied().enumerate() { let sample = i32::from(sample); planes[0][pixel] = sample; planes[1][pixel] = sample; planes[2][pixel] = sample; planes[3][pixel] = 255; } return Ok(planes); } if !has_color { return Err(Error::InvalidOperation); } for (pixel, red) in image.red.iter().copied().enumerate() { planes[0][pixel] = i32::from(red); planes[1][pixel] = i32::from(image.green[pixel]); planes[2][pixel] = i32::from(image.blue[pixel]); planes[3][pixel] = if has_alpha { i32::from(image.alpha[pixel]) } else { 255 }; } Ok(planes) } fn managed_from_reference_interleaved( width: i32, height: i32, channels: ManagedImageImageChannels, bytes: &[u8], ) -> Result { let components = component_count(channels); let pixels = usize::try_from(width) .ok() .and_then(|width| { usize::try_from(height) .ok() .and_then(|height| width.checked_mul(height)) }) .ok_or(Error::Argument)?; if bytes.len() != pixels.checked_mul(components).ok_or(Error::Argument)? { return Err(Error::Argument); } let mut image = ManagedImage::new(width, height, channels)?; for pixel in 0..pixels { let source = pixel * components; match components { 1 => image.red[pixel] = bytes[source], 2 => { image.red[pixel] = bytes[source]; image.alpha[pixel] = bytes[source + 1]; } 3 => { image.red[pixel] = bytes[source]; image.green[pixel] = bytes[source + 1]; image.blue[pixel] = bytes[source + 2]; } 4 => { image.red[pixel] = bytes[source]; image.green[pixel] = bytes[source + 1]; image.blue[pixel] = bytes[source + 2]; image.alpha[pixel] = bytes[source + 3]; } 5 => { image.red[pixel] = bytes[source]; image.green[pixel] = bytes[source + 1]; image.blue[pixel] = bytes[source + 2]; image.bump[pixel] = bytes[source + 3]; image.alpha[pixel] = bytes[source + 4]; } _ => return Err(Error::Argument), } } Ok(image) } fn channels_for_components(components: usize) -> Result { Ok(match components { 1 => ManagedImageImageChannels::GRAY, 2 => ManagedImageImageChannels::GRAY | ManagedImageImageChannels::ALPHA, 3 => ManagedImageImageChannels::COLOR, 4 => ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, 5 => { ManagedImageImageChannels::COLOR | ManagedImageImageChannels::BUMP | ManagedImageImageChannels::ALPHA } _ => return Err(Error::Argument), }) } fn component_count(channels: ManagedImageImageChannels) -> usize { if channels.contains(ManagedImageImageChannels::BUMP) { 5 } else if channels.contains(ManagedImageImageChannels::COLOR) { 3 + usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) } else if channels.contains(ManagedImageImageChannels::GRAY) { 1 + usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) } else { usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) } } fn detect_format(encoded: &[u8]) -> Result { const JP2_MAGIC: &[u8] = &[ 0x00, 0x00, 0x00, 0x0c, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a, ]; const J2K_MAGIC: &[u8] = &[0xff, 0x4f, 0xff, 0x51]; if encoded.starts_with(JP2_MAGIC) { Ok(J2kFormat::Jp2) } else if encoded.starts_with(J2K_MAGIC) { Ok(J2kFormat::Codestream) } else { Err(parse("JPEG 2000 magic")) } } const fn backend_format(format: J2kFormat) -> openjpeg::Format { match format { J2kFormat::Codestream => openjpeg::Format::J2k, J2kFormat::Jp2 => openjpeg::Format::Jp2, } } const fn map_decode_error(error: openjpeg::Error) -> Error { match error { openjpeg::Error::LimitExceeded => Error::Argument, openjpeg::Error::Allocation => Error::InvalidOperation, openjpeg::Error::InvalidInput | openjpeg::Error::Codec => parse("JPEG 2000 codestream"), _ => parse("JPEG 2000 backend"), } } const fn map_encode_error(error: openjpeg::Error) -> Error { match error { openjpeg::Error::InvalidInput | openjpeg::Error::LimitExceeded => Error::Argument, _ => Error::InvalidOperation, } } fn checked_dimensions(width: u32, height: u32, max_pixels: usize) -> Result<(i32, i32), Error> { let width_usize = usize::try_from(width).map_err(|_| Error::Argument)?; let height_usize = usize::try_from(height).map_err(|_| Error::Argument)?; let pixels = width_usize .checked_mul(height_usize) .ok_or(Error::Argument)?; if width == 0 || height == 0 || pixels > max_pixels || pixels > DEFAULT_MAX_PIXELS { return Err(Error::Argument); } Ok(( i32::try_from(width).map_err(|_| Error::Argument)?, i32::try_from(height).map_err(|_| Error::Argument)?, )) } const fn parse(context: &'static str) -> Error { Error::Parse { position: 0, context, } } #[cfg(test)] mod tests { use super::*; use std::io::Cursor; fn rgba(width: i32, height: i32) -> ManagedImage { let mut image = ManagedImage::new( width, height, ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA, ) .unwrap(); for pixel in 0..image.red.len() { image.red[pixel] = u8::try_from((pixel * 17) & 255).unwrap(); image.green[pixel] = u8::try_from((pixel * 29 + 3) & 255).unwrap(); image.blue[pixel] = u8::try_from((pixel * 43 + 7) & 255).unwrap(); image.alpha[pixel] = u8::try_from((pixel * 11 + 101) & 255).unwrap(); } image } #[test] fn lossless_codestream_and_jp2_round_trip_all_channels() { let source = rgba(17, 9); for format in [J2kFormat::Codestream, J2kFormat::Jp2] { let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default().with_format(format)) .expect("encode"); let decoded = J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default()).expect("decode"); assert_eq!(decoded, source); } } #[test] fn alpha_only_substitution_matches_managed_image_creator() { let mut source = ManagedImage::new(2, 1, ManagedImageImageChannels::ALPHA).unwrap(); source.alpha.copy_from_slice(&[17, 231]); let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap(); let decoded = J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default()).unwrap(); assert_eq!(decoded.red, [17, 231]); assert_eq!(decoded.green, [17, 231]); assert_eq!(decoded.blue, [17, 231]); assert_eq!(decoded.alpha, [255, 255]); } #[test] fn discard_levels_reduce_dimensions_and_stream_boundary_is_bounded() { let source = rgba(64, 32); let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap(); let reduced = J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default().with_discard_levels(1)) .unwrap(); assert_eq!((reduced.width, reduced.height), (32, 16)); let codec = J2kCodec::new(J2kDecodeOptions::default().with_limits(8, 64)); assert_eq!( codec.decode(Box::new(Cursor::new(encoded))), Err(Error::Argument) ); let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap(); assert_eq!( J2kCodec::decode_bytes( &encoded, J2kDecodeOptions::default().with_limits(encoded.len(), 128), ), Err(Error::Argument) ); } #[test] fn invalid_data_and_lossy_settings_fail_without_panicking() { assert!(matches!( J2kCodec::decode_bytes(b"not jpeg2000", J2kDecodeOptions::default()), Err(Error::Parse { .. }) )); let source = rgba(2, 2); assert_eq!( J2kCodec::encode( &source, J2kEncodeOptions::default().with_compression(J2kCompression::Lossy { compression_ratio: f32::NAN, }), ), Err(Error::Argument) ); assert!(matches!( J2kCodec::decode_bytes( b"\0\0\0\x0cjP \r\n\x87\n\0\0\0", J2kDecodeOptions::default(), ), Err(Error::Parse { .. }) )); assert_eq!( J2kCodec::encode( &source, J2kEncodeOptions::default().with_max_encoded_bytes(32), ), Err(Error::Argument) ); } #[test] fn lossy_mode_preserves_layout_with_bounded_sample_error() { let source = rgba(64, 64); let encoded = J2kCodec::encode( &source, J2kEncodeOptions::default() .with_format(J2kFormat::Jp2) .with_compression(J2kCompression::Lossy { compression_ratio: 8.0, }), ) .expect("lossy encode"); let decoded = J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default().with_quality_layers(1)) .expect("lossy decode"); assert_eq!( (decoded.width, decoded.height, decoded.channels), (source.width, source.height, source.channels) ); let total_error: u64 = source .red .iter() .chain(&source.green) .chain(&source.blue) .chain(&source.alpha) .zip( decoded .red .iter() .chain(&decoded.green) .chain(&decoded.blue) .chain(&decoded.alpha), ) .map(|(expected, actual)| u64::from(expected.abs_diff(*actual))) .sum(); let samples = u64::try_from(source.red.len() * 4).unwrap(); assert!( total_error > 0, "lossy mode unexpectedly reconstructed exactly" ); assert!(total_error / samples < 32, "mean sample error is too high"); } #[test] fn openjpeg_2_5_4_golden_retains_sixteen_bit_samples() { // Generated by OpenJPEG 2.5.4 `opj_compress` from the deterministic // 64x64 unsigned 16-bit gradient asserted below. const GOLDEN_HEX: &str = "ff4fff5100290000000000400000004000000000000000000000004000000040000000000000000000010f0101ff52000c00000001000504040001ff5c00134080888890888890888890888890888890ff640025000143726561746564206279204f70656e4a5045472076657273696f6e20322e352e34ff90000a00000000011c0001ff93dff890500c58e2753ee5f7dd26b3c7fe1811000fa8120007ce080d020629b05f7dbf0c199c5f0c44c3ff0306c001f3858000f90180221a085d6dcb5f4686076b4a7f00d09a4b7f01627fc1ff4005e000f9c440007c80c036a199ae63a3628986621949a89ff8bdd889db3a01813f439e44760735fc3f3e871dc0ff0278001f20f80007c2385fa7c788a00db0790644473e98d48fd92b5faff3c71e10932fde0e8f81be8b5bafccacfdd7cf9f9eab0ab177092bc3e079cc1037c0679d9aecfa3be703c07f2670001f09700007438b6201aba8dfc3791d615480235541001a680dce59014e5d9a88d45e9373f9519239613060c182ef7f7816874a3e947ceeaf7fe19cdb5b3899ff0636ff7fe18c4fffd9"; let encoded = GOLDEN_HEX .as_bytes() .chunks_exact(2) .map(|pair| { let pair = std::str::from_utf8(pair).unwrap(); u8::from_str_radix(pair, 16).unwrap() }) .collect::>(); let decoded = J2kCodec::decode_interleaved(&encoded, J2kDecodeOptions::default()) .expect("decode OpenJPEG golden"); assert_eq!((decoded.width(), decoded.height()), (64, 64)); assert_eq!(decoded.number_of_components(), 1); let component = &decoded.components()[0]; assert_eq!((component.precision(), component.is_signed()), (16, false)); let expected = (0..64) .flat_map(|y| (0..64).map(move |x| (x * 65_535 / 64) ^ ((y * 3) & 0xffff))) .collect::>(); assert_eq!(component.samples(), expected); } }