diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -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 }
diff --git a/crates/codecs/src/avif.rs b/crates/codecs/src/avif.rs
new file mode 100644
--- /dev/null
+++ b/crates/codecs/src/avif.rs
@@ -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")));
+    }
+}
diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs
--- a/crates/codecs/src/lib.rs
+++ b/crates/codecs/src/lib.rs
@@ -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(),
     }
 }
 
diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs
--- a/crates/codecs/src/sniff.rs
+++ b/crates/codecs/src/sniff.rs
@@ -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,
         }
diff --git a/crates/codecs/tests/it/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs
--- a/crates/codecs/tests/it/roundtrip.rs
+++ b/crates/codecs/tests/it/roundtrip.rs
@@ -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());
     }
diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs
--- a/crates/engine/src/import.rs
+++ b/crates/engine/src/import.rs
@@ -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",
 ];
 
