Skip to main content

codec/encode/tuning/
mod.rs

1//! AV1 encoder tuning adapter.
2//!
3//! Translates a single backend-agnostic perceptual quality target into
4//! per-encoder parameters so identical inputs yield visually consistent
5//! output across rav1e, NVENC AV1, and future backends (SVT-AV1, AMF,
6//! QSV).
7//!
8//! See `docs/av1-tuning-research.md` for the source tables and
9//! `docs/av1-tuning-methodology.md` for how to re-calibrate when a new
10//! encoder lands.
11//!
12//! # Design
13//!
14//! The user picks two things:
15//! 1. A `QualityTarget` — perceptual goal expressed in VMAF/SSIMULACRA2
16//!    bands, not encoder-native CRF. Every backend must reach roughly
17//!    the same VMAF for a given target (±2 VMAF band).
18//! 2. A `SpeedTier` — how much wallclock to spend getting there. Maps
19//!    to encoder-native speed presets.
20//!
21//! The adapter also takes `(width, height)` because tile grid and
22//! lookahead sizing depend on frame size.
23
24mod adapters;
25mod params;
26#[cfg(test)]
27mod tests;
28
29// ─── Re-exports: param structs, enums, and constants ────────────────────────
30pub use params::{
31    AmfAv1Params, AmfQualityPreset, AmfRateControl, MFX_CODINGOPTION_OFF, MFX_CODINGOPTION_ON,
32    NvencAv1Params, NvencRateControl, QsvAv1Params, QsvRateControl, Rav1eParams,
33};
34
35// ─── Re-exports: public adapter functions ───────────────────────────────────
36pub use adapters::{amf_av1_params, nvenc_av1_params, qsv_av1_params, rav1e_params};
37
38// ─── Public types ────────────────────────────────────────────────
39
40/// A single perceptual quality target, backend-agnostic.
41///
42/// Maps to VMAF / SSIMULACRA2 bands, NOT encoder CRF values:
43///
44/// | Variant             | Target VMAF | Target SSIMULACRA2 | Use case                         |
45/// |---------------------|:-----------:|:------------------:|----------------------------------|
46/// | `VisuallyLossless`  | ~98         | ~90                | Archive, master                  |
47/// | `High`              | ~95         | ~80                | Premium OTT / top ABR rung       |
48/// | `Standard`          | ~90         | ~70                | Default web / streaming          |
49/// | `Low`               | ~85         | ~60                | Mobile / bandwidth-constrained   |
50/// | `Vmaf(u8)`          | explicit    | n/a                | A/B testing escape hatch         |
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum QualityTarget {
53    VisuallyLossless,
54    High,
55    #[default]
56    Standard,
57    Low,
58    Vmaf(u8),
59}
60
61/// User-facing speed tier — maps to encoder-native speed presets.
62///
63/// | Variant    | rav1e | NVENC preset | SVT-AV1 preset | libaom cpu-used |
64/// |------------|:-----:|:------------:|:--------------:|:---------------:|
65/// | `Draft`    | 8     | P5           | 12             | 8               |
66/// | `Standard` | 6     | P6           | 8              | 6               |
67/// | `Archive`  | 4     | P7           | 4              | 4               |
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum SpeedTier {
70    Draft,
71    #[default]
72    Standard,
73    Archive,
74}
75
76// ─── SDK constant ────────────────────────────────────────────────
77
78/// SDK constant `NV_ENC_TUNING_INFO_HIGH_QUALITY = 1`.
79pub const NVENC_TUNING_HIGH_QUALITY: u32 = 1;
80
81// NVENC SDK 13.0 preset GUIDs (vendor/nvidia/nvEncodeAPI.h:226-251).
82//
83// CRITICAL: SDK 12.2 had ENTIRELY DIFFERENT preset GUIDs for P5/P6/P7
84// — when we vendored SDK 13's nvEncodeAPI.h on 2026-05-01 we updated
85// the NvEncFunctionList ordering + struct layouts but missed that the
86// preset-GUID values themselves were also reshuffled. Sending SDK
87// 12.2 P5/P6/P7 GUIDs to a SDK 13 driver returned NV_ENC_ERR_UNSUPPORTED_PARAM
88// (rc=12) from NvEncGetEncodePresetConfigEx (the driver doesn't
89// recognise the old GUIDs and rejects the lookup). For reference, the
90// 12.2 → 13 GUID rotation:
91//   P5: d0918ee2-a509-4681-af96-e9c3c45b7aa7 → 21c6e6b4-297a-4cba-998f-b6cbde72ade3
92//   P6: fc8ebf15-6e19-47b4-8ea7-b1917f379eed → 8e75c279-6299-4ab6-8302-0b215a335cf5
93//   P7: 84bdda58-33cb-4895-a372-ddeddb013ac4 → 84848c12-6f71-4c13-931b-53e283f57974
94pub(self) const NV_ENC_PRESET_P5_GUID_BYTES: [u8; 16] = [
95    0xb4, 0xe6, 0xc6, 0x21, // data1 = 0x21c6e6b4
96    0x7a, 0x29, // data2 = 0x297a
97    0xba, 0x4c, // data3 = 0x4cba
98    0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3,
99];
100
101pub(self) const NV_ENC_PRESET_P6_GUID_BYTES: [u8; 16] = [
102    0x79, 0xc2, 0x75, 0x8e, // data1 = 0x8e75c279
103    0x99, 0x62, // data2 = 0x6299
104    0xb6, 0x4a, // data3 = 0x4ab6
105    0x83, 0x02, 0x0b, 0x21, 0x5a, 0x33, 0x5c, 0xf5,
106];
107
108pub(self) const NV_ENC_PRESET_P7_GUID_BYTES: [u8; 16] = [
109    0x12, 0x8c, 0x84, 0x84, // data1 = 0x84848c12
110    0x71, 0x6f, // data2 = 0x6f71
111    0x13, 0x4c, // data3 = 0x4c13
112    0x93, 0x1b, 0x53, 0xe2, 0x83, 0xf5, 0x79, 0x74,
113];
114
115// ─── Shared helpers (used by adapters and tests) ──────────────────
116
117/// libaom `cq-level` that corresponds to a given QualityTarget. libaom
118/// is the cross-encoder reference: we equalize other encoders *to*
119/// libaom's VMAF at each CQ.
120///
121/// Exposed `pub` so the FFmpeg-wrapper encoder path
122/// (`encode::ffmpeg_enc`) can route `libsvtav1` / `libaom-av1` through
123/// the same adapter tables as the native encoders.
124pub fn libaom_cq_for_target(target: QualityTarget) -> u8 {
125    match target {
126        QualityTarget::VisuallyLossless => 20,
127        QualityTarget::High => 27,
128        QualityTarget::Standard => 32,
129        QualityTarget::Low => 38,
130        QualityTarget::Vmaf(v) => vmaf_to_libaom_cq(v),
131    }
132}
133
134/// NVENC CQ that hits the same VMAF as `libaom_cq_for_target`, per
135/// the research doc §2.4.
136fn nvenc_cq_for_target(target: QualityTarget) -> u8 {
137    match target {
138        QualityTarget::VisuallyLossless => 19,
139        QualityTarget::High => 25,
140        QualityTarget::Standard => 30,
141        QualityTarget::Low => 36,
142        QualityTarget::Vmaf(v) => vmaf_to_nvenc_cq(v),
143    }
144}
145
146/// Anchor points for libaom VMAF↔cq-level (research §2.1). Must stay
147/// in descending VMAF order; `piecewise_cq` below depends on it.
148const LIBAOM_ANCHORS: &[(i32, i32)] = &[
149    (100, 10), // asymptote beyond VisuallyLossless
150    (98, 20),
151    (95, 27),
152    (90, 32),
153    (85, 38),
154    (70, 55), // low-quality extrapolation
155];
156
157/// Anchor points for NVENC AV1 VMAF↔CQ. Calibrated down from libaom
158/// to compensate for NVENC's documented compression-efficiency gap
159/// (research §2.4). Same VMAF → lower CQ than libaom.
160const NVENC_ANCHORS: &[(i32, i32)] =
161    &[(100, 10), (98, 19), (95, 25), (90, 30), (85, 36), (70, 52)];
162
163/// Piecewise-linear interpolation between anchors. Anchors are
164/// `(vmaf, cq)` pairs in descending VMAF order. Out-of-range VMAF
165/// values clamp to the nearest anchor's CQ.
166fn piecewise_cq(vmaf: u8, anchors: &[(i32, i32)]) -> u8 {
167    let v = vmaf as i32;
168    // Above the top anchor: return its CQ (asymptote).
169    if v >= anchors[0].0 {
170        return anchors[0].1.clamp(0, 63) as u8;
171    }
172    // Below the bottom anchor: return its CQ.
173    let last = anchors.len() - 1;
174    if v <= anchors[last].0 {
175        return anchors[last].1.clamp(0, 63) as u8;
176    }
177    // Linear interpolation between surrounding anchors.
178    for pair in anchors.windows(2) {
179        let (v_hi, cq_hi) = pair[0];
180        let (v_lo, cq_lo) = pair[1];
181        if v <= v_hi && v >= v_lo {
182            let span = v_hi - v_lo;
183            if span == 0 {
184                return cq_hi.clamp(0, 63) as u8;
185            }
186            let t = v_hi - v; // 0 at high anchor, span at low anchor
187            let cq = cq_hi + (cq_lo - cq_hi) * t / span;
188            return cq.clamp(0, 63) as u8;
189        }
190    }
191    anchors[last].1.clamp(0, 63) as u8
192}
193
194/// Generic piecewise-linear interpolator for non-CQ scales. Mirrors
195/// `piecewise_cq` but with configurable clamp bounds so the same logic
196/// serves AMF's 1..100 and QSV's 1..51.
197fn piecewise_quality(vmaf: u8, anchors: &[(i32, i32)], lo: i32, hi: i32) -> u8 {
198    let v = vmaf as i32;
199    if v >= anchors[0].0 {
200        return anchors[0].1.clamp(lo, hi) as u8;
201    }
202    let last = anchors.len() - 1;
203    if v <= anchors[last].0 {
204        return anchors[last].1.clamp(lo, hi) as u8;
205    }
206    for pair in anchors.windows(2) {
207        let (v_hi, q_hi) = pair[0];
208        let (v_lo, q_lo) = pair[1];
209        if v <= v_hi && v >= v_lo {
210            let span = v_hi - v_lo;
211            if span == 0 {
212                return q_hi.clamp(lo, hi) as u8;
213            }
214            let t = v_hi - v;
215            let q = q_hi + (q_lo - q_hi) * t / span;
216            return q.clamp(lo, hi) as u8;
217        }
218    }
219    anchors[last].1.clamp(lo, hi) as u8
220}
221
222fn vmaf_to_libaom_cq(vmaf: u8) -> u8 {
223    piecewise_cq(vmaf, LIBAOM_ANCHORS)
224}
225
226fn vmaf_to_nvenc_cq(vmaf: u8) -> u8 {
227    piecewise_cq(vmaf, NVENC_ANCHORS)
228}
229
230/// Tile grid for rav1e (CPU). Returns `(columns, rows)`, literal counts.
231/// rav1e is memory-bandwidth-limited and benefits from aggressive tiling
232/// even at the cost of a small quality hit, because tile parallelism is
233/// most of its throughput story at 4K+.
234fn tile_grid_rav1e(width: u32, height: u32) -> (usize, usize) {
235    let max_dim = width.max(height);
236    if max_dim >= 3840 {
237        (4, 4) // 16 tiles at 4K — rav1e fans out across cores
238    } else if max_dim >= 1920 {
239        (2, 2)
240    } else {
241        (1, 1)
242    }
243}
244
245/// Tile grid for NVENC AV1. Returns `(columns, rows)`. NVENC has enough
246/// internal parallelism that it does not need large tile grids for
247/// throughput — and its HIGH_QUALITY tuning is sensitive to the ~1%
248/// quality cost per extra tile row/column (tile boundaries break loop
249/// filter continuity, and AV1 tiles are entropy-coded independently).
250/// Cap at 2×2 even at 4K.
251fn tile_grid_nvenc(width: u32, height: u32) -> (usize, usize) {
252    let max_dim = width.max(height);
253    if max_dim >= 1920 { (2, 2) } else { (1, 1) }
254}
255
256/// Shared HW-encoder tile grid. Used by NVENC, AMF, and QSV — all
257/// three are "HQ-equivalent hardware encoders" that don't need rav1e's
258/// aggressive tiling for throughput and are sensitive to the ~1%
259/// quality cost per extra tile row/column. Cap at 2×2 even at 4K.
260///
261/// This is an alias over `tile_grid_nvenc` so the shared rule is
262/// explicit at call sites. Changing the shared cap is a one-line
263/// change here.
264fn tile_grid_hw(width: u32, height: u32) -> (usize, usize) {
265    tile_grid_nvenc(width, height)
266}