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}