//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and, //! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is //! embedded where the format allows. use crate::{Error, Result}; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; /// Interleaved sample data. Integer samples are display-encoded (0..=MAX); float samples are /// written as-is. #[derive(Clone, Copy, Debug)] pub enum Samples<'a> { U8(&'a [u8]), U16(&'a [u16]), F32(&'a [f32]), } /// An image to encode: `channels` = 1 (gray), 2 (gray + alpha), 3 (RGB) or 4 (RGBA), straight alpha. #[derive(Clone, Copy, Debug)] pub struct EncodeImage<'a> { pub width: u32, pub height: u32, pub channels: u8, pub samples: Samples<'a>, } impl<'a> EncodeImage<'a> { pub fn rgba8(img: &'a Rgba8) -> Self { EncodeImage { width: img.width as u32, height: img.height as u32, channels: 4, samples: Samples::U8(bytemuck::cast_slice(&img.data)) } } pub fn new(width: u32, height: u32, channels: u8, samples: Samples<'a>) -> Self { EncodeImage { width, height, channels, samples } } fn validate(&self) -> Result<()> { if !(1..=4).contains(&self.channels) { return Err(Error::Encode(format!("unsupported channel count {}", self.channels))); } if self.width == 0 || self.height == 0 { return Err(Error::Encode("zero dimension".into())); } let need = self.width as u64 * self.height as u64 * self.channels as u64; let have = match self.samples { Samples::U8(s) => s.len(), Samples::U16(s) => s.len(), Samples::F32(s) => s.len(), } as u64; if have < need { return Err(Error::Encode(format!("sample buffer too short: {have} < {need}"))); } Ok(()) } fn has_alpha(&self) -> bool { self.channels == 2 || self.channels == 4 } } /// Metadata to embed. #[derive(Clone, Copy, Debug, Default)] pub struct EncodeMeta<'a> { pub icc: Option<&'a [u8]>, /// TIFF-structured EXIF (starting at `II`/`MM`, no `Exif\0\0` prefix). pub exif: Option<&'a [u8]>, pub xmp: Option<&'a str>, /// Print resolution in pixels per inch (JPEG JFIF density, PNG `pHYs`, TIFF X/YResolution). pub ppi: Option, } /// JPEG chroma subsampling. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ChromaSubsampling { /// No subsampling (best quality). #[default] S444, S422, S420, } const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; /// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF, /// XMP and the ICC profile in chunks, in that order. pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result)>> { let mut segs: Vec<(u8, Vec)> = Vec::new(); let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| { let [hi, lo] = p.to_be_bytes(); [1, hi, lo, hi, lo] }); segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat())); if let Some(exif) = meta.exif { if exif.len() > 65_527 { return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); } segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat())); } if let Some(xmp) = meta.xmp { segs.push((0xE1, xmp_segment(xmp)?)); } if let Some(icc) = meta.icc { // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); if chunks.len() > 255 { return Err(Error::Encode("ICC profile too large for JPEG".into())); } for (i, c) in chunks.iter().enumerate() { segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat())); } } Ok(segs) } /// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment. pub(crate) fn xmp_segment(xmp: &str) -> Result> { let seg = [XMP_NS, xmp.as_bytes()].concat(); if seg.len() > 65_533 { return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); } Ok(seg) } /// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100. pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result> { img.validate()?; let Samples::U8(data) = img.samples else { return Err(Error::Encode("JPEG needs 8-bit samples".into())); }; if img.width > u16::MAX as u32 || img.height > u16::MAX as u32 { return Err(Error::Encode("JPEG dimensions are limited to 65535".into())); } let n = img.width as usize * img.height as usize; let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels { 1 => (data[..n].into(), jpeg_encoder::ColorType::Luma), 2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::>().into(), jpeg_encoder::ColorType::Luma), 3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb), _ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba), }; if subsampling != ChromaSubsampling::S422 { // Parallel encoder (restart-interval bands on all cores). let segs = jpeg_app_segments(meta)?; let ch = if img.channels == 2 { 1 } else { img.channels as usize }; // `data` is already gray for 2-channel input (see above) return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs)); } let mut out = Vec::new(); let mut enc = jpeg_encoder::Encoder::new(&mut out, quality.clamp(1, 100)); enc.set_sampling_factor(match subsampling { ChromaSubsampling::S444 => jpeg_encoder::SamplingFactor::R_4_4_4, ChromaSubsampling::S422 => jpeg_encoder::SamplingFactor::R_4_2_2, ChromaSubsampling::S420 => jpeg_encoder::SamplingFactor::R_4_2_0, }); // Standard tables: jpeg-encoder's optimized-table mode writes non-interleaved scans, which some // decoders (incl. zune-jpeg 0.5) mishandle. Interleaved baseline is universally supported. let e = |e: jpeg_encoder::EncodingError| Error::Encode(e.to_string()); if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { enc.set_density(jpeg_encoder::PixelDensity::dpi(ppi)); } if let Some(exif) = meta.exif { if exif.len() > 65_527 { return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); } enc.add_exif_metadata(exif).map_err(e)?; } if let Some(xmp) = meta.xmp { let mut seg = XMP_NS.to_vec(); seg.extend_from_slice(xmp.as_bytes()); if seg.len() > 65_533 { return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); } enc.add_app_segment(1, seg).map_err(e)?; } if let Some(icc) = meta.icc { enc.add_icc_profile(icc).map_err(e)?; } enc.encode(&data, img.width as u16, img.height as u16, ct).map_err(e)?; Ok(out) } /// Encode PNG (8- or 16-bit; float input is rejected — quantize first). pub fn encode_png(img: &EncodeImage, meta: &EncodeMeta) -> Result> { img.validate()?; let n = img.width as usize * img.height as usize * img.channels as usize; let (bytes, depth): (Vec, png::BitDepth) = match img.samples { Samples::U8(s) => (s[..n].to_vec(), png::BitDepth::Eight), Samples::U16(s) => (s[..n].iter().flat_map(|v| v.to_be_bytes()).collect(), png::BitDepth::Sixteen), Samples::F32(_) => return Err(Error::Encode("PNG needs 8- or 16-bit samples".into())), }; let mut info = png::Info::with_size(img.width, img.height); info.color_type = match img.channels { 1 => png::ColorType::Grayscale, 2 => png::ColorType::GrayscaleAlpha, 3 => png::ColorType::Rgb, _ => png::ColorType::Rgba, }; info.bit_depth = depth; info.icc_profile = meta.icc.map(|b| b.to_vec().into()); info.exif_metadata = meta.exif.map(|b| b.to_vec().into()); info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| { let ppm = (p as f64 / 0.0254).round() as u32; png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter } }); let mut out = Vec::new(); let e = |e: png::EncodingError| Error::Encode(e.to_string()); { let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?; enc.set_compression(png::Compression::Fast); if let Some(xmp) = meta.xmp { enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?; } let mut w = enc.write_header().map_err(e)?; w.write_image_data(&bytes).map_err(e)?; w.finish().map_err(e)?; } Ok(out) } /// TIFF compression. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum TiffCompression { None, Lzw, #[default] Deflate, PackBits, } /// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha. /// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`). pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result> { use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype}; use tiff::tags::Predictor; img.validate()?; let e = |e: tiff::TiffError| Error::Encode(e.to_string()); let mut cur = std::io::Cursor::new(Vec::new()); let comp = match compression { TiffCompression::None => Compression::Uncompressed, TiffCompression::Lzw => Compression::Lzw, TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Fast), TiffCompression::PackBits => Compression::Packbits, }; let float = matches!(img.samples, Samples::F32(_)); let predictor = if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None }; let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor); let (w, h) = (img.width, img.height); let n = w as usize * h as usize; // Gray + alpha is stored as RGBA (the encoder has no GrayA colour type). let ch = img.channels as usize; macro_rules! write { ($ct:ty, $data:expr, $alpha:expr) => {{ let mut im = enc.new_image::<$ct>(w, h).map_err(e)?; if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); } if let Some(icc) = meta.icc { im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?; } if let Some(xmp) = meta.xmp { im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?; } if $alpha { im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?; } im.write_data($data).map_err(e)?; }}; } fn expand_ga(s: &[T], n: usize) -> Vec { s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() } match img.samples { Samples::U8(s) => match ch { 1 => write!(colortype::Gray8, &s[..n], false), 2 => write!(colortype::RGBA8, &expand_ga(s, n), true), 3 => write!(colortype::RGB8, &s[..n * 3], false), _ => write!(colortype::RGBA8, &s[..n * 4], true), }, Samples::U16(s) => match ch { 1 => write!(colortype::Gray16, &s[..n], false), 2 => write!(colortype::RGBA16, &expand_ga(s, n), true), 3 => write!(colortype::RGB16, &s[..n * 3], false), _ => write!(colortype::RGBA16, &s[..n * 4], true), }, Samples::F32(s) => match ch { 1 => write!(colortype::Gray32Float, &s[..n], false), 2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true), 3 => write!(colortype::RGB32Float, &s[..n * 3], false), _ => write!(colortype::RGBA32Float, &s[..n * 4], true), }, } Ok(cur.into_inner()) } /// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container. pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result> { img.validate()?; let Samples::U8(s) = img.samples else { return Err(Error::Encode("WebP needs 8-bit samples".into())); }; let n = img.width as usize * img.height as usize * img.channels as usize; let ct = match img.channels { 1 => image_webp::ColorType::L8, 2 => image_webp::ColorType::La8, 3 => image_webp::ColorType::Rgb8, _ => image_webp::ColorType::Rgba8, }; let mut out = Vec::new(); let mut enc = image_webp::WebPEncoder::new(&mut out); if let Some(icc) = meta.icc { enc.set_icc_profile(icc.to_vec()); } if let Some(exif) = meta.exif { enc.set_exif_metadata(exif.to_vec()); } if let Some(xmp) = meta.xmp { enc.set_xmp_metadata(xmp.as_bytes().to_vec()); } enc.encode(&s[..n], img.width, img.height, ct).map_err(|e| Error::Encode(e.to_string()))?; Ok(out) } /// Encode AVIF (sRGB, 4:4:4, 10-bit AV1) with `ravif` (rav1e). 8-bit samples are widened by the /// encoder; 16-bit samples keep 10 bits of precision (BT.601 full-range YCbCr computed here). /// `quality` 1..=100, `speed` 1 (slow) ..= 10. EXIF is embedded; ICC is not supported by the muxer /// (the file is tagged sRGB via `nclx`). Returns [`Error::Encode`] for float samples, on wasm32 or /// when built without the `avif` feature. pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { img.validate()?; #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] { let mut enc = ravif::Encoder::new() .with_quality(quality.clamp(1, 100) as f32) .with_alpha_quality(quality.clamp(1, 100) as f32) .with_speed(speed.clamp(1, 10)); if let Some(exif) = meta.exif { enc = enc.with_exif(exif.to_vec()); } let s = match img.samples { Samples::U8(s) => s, Samples::U16(s) => return avif_10bit(&enc, img, s), Samples::F32(_) => return Err(Error::Encode("AVIF encoder needs 8- or 16-bit samples".into())), }; let n = img.width as usize * img.height as usize; let ch = img.channels as usize; let px: Vec = s .chunks_exact(ch) .take(n) .map(|c| match ch { 1 => ravif::RGBA8::new(c[0], c[0], c[0], 255), 2 => ravif::RGBA8::new(c[0], c[0], c[0], c[1]), 3 => ravif::RGBA8::new(c[0], c[1], c[2], 255), _ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]), }) .collect(); let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?; let _ = img.has_alpha(); Ok(r.avif_file) } #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] { let _ = (quality, speed, meta, img.has_alpha()); Err(Error::Encode("AVIF encoding is not available in this build".into())) } } /// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m². pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0; /// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1. pub fn pq_encode(nits: f32) -> f32 { const M1: f32 = 2610.0 / 16384.0; const M2: f32 = 2523.0 / 4096.0 * 128.0; const C1: f32 = 3424.0 / 4096.0; const C2: f32 = 2413.0 / 4096.0 * 32.0; const C3: f32 = 2392.0 / 4096.0 * 32.0; let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1); ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2) } /// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m². pub fn pq_decode(e: f32) -> f32 { const M1: f32 = 2610.0 / 16384.0; const M2: f32 = 2523.0 / 4096.0 * 128.0; const C1: f32 = 3424.0 / 4096.0; const C2: f32 = 2413.0 / 4096.0 * 32.0; const C3: f32 = 2392.0 / 4096.0 * 32.0; let p = e.clamp(0.0, 1.0).powf(1.0 / M2); ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0 } /// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0 /// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the /// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box. /// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only. pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { let (w, h) = (width as usize, height as usize); let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?; if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) { return Err(Error::Encode("HDR AVIF: sample buffer too short".into())); } #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] { use rav1e::prelude::*; const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL let mut max_nits = 0f32; let mut sum_nits = 0f64; let planes: Vec<[u16; 3]> = rgb .as_chunks::<3>() .0 .iter() .take(n) .map(|c| { let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS); let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]; max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2])); sum_nits += y_nits as f64; let [r, g, b] = nits.map(pq_encode); let y = K[0] * r + K[1] * g + K[2] * b; let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5; let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5; [y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16) }) .collect(); let q = quality.clamp(1, 100) as f32 / 100.0; // ravif's quality → quantizer curve, so qualities match the SDR AVIF path let x = if q >= 0.82 { (1.0 - q) * 2.6 } else if q > 0.25 { 1.0 - 0.125 - q * 0.5 } else { 1.0 - q }; let quantizer = (x * 255.0).round() as usize; let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10)); ec.width = w; ec.height = h; ec.time_base = Rational::new(1, 1); ec.bit_depth = 10; ec.chroma_sampling = ChromaSampling::Cs444; ec.pixel_range = PixelRange::Full; ec.color_description = Some(ColorDescription { color_primaries: ColorPrimaries::BT2020, transfer_characteristics: TransferCharacteristics::SMPTE2084, matrix_coefficients: MatrixCoefficients::BT2020NCL, }); let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16); ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 }); ec.still_picture = true; ec.quantizer = quantizer; ec.min_quantizer = quantizer.min(255) as u8; ec.speed_settings.multiref = false; ec.speed_settings.rdo_lookahead_frames = 1; ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None; let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}")); let mut ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?; let mut frame = ctx.new_frame(); { let mut it = planes.iter(); let mut fp = frame.planes.iter_mut(); let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else { return Err(Error::Encode("AV1: frame has no planes".into())); }; let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default())); for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) { for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) { let Some(p) = it.next() else { break }; (*y, *u, *v) = (p[0], p[1], p[2]); } } } ctx.send_frame(frame).map_err(|x| e(&x))?; ctx.flush(); let mut av1 = Vec::new(); loop { match ctx.receive_packet() { Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data), Ok(_) => {} Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break, Err(x) => return Err(e(&x)), } } use avif_serialize::constants as c; let mut a = avif_serialize::Aviffy::new(); a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl) .set_transfer_characteristics(c::TransferCharacteristics::Smpte2084) .set_color_primaries(c::ColorPrimaries::Bt2020) .set_full_color_range(true) .set_content_light_level(cll.0, cll.1); if let Some(exif) = meta.exif { a.set_exif(exif.to_vec()); } Ok(a.to_vec(&av1, None, width, height, 10)) } #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] { let _ = (quality, speed, meta); Err(Error::Encode("AVIF encoding is not available in this build".into())) } } /// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input). #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> { const K: [f32; 3] = [0.299, 0.587, 0.114]; let n = img.width as usize * img.height as usize; let ch = img.channels as usize; let rgb = |c: &[u16]| -> [f32; 3] { let v = |x: u16| x as f32 * (1023.0 / 65535.0); match ch { 1 | 2 => [v(c[0]); 3], _ => [v(c[0]), v(c[1]), v(c[2])], } }; let planes = s.chunks_exact(ch).take(n).map(|c| { let [r, g, b] = rgb(c); let y = K[0] * r + K[1] * g + K[2] * b; let cb = (b - y) * (0.5 / (1.0 - K[2])) + 512.0; let cr = (r - y) * (0.5 / (1.0 - K[0])) + 512.0; [y, cb, cr].map(|v| v.round().clamp(0.0, 1023.0) as u16) }); let alpha_at = |c: &[u16]| if img.has_alpha() { c[ch - 1] } else { u16::MAX }; let has_alpha = s.chunks_exact(ch).take(n).any(|c| alpha_at(c) != u16::MAX); let alpha = has_alpha.then(|| s.chunks_exact(ch).take(n).map(move |c| ((alpha_at(c) as u32 * 1023 + 32767) / 65535) as u16)); let r = enc .encode_raw_planes_10_bit(img.width as usize, img.height as usize, planes, alpha, ravif::PixelRange::Full, ravif::MatrixCoefficients::BT601) .map_err(|e| Error::Encode(e.to_string()))?; Ok(r.avif_file) }