Browse / github.com/storytold/lightcraft / patch
LR-IMP-FORMATS: decode AVIF on import (pure-Rust AV1 decoder)
AVIF support Please add AVIF support Import export and editing.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- emmertex (imported from pull request #3017, open)
- Base
387bed199fca44d76af5aa1c24dd7cc3de4c8db2- Size
- +918 −5 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c2e1e62c2e717b870991537b
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/codecs/Cargo.toml crates/codecs/src/avif.rs crates/codecs/src/lib.rs crates/codecs/src/sniff.rs crates/codecs/tests/it/roundtrip.rs crates/engine/src/import.rs
@@ -1177,6 +1177,11 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]]+name = "craft-media"+version = "0.1.0"+source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"+ [[package]] name = "craft-ui" version = "0.1.0"@@ -1926,6 +1931,24 @@ dependencies = [ "libc", ] +[[package]]+name = "filmcraft-av1"+version = "0.13.0"+source = "git+https://github.com/storytold/filmcraft?rev=de529c88f4948d0803bef746a325707f2671a71d#de529c88f4948d0803bef746a325707f2671a71d"+dependencies = [+ "filmcraft-bitstream",+ "rayon",+ "thiserror 2.0.21",+]++[[package]]+name = "filmcraft-bitstream"+version = "0.13.0"+source = "git+https://github.com/storytold/filmcraft?rev=de529c88f4948d0803bef746a325707f2671a71d#de529c88f4948d0803bef746a325707f2671a71d"+dependencies = [+ "craft-media",+]+ [[package]] name = "find-msvc-tools" version = "0.1.14"@@ -3342,6 +3365,7 @@ dependencies = [ "avif-serialize", "bytemuck", "dhat",+ "filmcraft-av1", "image", "image-webp", "jixel",@@ -13,6 +13,10 @@ lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-heif = { workspace = true, optional = true }+# AVIF decode: clean-room pure-Rust AV1 decoder (MIT OR Apache-2.0, no unsafe). Native only: on+# wasm32 (panic = abort) a malformed file could take the page down instead of becoming an error,+# so the web build refuses AVIF.+filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "de529c88f4948d0803bef746a325707f2671a71d" } serde = { workspace = true } bytemuck = { workspace = true } thiserror = { workspace = true }new file mode 100644@@ -0,0 +1,875 @@+//! AVIF (AV1 Image File Format), read-only.+//!+//! The container is HEIF (ISO/IEC 23008-12) over ISO-BMFF; we read the boxes ourselves (`meta`,+//! `pitm`, `iloc`, `iinf`, `iref`, `iprp`/`ipco`/`ipma`, `idat`), and the AV1 still picture is+//! decoded by `filmcraft-av1`, a clean-room pure-Rust AV1 decoder (no `unsafe`). Supported: the+//! primary item as one `av01` image or a `grid` of them; 8-, 10- and 12-bit; 4:2:0, 4:2:2, 4:4:4+//! and monochrome; full or limited range; the `nclx` colour description (or the AV1 sequence+//! header's) and an ICC profile (`prof` / `rICC`); `clap`, `irot` and `imir` are applied (so the+//! reported orientation is 1, as for HEIC). PQ and HLG files decode to linear+//! light with SDR white (203 cd/m²) at 1.0, highlights above it kept. Alpha (an auxiliary image)+//! is ignored: the colour image is shown as if over black, as Lightroom shows it. EXIF and XMP+//! items aren't read here yet. Native only: on wasm32 a panic aborts instead of unwinding, so the web build refuses AVIF rather than risk the decoder.+//!+//! Sources: ISO/IEC 23008-12 (HEIF) and the AV1 Image File Format specification v1.1.0 (box+//! layouts), ITU-T H.273 (colour code points), ITU-R BT.2100 (PQ / HLG); own design.++use crate::convert::{Buf, Meta, Model, Raw, finish};+use crate::{DecodeOptions, Decoded, Error, Format, Result};++const F: Format = Format::Avif;+/// Deepest box nesting we follow (meta → iprp → ipco → property is 4).+const MAX_DEPTH: u32 = 8;+/// Most grid tiles / items / properties we accept: far above real files, bounds hostile ones.+const MAX_ITEMS: usize = 1 << 16;++fn bad(m: impl Into<String>) -> Error {+ Error::Malformed(F, m.into())+}++/// A big-endian reader over a byte slice that never panics.+struct Rd<'a> {+ b: &'a [u8],+ p: usize,+}++impl<'a> Rd<'a> {+ fn new(b: &'a [u8]) -> Self {+ Rd { b, p: 0 }+ }+ fn take(&mut self, n: usize) -> Result<&'a [u8]> {+ let end = self.p.checked_add(n).ok_or_else(|| bad("box overflows"))?;+ let s = self.b.get(self.p..end).ok_or_else(|| bad("box truncated"))?;+ self.p = end;+ Ok(s)+ }+ fn u8(&mut self) -> Result<u8> {+ Ok(self.take(1)?[0])+ }+ fn u16(&mut self) -> Result<u16> {+ let s = self.take(2)?;+ Ok(u16::from_be_bytes([s[0], s[1]]))+ }+ fn u32(&mut self) -> Result<u32> {+ let s = self.take(4)?;+ Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]]))+ }+ /// An unsigned big-endian number of `n` (0, 1, 2, 4 or 8) bytes.+ fn uint(&mut self, n: usize) -> Result<u64> {+ if n > 8 {+ return Err(bad("field too wide"));+ }+ Ok(self.take(n)?.iter().fold(0u64, |a, &x| (a << 8) | u64::from(x)))+ }+ fn cstr(&mut self) -> Result<&'a [u8]> {+ let rest = self.b.get(self.p..).unwrap_or(&[]);+ let n = rest.iter().position(|&c| c == 0).unwrap_or(rest.len());+ let s = self.take(n)?;+ if self.p < self.b.len() {+ self.p += 1;+ }+ Ok(s)+ }+ fn rest(&self) -> &'a [u8] {+ self.b.get(self.p..).unwrap_or(&[])+ }+}++/// The child boxes of `b`: (type, payload).+fn boxes(b: &[u8]) -> Result<Vec<([u8; 4], &[u8])>> {+ let mut out = Vec::new();+ let mut p = 0usize;+ while p < b.len() {+ let mut r = Rd::new(b.get(p..).unwrap_or(&[]));+ if r.b.len() < 8 {+ break; // trailing padding+ }+ let size32 = r.u32()?;+ let t = r.take(4)?;+ let ty = [t[0], t[1], t[2], t[3]];+ let (hdr, size) = match size32 {+ 0 => (8usize, r.b.len() as u64),+ 1 => (16, r.uint(8)?),+ s => (8, u64::from(s)),+ };+ let size = usize::try_from(size).map_err(|_| bad("box too large"))?;+ if size < hdr || size > r.b.len() {+ return Err(bad(format!("box {} has a bad size", String::from_utf8_lossy(&ty))));+ }+ out.push((ty, r.b.get(hdr..size).unwrap_or(&[])));+ if out.len() > MAX_ITEMS {+ return Err(bad("too many boxes"));+ }+ p = p.checked_add(size).ok_or_else(|| bad("box overflows"))?;+ }+ Ok(out)+}++#[derive(Clone, Debug, Default)]+struct Extent {+ offset: u64,+ length: u64,+}++#[derive(Clone, Debug, Default)]+struct Loc {+ method: u8,+ base: u64,+ extents: Vec<Extent>,+}++#[derive(Clone, Copy, Debug, PartialEq)]+struct Nclx {+ primaries: u16,+ transfer: u16,+ matrix: u16,+ full_range: bool,+}++#[derive(Clone, Copy, Debug)]+enum Transform {+ /// Clean aperture: width, height, horizontal and vertical offset of the centre (rationals as f64).+ Clap { w: f64, h: f64, dx: f64, dy: f64 },+ /// Anticlockwise rotation by `n` × 90°.+ Rot(u8),+ /// Mirror, as HEIF amendment 2, libheif and libavif read it: 0 = top and bottom exchanged, 1 = left and right exchanged.+ Mir(u8),+}++#[derive(Clone, Debug)]+enum Prop {+ Av1c(Vec<u8>),+ Nclx(Nclx),+ Icc(Vec<u8>),+ Ispe(u32, u32),+ T(Transform),+ Other,+}++#[derive(Default)]+struct Container<'a> {+ file: &'a [u8],+ primary: u32,+ types: Vec<(u32, [u8; 4], Option<String>)>,+ locs: Vec<(u32, Loc)>,+ idat: &'a [u8],+ props: Vec<Prop>,+ /// item → 1-based property indices, in order.+ assoc: Vec<(u32, Vec<usize>)>,+ /// (type, from, to…)+ refs: Vec<([u8; 4], u32, Vec<u32>)>,+ /// `iloc` extents read so far, across all items (capped at [`MAX_ITEMS`]).+ extents: usize,+}++fn parse(file: &[u8]) -> Result<Container<'_>> {+ let top = boxes(file)?;+ let meta = top.iter().find(|(t, _)| t == b"meta").map(|(_, b)| *b).ok_or_else(|| {+ if top.iter().any(|(t, _)| t == b"moov") { Error::Unsupported(F, "an AVIF image sequence (animated AVIF)") } else { bad("no meta box") }+ })?;+ let mut c = Container { file, ..Default::default() };+ let meta = meta.get(4..).ok_or_else(|| bad("meta box truncated"))?;+ for (t, b) in boxes(meta)? {+ let mut r = Rd::new(b);+ match &t {+ b"pitm" => {+ let v = r.u8()?;+ r.take(3)?;+ c.primary = if v == 0 { u32::from(r.u16()?) } else { r.u32()? };+ }+ b"iloc" => parse_iloc(&mut r, &mut c)?,+ b"iinf" => {+ let v = r.u8()?;+ r.take(3)?;+ let _count = if v == 0 { u32::from(r.u16()?) } else { r.u32()? };+ for (t, b) in boxes(r.rest())? {+ if &t != b"infe" {+ continue;+ }+ let mut r = Rd::new(b);+ let v = r.u8()?;+ r.take(3)?;+ if v < 2 {+ continue;+ }+ let id = if v == 2 { u32::from(r.u16()?) } else { r.u32()? };+ r.u16()?; // protection index+ let ty = r.take(4)?;+ let ty = [ty[0], ty[1], ty[2], ty[3]];+ let _name = r.cstr()?;+ let mime = (&ty == b"mime").then(|| r.cstr().map(|s| String::from_utf8_lossy(s).into_owned()).ok()).flatten();+ c.types.push((id, ty, mime));+ if c.types.len() > MAX_ITEMS {+ return Err(bad("too many items"));+ }+ }+ }+ b"iref" => {+ let v = r.u8()?;+ r.take(3)?;+ for (t, b) in boxes(r.rest())? {+ let mut r = Rd::new(b);+ let id = |r: &mut Rd| if v == 0 { r.u16().map(u32::from) } else { r.u32() };+ let from = id(&mut r)?;+ let n = r.u16()?;+ let mut to = Vec::with_capacity(usize::from(n));+ for _ in 0..n {+ to.push(id(&mut r)?);+ }+ c.refs.push((t, from, to));+ }+ }+ b"idat" => c.idat = b,+ b"iprp" => parse_iprp(b, &mut c, 0)?,+ _ => {}+ }+ }+ if c.primary == 0 {+ return Err(bad("no primary item"));+ }+ Ok(c)+}++fn parse_iloc(r: &mut Rd, c: &mut Container) -> Result<()> {+ let v = r.u8()?;+ r.take(3)?;+ if v > 2 {+ return Err(Error::Unsupported(F, "iloc version"));+ }+ let a = r.u8()?;+ let b = r.u8()?;+ let (off_size, len_size, base_size) = (usize::from(a >> 4), usize::from(a & 15), usize::from(b >> 4));+ let index_size = if v >= 1 { usize::from(b & 15) } else { 0 };+ let count = if v < 2 { u32::from(r.u16()?) } else { r.u32()? };+ for _ in 0..count {+ let id = if v < 2 { u32::from(r.u16()?) } else { r.u32()? };+ let method = if v >= 1 { (r.u16()? & 15) as u8 } else { 0 };+ r.u16()?; // data reference index+ let base = r.uint(base_size)?;+ let n = r.u16()?;+ let mut extents = Vec::with_capacity(usize::from(n).min(64));+ for _ in 0..n {+ r.uint(index_size)?;+ let offset = r.uint(off_size)?;+ let length = r.uint(len_size)?;+ extents.push(Extent { offset, length });+ // zero-width offset/length fields read no bytes, so the file size alone doesn't bound this+ c.extents += 1;+ if c.extents > MAX_ITEMS {+ return Err(bad("too many item extents"));+ }+ }+ c.locs.push((id, Loc { method, base, extents }));+ if c.locs.len() > MAX_ITEMS {+ return Err(bad("too many items"));+ }+ }+ Ok(())+}++fn parse_iprp(b: &[u8], c: &mut Container, depth: u32) -> Result<()> {+ if depth > MAX_DEPTH {+ return Err(bad("boxes nested too deep"));+ }+ for (t, b) in boxes(b)? {+ match &t {+ b"ipco" => {+ for (t, b) in boxes(b)? {+ c.props.push(parse_prop(&t, b)?);+ if c.props.len() > MAX_ITEMS {+ return Err(bad("too many properties"));+ }+ }+ }+ b"ipma" => {+ let mut r = Rd::new(b);+ let v = r.u8()?;+ let flags = r.take(3)?;+ let wide = flags.get(2).is_some_and(|f| f & 1 != 0);+ let n = r.u32()?;+ for _ in 0..n {+ let id = if v == 0 { u32::from(r.u16()?) } else { r.u32()? };+ let k = r.u8()?;+ let mut list = Vec::with_capacity(usize::from(k));+ for _ in 0..k {+ let idx = if wide { usize::from(r.u16()? & 0x7fff) } else { usize::from(r.u8()? & 0x7f) };+ list.push(idx);+ }+ c.assoc.push((id, list));+ if c.assoc.len() > MAX_ITEMS {+ return Err(bad("too many associations"));+ }+ }+ }+ _ => {}+ }+ }+ Ok(())+}++fn parse_prop(t: &[u8; 4], b: &[u8]) -> Result<Prop> {+ let mut r = Rd::new(b);+ Ok(match t {+ b"av1C" => Prop::Av1c(b.get(4..).unwrap_or(&[]).to_vec()),+ b"colr" => {+ let kind = r.take(4)?;+ match kind {+ b"nclx" => Prop::Nclx(Nclx { primaries: r.u16()?, transfer: r.u16()?, matrix: r.u16()?, full_range: r.u8()? & 0x80 != 0 }),+ b"prof" | b"rICC" => Prop::Icc(r.rest().to_vec()),+ _ => Prop::Other,+ }+ }+ b"ispe" => {+ r.take(4)?;+ Prop::Ispe(r.u32()?, r.u32()?)+ }+ b"irot" => Prop::T(Transform::Rot(r.u8()? & 3)),+ b"imir" => Prop::T(Transform::Mir(r.u8()? & 1)),+ b"clap" => {+ let mut q = || -> Result<f64> {+ let n = r.u32()? as i32;+ let d = r.u32()? as i32;+ if d == 0 {+ return Err(bad("clap with a zero denominator"));+ }+ Ok(f64::from(n) / f64::from(d))+ };+ let (w, h, dx, dy) = (q()?, q()?, q()?, q()?);+ Prop::T(Transform::Clap { w, h, dx, dy })+ }+ _ => Prop::Other,+ })+}++impl Container<'_> {+ fn item_type(&self, id: u32) -> Option<[u8; 4]> {+ self.types.iter().find(|t| t.0 == id).map(|t| t.1)+ }+ fn props_of(&self, id: u32) -> impl Iterator<Item = &Prop> {+ self.assoc.iter().filter(move |a| a.0 == id).flat_map(|a| a.1.iter()).filter_map(|&i| i.checked_sub(1).and_then(|i| self.props.get(i)))+ }+ fn data(&self, id: u32) -> Result<Vec<u8>> {+ let loc = self.locs.iter().find(|l| l.0 == id).map(|l| &l.1).ok_or_else(|| bad(format!("item {id} has no location")))?;+ let src = match loc.method {+ 0 => self.file,+ 1 => self.idat,+ _ => return Err(Error::Unsupported(F, "item data by reference to another item")),+ };+ let mut out = Vec::new();+ for e in &loc.extents {+ let start = loc.base.checked_add(e.offset).ok_or_else(|| bad("item offset overflows"))?;+ let start = usize::try_from(start).map_err(|_| bad("item offset out of range"))?;+ let end = if e.length == 0 {+ src.len()+ } else {+ usize::try_from(e.length).ok().and_then(|l| start.checked_add(l)).ok_or_else(|| bad("item length overflows"))?+ };+ let chunk = src.get(start..end).ok_or_else(|| bad("item data runs past the end of the file"))?;+ // Every byte comes from the file, so a real item is never larger than it; repeated or+ // overlapping extents that add up to more are refused before they allocate.+ if out.len().saturating_add(chunk.len()) > self.file.len() {+ return Err(bad("item data is larger than the file"));+ }+ out.extend_from_slice(chunk);+ }+ Ok(out)+ }+ fn refs_from(&self, kind: &[u8; 4], from: u32) -> Vec<u32> {+ self.refs.iter().filter(|r| &r.0 == kind && r.1 == from).flat_map(|r| r.2.iter().copied()).collect()+ }+ fn ispe(&self, id: u32) -> Option<(u32, u32)> {+ self.props_of(id).find_map(|p| if let Prop::Ispe(w, h) = p { Some((*w, *h)) } else { None })+ }+ fn transforms(&self, id: u32) -> Vec<Transform> {+ self.props_of(id).filter_map(|p| if let Prop::T(t) = p { Some(*t) } else { None }).collect()+ }+}++/// The size after `clap` / `irot`, from a stored size.+fn displayed(mut w: u32, mut h: u32, ts: &[Transform]) -> (u32, u32) {+ for t in ts {+ match *t {+ Transform::Clap { w: cw, h: ch, .. } => {+ if cw >= 1.0 && ch >= 1.0 {+ w = (cw.round() as u32).min(w).max(1);+ h = (ch.round() as u32).min(h).max(1);+ }+ }+ Transform::Rot(n) if n % 2 == 1 => std::mem::swap(&mut w, &mut h),+ _ => {}+ }+ }+ (w, h)+}++fn primary_size(c: &Container) -> Result<(u32, u32)> {+ let id = c.primary;+ match c.item_type(id) {+ Some(t) if &t == b"av01" || &t == b"grid" => {}+ Some(_) => return Err(Error::Unsupported(F, "the primary item is not an AV1 image or a grid")),+ None => return Err(bad("the primary item is not described")),+ }+ let (w, h) = match c.ispe(id) {+ Some(s) => s,+ None if c.item_type(id) == Some(*b"grid") => grid_header(&c.data(id)?)?.2,+ None => return Err(bad("the primary item has no size (ispe)")),+ };+ if w == 0 || h == 0 {+ return Err(bad("zero dimension"));+ }+ Ok((w, h))+}++/// The displayed size (container transforms applied, so orientation 1), from the boxes alone.+pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {+ let c = parse(bytes)?;+ let (w, h) = primary_size(&c)?;+ let (w, h) = displayed(w, h, &c.transforms(c.primary));+ Ok((w, h, 1))+}++/// (rows, columns, output size) of a `grid` item.+fn grid_header(d: &[u8]) -> Result<(u32, u32, (u32, u32))> {+ let mut r = Rd::new(d);+ r.u8()?; // version+ let flags = r.u8()?;+ let rows = u32::from(r.u8()?) + 1;+ let cols = u32::from(r.u8()?) + 1;+ let n = if flags & 1 != 0 { 4 } else { 2 };+ let w = r.uint(n)? as u32;+ let h = r.uint(n)? as u32;+ Ok((rows, cols, (w, h)))+}++/// One decoded AV1 picture with the colour facts we need.+struct Pic {+ w: usize,+ h: usize,+ bits: u8,+ ssx: usize,+ ssy: usize,+ mono: bool,+ planes: [Vec<u16>; 3],+ nclx: Nclx,+}++fn decode_av1(c: &Container, id: u32) -> Result<Pic> {+ let data = c.data(id)?;+ let mut dec = filmcraft_av1::Decoder::new();+ let e = |e: filmcraft_av1::Error| match e {+ filmcraft_av1::Error::Unsupported(w) => Error::Unsupported(F, w),+ other => bad(format!("AV1: {other}")),+ };+ // The sequence header normally travels in the item too; the av1C copy covers files where it doesn't.+ let config = c.props_of(id).find_map(|p| if let Prop::Av1c(o) = p { Some(o.clone()) } else { None });+ let mut pics = Vec::new();+ if let Some(cfg) = config.filter(|o| !o.is_empty()) {+ pics.extend(dec.decode(&cfg).map_err(e)?);+ }+ // The decoder refuses frames above the AV1 level 6.3 size before allocating; `max_pixels`+ // applies to the decoded frame (the sequence maximum may exceed the real frame size).+ pics.extend(dec.decode(&data).map_err(e)?);+ pics.extend(dec.flush_result().map_err(e)?);+ let p = pics.into_iter().next().ok_or_else(|| bad("the AV1 data holds no picture"))?;+ let (w, h) = (p.width as usize, p.height as usize);+ let (ssx, ssy) = (usize::from(p.subsampling_x.min(1)), usize::from(p.subsampling_y.min(1)));+ let need = w.checked_mul(h).ok_or_else(|| bad("picture too large"))?;+ let cneed = ((w + ssx) >> ssx) * ((h + ssy) >> ssy);+ if need == 0 || p.planes[0].len() < need || (!p.mono_chrome && (p.planes[1].len() < cneed || p.planes[2].len() < cneed)) {+ return Err(bad("AV1 picture planes are short"));+ }+ let nclx = Nclx {+ primaries: u16::from(p.color_primaries),+ transfer: u16::from(p.transfer_characteristics),+ matrix: u16::from(p.matrix_coefficients),+ full_range: p.full_range,+ };+ Ok(Pic { w, h, bits: p.bit_depth.clamp(8, 16), ssx, ssy, mono: p.mono_chrome, planes: p.planes, nclx })+}++/// Kr, Kb of an H.273 matrix code point; `None` = identity (GBR).+fn coefficients(matrix: u16) -> Option<(f32, f32)> {+ match matrix {+ 0 => None,+ 1 => Some((0.2126, 0.0722)),+ 4 => Some((0.30, 0.11)),+ 7 => Some((0.212, 0.087)),+ 9 | 10 => Some((0.2627, 0.0593)),+ // 5, 6 (BT.601), 2 (unspecified: AVIF readers assume BT.601) and the rest+ _ => Some((0.299, 0.114)),+ }+}++/// A chroma sample at luma position (x, y), bilinear between chroma sites centred on their luma pairs.+#[inline]+fn chroma_at(plane: &[u16], cw: usize, ch: usize, ssx: usize, ssy: usize, x: usize, y: usize) -> f32 {+ let pos = |v: usize, ss: usize, n: usize| -> (usize, usize, f32) {+ if ss == 0 {+ return (v.min(n - 1), v.min(n - 1), 0.0);+ }+ let f = (v as f32 + 0.5) / 2.0 - 0.5;+ let f = f.max(0.0);+ let i = f as usize;+ let i0 = i.min(n - 1);+ (i0, (i + 1).min(n - 1), f - i as f32)+ };+ let (x0, x1, fx) = pos(x, ssx, cw);+ let (y0, y1, fy) = pos(y, ssy, ch);+ let s = |xx: usize, yy: usize| f32::from(plane.get(yy * cw + xx).copied().unwrap_or(0));+ let top = s(x0, y0) * (1.0 - fx) + s(x1, y0) * fx;+ let bot = s(x0, y1) * (1.0 - fx) + s(x1, y1) * fx;+ top * (1.0 - fy) + bot * fy+}++/// YCbCr → non-linear R'G'B' 0..1 (may slightly exceed it), interleaved.+fn to_rgb(p: &Pic, nclx: Nclx) -> Vec<f32> {+ let max = ((1u32 << p.bits) - 1) as f32;+ let scale = f32::from(1u16 << (p.bits - 8).min(8));+ let (yoff, yrange, crange) = if nclx.full_range { (0.0, max, max) } else { (16.0 * scale, 219.0 * scale, 224.0 * scale) };+ let mid = f32::from(1u16 << (p.bits - 1).min(15));+ let (cw, ch) = ((p.w + p.ssx) >> p.ssx, (p.h + p.ssy) >> p.ssy);+ let k = coefficients(nclx.matrix);+ let mut out = vec![0f32; p.w * p.h * 3];+ for y in 0..p.h {+ for x in 0..p.w {+ let i = y * p.w + x;+ let luma = (f32::from(p.planes[0].get(i).copied().unwrap_or(0)) - yoff) / yrange;+ let (cb, cr) = if p.mono {+ (0.0, 0.0)+ } else {+ (+ (chroma_at(&p.planes[1], cw, ch, p.ssx, p.ssy, x, y) - mid) / crange,+ (chroma_at(&p.planes[2], cw, ch, p.ssx, p.ssy, x, y) - mid) / crange,+ )+ };+ let rgb = match k {+ // identity: Y = G, Cb = B, Cr = R (no offsets on the "chroma" planes)+ None => {+ let g = luma;+ let b = cb + mid / crange - yoff / yrange;+ let r = cr + mid / crange - yoff / yrange;+ [r, g, b]+ }+ Some((kr, kb)) => {+ let kg = 1.0 - kr - kb;+ let r = luma + 2.0 * (1.0 - kr) * cr;+ let b = luma + 2.0 * (1.0 - kb) * cb;+ let g = (luma - kr * r - kb * b) / kg;+ [r, g, b]+ }+ };+ out[i * 3..i * 3 + 3].copy_from_slice(&rgb);+ }+ }+ out+}++/// Apply the container's transforms in order to an interleaved 3-channel image.+fn apply_transforms(mut v: Vec<f32>, mut w: usize, mut h: usize, ts: &[Transform]) -> (Vec<f32>, usize, usize) {+ for t in ts {+ match *t {+ Transform::Clap { w: cw, h: ch, dx, dy } => {+ if !(cw >= 1.0 && ch >= 1.0 && cw.is_finite() && ch.is_finite() && dx.is_finite() && dy.is_finite()) {+ continue;+ }+ let nw = (cw.round() as usize).clamp(1, w);+ let nh = (ch.round() as usize).clamp(1, h);+ // centre of the aperture = image centre + offset+ let x0 = ((w as f64 - 1.0) / 2.0 + dx - (nw as f64 - 1.0) / 2.0).round().clamp(0.0, (w - nw) as f64) as usize;+ let y0 = ((h as f64 - 1.0) / 2.0 + dy - (nh as f64 - 1.0) / 2.0).round().clamp(0.0, (h - nh) as f64) as usize;+ let mut o = Vec::with_capacity(nw * nh * 3);+ for y in y0..y0 + nh {+ o.extend_from_slice(&v[(y * w + x0) * 3..(y * w + x0 + nw) * 3]);+ }+ (v, w, h) = (o, nw, nh);+ }+ Transform::Rot(n) => {+ for _ in 0..n {+ // 90° anticlockwise: (x, y) → (y, w-1-x), new size h × w+ let mut o = vec![0f32; v.len()];+ for y in 0..h {+ for x in 0..w {+ let (nx, ny) = (y, w - 1 - x);+ let d = (ny * h + nx) * 3;+ let s = (y * w + x) * 3;+ o[d..d + 3].copy_from_slice(&v[s..s + 3]);+ }+ }+ (v, w, h) = (o, h, w);+ }+ }+ Transform::Mir(axis) => {+ for y in 0..h {+ for x in 0..w {+ let (sx, sy) = if axis == 0 { (x, h - 1 - y) } else { (w - 1 - x, y) };+ if (sy * w + sx) > (y * w + x) {+ for c in 0..3 {+ v.swap((y * w + x) * 3 + c, (sy * w + sx) * 3 + c);+ }+ }+ }+ }+ }+ }+ }+ (v, w, h)+}++pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {+ let c = parse(bytes)?;+ let (sw, sh) = primary_size(&c)?;+ if u64::from(sw).saturating_mul(u64::from(sh)) > opts.max_pixels {+ return Err(Error::TooLarge(u64::from(sw), u64::from(sh)));+ }+ let id = c.primary;+ let file_nclx = c.props_of(id).find_map(|p| if let Prop::Nclx(n) = p { Some(*n) } else { None });+ let icc = c.props_of(id).find_map(|p| if let Prop::Icc(b) = p { Some(b.clone()) } else { None });+ let check = |p: &Pic| -> Result<()> {+ if (p.w as u64).saturating_mul(p.h as u64) > opts.max_pixels {+ return Err(Error::TooLarge(p.w as u64, p.h as u64));+ }+ Ok(())+ };++ let (rgb, w, h, bits, nclx) = if c.item_type(id) == Some(*b"grid") {+ let (rows, cols, (gw, gh)) = grid_header(&c.data(id)?)?;+ let tiles = c.refs_from(b"dimg", id);+ if tiles.len() as u64 != u64::from(rows) * u64::from(cols) || tiles.is_empty() {+ return Err(bad("grid tile count doesn't match its rows and columns"));+ }+ let (gw, gh) = (gw as usize, gh as usize);+ if gw == 0 || gh == 0 || (gw as u64).saturating_mul(gh as u64) > opts.max_pixels {+ return Err(Error::TooLarge(gw as u64, gh as u64));+ }+ let mut out = vec![0f32; gw * gh * 3];+ let (mut tw, mut th, mut bits, mut nclx) = (0usize, 0usize, 8u8, None);+ for (i, &t) in tiles.iter().enumerate() {+ let p = decode_av1(&c, t)?;+ check(&p)?;+ if i == 0 {+ (tw, th, bits) = (p.w, p.h, p.bits);+ nclx = Some(file_nclx.unwrap_or(p.nclx));+ } else if (p.w, p.h) != (tw, th) {+ return Err(bad("grid tiles differ in size"));+ }+ let Some(n) = nclx else { return Err(bad("grid without tiles")) };+ let tile = to_rgb(&p, n);+ let (r, col) = (i / cols as usize, i % cols as usize);+ for y in 0..th {+ let oy = r * th + y;+ if oy >= gh {+ break;+ }+ for x in 0..tw {+ let ox = col * tw + x;+ if ox >= gw {+ break;+ }+ let (d, s) = ((oy * gw + ox) * 3, (y * tw + x) * 3);+ out[d..d + 3].copy_from_slice(&tile[s..s + 3]);+ }+ }+ }+ let n = nclx.ok_or_else(|| bad("grid without tiles"))?;+ (out, gw, gh, bits, n)+ } else {+ let p = decode_av1(&c, id)?;+ check(&p)?;+ let n = file_nclx.unwrap_or(p.nclx);+ (to_rgb(&p, n), p.w, p.h, p.bits, n)+ };+ let (rgb, w, h) = apply_transforms(rgb, w, h, &c.transforms(id));++ let hdr = icc.is_none() && matches!(nclx.transfer, 16 | 18);+ let (buf, meta) = if hdr {+ // PQ / HLG: linear light with SDR white at 1.0, kept above it.+ let k = coefficients(9).unwrap_or((0.2627, 0.0593));+ let lin: Vec<f32> = if nclx.transfer == 16 {+ rgb.iter().map(|&e| crate::encode::pq_decode(e) / crate::encode::HDR_REFERENCE_WHITE_NITS).collect()+ } else {+ let mut v = Vec::with_capacity(rgb.len());+ for c3 in rgb.as_chunks::<3>().0 {+ // inverse OETF, then the OOTF (system gamma 1.2 on luminance), display light / SDR white+ let s = [c3[0], c3[1], c3[2]].map(|e| lightcraft_color::transfer::hlg_decode(f64::from(e.clamp(0.0, 1.0))) as f32);+ let ys = k.0 * s[0] + (1.0 - k.0 - k.1) * s[1] + k.1 * s[2];+ let g = if ys > 0.0 { ys.powf(0.2) } else { 0.0 };+ let f = crate::encode::HLG_REFERENCE_PEAK_NITS / crate::encode::HDR_REFERENCE_WHITE_NITS;+ v.extend(s.map(|e| e * g * f));+ }+ v+ };+ let space = crate::space::SourceSpace::from_cicp(nclx.primaries, 8)+ .or_else(|| crate::space::SourceSpace::from_cicp(9, 8))+ .ok_or_else(|| bad("no linear colour space"))?;+ (Buf::F32(lin), Meta { orientation: Some(1), already_linear: Some(space), ..Default::default() })+ } else {+ let v: Vec<u16> = rgb.iter().map(|&e| (e.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();+ let hint = crate::space::SourceSpace::from_cicp(nclx.primaries, nclx.transfer);+ (Buf::U16(v), Meta { icc, orientation: Some(1), hint, ..Default::default() })+ };+ let raw = Raw { width: w, height: h, model: Model::Rgb, alpha: false, premultiplied: false, buf, bit_depth: bits };+ let (dw, dh) = displayed(sw, sh, &c.transforms(id));+ finish(F, raw, meta, (dw, dh), opts)+}++#[cfg(all(test, feature = "avif", not(target_arch = "wasm32")))]+mod tests {+ use super::*;+ use crate::{EncodeImage, EncodeMeta, HdrTransfer, Samples, encode_avif, encode_avif_hdr};++ /// A smooth sRGB test card with a saturated band, 8-bit RGB.+ fn card(w: usize, h: usize) -> Vec<u8> {+ (0..w * h).flat_map(|i| [(i % w * 255 / (w - 1)) as u8, (i / w * 255 / (h - 1)) as u8, if i % w < w / 4 { 230 } else { 40 }]).collect()+ }++ fn mean_abs_diff(a: &[u8], b: &[u8]) -> f64 {+ a.iter().zip(b).map(|(&x, &y)| (f64::from(x) - f64::from(y)).abs()).sum::<f64>() / a.len() as f64+ }++ fn rgb8(d: &Decoded) -> Vec<u8> {+ d.to_srgb8().data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect()+ }++ #[test]+ fn eight_bit_round_trip() {+ let (w, h) = (48usize, 32usize);+ let px = card(w, h);+ let file = encode_avif(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), 95, 8, &EncodeMeta::default()).unwrap();+ assert_eq!(crate::read_header(&file).unwrap().width, w as u32);+ let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();+ assert_eq!((d.width, d.height, d.orientation, d.format), (w as u32, h as u32, 1, Format::Avif));+ let diff = mean_abs_diff(&px, &rgb8(&d));+ assert!(diff < 4.0, "mean difference {diff}");+ }++ #[test]+ fn ten_bit_round_trip() {+ let (w, h) = (40usize, 24usize);+ let px: Vec<u16> = card(w, h).iter().map(|&v| u16::from(v) * 257).collect();+ let file = encode_avif(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&px)), 95, 8, &EncodeMeta::default()).unwrap();+ let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();+ assert_eq!(d.bit_depth, 10);+ let diff = mean_abs_diff(&card(w, h), &rgb8(&d));+ assert!(diff < 4.0, "mean difference {diff}");+ }++ #[test]+ fn hdr_round_trips_keep_highlights_above_white() {+ let (w, h) = (32usize, 16usize);+ // linear Rec. 2020: left half grey 0.18, right half 4 × SDR white+ let rgb: Vec<f32> = (0..w * h).flat_map(|i| if i % w < w / 2 { [0.18f32; 3] } else { [4.0; 3] }).collect();+ for t in [HdrTransfer::Pq, HdrTransfer::Hlg] {+ let file = encode_avif_hdr(w as u32, h as u32, &rgb, 98, 8, &EncodeMeta::default(), t).unwrap();+ let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();+ let lin = d.to_working();+ let at = |x: usize| lin.data[8 * w + x][1];+ assert!((at(4) / 0.18 - 1.0).abs() < 0.08, "{t:?}: grey {}", at(4));+ assert!((at(w - 4) / 4.0 - 1.0).abs() < 0.08, "{t:?}: highlight {}", at(w - 4));+ }+ }++ #[test]+ fn downscaled_and_size_limited_decodes() {+ let px = card(64, 64);+ let file = encode_avif(&EncodeImage::new(64, 64, 3, Samples::U8(&px)), 80, 10, &EncodeMeta::default()).unwrap();+ let d = crate::decode(&file, crate::DecodeOptions::fit(16, 16)).unwrap();+ assert_eq!((d.width, d.height, d.source_width), (16, 16, 64));+ let small = crate::DecodeOptions { max_pixels: 100, ..Default::default() };+ assert!(matches!(crate::decode(&file, small), Err(Error::TooLarge(64, 64))));+ }++ #[test]+ fn transforms_rotate_mirror_and_crop() {+ // 3 × 2 image, pixel value = index+ let v: Vec<f32> = (0..6).flat_map(|i| [i as f32; 3]).collect();+ let g = |v: &[f32]| v.chunks(3).map(|c| c[0] as u8).collect::<Vec<_>>();+ let (r, w, h) = apply_transforms(v.clone(), 3, 2, &[Transform::Rot(1)]);+ // anticlockwise: the right column becomes the top row+ assert_eq!((w, h, g(&r)), (2, 3, vec![2, 5, 1, 4, 0, 3]));+ let (m, ..) = apply_transforms(v.clone(), 3, 2, &[Transform::Mir(0)]);+ // axis 0 exchanges top and bottom, axis 1 left and right (libheif's and libavif's reading)+ assert_eq!(g(&m), vec![3, 4, 5, 0, 1, 2]);+ let (m, ..) = apply_transforms(v.clone(), 3, 2, &[Transform::Mir(1)]);+ assert_eq!(g(&m), vec![2, 1, 0, 5, 4, 3]);+ let (c, w, h) = apply_transforms(v, 3, 2, &[Transform::Clap { w: 1.0, h: 1.0, dx: 1.0, dy: 0.0 }]);+ assert_eq!((w, h), (1, 1));+ assert!(g(&c)[0] == 2 || g(&c)[0] == 5);+ assert_eq!(displayed(3, 2, &[Transform::Rot(3)]), (2, 3));+ }++ #[test]+ fn hostile_files_are_errors_not_panics() {+ let px = card(16, 16);+ let file = encode_avif(&EncodeImage::new(16, 16, 3, Samples::U8(&px)), 60, 10, &EncodeMeta::default()).unwrap();+ // every truncation: the box parser never panics, and a decode is an error or an image+ for n in 0..file.len() {+ let _ = header(&file[..n]);+ let _ = crate::decode_unguarded(&file[..n], Format::Avif, &crate::DecodeOptions::default());+ }+ // byte flips through the container and the AV1 data+ for i in (0..file.len()).step_by(3) {+ for x in [0x01u8, 0x80, 0xff] {+ let mut m = file.clone();+ m[i] ^= x;+ let _ = header(&m);+ let _ = crate::decode(&m, crate::DecodeOptions::default());+ }+ }+ // box sizes that point far outside the file+ let mut b = b"\0\0\0\x14ftypavif\0\0\0\0avif".to_vec();+ b.extend_from_slice(b"\xff\xff\xff\xffmeta\0\0\0\0");+ assert!(crate::decode(&b, crate::DecodeOptions::default()).is_err());+ let mut b = b"\0\0\0\x14ftypavif\0\0\0\0avif".to_vec();+ b.extend_from_slice(b"\0\0\0\x01meta\xff\xff\xff\xff\xff\xff\xff\xff");+ assert!(header(&b).is_err());+ }++ #[test]+ fn an_animated_avif_is_unsupported() {+ let b = b"\0\0\0\x14ftypavis\0\0\0\0avis\0\0\0\x08moov";+ assert!(matches!(header(b), Err(Error::Unsupported(Format::Avif, _))));+ }++ /// A box of type `t` around `body`.+ fn bx(t: &[u8; 4], body: &[u8]) -> Vec<u8> {+ let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();+ v.extend_from_slice(t);+ v.extend_from_slice(body);+ v+ }++ #[test]+ fn zero_width_iloc_extents_are_capped() {+ // iloc v2 with zero-byte offset/length/base/index fields: each extent costs no input bytes+ let mut iloc = vec![2, 0, 0, 0, 0, 0];+ iloc.extend_from_slice(&3u32.to_be_bytes());+ for id in 1..=3u32 {+ iloc.extend_from_slice(&id.to_be_bytes());+ iloc.extend_from_slice(&[0, 0, 0, 0, 0xff, 0xff]); // method, data ref, 65535 extents+ }+ let mut meta = vec![0, 0, 0, 0];+ meta.extend(bx(b"iloc", &iloc));+ let mut b = bx(b"ftyp", b"avif\0\0\0\0avif");+ b.extend(bx(b"meta", &meta));+ assert!(matches!(header(&b), Err(Error::Malformed(Format::Avif, m)) if m.contains("extents")));+ }++ #[test]+ fn item_data_larger_than_the_file_is_refused() {+ // one item whose 1000 extents each repeat the whole file (length 0 = to the end)+ let mut iloc = vec![1, 0, 0, 0, 0x44, 0];+ iloc.extend_from_slice(&1u16.to_be_bytes());+ iloc.extend_from_slice(&[0, 1, 0, 0, 0, 0]); // id 1, method 0, data ref 0+ iloc.extend_from_slice(&1000u16.to_be_bytes());+ for _ in 0..1000 {+ iloc.extend_from_slice(&[0; 8]);+ }+ let mut meta = vec![0, 0, 0, 0];+ meta.extend(bx(b"pitm", &[0, 0, 0, 0, 0, 1]));+ meta.extend(bx(b"iloc", &iloc));+ let mut b = bx(b"ftyp", b"avif\0\0\0\0avif");+ b.extend(bx(b"meta", &meta));+ let c = parse(&b).unwrap();+ assert!(matches!(c.data(1), Err(Error::Malformed(Format::Avif, m)) if m.contains("larger than the file")));+ }+}@@ -18,6 +18,8 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(not(target_arch = "wasm32"))]+mod avif; mod convert; pub mod display; pub mod encode;@@ -181,7 +183,7 @@ pub struct Header { } /// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with-/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read+/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD, HEIF and AVIF are read /// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to /// a small decode. ///@@ -207,6 +209,8 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> { Format::WebP => webp::header(bytes)?, Format::Psd => psd::header(bytes)?, Format::Heif => heif::header(bytes)?,+ #[cfg(not(target_arch = "wasm32"))]+ Format::Avif => avif::header(bytes)?, // rare here (or, for JPEG XL, without a header reader yet): a small decode, as before _ => { let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;@@ -242,7 +246,11 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R #[cfg(not(feature = "jxl"))] Format::Jxl => unsupported(), Format::Heif => heif::decode(bytes, &opts),- Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),+ #[cfg(not(target_arch = "wasm32"))]+ Format::Avif => avif::decode(bytes, &opts),+ #[cfg(target_arch = "wasm32")]+ Format::Avif => unsupported(),+ Format::RawTiffLike | Format::RawOther => unsupported(), } } @@ -75,7 +75,8 @@ impl Format { Format::Jxl => Some("built without the `jxl` feature"), #[cfg(not(feature = "heif"))] Format::Heif => Some(crate::heif::NOT_IN_BUILD),- Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"),+ #[cfg(target_arch = "wasm32")]+ Format::Avif => Some("AVIF isn't decoded in the web build"), Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"), _ => None, }@@ -609,7 +609,8 @@ fn avif_encode_native() { if cfg!(feature = "avif") { let b = r.unwrap(); assert_eq!(sniff(&b), Some(Format::Avif));- assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::Avif, _))));+ let d = decode(&b, DecodeOptions::default()).unwrap();+ assert_eq!((d.format, d.width, d.height), (Format::Avif, 64, 48)); } else { assert!(r.is_err()); }@@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[ "srw", "psd", "psb", "jxl", "gif", "bmp", // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build").- "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature+ "heic", "heif", "avif", // a HEIC without the `heif` feature is reported as failed "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", ];