use crate::traits::{DynamicImageTraitInfo, DynamicImageTraitOperation};
use anyhow::{Result, bail};
use image::{DynamicImage, ImageBuffer};
use libwebp_sys::{
VP8StatusCode, WebPBitstreamFeatures, WebPConfig, WebPDecodeRGB, WebPDecodeRGBA, WebPEncode, WebPFree,
WebPGetFeatures, WebPMemoryWrite, WebPMemoryWriter, WebPMemoryWriterClear, WebPMemoryWriterInit, WebPPicture,
WebPPictureFree, WebPPictureImportRGB, WebPPictureImportRGBA,
};
use versatiles_core::Blob;
use versatiles_derive::context;
#[context("encoding {}x{} {:?} as WebP (q={:?}, e={:?})", image.width(), image.height(), image.color(), quality, effort)]
pub fn encode(image: &DynamicImage, quality: Option<u8>, effort: Option<u8>) -> Result<Blob> {
if image.bits_per_value() != 8 {
bail!("webp only supports 8-bit images");
}
if (image.channel_count() != 3) && (image.channel_count() != 4) {
bail!("webp only supports RGB or RGBA images");
}
let mut image_ref = image;
#[allow(unused_assignments)]
let mut optional_image: Option<DynamicImage> = None;
if image.has_alpha() && image.is_opaque() {
let i = image.to_no_alpha()?;
optional_image = Some(i);
image_ref = optional_image.as_ref().expect("just assigned Some above");
}
let quality = quality.unwrap_or(95);
#[allow(clippy::cast_possible_wrap)]
let width = image_ref.width() as i32;
#[allow(clippy::cast_possible_wrap)]
let height = image_ref.height() as i32;
let data = image_ref.as_bytes();
let has_alpha = image_ref.has_alpha();
let effort = effort.unwrap_or(80);
if quality >= 100 {
encode_lossless(data, width, height, has_alpha, effort)
} else {
encode_lossy(data, width, height, has_alpha, quality, effort)
}
}
fn webp_encode_advanced(data: &[u8], width: i32, height: i32, has_alpha: bool, config: &WebPConfig) -> Result<Blob> {
unsafe {
let mut picture = WebPPicture::new().map_err(|()| anyhow::anyhow!("WebPPictureInit failed"))?;
picture.use_argb = 1;
picture.width = width;
picture.height = height;
let stride = if has_alpha { width * 4 } else { width * 3 };
let import_ok = if has_alpha {
WebPPictureImportRGBA(&raw mut picture, data.as_ptr(), stride)
} else {
WebPPictureImportRGB(&raw mut picture, data.as_ptr(), stride)
};
if import_ok == 0 {
WebPPictureFree(&raw mut picture);
bail!("WebP picture import failed");
}
let mut writer: WebPMemoryWriter = std::mem::zeroed();
WebPMemoryWriterInit(&raw mut writer);
picture.writer = Some(WebPMemoryWrite);
picture.custom_ptr = (&raw mut writer).cast::<std::ffi::c_void>();
let ok = WebPEncode(config, &raw mut picture);
WebPPictureFree(&raw mut picture);
if ok == 0 {
WebPMemoryWriterClear(&raw mut writer);
bail!("WebP encoding failed");
}
let result = std::slice::from_raw_parts(writer.mem, writer.size).to_vec();
WebPFree(writer.mem.cast::<std::ffi::c_void>());
Ok(Blob::from(result))
}
}
fn encode_lossy(data: &[u8], width: i32, height: i32, has_alpha: bool, quality: u8, effort: u8) -> Result<Blob> {
let mut config = WebPConfig::new().map_err(|()| anyhow::anyhow!("WebPConfigInit failed"))?;
config.lossless = 0;
config.quality = f32::from(quality);
#[allow(clippy::cast_possible_truncation)]
{
config.method = (f32::from(effort.clamp(0, 100)) / 100.0 * 6.0).round() as i32;
}
webp_encode_advanced(data, width, height, has_alpha, &config)
}
fn encode_lossless(data: &[u8], width: i32, height: i32, has_alpha: bool, effort: u8) -> Result<Blob> {
let mut config = WebPConfig::new().map_err(|()| anyhow::anyhow!("WebPConfigInit failed"))?;
config.lossless = 1;
config.exact = 1;
let (method, quality) = if effort == 100 {
(6, 100.0)
} else if effort >= 90 {
(5, 80.0)
} else if effort >= 80 {
(4, 70.0)
} else if effort >= 60 {
(2, 40.0)
} else if effort >= 40 {
(1, 30.0)
} else if effort >= 20 {
(1, 0.0)
} else if effort >= 10 {
(0, 50.0)
} else {
(0, 0.0)
};
config.method = method;
config.quality = quality;
webp_encode_advanced(data, width, height, has_alpha, &config)
}
#[context("encoding image {:?} as WebP (q={:?})", image.color(), quality)]
pub fn image2blob(image: &DynamicImage, quality: Option<u8>) -> Result<Blob> {
encode(image, quality, None)
}
#[context("encoding image {:?} as lossless WebP", image.color())]
pub fn image2blob_lossless(image: &DynamicImage) -> Result<Blob> {
encode(image, Some(100), None)
}
#[context("decoding WebP image ({} bytes)", blob.len())]
pub fn blob2image(blob: &Blob) -> Result<DynamicImage> {
let data = blob.as_slice();
if data.is_empty() {
bail!("Failed to decode WebP image: empty input");
}
unsafe {
let mut features: WebPBitstreamFeatures = std::mem::zeroed();
let status = WebPGetFeatures(data.as_ptr(), data.len(), &raw mut features);
if status != VP8StatusCode::VP8_STATUS_OK {
bail!("Failed to decode WebP image: invalid WebP data");
}
#[allow(clippy::cast_sign_loss)]
let width = features.width as u32;
#[allow(clippy::cast_sign_loss)]
let height = features.height as u32;
if features.has_alpha != 0 {
let mut out_width: i32 = 0;
let mut out_height: i32 = 0;
let ptr = WebPDecodeRGBA(data.as_ptr(), data.len(), &raw mut out_width, &raw mut out_height);
if ptr.is_null() {
bail!("Failed to decode WebP image: RGBA decoding failed");
}
let buf_size = (width as usize) * (height as usize) * 4;
let pixels = std::slice::from_raw_parts(ptr, buf_size).to_vec();
WebPFree(ptr.cast::<std::ffi::c_void>());
let buffer = ImageBuffer::from_raw(width, height, pixels)
.ok_or_else(|| anyhow::anyhow!("Failed to create RGBA image buffer"))?;
Ok(DynamicImage::ImageRgba8(buffer))
} else {
let mut out_width: i32 = 0;
let mut out_height: i32 = 0;
let ptr = WebPDecodeRGB(data.as_ptr(), data.len(), &raw mut out_width, &raw mut out_height);
if ptr.is_null() {
bail!("Failed to decode WebP image: RGB decoding failed");
}
let buf_size = (width as usize) * (height as usize) * 3;
let pixels = std::slice::from_raw_parts(ptr, buf_size).to_vec();
WebPFree(ptr.cast::<std::ffi::c_void>());
let buffer = ImageBuffer::from_raw(width, height, pixels)
.ok_or_else(|| anyhow::anyhow!("Failed to create RGB image buffer"))?;
Ok(DynamicImage::ImageRgb8(buffer))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::DynamicImageTraitTest;
use approx::assert_relative_eq;
use rstest::rstest;
#[rstest]
#[case::rgb( DynamicImage::new_test_rgb(), false, 0.95, vec![0.9, 0.5, 1.5] )]
#[case::rgba( DynamicImage::new_test_rgba(), false, 0.75, vec![0.9, 0.5, 1.5, 0.0])]
#[case::lossless_rgb( DynamicImage::new_test_rgb(), true, 0.04, vec![0.0, 0.0, 0.0] )]
#[case::lossless_rgba(DynamicImage::new_test_rgba(), true, 0.03, vec![0.0, 0.0, 0.0, 0.0])]
fn webp_ok(
#[case] img: DynamicImage,
#[case] lossless: bool,
#[case] expected_compression_percent: f64,
#[case] expected_diff: Vec<f64>,
) -> Result<()> {
let blob = if lossless {
image2blob_lossless(&img)?
} else {
image2blob(&img, None)?
};
assert_eq!(img.diff(&blob2image(&blob)?)?, expected_diff);
assert_relative_eq!(
((10000 * blob.len()) as f64 / img.as_bytes().len() as f64).round() / 100.0,
expected_compression_percent
);
Ok(())
}
#[rstest]
#[case::grey(DynamicImage::new_test_grey(), false, "webp only supports RGB or RGBA images")]
#[case::greya(DynamicImage::new_test_greya(), false, "webp only supports RGB or RGBA images")]
#[case::lossless_grey(DynamicImage::new_test_grey(), true, "webp only supports RGB or RGBA images")]
#[case::lossless_greya(DynamicImage::new_test_greya(), true, "webp only supports RGB or RGBA images")]
fn webp_errors(#[case] img: DynamicImage, #[case] lossless: bool, #[case] expected_msg: &str) {
let res = if lossless {
image2blob_lossless(&img)
} else {
image2blob(&img, None)
};
assert_eq!(res.unwrap_err().chain().last().unwrap().to_string(), expected_msg);
}
#[rstest]
#[case::rgba(DynamicImage::new_test_rgba())]
#[test]
fn opaque_is_saved_without_alpha(#[case] mut img: DynamicImage) -> Result<()> {
assert!(img.has_alpha());
img.make_opaque()?;
assert!(!blob2image(&encode(&img, Some(80), None)?)?.has_alpha());
assert!(!blob2image(&encode(&img, Some(100), None)?)?.has_alpha());
Ok(())
}
#[test]
fn encode_with_custom_quality() -> Result<()> {
let img = DynamicImage::new_test_rgb();
let blob_q50 = encode(&img, Some(50), None)?;
let blob_q95 = encode(&img, Some(95), None)?;
assert!(!blob_q50.is_empty());
assert!(!blob_q95.is_empty());
assert!(blob_q50.len() < blob_q95.len());
Ok(())
}
#[test]
fn encode_quality_boundary() -> Result<()> {
let img = DynamicImage::new_test_rgb();
let blob_lossy = encode(&img, Some(99), None)?;
let blob_lossless = encode(&img, Some(100), None)?;
assert!(!blob_lossy.is_empty());
assert!(!blob_lossless.is_empty());
Ok(())
}
#[test]
fn encode_default_quality() -> Result<()> {
let img = DynamicImage::new_test_rgb();
let blob_default = encode(&img, None, None)?;
let blob_95 = encode(&img, Some(95), None)?;
assert_eq!(blob_default.len(), blob_95.len());
Ok(())
}
#[test]
fn encode_non_8bit_image_fails() {
use image::{ImageBuffer, Rgb};
let img16: ImageBuffer<Rgb<u16>, Vec<u16>> = ImageBuffer::new(8, 8);
let dynamic_img = DynamicImage::from(img16);
let result = encode(&dynamic_img, None, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().chain().last().unwrap().to_string();
assert!(err_msg.contains("8-bit"), "Expected '8-bit' in: {err_msg}");
}
#[test]
fn blob2image_invalid_data() {
let blob = Blob::from(vec![1, 2, 3, 4, 5]);
let result = blob2image(&blob);
assert!(result.is_err());
}
#[test]
fn blob2image_empty_blob() {
let blob = Blob::from(vec![]);
let result = blob2image(&blob);
assert!(result.is_err());
}
}