529 lines
23 KiB
Rust
529 lines
23 KiB
Rust
//! 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<u16>,
|
||
}
|
||
|
||
/// 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<Vec<(u8, Vec<u8>)>> {
|
||
let mut segs: Vec<(u8, Vec<u8>)> = 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<Vec<u8>> {
|
||
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<Vec<u8>> {
|
||
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::<Vec<_>>().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<Vec<u8>> {
|
||
img.validate()?;
|
||
let n = img.width as usize * img.height as usize * img.channels as usize;
|
||
let (bytes, depth): (Vec<u8>, 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<Vec<u8>> {
|
||
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<T: Copy>(s: &[T], n: usize) -> Vec<T> {
|
||
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<Vec<u8>> {
|
||
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<Vec<u8>> {
|
||
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<ravif::RGBA8> = 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<Vec<u8>> {
|
||
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<u16> = 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<Vec<u8>> {
|
||
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)
|
||
}
|