Skip to main content

oxideav_avif/
encoder.rs

1//! AVIF `Encoder` trait wiring — registry-gated.
2//!
3//! Each frame sent through [`Encoder::send_frame`] encodes into one
4//! **complete AVIF file** (AVIF is a still-image container: the
5//! packet boundary is the file boundary), produced by the
6//! [`crate::still`] pipeline — AV1 KEY-frame encode via `oxideav_av1`
7//! plus this crate's container muxer. [`Encoder::receive_packet`]
8//! hands the file bytes back as a data packet carrying the frame's
9//! `pts`.
10//!
11//! # Input mapping
12//!
13//! [`CodecParameters`] must declare `width`, `height` and
14//! `pixel_format`. Supported formats:
15//!
16//! * Planar YUV, 8-bit: `Yuv420P` / `Yuv422P` / `Yuv444P` / `Gray8`,
17//!   plus the full-range `YuvJ420P` / `YuvJ422P` / `YuvJ444P`
18//!   variants (same plane layout; a full-range `colr` `nclx` is
19//!   attached and the coded stream signals §5.5.2 `color_range = 1`).
20//! * Planar YUV, high bit depth: `Yuv420P10Le` / `Yuv422P10Le` /
21//!   `Yuv444P10Le` / `Yuv420P12Le` / `Yuv422P12Le` / `Yuv444P12Le` /
22//!   `Gray10Le` / `Gray12Le` (little-endian 2-byte samples).
23//! * Packed RGB(A): `Rgb24` / `Rgba` / `Bgr24` / `Bgra` — at the
24//!   lossless quality coded via the H.273 identity matrix in 4:4:4
25//!   (byte-exact; see [`crate::still::StillImage::rgb8`]); at a lossy
26//!   quality converted to Y′CbCr (the signalled matrix, else the MIAF
27//!   default BT.601, at the signalled range, else full) and coded in
28//!   the `chroma` layout (4:2:0 by default, 4:4:4 at odd extents) —
29//!   the coding a lossy RGB picture is expected to land on (the
30//!   identity matrix spends bits on the un-decorrelated planes). The
31//!   alpha channel becomes an alpha auxiliary item either way.
32//! * Planar RGB(A): `Gbrp8` / `Gbrap8` / `Gbrp10Le` / `Gbrap10Le` /
33//!   `Gbrp12Le` / `Gbrap12Le` (planes G, B, R [, A] — the layout the
34//!   decoder labels an identity-matrix item with): coded as an
35//!   identity-matrix 4:4:4 item at the input depth, alpha as the
36//!   auxiliary.
37//! * Alpha-carrying planar: `Yuva420P` (four planes, full-resolution
38//!   alpha) and `Ya8` (interleaved luma + alpha).
39//!
40//! # Colour signal
41//!
42//! [`CodecParameters::color_signal`] (oxideav-core 0.1.37) decides the
43//! `colr` `nclx` the item is written with: a specified range writes
44//! `full_range_flag` (and codes the AV1 `color_range` bit to match),
45//! specified primaries / transfer / matrix code points are written as
46//! given. The `YuvJ*` labels imply full range and the RGB layouts the
47//! identity matrix (a signalled matrix cannot override it — the planes
48//! *are* G B R); with nothing signalled the item keeps this crate's
49//! historical defaults (no `colr` for Y′CbCr input, `1 / 13 / 0` full
50//! range for RGB). An item that carries primaries / transfer but no
51//! range is written limited (H.273's inferred `VideoFullRangeFlag`).
52//! The encoder's [`Encoder::output_params`] carry the signal the file
53//! was written with.
54//!
55//! The encoder's [`Encoder::output_params`] carry the **coded**
56//! label after a frame — what the file decodes back as — rather than
57//! the input label: packed RGB(A) codes as `Gbrp8` / `Gbrap8`
58//! losslessly and as Y′CbCr at a lossy quality; an 8-bit `Yuv*` label
59//! (no implied range) is written without `colr`, which MIAF reads as
60//! full range, so it decodes back as `YuvJ*` — the samples are stored
61//! as given either way; set `color_signal.range = Limited` to write a
62//! limited-range `colr` instead.
63//!
64//! The accepted layouts are published as [`ENCODER_PIXEL_FORMATS`]
65//! (the registry's `CodecCapabilities::accepted_pixel_formats`, the
66//! set a gateway's conversion ladder reads); any other label is a
67//! typed `Unsupported` error naming it.
68//!
69//! # Options
70//!
71//! Codec options (string map on [`CodecParameters::options`]; the
72//! schema is [`AvifEncoderOptions`], declared on the registry entry):
73//!
74//! * `quality` — `0..=100`, `100` = lossless (the default). When `q`
75//!   is not given, `base_q_idx = ((100 − quality) × 255 + 50) / 100`
76//!   (the [`EncodeOptions::with_quality`] mapping: 100 → 0, 90 → 26,
77//!   75 → 64, 50 → 128, 10 → 230, 0 → 255); the alpha auxiliary
78//!   follows the same mapping when `alpha_q` is not given.
79//! * `q` — AV1 `base_q_idx` for the colour planes, `0..=255`
80//!   (`0` = lossless). An explicit `q` wins over `quality`.
81//! * `alpha_q` — `base_q_idx` for the alpha auxiliary; wins over
82//!   `quality` for that plane.
83//! * `chroma` — `420` (default) / `422` / `444`: the Y′CbCr layout a
84//!   packed RGB(A) / BGR(A) frame is coded in at a lossy quality (odd
85//!   extents promote to 4:4:4). Planar frames keep their own layout.
86//! * `premultiplied` — `true` to emit the `prem` iref declaring
87//!   premultiplied alpha (default `false`).
88//! * `speed` — `fast` (default) / `balanced` / `thorough`: the AV1
89//!   search effort ([`EncodeSpeed`]).
90//! * `threads` — worker threads for the AV1 tile search; `0` (default)
91//!   follows the budget of [`Encoder::set_execution_context`] (serial
92//!   until one is granted). The tile layout is derived from the
93//!   picture size alone, so the bytes never depend on the budget.
94//!
95//! With neither `quality` nor `q` the encode is lossless.
96
97use oxideav_core::frame::VideoFrame;
98use oxideav_core::{
99    parse_options, CodecId, CodecOptionsStruct, CodecParameters, ColorRange, ColorSignal, Encoder,
100    Error, Frame, OptionField, OptionKind, OptionValue, Packet, PixelFormat, Result, TimeBase,
101};
102
103use crate::api::{ColorInfo, EncodeOptions, EncodeSpeed};
104use crate::meta::Colr;
105use crate::still::{encode_still, identity_full_range_colr, StillChroma, StillImage};
106
107/// Every input layout the encoder accepts (the module docs' list) —
108/// the registry's `accepted_pixel_formats`. A frame in any other
109/// layout is refused with a typed `Unsupported` error naming it.
110pub const ENCODER_PIXEL_FORMATS: &[PixelFormat] = &[
111    // Planar Y′CbCr, 8-bit (+ the full-range J labels) and grey.
112    PixelFormat::Yuv420P,
113    PixelFormat::Yuv422P,
114    PixelFormat::Yuv444P,
115    PixelFormat::YuvJ420P,
116    PixelFormat::YuvJ422P,
117    PixelFormat::YuvJ444P,
118    PixelFormat::Gray8,
119    // Planar Y′CbCr / grey, 10- and 12-bit little-endian words.
120    PixelFormat::Yuv420P10Le,
121    PixelFormat::Yuv422P10Le,
122    PixelFormat::Yuv444P10Le,
123    PixelFormat::Yuv420P12Le,
124    PixelFormat::Yuv422P12Le,
125    PixelFormat::Yuv444P12Le,
126    PixelFormat::Gray10Le,
127    PixelFormat::Gray12Le,
128    // Packed RGB(A) / BGR(A).
129    PixelFormat::Rgb24,
130    PixelFormat::Rgba,
131    PixelFormat::Bgr24,
132    PixelFormat::Bgra,
133    // Planar RGB(A) — the identity-matrix 4:4:4 coding at 8 / 10 / 12 bits.
134    PixelFormat::Gbrp8,
135    PixelFormat::Gbrap8,
136    PixelFormat::Gbrp10Le,
137    PixelFormat::Gbrap10Le,
138    PixelFormat::Gbrp12Le,
139    PixelFormat::Gbrap12Le,
140    // Alpha-carrying planar / interleaved grey + alpha.
141    PixelFormat::Yuva420P,
142    PixelFormat::Ya8,
143];
144
145/// The `quality` → `base_q_idx` mapping of the module docs
146/// (`((100 − quality) × 255 + 50) / 100`; qualities above 100 clamp).
147pub fn quality_to_base_q_idx(quality: u8) -> u8 {
148    EncodeOptions::default().with_quality(quality).base_q_idx
149}
150
151/// The registry encoder's option schema (see the module docs): the
152/// typed form of the `quality` / `q` / `alpha_q` / `chroma` /
153/// `premultiplied` codec options, parsed strictly at construction
154/// through [`oxideav_core::parse_options`] (an unknown key or a
155/// malformed value is `InvalidData`).
156#[non_exhaustive]
157#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub struct AvifEncoderOptions {
159    /// `quality` 0..=100 (100 = lossless); `None` when not given.
160    pub quality: Option<u8>,
161    /// Explicit colour `base_q_idx` (`q`); wins over `quality`.
162    pub q: Option<u8>,
163    /// Explicit alpha `base_q_idx` (`alpha_q`); wins over `quality`.
164    pub alpha_q: Option<u8>,
165    /// Chroma layout for a lossy packed-RGB encode (`chroma`).
166    pub chroma: StillChroma,
167    /// Emit the `prem` iref (`premultiplied`).
168    pub premultiplied: bool,
169    /// AV1 search effort (`speed`).
170    pub speed: EncodeSpeed,
171    /// Explicit worker threads (`threads`); `None` = the execution
172    /// context's budget.
173    pub threads: Option<usize>,
174}
175
176impl Default for AvifEncoderOptions {
177    fn default() -> Self {
178        Self {
179            quality: None,
180            q: None,
181            alpha_q: None,
182            chroma: StillChroma::Yuv420,
183            premultiplied: false,
184            speed: EncodeSpeed::Fast,
185            threads: None,
186        }
187    }
188}
189
190impl AvifEncoderOptions {
191    /// The defaults (lossless, 4:2:0 for lossy RGB, straight alpha).
192    pub fn new() -> Self {
193        Self::default()
194    }
195    /// Setter: replace `quality`.
196    pub fn with_quality(mut self, quality: Option<u8>) -> Self {
197        self.quality = quality;
198        self
199    }
200    /// Setter: replace `q`.
201    pub fn with_q(mut self, q: Option<u8>) -> Self {
202        self.q = q;
203        self
204    }
205    /// Setter: replace `alpha_q`.
206    pub fn with_alpha_q(mut self, alpha_q: Option<u8>) -> Self {
207        self.alpha_q = alpha_q;
208        self
209    }
210    /// Setter: replace `chroma`.
211    pub fn with_chroma(mut self, chroma: StillChroma) -> Self {
212        self.chroma = chroma;
213        self
214    }
215    /// Setter: replace `premultiplied`.
216    pub fn with_premultiplied(mut self, premultiplied: bool) -> Self {
217        self.premultiplied = premultiplied;
218        self
219    }
220    /// Setter: replace `speed`.
221    pub fn with_speed(mut self, speed: EncodeSpeed) -> Self {
222        self.speed = speed;
223        self
224    }
225    /// Setter: replace `threads` (`None` = the execution context's budget).
226    pub fn with_threads(mut self, threads: Option<usize>) -> Self {
227        self.threads = threads;
228        self
229    }
230
231    /// Strict parse of a parameter set's option bag.
232    pub fn from_params(params: &CodecParameters) -> Result<Self> {
233        parse_options::<Self>(&params.options)
234    }
235
236    /// Lenient parse: unknown keys and malformed values are ignored
237    /// (the historical [`AvifEncoder::with_params`] behaviour).
238    fn from_params_lenient(params: &CodecParameters) -> Self {
239        let mut out = Self::default();
240        for (k, v) in params.options.iter() {
241            let one = oxideav_core::CodecOptions::new().set(k, v);
242            if let Ok(parsed) = parse_options::<Self>(&one) {
243                if let Err(e) = parsed.apply_into(&mut out, k) {
244                    debug_assert!(false, "lenient option apply: {e}");
245                }
246            }
247        }
248        out
249    }
250
251    /// Copy the field named `key` from `self` into `into`.
252    fn apply_into(&self, into: &mut Self, key: &str) -> Result<()> {
253        match key {
254            "quality" => into.quality = self.quality,
255            "q" => into.q = self.q,
256            "alpha_q" => into.alpha_q = self.alpha_q,
257            "chroma" => into.chroma = self.chroma,
258            "premultiplied" => into.premultiplied = self.premultiplied,
259            "speed" => into.speed = self.speed,
260            "threads" => into.threads = self.threads,
261            other => return Err(Error::invalid(format!("unknown option '{other}'"))),
262        }
263        Ok(())
264    }
265
266    /// The still-encoder tuning these options resolve to: `q` else the
267    /// `quality` mapping else lossless for the colour planes, the same
268    /// for alpha with `alpha_q`; `chroma` / `premultiplied` / `speed`
269    /// as given; `threads` as given, else `1` (the encoder fills the
270    /// execution context's budget in per frame).
271    pub fn resolve(&self) -> EncodeOptions {
272        let from_quality = self.quality.map(quality_to_base_q_idx).unwrap_or(0);
273        EncodeOptions::new(
274            self.q.unwrap_or(from_quality),
275            self.alpha_q.unwrap_or(from_quality),
276            self.premultiplied,
277        )
278        .with_chroma(self.chroma)
279        .with_speed(self.speed)
280        .with_threads(self.threads.unwrap_or(1))
281    }
282}
283
284fn parse_u8_option(key: &str, v: &OptionValue, max: u32) -> Result<u8> {
285    let n = v.as_u32()?;
286    if n > max {
287        return Err(Error::invalid(format!(
288            "avif encode: option '{key}' = {n} is outside 0..={max}"
289        )));
290    }
291    Ok(n as u8)
292}
293
294impl CodecOptionsStruct for AvifEncoderOptions {
295    const SCHEMA: &'static [OptionField] = &[
296        OptionField {
297            name: "quality",
298            kind: OptionKind::U32,
299            default: OptionValue::U32(100),
300            help: "Quality 0..=100 (100 = lossless): when q is not given, base_q_idx = ((100 - quality) * 255 + 50) / 100 for the colour planes and, when alpha_q is not given, for the alpha auxiliary",
301        },
302        OptionField {
303            name: "q",
304            kind: OptionKind::U32,
305            default: OptionValue::U32(0),
306            help: "AV1 base_q_idx 0..=255 for the colour planes (0 = lossless); wins over quality",
307        },
308        OptionField {
309            name: "alpha_q",
310            kind: OptionKind::U32,
311            default: OptionValue::U32(0),
312            help: "AV1 base_q_idx 0..=255 for the alpha auxiliary (0 = lossless); wins over quality",
313        },
314        OptionField {
315            name: "chroma",
316            kind: OptionKind::Enum(&["420", "422", "444"]),
317            default: OptionValue::String(String::new()),
318            help: "Y'CbCr layout a packed RGB(A) / BGR(A) frame is coded in at a lossy quality (default 420; odd extents promote to 444); lossless RGB keeps the identity-matrix 4:4:4 coding",
319        },
320        OptionField {
321            name: "premultiplied",
322            kind: OptionKind::Bool,
323            default: OptionValue::Bool(false),
324            help: "Emit the prem reference declaring the colour planes premultiplied by alpha (signalling only)",
325        },
326        OptionField {
327            name: "speed",
328            kind: OptionKind::Enum(&["fast", "balanced", "thorough"]),
329            default: OptionValue::String(String::new()),
330            help: "AV1 search effort (default fast)",
331        },
332        OptionField {
333            name: "threads",
334            kind: OptionKind::U32,
335            default: OptionValue::U32(0),
336            help: "Worker threads for the AV1 tile search (bytes never depend on it); 0 = the execution context's budget (serial unless granted)",
337        },
338    ];
339
340    fn apply(&mut self, key: &str, value: &OptionValue) -> Result<()> {
341        match key {
342            "quality" => self.quality = Some(parse_u8_option(key, value, 100)?),
343            "q" => self.q = Some(parse_u8_option(key, value, 255)?),
344            "alpha_q" => self.alpha_q = Some(parse_u8_option(key, value, 255)?),
345            "chroma" => {
346                self.chroma = match value.as_str()? {
347                    "420" => StillChroma::Yuv420,
348                    "422" => StillChroma::Yuv422,
349                    "444" => StillChroma::Yuv444,
350                    other => {
351                        return Err(Error::invalid(format!(
352                            "avif encode: chroma '{other}' is not 420 / 422 / 444"
353                        )))
354                    }
355                }
356            }
357            "premultiplied" => self.premultiplied = value.as_bool()?,
358            "speed" => {
359                self.speed = match value.as_str()? {
360                    "fast" => EncodeSpeed::Fast,
361                    "balanced" => EncodeSpeed::Balanced,
362                    "thorough" => EncodeSpeed::Thorough,
363                    other => {
364                        return Err(Error::invalid(format!(
365                            "avif encode: speed '{other}' is not fast / balanced / thorough"
366                        )))
367                    }
368                }
369            }
370            "threads" => {
371                let n = value.as_u32()?;
372                self.threads = (n > 0).then_some(n as usize);
373            }
374            other => return Err(Error::invalid(format!("unknown option '{other}'"))),
375        }
376        Ok(())
377    }
378}
379
380/// Frame-to-AVIF encoder: every video frame becomes one complete AVIF
381/// file packet. See the module docs for the input-format mapping and
382/// the option surface.
383pub struct AvifEncoder {
384    params: CodecParameters,
385    opts: EncodeOptions,
386    /// `threads` given explicitly (wins over the execution context).
387    threads_option: Option<usize>,
388    /// The budget of the last [`Encoder::set_execution_context`].
389    exec_threads: usize,
390    pending: Vec<Packet>,
391    flushed: bool,
392}
393
394impl AvifEncoder {
395    /// Build an encoder announcing `codec_id` in its output parameters.
396    pub fn new(codec_id: CodecId) -> Self {
397        Self::with_params(CodecParameters::video(codec_id))
398    }
399
400    /// Build an encoder from an explicit parameter set, reading the
401    /// options leniently: unknown keys and malformed values are
402    /// ignored (the historical behaviour of this constructor).
403    /// [`Self::try_with_params`] — what [`make_encoder`] and the
404    /// registry use — refuses them instead. Option parsing happens
405    /// here (construction time), never on the frame path.
406    pub fn with_params(params: CodecParameters) -> Self {
407        let opts = AvifEncoderOptions::from_params_lenient(&params);
408        Self::from_options(params, opts)
409    }
410
411    /// Build an encoder from a parameter set whose options are parsed
412    /// strictly against [`AvifEncoderOptions`]'s schema: an unknown
413    /// key, a malformed value or one outside its range is
414    /// `InvalidData`.
415    pub fn try_with_params(params: CodecParameters) -> Result<Self> {
416        let opts = AvifEncoderOptions::from_params(&params)?;
417        Ok(Self::from_options(params, opts))
418    }
419
420    /// Build an encoder from a parameter set and already-parsed
421    /// options (the option bag on `params` is not read again).
422    pub fn from_options(params: CodecParameters, opts: AvifEncoderOptions) -> Self {
423        Self {
424            params,
425            opts: opts.resolve(),
426            threads_option: opts.threads,
427            exec_threads: 1,
428            pending: Vec::new(),
429            flushed: false,
430        }
431    }
432
433    /// The still-encoder tuning this encoder codes with (`q`,
434    /// `alpha_q`, the `quality` mapping, `chroma`, `premultiplied`,
435    /// `speed`, and the worker threads the next frame runs on: the
436    /// explicit `threads` option, else the execution context's budget).
437    pub fn encode_options(&self) -> EncodeOptions {
438        self.opts
439            .with_threads(self.threads_option.unwrap_or(self.exec_threads))
440    }
441
442    /// The colour a lossy packed-RGB frame is converted to Y′CbCr
443    /// with: the stream's signalled primaries / transfer / matrix /
444    /// range where specified (a matrix the converter has no kernel for
445    /// falls back), the MIAF default (BT.709 / sRGB / BT.601, full
446    /// range) otherwise.
447    fn lossy_rgb_color(&self) -> ColorInfo {
448        let signal = self.params.color_signal;
449        let mut color = ColorInfo::miaf_default();
450        if !signal.primaries.is_unspecified() {
451            color.primaries = signal.primaries.0;
452        }
453        if !signal.transfer.is_unspecified() {
454            color.transfer = signal.transfer.0;
455        }
456        let m = signal.matrix.0;
457        if !signal.matrix.is_unspecified()
458            && m != 0
459            && oxideav_heif::rgb::matrix_kr_kb(u16::from(m)).is_some()
460        {
461            color.matrix = m;
462        }
463        match self.params.resolved_color_range() {
464            ColorRange::Limited => color.range = crate::api::ColorRange::Limited,
465            ColorRange::Full => color.range = crate::api::ColorRange::Full,
466            _ => {}
467        }
468        color
469    }
470
471    /// The Y′CbCr conversion a packed RGB(A) frame goes through — only
472    /// at a lossy colour quality (the lossless identity-matrix coding
473    /// is byte-exact and stays).
474    fn lossy_rgb(&self) -> Option<(ColorInfo, StillChroma)> {
475        (self.opts.base_q_idx > 0).then(|| (self.lossy_rgb_color(), self.opts.chroma))
476    }
477
478    /// Convert one framework video frame into a [`StillImage`] per the
479    /// declared `pixel_format`.
480    fn frame_to_still(&self, vf: &VideoFrame) -> Result<StillImage> {
481        let width = self
482            .params
483            .width
484            .ok_or_else(|| Error::invalid("avif encode: CodecParameters.width required"))?;
485        let height = self
486            .params
487            .height
488            .ok_or_else(|| Error::invalid("avif encode: CodecParameters.height required"))?;
489        let fmt = self
490            .params
491            .pixel_format
492            .ok_or_else(|| Error::invalid("avif encode: CodecParameters.pixel_format required"))?;
493
494        let img = match fmt {
495            PixelFormat::Yuv420P | PixelFormat::YuvJ420P => planar8(
496                vf,
497                width,
498                height,
499                StillChroma::Yuv420,
500                fmt == PixelFormat::YuvJ420P,
501            )?,
502            PixelFormat::Yuv422P | PixelFormat::YuvJ422P => planar8(
503                vf,
504                width,
505                height,
506                StillChroma::Yuv422,
507                fmt == PixelFormat::YuvJ422P,
508            )?,
509            PixelFormat::Yuv444P | PixelFormat::YuvJ444P => planar8(
510                vf,
511                width,
512                height,
513                StillChroma::Yuv444,
514                fmt == PixelFormat::YuvJ444P,
515            )?,
516            PixelFormat::Gray8 => planar8(vf, width, height, StillChroma::Monochrome, false)?,
517            PixelFormat::Yuv420P10Le => planar16(vf, width, height, 10, StillChroma::Yuv420)?,
518            PixelFormat::Yuv422P10Le => planar16(vf, width, height, 10, StillChroma::Yuv422)?,
519            PixelFormat::Yuv444P10Le => planar16(vf, width, height, 10, StillChroma::Yuv444)?,
520            PixelFormat::Yuv420P12Le => planar16(vf, width, height, 12, StillChroma::Yuv420)?,
521            PixelFormat::Yuv422P12Le => planar16(vf, width, height, 12, StillChroma::Yuv422)?,
522            PixelFormat::Yuv444P12Le => planar16(vf, width, height, 12, StillChroma::Yuv444)?,
523            PixelFormat::Gray10Le => planar16(vf, width, height, 10, StillChroma::Monochrome)?,
524            PixelFormat::Gray12Le => planar16(vf, width, height, 12, StillChroma::Monochrome)?,
525            PixelFormat::Rgb24 => packed_rgb(vf, width, height, 3, [0, 1, 2], self.lossy_rgb())?,
526            PixelFormat::Bgr24 => packed_rgb(vf, width, height, 3, [2, 1, 0], self.lossy_rgb())?,
527            PixelFormat::Rgba => packed_rgb(vf, width, height, 4, [0, 1, 2], self.lossy_rgb())?,
528            PixelFormat::Bgra => packed_rgb(vf, width, height, 4, [2, 1, 0], self.lossy_rgb())?,
529            PixelFormat::Yuva420P => yuva420(vf, width, height)?,
530            PixelFormat::Ya8 => ya8(vf, width, height)?,
531            PixelFormat::Gbrp8 => planar_gbr(vf, width, height, 8, false)?,
532            PixelFormat::Gbrap8 => planar_gbr(vf, width, height, 8, true)?,
533            PixelFormat::Gbrp10Le => planar_gbr(vf, width, height, 10, false)?,
534            PixelFormat::Gbrap10Le => planar_gbr(vf, width, height, 10, true)?,
535            PixelFormat::Gbrp12Le => planar_gbr(vf, width, height, 12, false)?,
536            PixelFormat::Gbrap12Le => planar_gbr(vf, width, height, 12, true)?,
537            other => {
538                return Err(Error::unsupported(format!(
539                    "avif encode: pixel format {other:?} is not supported \
540                     (planar YUV 8/10/12-bit, Gray, RGB(A)/BGR(A), Gbrp(a) 8/10/12-bit, \
541                     Yuva420P, Ya8)"
542                )))
543            }
544        };
545        Ok(apply_color_signal(img, &self.params))
546    }
547}
548
549/// Fold the stream's [`CodecParameters::color_signal`] into the item's
550/// `colr` (see the module docs): the layout's own implications (`YuvJ*`
551/// full range, RGB identity) stay, every specified field of the signal
552/// is written, and with nothing specified the image is left as built.
553fn apply_color_signal(mut img: StillImage, params: &CodecParameters) -> StillImage {
554    let signal = params.color_signal;
555    let range = params.resolved_color_range();
556    if signal.is_unspecified() && range.is_unspecified() {
557        return img;
558    }
559    let (mut p, mut t, mut m, mut full) = match img.colr {
560        Some(Colr::Nclx {
561            colour_primaries,
562            transfer_characteristics,
563            matrix_coefficients,
564            full_range,
565        }) => (
566            colour_primaries,
567            transfer_characteristics,
568            matrix_coefficients,
569            full_range,
570        ),
571        _ => (2, 2, 2, false),
572    };
573    let rgb = m == 0;
574    if !signal.primaries.is_unspecified() {
575        p = u16::from(signal.primaries.0);
576    }
577    if !signal.transfer.is_unspecified() {
578        t = u16::from(signal.transfer.0);
579    }
580    if !rgb && !signal.matrix.is_unspecified() {
581        m = u16::from(signal.matrix.0);
582    }
583    match range {
584        ColorRange::Full => full = true,
585        ColorRange::Limited => full = false,
586        _ => {}
587    }
588    img.colr = Some(Colr::Nclx {
589        colour_primaries: p,
590        transfer_characteristics: t,
591        matrix_coefficients: m,
592        full_range: full,
593    });
594    img
595}
596
597/// The colour signal an encoded item carries, from its `colr` —
598/// what [`Encoder::output_params`] announce.
599fn signal_of(img: &StillImage) -> ColorSignal {
600    crate::signal::color_signal_for(img.colr.as_ref())
601}
602
603/// Extract one tightly-packed plane (`w × h` samples, 1 byte each)
604/// from a frame plane whose stride may exceed the row width.
605fn pack8(plane: &oxideav_core::frame::VideoPlane, w: usize, h: usize) -> Result<Vec<u16>> {
606    if plane.data.len() < plane.stride * (h - 1) + w || plane.stride < w {
607        return Err(Error::invalid(format!(
608            "avif encode: plane too short (stride {}, need {w}x{h})",
609            plane.stride
610        )));
611    }
612    let mut out = Vec::with_capacity(w * h);
613    for row in 0..h {
614        let s = row * plane.stride;
615        out.extend(plane.data[s..s + w].iter().map(|&b| b as u16));
616    }
617    Ok(out)
618}
619
620/// Extract one tightly-packed plane of little-endian 2-byte samples.
621fn pack16le(plane: &oxideav_core::frame::VideoPlane, w: usize, h: usize) -> Result<Vec<u16>> {
622    let row_bytes = w * 2;
623    if plane.data.len() < plane.stride * (h - 1) + row_bytes || plane.stride < row_bytes {
624        return Err(Error::invalid(format!(
625            "avif encode: 16-bit plane too short (stride {}, need {w}x{h}x2)",
626            plane.stride
627        )));
628    }
629    let mut out = Vec::with_capacity(w * h);
630    for row in 0..h {
631        let s = row * plane.stride;
632        out.extend(
633            plane.data[s..s + row_bytes]
634                .chunks_exact(2)
635                .map(|c| u16::from_le_bytes([c[0], c[1]])),
636        );
637    }
638    Ok(out)
639}
640
641fn chroma_shift(chroma: StillChroma) -> (u32, u32) {
642    match chroma {
643        StillChroma::Yuv420 => (1, 1),
644        StillChroma::Yuv422 => (1, 0),
645        StillChroma::Yuv444 | StillChroma::Monochrome => (0, 0),
646    }
647}
648
649fn planar8(
650    vf: &VideoFrame,
651    width: u32,
652    height: u32,
653    chroma: StillChroma,
654    full_range: bool,
655) -> Result<StillImage> {
656    let planes_needed = if chroma == StillChroma::Monochrome {
657        1
658    } else {
659        3
660    };
661    if vf.image_plane_count() != planes_needed {
662        return Err(Error::invalid(format!(
663            "avif encode: {chroma:?} expects {planes_needed} planes, got {}",
664            vf.image_plane_count()
665        )));
666    }
667    let (sx, sy) = chroma_shift(chroma);
668    let y = pack8(&vf.planes[0], width as usize, height as usize)?;
669    let (u, v) = if planes_needed == 3 {
670        let (cw, ch) = ((width >> sx) as usize, (height >> sy) as usize);
671        (pack8(&vf.planes[1], cw, ch)?, pack8(&vf.planes[2], cw, ch)?)
672    } else {
673        (Vec::new(), Vec::new())
674    };
675    let mut img = StillImage::yuv(width, height, 8, chroma, y, u, v)?;
676    if full_range {
677        // The J-variants declare full-range samples with unspecified
678        // CICP; carry that through as an nclx colr (H.273 code 2 =
679        // unspecified for each of the three fields).
680        img = img.with_colr(crate::meta::Colr::Nclx {
681            colour_primaries: 2,
682            transfer_characteristics: 2,
683            matrix_coefficients: 2,
684            full_range: true,
685        });
686    }
687    Ok(img)
688}
689
690fn planar16(
691    vf: &VideoFrame,
692    width: u32,
693    height: u32,
694    bit_depth: u8,
695    chroma: StillChroma,
696) -> Result<StillImage> {
697    let planes_needed = if chroma == StillChroma::Monochrome {
698        1
699    } else {
700        3
701    };
702    if vf.image_plane_count() != planes_needed {
703        return Err(Error::invalid(format!(
704            "avif encode: {chroma:?} {bit_depth}-bit expects {planes_needed} planes, got {}",
705            vf.image_plane_count()
706        )));
707    }
708    let (sx, sy) = chroma_shift(chroma);
709    let y = pack16le(&vf.planes[0], width as usize, height as usize)?;
710    let (u, v) = if planes_needed == 3 {
711        let (cw, ch) = ((width >> sx) as usize, (height >> sy) as usize);
712        (
713            pack16le(&vf.planes[1], cw, ch)?,
714            pack16le(&vf.planes[2], cw, ch)?,
715        )
716    } else {
717        (Vec::new(), Vec::new())
718    };
719    Ok(StillImage::yuv(width, height, bit_depth, chroma, y, u, v)?)
720}
721
722/// Packed RGB(A)/BGR(A): `rgb_at` gives the byte offsets of R, G, B
723/// within each `bpp`-byte pixel; a 4th byte (when `bpp == 4`) is the
724/// alpha channel. `lossy` = `Some((colour, chroma))` converts the
725/// picture to Y′CbCr in `colour` and codes it in `chroma` (odd extents
726/// 4:4:4); `None` keeps the byte-exact identity-matrix 4:4:4 coding.
727fn packed_rgb(
728    vf: &VideoFrame,
729    width: u32,
730    height: u32,
731    bpp: usize,
732    rgb_at: [usize; 3],
733    lossy: Option<(ColorInfo, StillChroma)>,
734) -> Result<StillImage> {
735    if vf.image_plane_count() != 1 {
736        return Err(Error::invalid(format!(
737            "avif encode: packed RGB expects 1 plane, got {}",
738            vf.image_plane_count()
739        )));
740    }
741    let plane = &vf.planes[0];
742    let (w, h) = (width as usize, height as usize);
743    let row_bytes = w * bpp;
744    if plane.data.len() < plane.stride * (h - 1) + row_bytes || plane.stride < row_bytes {
745        return Err(Error::invalid(format!(
746            "avif encode: packed plane too short (stride {}, need {w}x{h}x{bpp})",
747            plane.stride
748        )));
749    }
750    let mut rgb = Vec::with_capacity(w * h * 3);
751    let mut alpha = if bpp == 4 {
752        Vec::with_capacity(w * h)
753    } else {
754        Vec::new()
755    };
756    for row in 0..h {
757        let s = row * plane.stride;
758        for px in plane.data[s..s + row_bytes].chunks_exact(bpp) {
759            rgb.push(px[rgb_at[0]]);
760            rgb.push(px[rgb_at[1]]);
761            rgb.push(px[rgb_at[2]]);
762            if bpp == 4 {
763                alpha.push(px[3] as u16);
764            }
765        }
766    }
767    let img = match lossy {
768        Some((color, chroma)) => {
769            crate::api_codec::still_of_rgb_in(width, height, &rgb, 3, color, chroma)?
770        }
771        None => StillImage::rgb8(width, height, &rgb)?,
772    };
773    if bpp == 4 {
774        Ok(img.with_alpha(alpha)?)
775    } else {
776        Ok(img)
777    }
778}
779
780/// The framework label of the layout an item is coded in (what the
781/// crate's decoder reports for the file): planar RGB for the identity
782/// matrix, `YuvJ*` for full-range 8-bit Y′CbCr, the storage label
783/// otherwise.
784fn coded_label(img: &StillImage) -> Option<PixelFormat> {
785    let (sx, sy) = img.chroma.subsampling();
786    let layout = crate::image::AvifPixelFormat::from_layout(
787        img.bit_depth,
788        sx as u8,
789        sy as u8,
790        img.chroma == StillChroma::Monochrome,
791        img.alpha.is_some(),
792    )?;
793    Some(crate::signal::labelled_pixel_format(
794        layout,
795        &signal_of(img),
796    ))
797}
798
799/// Planar RGB(A) (`Gbrp*` / `Gbrap*`: planes G, B, R [, A]) → an
800/// identity-matrix 4:4:4 item (Y = G, Cb = B, Cr = R, H.273 §8.3) at
801/// `bit_depth`, the alpha plane as the auxiliary.
802fn planar_gbr(
803    vf: &VideoFrame,
804    width: u32,
805    height: u32,
806    bit_depth: u8,
807    alpha: bool,
808) -> Result<StillImage> {
809    let planes_needed = if alpha { 4 } else { 3 };
810    if vf.image_plane_count() != planes_needed {
811        return Err(Error::invalid(format!(
812            "avif encode: planar RGB{} {bit_depth}-bit expects {planes_needed} planes, got {}",
813            if alpha { "A" } else { "" },
814            vf.image_plane_count()
815        )));
816    }
817    let (w, h) = (width as usize, height as usize);
818    let pack = |i: usize| -> Result<Vec<u16>> {
819        if bit_depth == 8 {
820            pack8(&vf.planes[i], w, h)
821        } else {
822            pack16le(&vf.planes[i], w, h)
823        }
824    };
825    let (g, b, r) = (pack(0)?, pack(1)?, pack(2)?);
826    let img = StillImage::yuv(width, height, bit_depth, StillChroma::Yuv444, g, b, r)?
827        .with_colr(identity_full_range_colr());
828    if alpha {
829        Ok(img.with_alpha(pack(3)?)?)
830    } else {
831        Ok(img)
832    }
833}
834
835fn yuva420(vf: &VideoFrame, width: u32, height: u32) -> Result<StillImage> {
836    if vf.image_plane_count() != 4 {
837        return Err(Error::invalid(format!(
838            "avif encode: Yuva420P expects 4 planes, got {}",
839            vf.image_plane_count()
840        )));
841    }
842    let (w, h) = (width as usize, height as usize);
843    let (cw, ch) = (width.div_ceil(2) as usize, height.div_ceil(2) as usize);
844    let img = StillImage::yuv(
845        width,
846        height,
847        8,
848        StillChroma::Yuv420,
849        pack8(&vf.planes[0], w, h)?,
850        pack8(&vf.planes[1], cw, ch)?,
851        pack8(&vf.planes[2], cw, ch)?,
852    )?;
853    Ok(img.with_alpha(pack8(&vf.planes[3], w, h)?)?)
854}
855
856fn ya8(vf: &VideoFrame, width: u32, height: u32) -> Result<StillImage> {
857    if vf.image_plane_count() != 1 {
858        return Err(Error::invalid(format!(
859            "avif encode: Ya8 expects 1 interleaved plane, got {}",
860            vf.image_plane_count()
861        )));
862    }
863    let plane = &vf.planes[0];
864    let (w, h) = (width as usize, height as usize);
865    let row_bytes = w * 2;
866    if plane.data.len() < plane.stride * (h - 1) + row_bytes || plane.stride < row_bytes {
867        return Err(Error::invalid(format!(
868            "avif encode: Ya8 plane too short (stride {}, need {w}x{h}x2)",
869            plane.stride
870        )));
871    }
872    let mut y = Vec::with_capacity(w * h);
873    let mut a = Vec::with_capacity(w * h);
874    for row in 0..h {
875        let s = row * plane.stride;
876        for px in plane.data[s..s + row_bytes].chunks_exact(2) {
877            y.push(px[0] as u16);
878            a.push(px[1] as u16);
879        }
880    }
881    let img = StillImage::yuv(
882        width,
883        height,
884        8,
885        StillChroma::Monochrome,
886        y,
887        Vec::new(),
888        Vec::new(),
889    )?;
890    Ok(img.with_alpha(a)?)
891}
892
893impl Encoder for AvifEncoder {
894    fn codec_id(&self) -> &CodecId {
895        &self.params.codec_id
896    }
897
898    fn output_params(&self) -> &CodecParameters {
899        &self.params
900    }
901
902    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
903        let vf = match frame {
904            Frame::Video(v) => v,
905            other => {
906                return Err(Error::invalid(format!(
907                    "avif encode: expected a video frame, got {other:?}"
908                )))
909            }
910        };
911        let img = self.frame_to_still(vf)?;
912        self.params.color_signal = signal_of(&img);
913        // The output parameters carry the coded layout's label (what
914        // the file decodes back as), not the input label: `Rgb24` is
915        // coded as `Gbrp8` losslessly and as Y′CbCr at a lossy quality.
916        if let Some(label) = coded_label(&img) {
917            self.params.pixel_format = Some(label);
918        }
919        let bytes = encode_still(&img, &self.encode_options())?;
920        let mut pkt = Packet::new(0, TimeBase::new(1, 90_000), bytes);
921        if let Some(pts) = vf.pts {
922            pkt = pkt.with_pts(pts);
923        }
924        pkt = pkt.with_keyframe(true);
925        self.pending.push(pkt);
926        Ok(())
927    }
928
929    fn receive_packet(&mut self) -> Result<Packet> {
930        if self.pending.is_empty() {
931            return if self.flushed {
932                Err(Error::Eof)
933            } else {
934                Err(Error::NeedMore)
935            };
936        }
937        Ok(self.pending.remove(0))
938    }
939
940    fn flush(&mut self) -> Result<()> {
941        self.flushed = true;
942        Ok(())
943    }
944
945    /// The worker budget for the AV1 tile search of every coded item;
946    /// an explicit `threads` option wins over it. The bytes never
947    /// depend on the budget (the tile layout follows the picture size).
948    fn set_execution_context(&mut self, ctx: &oxideav_core::ExecutionContext) {
949        self.exec_threads = ctx.threads.max(1);
950    }
951}
952
953/// Direct factory endpoint (matches the crate's dual-API convention):
954/// build a boxed AVIF [`Encoder`] from a parameter set. The options
955/// are parsed strictly ([`AvifEncoder::try_with_params`]): an unknown
956/// key or a malformed value is `InvalidData` here rather than silently
957/// ignored.
958pub fn make_encoder(params: &CodecParameters) -> Result<Box<dyn Encoder>> {
959    Ok(Box::new(AvifEncoder::try_with_params(params.clone())?))
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use oxideav_core::frame::{VideoFrame, VideoPlane};
966    use oxideav_core::Decoder;
967
968    fn video_params(fmt: PixelFormat, w: u32, h: u32) -> CodecParameters {
969        let mut p = CodecParameters::video(CodecId::new(crate::CODEC_ID_STR));
970        p.width = Some(w);
971        p.height = Some(h);
972        p.pixel_format = Some(fmt);
973        p
974    }
975
976    fn planar_frame(dims: &[(usize, usize)], seed: u8) -> Frame {
977        Frame::Video(VideoFrame {
978            pts: Some(7),
979            planes: dims
980                .iter()
981                .enumerate()
982                .map(|(i, &(w, h))| VideoPlane {
983                    stride: w,
984                    data: (0..w * h)
985                        .map(|j| (j as u8).wrapping_mul(31) ^ seed.wrapping_add(i as u8))
986                        .collect(),
987                })
988                .collect(),
989        })
990    }
991
992    /// Trait-level end-to-end: send a Yuv420P frame, receive one
993    /// complete AVIF file packet, decode it back pixel-exact through
994    /// the crate's own decoder.
995    #[test]
996    fn encoder_trait_round_trips_yuv420_frame() {
997        let params = video_params(PixelFormat::Yuv420P, 16, 16);
998        let mut enc = make_encoder(&params).expect("make encoder");
999        let frame = planar_frame(&[(16, 16), (8, 8), (8, 8)], 3);
1000        enc.send_frame(&frame).expect("send_frame");
1001        let pkt = enc.receive_packet().expect("receive_packet");
1002        assert_eq!(pkt.pts, Some(7), "pts carried through");
1003        assert!(pkt.flags.keyframe, "still images are sync samples");
1004
1005        // NeedMore until the next frame, Eof after flush.
1006        assert!(matches!(enc.receive_packet(), Err(Error::NeedMore)));
1007        enc.flush().expect("flush");
1008        assert!(matches!(enc.receive_packet(), Err(Error::Eof)));
1009
1010        // The packet is a complete AVIF file that decodes back to the
1011        // input planes exactly (lossless default).
1012        let mut dec = crate::decoder::AvifDecoder::new(CodecId::new(crate::CODEC_ID_STR));
1013        dec.send_packet(&Packet::new(0, TimeBase::new(1, 1), pkt.data.clone()))
1014            .expect("decode muxed file");
1015        let vf = match dec.receive_frame().expect("frame") {
1016            Frame::Video(v) => v,
1017            other => panic!("expected VideoFrame, got {other:?}"),
1018        };
1019        let src = match &frame {
1020            Frame::Video(v) => v,
1021            _ => unreachable!(),
1022        };
1023        assert_eq!(vf.image_plane_count(), 3);
1024        for i in 0..3 {
1025            assert_eq!(vf.planes[i].data, src.planes[i].data, "plane {i}");
1026        }
1027    }
1028
1029    /// Rgba input: the trait path routes through the identity-matrix
1030    /// 4:4:4 encode with an alpha auxiliary; a lossy `q` option
1031    /// produces a smaller file than lossless.
1032    #[test]
1033    fn encoder_trait_rgba_and_q_option() {
1034        let (w, h) = (16u32, 16u32);
1035        let rgba: Vec<u8> = (0..w * h * 4).map(|i| ((i * 13) & 0xff) as u8).collect();
1036        let mk_frame = || {
1037            Frame::Video(VideoFrame {
1038                pts: Some(0),
1039                planes: vec![VideoPlane {
1040                    stride: (w * 4) as usize,
1041                    data: rgba.clone(),
1042                }],
1043            })
1044        };
1045        let params = video_params(PixelFormat::Rgba, w, h);
1046        let mut enc = make_encoder(&params).expect("encoder");
1047        enc.send_frame(&mk_frame()).expect("send");
1048        let lossless = enc.receive_packet().expect("packet");
1049        let info = crate::inspect::inspect(&lossless.data).expect("inspect");
1050        assert!(info.has_alpha, "alpha auxiliary present");
1051
1052        let mut lossy_params = video_params(PixelFormat::Rgba, w, h);
1053        lossy_params.options = oxideav_core::CodecOptions::new().set("q", "120");
1054        let mut enc = make_encoder(&lossy_params).expect("encoder");
1055        enc.send_frame(&mk_frame()).expect("send");
1056        let lossy = enc.receive_packet().expect("packet");
1057        assert!(
1058            lossy.data.len() < lossless.data.len(),
1059            "q=120 ({}) must be smaller than lossless ({})",
1060            lossy.data.len(),
1061            lossless.data.len()
1062        );
1063    }
1064
1065    /// Missing parameters and unsupported formats surface precise
1066    /// errors instead of panics.
1067    #[test]
1068    fn encoder_trait_rejects_bad_setup() {
1069        // No width/height/pixel_format.
1070        let params = CodecParameters::video(CodecId::new(crate::CODEC_ID_STR));
1071        let mut enc = make_encoder(&params).expect("encoder");
1072        let frame = planar_frame(&[(8, 8)], 0);
1073        match enc.send_frame(&frame) {
1074            Err(Error::InvalidData(msg)) => assert!(msg.contains("width"), "{msg}"),
1075            other => panic!("expected InvalidData, got {other:?}"),
1076        }
1077        // Unsupported pixel format.
1078        let params = video_params(PixelFormat::Pal8, 8, 8);
1079        let mut enc = make_encoder(&params).expect("encoder");
1080        match enc.send_frame(&frame) {
1081            Err(Error::Unsupported(msg)) => assert!(msg.contains("Pal8"), "{msg}"),
1082            other => panic!("expected Unsupported, got {other:?}"),
1083        }
1084    }
1085}