From e9ccfcfc30950fa242957d881195bb495abaf649 Mon Sep 17 00:00:00 2001 From: Craig Bester Date: Wed, 7 Sep 2022 22:38:00 +0200 Subject: [PATCH] Add webp-encoder --- .github/workflows/rust.yml | 4 +- Cargo.toml | 7 +- README.md | 2 +- src/codecs/webp/encoder.rs | 238 +++++++++++++++++++++++++++++++++++++ src/codecs/webp/mod.rs | 25 +++- src/image.rs | 8 +- src/io/free_functions.rs | 4 + src/lib.rs | 4 +- 8 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 src/codecs/webp/encoder.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8bf3f1eea8..01bbd3284d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: rust: ["1.56.1", stable, beta, nightly] - features: [gif, jpeg, png, tiff, ico, pnm, tga, webp, bmp, hdr, dxt, dds, farbfeld, openexr, jpeg_rayon, ''] + features: [gif, jpeg, png, tiff, ico, pnm, tga, webp, bmp, hdr, dxt, dds, farbfeld, openexr, jpeg_rayon, webp-encoder, ''] steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - features: [gif, jpeg, png, tiff, ico, pnm, tga, webp, bmp, hdr, dxt, dds, farbfeld, openexr, jpeg_rayon, ''] + features: [gif, jpeg, png, tiff, ico, pnm, tga, webp, bmp, hdr, dxt, dds, farbfeld, openexr, jpeg_rayon, webp-encoder, ''] # we are using the cross project for cross compilation to mips: # https://github.com/cross-rs/cross diff --git a/Cargo.toml b/Cargo.toml index f0dbdacbce..34225950ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ dav1d = { version = "0.6.0", optional = true } dcv-color-primitives = { version = "0.4.0", optional = true } color_quant = "1.1" exr = { version = "1.5.0", optional = true } +libwebp = { package = "webp", version = "0.2.2", default-features = false, optional = true } [dev-dependencies] crc32fast = "1.2.0" @@ -64,7 +65,6 @@ default = ["gif", "jpeg", "ico", "png", "pnm", "tga", "tiff", "webp", "bmp", "hd ico = ["bmp", "png"] pnm = [] tga = [] -webp = [] bmp = [] hdr = ["scoped_threadpool"] dxt = [] @@ -72,6 +72,11 @@ dds = ["dxt"] farbfeld = [] openexr = ["exr"] +# Enables WebP decoder support. +webp = [] +# Non-default, not included in `webp`. Requires native dependency libwebp. +webp-encoder = ["libwebp"] + # Enables multi-threading. # Requires latest stable Rust. jpeg_rayon = ["jpeg/rayon"] diff --git a/README.md b/README.md index af4acd930c..2d13325c88 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ All image processing functions provided operate on types that implement the `Gen | BMP | Yes | Rgb8, Rgba8, Gray8, GrayA8 | | ICO | Yes | Yes | | TIFF | Baseline(no fax support) + LZW + PackBits | Rgb8, Rgba8, Gray8 | -| WebP | Yes | No | +| WebP | Yes | Rgb8, Rgba8 | | AVIF | Only 8-bit | Lossy | | PNM | PBM, PGM, PPM, standard PAM | Yes | | DDS | DXT1, DXT3, DXT5 | No | diff --git a/src/codecs/webp/encoder.rs b/src/codecs/webp/encoder.rs new file mode 100644 index 0000000000..6ecef9364d --- /dev/null +++ b/src/codecs/webp/encoder.rs @@ -0,0 +1,238 @@ +//! Encoding of WebP images. +/// +/// Uses the simple encoding API from the [libwebp] library. +/// +/// [libwebp]: https://developers.google.com/speed/webp/docs/api#simple_encoding_api +use std::io::Write; + +use libwebp::{Encoder, PixelLayout, WebPMemory}; + +use crate::error::{ + EncodingError, ParameterError, ParameterErrorKind, UnsupportedError, UnsupportedErrorKind, +}; +use crate::{ColorType, ImageEncoder, ImageError, ImageFormat, ImageResult}; + +/// WebP Encoder. +pub struct WebPEncoder { + inner: W, + quality: WebPQuality, +} + +/// WebP encoder quality. +#[derive(Debug, Copy, Clone)] +pub struct WebPQuality(Quality); + +#[derive(Debug, Copy, Clone)] +enum Quality { + Lossless, + Lossy(u8), +} + +impl WebPQuality { + /// Minimum lossy quality value (0). + pub const MIN: u8 = 0; + /// Maximum lossy quality value (100). + pub const MAX: u8 = 100; + /// Default lossy quality (80), providing a balance of quality and file size. + pub const DEFAULT: u8 = 80; + + /// Lossless encoding. + pub fn lossless() -> Self { + Self(Quality::Lossless) + } + + /// Lossy encoding. 0 = low quality, small size; 100 = high quality, large size. + /// + /// Values are clamped from 0 to 100. + pub fn lossy(quality: u8) -> Self { + Self(Quality::Lossy(quality.clamp(Self::MIN, Self::MAX))) + } +} + +impl Default for WebPQuality { + fn default() -> Self { + Self::lossy(WebPQuality::DEFAULT) + } +} + +impl WebPEncoder { + /// Create a new encoder that writes its output to `w`. + /// + /// Defaults to lossy encoding, see [`WebPQuality::DEFAULT`]. + pub fn new(w: W) -> Self { + WebPEncoder::new_with_quality(w, WebPQuality::default()) + } + + /// Create a new encoder with the specified quality, that writes its output to `w`. + pub fn new_with_quality(w: W, quality: WebPQuality) -> Self { + Self { inner: w, quality } + } + + /// Encode image data with the indicated color type. + /// + /// The encoder requires image data be Rgb8 or Rgba8. + pub fn encode( + mut self, + data: &[u8], + width: u32, + height: u32, + color: ColorType, + ) -> ImageResult<()> { + // TODO: convert color types internally? + let (layout, stride) = match color { + ColorType::Rgb8 => (PixelLayout::Rgb, 3), + ColorType::Rgba8 => (PixelLayout::Rgba, 4), + _ => { + return Err(ImageError::Unsupported( + UnsupportedError::from_format_and_kind( + ImageFormat::WebP.into(), + UnsupportedErrorKind::Color(color.into()), + ), + )) + } + }; + + // Validate dimensions upfront to avoid panics. + let expected_len = stride * (width * height) as u64; + if expected_len > data.len() as u64 { + return Err(ImageError::Parameter(ParameterError::from_kind( + ParameterErrorKind::DimensionMismatch, + ))); + } + + // Call the native libwebp library to encode the image. + let encoder = Encoder::new(data, layout, width, height); + let encoded: WebPMemory = match self.quality.0 { + Quality::Lossless => encoder.encode_lossless(), + Quality::Lossy(quality) => encoder.encode(quality as f32), + }; + + // The simple encoding API in libwebp does not return errors. + if encoded.is_empty() { + return Err(ImageError::Encoding(EncodingError::new( + ImageFormat::WebP.into(), + "encoding failed, output empty", + ))); + } + + self.inner.write_all(&encoded)?; + Ok(()) + } +} + +impl ImageEncoder for WebPEncoder { + fn write_image( + self, + buf: &[u8], + width: u32, + height: u32, + color_type: ColorType, + ) -> ImageResult<()> { + self.encode(buf, width, height, color_type) + } +} + +#[cfg(test)] +mod tests { + use crate::codecs::webp::{WebPEncoder, WebPQuality}; + use crate::{ColorType, ImageEncoder}; + + #[test] + fn webp_lossless_deterministic() { + // 1x1 8-bit image buffer containing a single red pixel. + let rgb: &[u8] = &[255, 0, 0]; + let rgba: &[u8] = &[255, 0, 0, 128]; + for (color, img, expected) in [ + ( + ColorType::Rgb8, + rgb, + [ + 82, 73, 70, 70, 28, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 76, 15, 0, 0, 0, 47, + 0, 0, 0, 0, 7, 16, 253, 143, 254, 7, 34, 162, 255, 1, 0, + ], + ), + ( + ColorType::Rgba8, + rgba, + [ + 82, 73, 70, 70, 28, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 76, 15, 0, 0, 0, 47, + 0, 0, 0, 16, 7, 16, 253, 143, 2, 6, 34, 162, 255, 1, 0, + ], + ), + ] { + // Encode it into a memory buffer. + let mut encoded_img = Vec::new(); + { + let encoder = + WebPEncoder::new_with_quality(&mut encoded_img, WebPQuality::lossless()); + encoder + .write_image(&img, 1, 1, color) + .expect("image encoding failed"); + } + + // WebP encoding should be deterministic. + assert_eq!(encoded_img, expected); + } + } + + #[derive(Debug, Clone)] + struct MockImage { + width: u32, + height: u32, + color: ColorType, + data: Vec, + } + + impl quickcheck::Arbitrary for MockImage { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + // Limit to small, non-empty images <= 512x512. + let width = u32::arbitrary(g) % 512 + 1; + let height = u32::arbitrary(g) % 512 + 1; + let (color, stride) = if bool::arbitrary(g) { + (ColorType::Rgb8, 3) + } else { + (ColorType::Rgba8, 4) + }; + let size = width * height * stride; + let data: Vec = (0..size).map(|_| u8::arbitrary(g)).collect(); + MockImage { + width, + height, + color, + data, + } + } + } + + quickcheck! { + fn fuzz_webp_valid_image(image: MockImage, quality: u8) -> bool { + // Check valid images do not panic. + let mut buffer = Vec::::new(); + for webp_quality in [WebPQuality::lossless(), WebPQuality::lossy(quality)] { + buffer.clear(); + let encoder = WebPEncoder::new_with_quality(&mut buffer, webp_quality); + if !encoder + .write_image(&image.data, image.width, image.height, image.color) + .is_ok() { + return false; + } + } + true + } + + fn fuzz_webp_no_panic(data: Vec, width: u8, height: u8, quality: u8) -> bool { + // Check random (usually invalid) parameters do not panic. + let mut buffer = Vec::::new(); + for color in [ColorType::Rgb8, ColorType::Rgba8] { + for webp_quality in [WebPQuality::lossless(), WebPQuality::lossy(quality)] { + buffer.clear(); + let encoder = WebPEncoder::new_with_quality(&mut buffer, webp_quality); + // Ignore errors. + let _ = encoder + .write_image(&data, width as u32, height as u32, color); + } + } + true + } + } +} diff --git a/src/codecs/webp/mod.rs b/src/codecs/webp/mod.rs index 99b94a4f37..b38faedfce 100644 --- a/src/codecs/webp/mod.rs +++ b/src/codecs/webp/mod.rs @@ -1,15 +1,28 @@ -//! Decoding of WebP Images +//! Decoding and Encoding of WebP Images +#[cfg(feature = "webp-encoder")] +pub use self::encoder::{WebPEncoder, WebPQuality}; + +#[cfg(feature = "webp-encoder")] +mod encoder; + +#[cfg(feature = "webp")] pub use self::decoder::WebPDecoder; +#[cfg(feature = "webp")] mod decoder; -mod loop_filter; -mod transform; - +#[cfg(feature = "webp")] +mod extended; +#[cfg(feature = "webp")] mod huffman; +#[cfg(feature = "webp")] +mod loop_filter; +#[cfg(feature = "webp")] mod lossless; +#[cfg(feature = "webp")] mod lossless_transform; +#[cfg(feature = "webp")] +mod transform; -mod extended; - +#[cfg(feature = "webp")] pub mod vp8; diff --git a/src/image.rs b/src/image.rs index 59c00a4c39..678e652b0b 100644 --- a/src/image.rs +++ b/src/image.rs @@ -217,7 +217,7 @@ impl ImageFormat { ImageFormat::Pnm => true, ImageFormat::Farbfeld => true, ImageFormat::Avif => true, - ImageFormat::WebP => false, + ImageFormat::WebP => true, ImageFormat::Hdr => false, ImageFormat::OpenExr => true, ImageFormat::Dds => false, @@ -302,6 +302,10 @@ pub enum ImageOutputFormat { /// An image in AVIF Format Avif, + #[cfg(feature = "webp-encoder")] + /// An image in WebP Format. + WebP, + /// A value for signalling an error: An unsupported format was requested // Note: When TryFrom is stabilized, this value should not be needed, and // a TryInto should be used instead of an Into. @@ -334,6 +338,8 @@ impl From for ImageOutputFormat { #[cfg(feature = "avif-encoder")] ImageFormat::Avif => ImageOutputFormat::Avif, + #[cfg(feature = "webp-encoder")] + ImageFormat::WebP => ImageOutputFormat::WebP, f => ImageOutputFormat::Unsupported(format!("{:?}", f)), } diff --git a/src/io/free_functions.rs b/src/io/free_functions.rs index 5c3dd91f28..fcb492fe33 100644 --- a/src/io/free_functions.rs +++ b/src/io/free_functions.rs @@ -242,6 +242,10 @@ pub(crate) fn write_buffer_impl( ImageOutputFormat::Avif => { avif::AvifEncoder::new(buffered_write).write_image(buf, width, height, color) } + #[cfg(feature = "webp-encoder")] + ImageOutputFormat::WebP => { + webp::WebPEncoder::new(buffered_write).write_image(buf, width, height, color) + } image::ImageOutputFormat::Unsupported(msg) => Err(ImageError::Unsupported( UnsupportedError::from_format_and_kind( diff --git a/src/lib.rs b/src/lib.rs index 41d7d95a30..1f764fa4ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -185,7 +185,7 @@ pub mod flat; /// | BMP | Yes | Rgb8, Rgba8, Gray8, GrayA8 | /// | ICO | Yes | Yes | /// | TIFF | Baseline(no fax support) + LZW + PackBits | Rgb8, Rgba8, Gray8 | -/// | WebP | Yes | No | +/// | WebP | Yes | Rgb8, Rgba8 | /// | AVIF | Only 8-bit | Lossy | /// | PNM | PBM, PGM, PPM, standard PAM | Yes | /// | DDS | DXT1, DXT3, DXT5 | No | @@ -244,7 +244,7 @@ pub mod codecs { pub mod tga; #[cfg(feature = "tiff")] pub mod tiff; - #[cfg(feature = "webp")] + #[cfg(any(feature = "webp", feature = "webp-encoder"))] pub mod webp; }