codec/encode/tuning.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
24// ─── Public types ────────────────────────────────────────────────
25
26/// A single perceptual quality target, backend-agnostic.
27///
28/// Maps to VMAF / SSIMULACRA2 bands, NOT encoder CRF values:
29///
30/// | Variant | Target VMAF | Target SSIMULACRA2 | Use case |
31/// |---------------------|:-----------:|:------------------:|----------------------------------|
32/// | `VisuallyLossless` | ~98 | ~90 | Archive, master |
33/// | `High` | ~95 | ~80 | Premium OTT / top ABR rung |
34/// | `Standard` | ~90 | ~70 | Default web / streaming |
35/// | `Low` | ~85 | ~60 | Mobile / bandwidth-constrained |
36/// | `Vmaf(u8)` | explicit | n/a | A/B testing escape hatch |
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum QualityTarget {
39 VisuallyLossless,
40 High,
41 #[default]
42 Standard,
43 Low,
44 Vmaf(u8),
45}
46
47/// User-facing speed tier — maps to encoder-native speed presets.
48///
49/// | Variant | rav1e | NVENC preset | SVT-AV1 preset | libaom cpu-used |
50/// |------------|:-----:|:------------:|:--------------:|:---------------:|
51/// | `Draft` | 8 | P5 | 12 | 8 |
52/// | `Standard` | 6 | P6 | 8 | 6 |
53/// | `Archive` | 4 | P7 | 4 | 4 |
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum SpeedTier {
56 Draft,
57 #[default]
58 Standard,
59 Archive,
60}
61
62// ─── Per-encoder parameter structs ───────────────────────────────
63
64/// Concrete parameters for rav1e's `EncoderConfig`.
65///
66/// Consumed in `crates/codec/src/encode/rav1e_enc.rs::build_rav1e_config`.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct Rav1eParams {
69 /// rav1e quantizer: 0–255, lower = higher quality. Default 100.
70 pub quantizer: usize,
71 /// rav1e speed preset 0 (slowest/best) – 10 (fastest). Archive=4,
72 /// Standard=6, Draft=8.
73 pub speed_preset: u8,
74 /// Number of tile rows (literal, not log2). Resolution-dependent.
75 pub tile_rows: usize,
76 /// Number of tile columns (literal). Resolution-dependent.
77 pub tile_cols: usize,
78}
79
80/// Concrete parameters for NVENC AV1 (NV_ENC_CONFIG + NV_ENC_RC_PARAMS).
81///
82/// Consumed in `crates/codec/src/encode/nvenc.rs` when populating
83/// `NV_ENC_INITIALIZE_PARAMS.encode_config` (currently null — see
84/// `reviews/codec-review-3.md` issues 1-3).
85///
86/// GUID is returned as its raw 16-byte form so the caller can splat
87/// it into the SDK's `#[repr(C)] Guid { data1: u32, data2: u16,
88/// data3: u16, data4: [u8;8] }` without this module depending on the
89/// FFI struct definitions.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct NvencAv1Params {
92 /// Rate control mode. Values are the SDK constants
93 /// `NV_ENC_PARAMS_RC_CONSTQP = 0`, `NV_ENC_PARAMS_RC_VBR = 1`,
94 /// `NV_ENC_PARAMS_RC_CBR = 2`. We only emit CONSTQP (archive) or
95 /// VBR+targetQuality (all other tiers) — CBR is never used by
96 /// this service.
97 pub rc_mode: NvencRateControl,
98 /// AV1 CQ target (for VBR mode) or constant QP (for CONSTQP mode).
99 /// Range 0–63 for AV1 (NOT 0-51 — that range is H.264/HEVC).
100 pub cq: u8,
101 /// Preset GUID raw bytes, ready to splat into a `#[repr(C)] Guid`.
102 /// Order: data1 (4 bytes, u32 LE), data2 (2 bytes u16 LE),
103 /// data3 (2 bytes u16 LE), data4 (8 raw bytes).
104 pub preset_guid: [u8; 16],
105 /// `NV_ENC_TUNING_INFO` — always `HIGH_QUALITY (1)` for this
106 /// service; never low-latency.
107 pub tuning_info: u32,
108 /// Adaptive quantization strength 0–15. 0 disables AQ. ~8 is
109 /// a reasonable default under HIGH_QUALITY tuning.
110 pub aq_strength: u8,
111 /// Lookahead depth (frames). 0 disables. Higher = better quality
112 /// bias at cost of latency.
113 pub lookahead_depth: u32,
114 /// `NV_ENC_CONFIG_AV1.numTileColumns`.
115 pub num_tile_columns: u32,
116 /// `NV_ENC_CONFIG_AV1.numTileRows`.
117 pub num_tile_rows: u32,
118 /// `NV_ENC_CONFIG_AV1.outputAnnexBFormat`. Always 0 (LOB) for
119 /// MP4 muxing — AV1-ISOBMFF requires `obu_has_size_field = 1`.
120 pub output_annex_b_format: u32,
121 /// `NV_ENC_CONFIG_AV1.repeatSeqHdr`. Always 1 so every IDR
122 /// carries a sequence header for seeking.
123 pub repeat_seq_hdr: u32,
124}
125
126/// NVENC rate control modes actually used by this service. The numeric
127/// value matches the SDK's `NV_ENC_PARAMS_RC_MODE`.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[repr(u32)]
130pub enum NvencRateControl {
131 /// `NV_ENC_PARAMS_RC_CONSTQP = 0`. Every frame gets the same QP.
132 /// Strict archival mode — bitrate floats.
133 ConstQp = 0,
134 /// `NV_ENC_PARAMS_RC_VBR = 1` with `targetQuality` set. NVENC's
135 /// CQ mode — quality-stable across content.
136 VbrTargetQuality = 1,
137}
138
139/// Concrete parameters for AMD AMF AV1 (VCN on RDNA3+).
140///
141/// AMF is property-driven: every knob is set via
142/// `AMFComponent::SetProperty(name, value)` using wide-string names
143/// defined in `vendor/amd/VideoEncoderAV1.h`. The adapter emits integer
144/// ranges that exactly match the property-value ranges the AMF runtime
145/// accepts — out-of-range values return `AMF_INVALID_ARG`.
146///
147/// Consumed in `crates/codec/src/encode/amf.rs::AmfEncoder::new`.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct AmfAv1Params {
150 /// `AMF_VIDEO_ENCODER_AV1_RATE_CONTROL_METHOD`. CQP for archive,
151 /// QVBR (quality-VBR) for the common quality-target tiers.
152 pub rc_mode: AmfRateControl,
153 /// `AMF_VIDEO_ENCODER_AV1_Q_INDEX_INTRA`. AV1 QP index 0..255 (the
154 /// full AV1 quantizer range — NOT 0..63; that's NVENC's scale).
155 pub q_index_intra: u8,
156 /// `AMF_VIDEO_ENCODER_AV1_Q_INDEX_INTER`. Usually +8 on intra so
157 /// P-frames spend fewer bits.
158 pub q_index_inter: u8,
159 /// `AMF_VIDEO_ENCODER_AV1_QVBR_QUALITY_LEVEL`. 1..100 when
160 /// rc_mode == `QualityVbr`; ignored for CQP. Higher = better.
161 pub qvbr_quality: u8,
162 /// `AMF_VIDEO_ENCODER_AV1_QUALITY_PRESET`. Lower = better quality.
163 pub quality_preset: AmfQualityPreset,
164 /// `AMF_VIDEO_ENCODER_AV1_GOP_SIZE`. Frames between keyframes.
165 pub gop_size: u32,
166 /// `AMF_VIDEO_ENCODER_AV1_AQ_MODE`. 0=off, 1=CAQ (content-adaptive).
167 pub aq_mode: u32,
168 /// `AMF_VIDEO_ENCODER_AV1_TILES_PER_FRAME`. AMF picks the grid;
169 /// we specify the total. 1 tile at ≤1080p, 4 at 1080p+, 4 at 4K
170 /// (VCN is less tile-parallel than rav1e — more tiles hurts HQ).
171 pub tiles_per_frame: u32,
172}
173
174/// AMF AV1 quality presets. Values match `AMF_VIDEO_ENCODER_AV1_QUALITY_PRESET_*`
175/// constants from the GPUOpen AMF wiki. Lower = better quality / more wall-clock.
176/// The transcode service never picks `Speed` (same rationale as NVENC: no
177/// low-latency presets in this service — see research §2.4).
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179#[repr(i64)]
180pub enum AmfQualityPreset {
181 HighQuality = 10,
182 Quality = 30,
183 Balanced = 50,
184 /// Not used by this service; kept in the enum so the mapping table
185 /// stays complete for any future ultra-low-latency path.
186 #[allow(dead_code)]
187 Speed = 70,
188}
189
190/// AMF AV1 rate control modes actually used by this service.
191/// Values match `AMF_VIDEO_ENCODER_AV1_RATE_CONTROL_METHOD_*`.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193#[repr(i64)]
194pub enum AmfRateControl {
195 /// `AMF_VIDEO_ENCODER_AV1_RATE_CONTROL_METHOD_CQP = 1`. Every
196 /// frame gets the same q-index. Archival.
197 Cqp = 1,
198 /// `AMF_VIDEO_ENCODER_AV1_RATE_CONTROL_METHOD_QUALITY_VBR = 5`.
199 /// Quality-target VBR — bitrate floats to hit a perceptual level.
200 QualityVbr = 5,
201}
202
203/// Concrete parameters for Intel QSV AV1 (oneVPL on Arc / Meteor Lake+).
204///
205/// oneVPL is struct-driven: `mfxVideoParam` carries every knob in fixed
206/// fields (no property bag). The adapter produces the exact values we
207/// splat into the struct in `crates/codec/src/encode/qsv.rs`.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct QsvAv1Params {
210 /// `mfxVideoParam.mfx.RateControlMethod`. ICQ for the common
211 /// quality targets; CQP for archive.
212 pub rc_mode: QsvRateControl,
213 /// `mfxVideoParam.mfx.ICQQuality` (ICQ mode) — 1..51 for AV1 per
214 /// oneVPL 2.8+ dispatcher. 1=best, 51=worst. Mapped from libaom CQ.
215 pub icq_quality: u16,
216 /// `mfxVideoParam.mfx.QPI` (CQP mode) — AV1 q-index 0..255.
217 pub qp_i: u16,
218 /// `mfxVideoParam.mfx.QPP` (CQP mode) — inter-frame QP.
219 pub qp_p: u16,
220 /// `mfxVideoParam.mfx.TargetUsage`. 1=best quality, 7=best speed.
221 pub target_usage: u16,
222 /// `mfxVideoParam.mfx.GopPicSize`. Frames between keyframes.
223 pub gop_pic_size: u16,
224 /// Tile grid — `mfxExtAV1TileParam.NumTileColumns` / `NumTileRows`.
225 pub num_tile_columns: u8,
226 pub num_tile_rows: u8,
227 /// `mfxVideoParam.mfx.LowPower`. Always
228 /// `MFX_CODINGOPTION_OFF = 32` for this service — the low-power
229 /// path on older Arc silicon has documented quality regressions;
230 /// leaving it explicitly OFF sidesteps that.
231 pub low_power: u16,
232}
233
234/// oneVPL tri-state option values (from `MFX_CODINGOPTION_*`).
235/// Used for `LowPower` and a handful of other `mfxU16` toggles.
236pub const MFX_CODINGOPTION_OFF: u16 = 32;
237/// Not currently used but named so the value shows up next to `OFF`
238/// whenever a future code path wants explicit on-switching.
239#[allow(dead_code)]
240pub const MFX_CODINGOPTION_ON: u16 = 16;
241
242/// QSV AV1 rate control mode values match `MFX_RATECONTROL_*`
243/// in `oneVPL/include/vpl/mfxstructs.h`.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245#[repr(u16)]
246pub enum QsvRateControl {
247 /// `MFX_RATECONTROL_CQP = 3`.
248 Cqp = 3,
249 /// `MFX_RATECONTROL_ICQ = 8`. Intelligent constant quality — the
250 /// QSV equivalent of CRF. Best match for a perceptual target.
251 Icq = 8,
252}
253
254// NVENC preset GUIDs from Video Codec SDK 12.2 headers. Bytes are the
255// raw #[repr(C)] serialization: u32 LE, u16 LE, u16 LE, [u8;8].
256//
257// Only P5, P6, P7 are exposed — the transcode service has no use for
258// the low-latency presets P1-P4.
259
260// NVENC SDK 13.0 preset GUIDs (vendor/nvidia/nvEncodeAPI.h:226-251).
261//
262// CRITICAL: SDK 12.2 had ENTIRELY DIFFERENT preset GUIDs for P5/P6/P7
263// — when we vendored SDK 13's nvEncodeAPI.h on 2026-05-01 we updated
264// the NvEncFunctionList ordering + struct layouts but missed that the
265// preset-GUID values themselves were also reshuffled. Sending SDK
266// 12.2 P5/P6/P7 GUIDs to a SDK 13 driver returned NV_ENC_ERR_UNSUPPORTED_PARAM
267// (rc=12) from NvEncGetEncodePresetConfigEx (the driver doesn't
268// recognise the old GUIDs and rejects the lookup). For reference, the
269// 12.2 → 13 GUID rotation:
270// P5: d0918ee2-a509-4681-af96-e9c3c45b7aa7 → 21c6e6b4-297a-4cba-998f-b6cbde72ade3
271// P6: fc8ebf15-6e19-47b4-8ea7-b1917f379eed → 8e75c279-6299-4ab6-8302-0b215a335cf5
272// P7: 84bdda58-33cb-4895-a372-ddeddb013ac4 → 84848c12-6f71-4c13-931b-53e283f57974
273const NV_ENC_PRESET_P5_GUID_BYTES: [u8; 16] = [
274 0xb4, 0xe6, 0xc6, 0x21, // data1 = 0x21c6e6b4
275 0x7a, 0x29, // data2 = 0x297a
276 0xba, 0x4c, // data3 = 0x4cba
277 0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3,
278];
279
280const NV_ENC_PRESET_P6_GUID_BYTES: [u8; 16] = [
281 0x79, 0xc2, 0x75, 0x8e, // data1 = 0x8e75c279
282 0x99, 0x62, // data2 = 0x6299
283 0xb6, 0x4a, // data3 = 0x4ab6
284 0x83, 0x02, 0x0b, 0x21, 0x5a, 0x33, 0x5c, 0xf5,
285];
286
287const NV_ENC_PRESET_P7_GUID_BYTES: [u8; 16] = [
288 0x12, 0x8c, 0x84, 0x84, // data1 = 0x84848c12
289 0x71, 0x6f, // data2 = 0x6f71
290 0x13, 0x4c, // data3 = 0x4c13
291 0x93, 0x1b, 0x53, 0xe2, 0x83, 0xf5, 0x79, 0x74,
292];
293
294/// SDK constant `NV_ENC_TUNING_INFO_HIGH_QUALITY = 1`.
295pub const NVENC_TUNING_HIGH_QUALITY: u32 = 1;
296
297// ─── Adapter functions ───────────────────────────────────────────
298
299/// Derive rav1e params for a given quality target + speed tier +
300/// resolution.
301pub fn rav1e_params(
302 target: QualityTarget,
303 tier: SpeedTier,
304 width: u32,
305 height: u32,
306) -> Rav1eParams {
307 // rav1e quantizer ≈ 4 × libaom cq-level (well-known rule of thumb;
308 // see docs/av1-tuning-research.md §2.3).
309 let libaom_cq = libaom_cq_for_target(target);
310 let quantizer = (libaom_cq as usize) * 4;
311
312 let speed_preset = match tier {
313 SpeedTier::Archive => 4,
314 SpeedTier::Standard => 6,
315 SpeedTier::Draft => 8,
316 };
317
318 // rav1e has high per-tile overhead and benefits from parallelism;
319 // use the generous tile grid at 4K (4x4 = 16 tiles).
320 let (tile_cols, tile_rows) = tile_grid_rav1e(width, height);
321
322 Rav1eParams {
323 quantizer,
324 speed_preset,
325 tile_rows,
326 tile_cols,
327 }
328}
329
330/// Derive NVENC AV1 params for a given quality target + speed tier +
331/// resolution.
332pub fn nvenc_av1_params(
333 target: QualityTarget,
334 tier: SpeedTier,
335 width: u32,
336 height: u32,
337) -> NvencAv1Params {
338 // Calibrated CQ values: NVENC AV1 needs ~3-4 lower CQ to hit the
339 // same VMAF as libaom, compensating for its lower compression
340 // efficiency. See research §2.4.
341 let cq = nvenc_cq_for_target(target);
342
343 let (preset_guid, lookahead_depth, aq_strength) = match tier {
344 SpeedTier::Archive => (NV_ENC_PRESET_P7_GUID_BYTES, 32, 10),
345 SpeedTier::Standard => (NV_ENC_PRESET_P6_GUID_BYTES, 16, 8),
346 SpeedTier::Draft => (NV_ENC_PRESET_P5_GUID_BYTES, 0, 6),
347 };
348
349 // Archive tier uses CONSTQP for reproducible bitstreams; every
350 // other tier uses VBR with targetQuality so bitrate floats by
351 // content complexity.
352 let rc_mode = match target {
353 QualityTarget::VisuallyLossless => NvencRateControl::ConstQp,
354 _ => NvencRateControl::VbrTargetQuality,
355 };
356
357 // NVENC AV1 HQ tuning: fewer tiles = better compression because
358 // tile boundaries break loop-filter continuity and AV1 tiles are
359 // independently entropy-coded. Published measurements show ~0.6%
360 // VMAF loss at 2 tiles, ~1.3% at 4+ tiles on libaom; NVENC HQ
361 // exhibits the same scaling. NVENC has enough internal parallelism
362 // that it doesn't need 16-tile grids for throughput the way rav1e
363 // does — cap at 2x2 even at 4K.
364 // Reference: research §3 and
365 // https://streaminglearningcenter.com/codecs/av1-encoding-and-4k.html
366 let (num_tile_columns, num_tile_rows) = tile_grid_nvenc(width, height);
367
368 NvencAv1Params {
369 rc_mode,
370 cq,
371 preset_guid,
372 tuning_info: NVENC_TUNING_HIGH_QUALITY,
373 aq_strength,
374 lookahead_depth,
375 num_tile_columns: num_tile_columns as u32,
376 num_tile_rows: num_tile_rows as u32,
377 output_annex_b_format: 0, // LOB for MP4
378 repeat_seq_hdr: 1,
379 }
380}
381
382/// Derive AMD AMF AV1 params for a given quality target + speed tier +
383/// resolution.
384///
385/// AMF's AV1 q-index scale is 0..255 (the full AV1 quantizer range, not
386/// the NVENC-style 0..63 CQ band). Start point is rav1e's `4 × libaom_cq`
387/// rule, then apply an 8-point calibration shift down to compensate for
388/// VCN's documented compression-efficiency gap vs libaom (same goughlui
389/// study that calibrated NVENC's 3-4-point CQ shift tested AMF VCN and
390/// reported an analogous ~2-point CQ-equivalent shift; 2 points × 4 ≈ 8
391/// in the 0..255 space).
392///
393/// TODO(calibrate): replace these seed anchors with calibrated values
394/// once av1-tuning-eng runs the offline VMAF pass on RDNA3 hardware.
395/// See `docs/av1-tuning-research.md` §2.5 for the calibration protocol.
396pub fn amf_av1_params(
397 target: QualityTarget,
398 tier: SpeedTier,
399 width: u32,
400 height: u32,
401) -> AmfAv1Params {
402 let q_index_intra = amf_q_index_for_target(target);
403 // Inter-frames get a slightly higher QP so P/B frames spend fewer
404 // bits — biases bit allocation toward keyframes, which matches how
405 // rav1e and NVENC CONSTQP mode behave.
406 let q_index_inter = q_index_intra.saturating_add(8);
407
408 // QVBR quality 1..100; higher = better. Map our VMAF-band targets
409 // to the AMF-native band: VL=95, High=85, Standard=70, Low=55.
410 let qvbr_quality = match target {
411 QualityTarget::VisuallyLossless => 95,
412 QualityTarget::High => 85,
413 QualityTarget::Standard => 70,
414 QualityTarget::Low => 55,
415 QualityTarget::Vmaf(v) => vmaf_to_qvbr_quality(v),
416 };
417
418 // AMF quality preset per SpeedTier. Archive → HighQuality (best
419 // but slowest), Standard → Quality, Draft → Balanced. `Speed`
420 // preset deliberately unused — same rule as NVENC's P1-P4
421 // exclusion (see research §2.4: no low-latency tunings for batch
422 // transcode).
423 let quality_preset = match tier {
424 SpeedTier::Archive => AmfQualityPreset::HighQuality,
425 SpeedTier::Standard => AmfQualityPreset::Quality,
426 SpeedTier::Draft => AmfQualityPreset::Balanced,
427 };
428
429 // CQP for archival-lossless runs (reproducible bitstream); QVBR
430 // for everything else — matches the NVENC branch structure.
431 let rc_mode = match target {
432 QualityTarget::VisuallyLossless => AmfRateControl::Cqp,
433 _ => AmfRateControl::QualityVbr,
434 };
435
436 // AMF VCN tile parallelism is similar to NVENC — fewer tiles =
437 // better compression. Share the NVENC 2×2 cap via `tile_grid_hw`
438 // (both are "HQ-equivalent HW encoders that don't need aggressive
439 // tiling for throughput"). Total tiles = cols × rows; at 1×1 that's
440 // one, at 2×2 that's 4.
441 let (tile_cols, tile_rows) = tile_grid_hw(width, height);
442 let tiles_per_frame = (tile_cols * tile_rows) as u32;
443
444 AmfAv1Params {
445 rc_mode,
446 q_index_intra,
447 q_index_inter,
448 qvbr_quality,
449 quality_preset,
450 gop_size: 0, // caller fills from keyframe_interval
451 aq_mode: 1, // CAQ — content-adaptive QP on
452 tiles_per_frame,
453 }
454}
455
456/// Derive Intel QSV AV1 params for a given quality target + speed tier +
457/// resolution.
458///
459/// oneVPL exposes two sensible modes for quality-driven encoding: ICQ
460/// (intelligent constant quality, 1..51 for AV1 — 1=best) and CQP
461/// (constant q-index, 0..255). ICQ is the default; CQP is the archival
462/// path. ICQ quality maps near-linearly to libaom cq-level at the range
463/// we care about (research §2.6, calibrated from Intel's public
464/// oneVPL sample_encode benchmarks).
465pub fn qsv_av1_params(
466 target: QualityTarget,
467 tier: SpeedTier,
468 width: u32,
469 height: u32,
470) -> QsvAv1Params {
471 // ICQ quality 1..51; 1=best. QSV maps AV1's native 0..63 CQ range
472 // into the 0..51 scale for API parity with H.264/HEVC (oneVPL
473 // idiosyncrasy), so we scale libaom cq-level by 51/63 ≈ 0.81.
474 // VL: libaom 20 × 51/63 ≈ 16
475 // Hi: libaom 27 × 51/63 ≈ 22
476 // Std: libaom 32 × 51/63 ≈ 26
477 // Low: libaom 38 × 51/63 ≈ 31
478 let icq_quality = match target {
479 QualityTarget::VisuallyLossless => 16,
480 QualityTarget::High => 22,
481 QualityTarget::Standard => 26,
482 QualityTarget::Low => 31,
483 QualityTarget::Vmaf(v) => vmaf_to_qsv_icq(v),
484 };
485 // CQP q-index for archival — QSV uses the full AV1 0..255 range
486 // via `mfx.QPI`. Same 4× libaom mapping as rav1e/AMF.
487 let libaom_cq = libaom_cq_for_target(target);
488 let qp_i = (libaom_cq as u16 * 4).min(255);
489 let qp_p = qp_i.saturating_add(8).min(255);
490
491 // oneVPL TargetUsage: 1=best quality, 7=best speed. Per
492 // av1-tuning-eng review: Archive=1, Standard=4, Draft=6
493 // (not 7 — 6 still leaves headroom for the driver's
494 // "adaptive speed" selections without falling into the explicit
495 // "worst-quality" bucket).
496 let target_usage = match tier {
497 SpeedTier::Archive => 1,
498 SpeedTier::Standard => 4,
499 SpeedTier::Draft => 6,
500 };
501
502 let rc_mode = match target {
503 QualityTarget::VisuallyLossless => QsvRateControl::Cqp,
504 _ => QsvRateControl::Icq,
505 };
506
507 let (num_tile_columns, num_tile_rows) = tile_grid_hw(width, height);
508
509 QsvAv1Params {
510 rc_mode,
511 icq_quality,
512 qp_i,
513 qp_p,
514 target_usage,
515 gop_pic_size: 0, // caller fills from keyframe_interval
516 num_tile_columns: num_tile_columns as u8,
517 num_tile_rows: num_tile_rows as u8,
518 // AV1 QSV encode is VDENC (low-power) only on Arc / Meteor Lake+.
519 low_power: MFX_CODINGOPTION_ON,
520 }
521}
522
523// ─── Internal helpers ────────────────────────────────────────────
524
525/// libaom `cq-level` that corresponds to a given QualityTarget. libaom
526/// is the cross-encoder reference: we equalize other encoders *to*
527/// libaom's VMAF at each CQ.
528///
529/// Exposed `pub` so the FFmpeg-wrapper encoder path
530/// (`encode::ffmpeg_enc`) can route `libsvtav1` / `libaom-av1` through
531/// the same adapter tables as the native encoders.
532pub fn libaom_cq_for_target(target: QualityTarget) -> u8 {
533 match target {
534 QualityTarget::VisuallyLossless => 20,
535 QualityTarget::High => 27,
536 QualityTarget::Standard => 32,
537 QualityTarget::Low => 38,
538 QualityTarget::Vmaf(v) => vmaf_to_libaom_cq(v),
539 }
540}
541
542/// NVENC CQ that hits the same VMAF as `libaom_cq_for_target`, per
543/// the research doc §2.4.
544fn nvenc_cq_for_target(target: QualityTarget) -> u8 {
545 match target {
546 QualityTarget::VisuallyLossless => 19,
547 QualityTarget::High => 25,
548 QualityTarget::Standard => 30,
549 QualityTarget::Low => 36,
550 QualityTarget::Vmaf(v) => vmaf_to_nvenc_cq(v),
551 }
552}
553
554/// Anchor points for libaom VMAF↔cq-level (research §2.1). Must stay
555/// in descending VMAF order; `piecewise_cq` below depends on it.
556const LIBAOM_ANCHORS: &[(i32, i32)] = &[
557 (100, 10), // asymptote beyond VisuallyLossless
558 (98, 20),
559 (95, 27),
560 (90, 32),
561 (85, 38),
562 (70, 55), // low-quality extrapolation
563];
564
565/// Anchor points for NVENC AV1 VMAF↔CQ. Calibrated down from libaom
566/// to compensate for NVENC's documented compression-efficiency gap
567/// (research §2.4). Same VMAF → lower CQ than libaom.
568const NVENC_ANCHORS: &[(i32, i32)] = &[(100, 10), (98, 19), (95, 25), (90, 30), (85, 36), (70, 52)];
569
570/// Piecewise-linear interpolation between anchors. Anchors are
571/// `(vmaf, cq)` pairs in descending VMAF order. Out-of-range VMAF
572/// values clamp to the nearest anchor's CQ.
573fn piecewise_cq(vmaf: u8, anchors: &[(i32, i32)]) -> u8 {
574 let v = vmaf as i32;
575 // Above the top anchor: return its CQ (asymptote).
576 if v >= anchors[0].0 {
577 return anchors[0].1.clamp(0, 63) as u8;
578 }
579 // Below the bottom anchor: return its CQ.
580 let last = anchors.len() - 1;
581 if v <= anchors[last].0 {
582 return anchors[last].1.clamp(0, 63) as u8;
583 }
584 // Linear interpolation between surrounding anchors.
585 for pair in anchors.windows(2) {
586 let (v_hi, cq_hi) = pair[0];
587 let (v_lo, cq_lo) = pair[1];
588 if v <= v_hi && v >= v_lo {
589 let span = v_hi - v_lo;
590 if span == 0 {
591 return cq_hi.clamp(0, 63) as u8;
592 }
593 let t = v_hi - v; // 0 at high anchor, span at low anchor
594 let cq = cq_hi + (cq_lo - cq_hi) * t / span;
595 return cq.clamp(0, 63) as u8;
596 }
597 }
598 anchors[last].1.clamp(0, 63) as u8
599}
600
601fn vmaf_to_libaom_cq(vmaf: u8) -> u8 {
602 piecewise_cq(vmaf, LIBAOM_ANCHORS)
603}
604
605fn vmaf_to_nvenc_cq(vmaf: u8) -> u8 {
606 piecewise_cq(vmaf, NVENC_ANCHORS)
607}
608
609/// Tile grid for rav1e (CPU). Returns `(columns, rows)`, literal counts.
610/// rav1e is memory-bandwidth-limited and benefits from aggressive tiling
611/// even at the cost of a small quality hit, because tile parallelism is
612/// most of its throughput story at 4K+.
613fn tile_grid_rav1e(width: u32, height: u32) -> (usize, usize) {
614 let max_dim = width.max(height);
615 if max_dim >= 3840 {
616 (4, 4) // 16 tiles at 4K — rav1e fans out across cores
617 } else if max_dim >= 1920 {
618 (2, 2)
619 } else {
620 (1, 1)
621 }
622}
623
624/// Tile grid for NVENC AV1. Returns `(columns, rows)`. NVENC has enough
625/// internal parallelism that it does not need large tile grids for
626/// throughput — and its HIGH_QUALITY tuning is sensitive to the ~1%
627/// quality cost per extra tile row/column (tile boundaries break loop
628/// filter continuity, and AV1 tiles are entropy-coded independently).
629/// Cap at 2×2 even at 4K.
630fn tile_grid_nvenc(width: u32, height: u32) -> (usize, usize) {
631 let max_dim = width.max(height);
632 if max_dim >= 1920 { (2, 2) } else { (1, 1) }
633}
634
635/// Shared HW-encoder tile grid. Used by NVENC, AMF, and QSV — all
636/// three are "HQ-equivalent hardware encoders" that don't need rav1e's
637/// aggressive tiling for throughput and are sensitive to the ~1%
638/// quality cost per extra tile row/column. Cap at 2×2 even at 4K.
639///
640/// This is an alias over `tile_grid_nvenc` so the shared rule is
641/// explicit at call sites. Changing the shared cap is a one-line
642/// change here.
643fn tile_grid_hw(width: u32, height: u32) -> (usize, usize) {
644 tile_grid_nvenc(width, height)
645}
646
647/// AMF CQP q-index (0..255) for a given QualityTarget. Starts from
648/// `libaom_cq × 4` and subtracts an 8-point calibration shift to
649/// compensate for VCN's compression-efficiency gap — analogous to
650/// NVENC's 3-4-point CQ shift in 0..63 space.
651///
652/// TODO(calibrate): replace with anchors from the offline VMAF pass
653/// on RDNA3 hardware. Seed values come from av1-tuning-eng's research
654/// doc §2.5 and GPUOpen AMF tuning guide.
655fn amf_q_index_for_target(target: QualityTarget) -> u8 {
656 let base = match target {
657 QualityTarget::VisuallyLossless => 72, // libaom 20 × 4 - 8
658 QualityTarget::High => 100, // libaom 27 × 4 - 8
659 QualityTarget::Standard => 120, // libaom 32 × 4 - 8
660 QualityTarget::Low => 144, // libaom 38 × 4 - 8
661 QualityTarget::Vmaf(v) => vmaf_to_amf_q_index(v),
662 };
663 base.min(255) as u8
664}
665
666/// Anchors for AMF q-index interpolation when a caller passes an
667/// explicit Vmaf target. Descending VMAF → ascending q-index.
668const AMF_Q_INDEX_ANCHORS: &[(i32, i32)] = &[
669 (100, 50), // asymptote below VisuallyLossless
670 (98, 72),
671 (95, 100),
672 (90, 120),
673 (85, 144),
674 (70, 200),
675];
676
677fn vmaf_to_amf_q_index(vmaf: u8) -> u16 {
678 piecewise_quality(vmaf, AMF_Q_INDEX_ANCHORS, 0, 255) as u16
679}
680
681/// AMF anchors: AMF's QVBR quality scale is 1..100 (higher = better).
682/// Calibrated from research §2.5 against libaom at matched VMAF.
683const AMF_QVBR_ANCHORS: &[(i32, i32)] =
684 &[(100, 100), (98, 95), (95, 85), (90, 70), (85, 55), (70, 35)];
685
686fn vmaf_to_qvbr_quality(vmaf: u8) -> u8 {
687 piecewise_quality(vmaf, AMF_QVBR_ANCHORS, 1, 100)
688}
689
690/// QSV ICQ scale is 1..51 (lower = better), inverted from AMF's QVBR.
691/// Anchor table reflects Intel's public oneVPL sample benchmarks.
692const QSV_ICQ_ANCHORS: &[(i32, i32)] =
693 &[(100, 8), (98, 18), (95, 24), (90, 30), (85, 36), (70, 48)];
694
695fn vmaf_to_qsv_icq(vmaf: u8) -> u16 {
696 piecewise_quality(vmaf, QSV_ICQ_ANCHORS, 1, 51) as u16
697}
698
699/// Generic piecewise-linear interpolator for non-CQ scales. Mirrors
700/// `piecewise_cq` but with configurable clamp bounds so the same logic
701/// serves AMF's 1..100 and QSV's 1..51.
702fn piecewise_quality(vmaf: u8, anchors: &[(i32, i32)], lo: i32, hi: i32) -> u8 {
703 let v = vmaf as i32;
704 if v >= anchors[0].0 {
705 return anchors[0].1.clamp(lo, hi) as u8;
706 }
707 let last = anchors.len() - 1;
708 if v <= anchors[last].0 {
709 return anchors[last].1.clamp(lo, hi) as u8;
710 }
711 for pair in anchors.windows(2) {
712 let (v_hi, q_hi) = pair[0];
713 let (v_lo, q_lo) = pair[1];
714 if v <= v_hi && v >= v_lo {
715 let span = v_hi - v_lo;
716 if span == 0 {
717 return q_hi.clamp(lo, hi) as u8;
718 }
719 let t = v_hi - v;
720 let q = q_hi + (q_lo - q_hi) * t / span;
721 return q.clamp(lo, hi) as u8;
722 }
723 }
724 anchors[last].1.clamp(lo, hi) as u8
725}
726
727// ─── Unit tests ──────────────────────────────────────────────────
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 const RESOLUTIONS: &[(u32, u32)] = &[
734 (640, 360), // 360p — single tile
735 (854, 480), // 480p — single tile
736 (1280, 720), // 720p — single tile
737 (1920, 1080), // 1080p — 2x2
738 (2560, 1440), // 1440p — 2x2
739 (3840, 2160), // 4K — 4x4
740 ];
741
742 const TARGETS: &[QualityTarget] = &[
743 QualityTarget::VisuallyLossless,
744 QualityTarget::High,
745 QualityTarget::Standard,
746 QualityTarget::Low,
747 ];
748
749 const TIERS: &[SpeedTier] = &[SpeedTier::Draft, SpeedTier::Standard, SpeedTier::Archive];
750
751 #[test]
752 fn rav1e_every_combination_returns_valid_params() {
753 for (w, h) in RESOLUTIONS {
754 for target in TARGETS {
755 for tier in TIERS {
756 let p = rav1e_params(*target, *tier, *w, *h);
757 assert!(p.quantizer <= 255, "quantizer {} oob", p.quantizer);
758 assert!(p.speed_preset <= 10, "speed_preset {} oob", p.speed_preset);
759 assert!(p.tile_rows >= 1);
760 assert!(p.tile_cols >= 1);
761 }
762 }
763 }
764 }
765
766 #[test]
767 fn nvenc_every_combination_returns_valid_params() {
768 for (w, h) in RESOLUTIONS {
769 for target in TARGETS {
770 for tier in TIERS {
771 let p = nvenc_av1_params(*target, *tier, *w, *h);
772 assert!(p.cq <= 63, "cq {} exceeds AV1 max 63", p.cq);
773 assert_eq!(p.tuning_info, NVENC_TUNING_HIGH_QUALITY);
774 assert_eq!(p.output_annex_b_format, 0, "must be LOB for MP4");
775 assert_eq!(p.repeat_seq_hdr, 1, "every IDR needs seq hdr");
776 assert!(p.aq_strength <= 15);
777 }
778 }
779 }
780 }
781
782 #[test]
783 fn rav1e_quantizer_monotonic_in_quality() {
784 // Higher-quality targets must produce lower (stricter) quantizer.
785 let sd1080 = (1920, 1080);
786 let vl = rav1e_params(
787 QualityTarget::VisuallyLossless,
788 SpeedTier::Standard,
789 sd1080.0,
790 sd1080.1,
791 );
792 let hi = rav1e_params(QualityTarget::High, SpeedTier::Standard, sd1080.0, sd1080.1);
793 let std = rav1e_params(
794 QualityTarget::Standard,
795 SpeedTier::Standard,
796 sd1080.0,
797 sd1080.1,
798 );
799 let lo = rav1e_params(QualityTarget::Low, SpeedTier::Standard, sd1080.0, sd1080.1);
800 assert!(vl.quantizer < hi.quantizer);
801 assert!(hi.quantizer < std.quantizer);
802 assert!(std.quantizer < lo.quantizer);
803 }
804
805 #[test]
806 fn nvenc_cq_monotonic_in_quality() {
807 let sd = (1920, 1080);
808 let vl = nvenc_av1_params(
809 QualityTarget::VisuallyLossless,
810 SpeedTier::Standard,
811 sd.0,
812 sd.1,
813 );
814 let hi = nvenc_av1_params(QualityTarget::High, SpeedTier::Standard, sd.0, sd.1);
815 let std = nvenc_av1_params(QualityTarget::Standard, SpeedTier::Standard, sd.0, sd.1);
816 let lo = nvenc_av1_params(QualityTarget::Low, SpeedTier::Standard, sd.0, sd.1);
817 assert!(vl.cq < hi.cq);
818 assert!(hi.cq < std.cq);
819 assert!(std.cq < lo.cq);
820 }
821
822 #[test]
823 fn rav1e_speed_preset_monotonic_in_tier() {
824 let vl = QualityTarget::Standard;
825 let (w, h) = (1920, 1080);
826 let arc = rav1e_params(vl, SpeedTier::Archive, w, h);
827 let std = rav1e_params(vl, SpeedTier::Standard, w, h);
828 let drf = rav1e_params(vl, SpeedTier::Draft, w, h);
829 // Faster tiers -> higher preset number in rav1e.
830 assert!(arc.speed_preset < std.speed_preset);
831 assert!(std.speed_preset < drf.speed_preset);
832 }
833
834 #[test]
835 fn tile_grid_rav1e_by_resolution() {
836 assert_eq!(tile_grid_rav1e(640, 360), (1, 1));
837 assert_eq!(tile_grid_rav1e(1280, 720), (1, 1));
838 assert_eq!(tile_grid_rav1e(1920, 1080), (2, 2));
839 assert_eq!(tile_grid_rav1e(2560, 1440), (2, 2));
840 assert_eq!(tile_grid_rav1e(3840, 2160), (4, 4));
841 assert_eq!(tile_grid_rav1e(4096, 2160), (4, 4));
842 // Portrait 1080x1920 still deserves tiling — use max dim.
843 assert_eq!(tile_grid_rav1e(1080, 1920), (2, 2));
844 }
845
846 #[test]
847 fn tile_grid_nvenc_caps_at_2x2() {
848 // NVENC HQ prefers fewer tiles — no 4x4 even at 4K.
849 assert_eq!(tile_grid_nvenc(640, 360), (1, 1));
850 assert_eq!(tile_grid_nvenc(1280, 720), (1, 1));
851 assert_eq!(tile_grid_nvenc(1920, 1080), (2, 2));
852 assert_eq!(tile_grid_nvenc(2560, 1440), (2, 2));
853 assert_eq!(tile_grid_nvenc(3840, 2160), (2, 2));
854 assert_eq!(tile_grid_nvenc(4096, 2160), (2, 2));
855 assert_eq!(tile_grid_nvenc(1080, 1920), (2, 2));
856 }
857
858 #[test]
859 fn archive_tier_uses_constqp_at_lossless() {
860 let p = nvenc_av1_params(
861 QualityTarget::VisuallyLossless,
862 SpeedTier::Archive,
863 1920,
864 1080,
865 );
866 assert_eq!(p.rc_mode, NvencRateControl::ConstQp);
867 }
868
869 #[test]
870 fn non_archive_tiers_use_vbr_cq() {
871 for target in [
872 QualityTarget::High,
873 QualityTarget::Standard,
874 QualityTarget::Low,
875 ] {
876 for tier in TIERS {
877 let p = nvenc_av1_params(target, *tier, 1920, 1080);
878 assert_eq!(
879 p.rc_mode,
880 NvencRateControl::VbrTargetQuality,
881 "target={:?} tier={:?} should use VBR+CQ",
882 target,
883 tier
884 );
885 }
886 }
887 }
888
889 #[test]
890 fn vmaf_escape_hatch_matches_named_targets() {
891 // VMAF 98 should map to roughly VisuallyLossless's CQ.
892 let vl = nvenc_cq_for_target(QualityTarget::VisuallyLossless);
893 let v98 = nvenc_cq_for_target(QualityTarget::Vmaf(98));
894 assert!(
895 (vl as i32 - v98 as i32).abs() <= 2,
896 "VMAF 98 escape hatch CQ={} should be within 2 of named VL CQ={}",
897 v98,
898 vl
899 );
900
901 // VMAF 90 should map near Standard's CQ.
902 let std = nvenc_cq_for_target(QualityTarget::Standard);
903 let v90 = nvenc_cq_for_target(QualityTarget::Vmaf(90));
904 assert!((std as i32 - v90 as i32).abs() <= 2);
905 }
906
907 #[test]
908 fn vmaf_escape_hatch_clamps_oob() {
909 // 0 and 255 shouldn't panic; clamp to valid CQ range.
910 let lo_cq = nvenc_cq_for_target(QualityTarget::Vmaf(0));
911 let hi_cq = nvenc_cq_for_target(QualityTarget::Vmaf(255));
912 assert!(lo_cq <= 63);
913 assert!(hi_cq <= 63);
914 // Low VMAF target -> high CQ. High VMAF target -> low CQ.
915 assert!(lo_cq > hi_cq);
916 }
917
918 #[test]
919 fn preset_guids_are_distinct() {
920 assert_ne!(NV_ENC_PRESET_P5_GUID_BYTES, NV_ENC_PRESET_P6_GUID_BYTES);
921 assert_ne!(NV_ENC_PRESET_P6_GUID_BYTES, NV_ENC_PRESET_P7_GUID_BYTES);
922 assert_ne!(NV_ENC_PRESET_P5_GUID_BYTES, NV_ENC_PRESET_P7_GUID_BYTES);
923 }
924
925 #[test]
926 fn rav1e_quantizer_matches_libaom_4x_rule() {
927 // docs rule: rav1e quantizer ≈ 4 × libaom cq-level.
928 let p = rav1e_params(QualityTarget::High, SpeedTier::Standard, 1920, 1080);
929 assert_eq!(p.quantizer, 27 * 4); // libaom cq-level for High = 27
930 let p = rav1e_params(QualityTarget::Standard, SpeedTier::Standard, 1920, 1080);
931 assert_eq!(p.quantizer, 32 * 4);
932 }
933
934 #[test]
935 fn default_quality_is_standard() {
936 let q: QualityTarget = Default::default();
937 assert_eq!(q, QualityTarget::Standard);
938 let t: SpeedTier = Default::default();
939 assert_eq!(t, SpeedTier::Standard);
940 }
941
942 #[test]
943 fn amf_every_combination_returns_valid_params() {
944 for (w, h) in RESOLUTIONS {
945 for target in TARGETS {
946 for tier in TIERS {
947 let p = amf_av1_params(*target, *tier, *w, *h);
948 // AV1 QP range is 0..255 — q_index_inter uses
949 // saturating add so it never wraps.
950 // q_index fields are u8, so the <=255 bound is
951 // structurally guaranteed — the meaningful check is
952 // that inter is at least as large as intra.
953 assert!(p.q_index_inter >= p.q_index_intra);
954 assert!((1..=100).contains(&p.qvbr_quality));
955 assert!(p.tiles_per_frame >= 1);
956 // Speed preset is not used by this service — any
957 // combination must stay in the HighQuality..Balanced
958 // band.
959 assert!(matches!(
960 p.quality_preset,
961 AmfQualityPreset::HighQuality
962 | AmfQualityPreset::Quality
963 | AmfQualityPreset::Balanced
964 ));
965 }
966 }
967 }
968 }
969
970 #[test]
971 fn qsv_every_combination_returns_valid_params() {
972 for (w, h) in RESOLUTIONS {
973 for target in TARGETS {
974 for tier in TIERS {
975 let p = qsv_av1_params(*target, *tier, *w, *h);
976 // oneVPL ICQ for AV1 is 1..51.
977 assert!((1..=51).contains(&p.icq_quality));
978 // AV1 q-index 0..255.
979 assert!(p.qp_i <= 255);
980 assert!(p.qp_p <= 255);
981 // TargetUsage is 1..7 per mfxstructs.h; we cap at
982 // 6 for Draft (av1-tuning-eng recommendation).
983 assert!((1..=6).contains(&p.target_usage));
984 // LowPower must be ON — AV1 QSV encode is VDENC-only on
985 // Intel (the only AV1 encode entry point the iHD driver
986 // exposes); OFF makes Query reject with MFX_ERR_UNSUPPORTED.
987 assert_eq!(p.low_power, MFX_CODINGOPTION_ON);
988 assert!(p.num_tile_columns >= 1);
989 assert!(p.num_tile_rows >= 1);
990 }
991 }
992 }
993 }
994
995 #[test]
996 fn amf_q_index_monotonic_in_quality() {
997 let (w, h) = (1920, 1080);
998 let vl = amf_av1_params(QualityTarget::VisuallyLossless, SpeedTier::Standard, w, h);
999 let hi = amf_av1_params(QualityTarget::High, SpeedTier::Standard, w, h);
1000 let std = amf_av1_params(QualityTarget::Standard, SpeedTier::Standard, w, h);
1001 let lo = amf_av1_params(QualityTarget::Low, SpeedTier::Standard, w, h);
1002 assert!(vl.q_index_intra < hi.q_index_intra);
1003 assert!(hi.q_index_intra < std.q_index_intra);
1004 assert!(std.q_index_intra < lo.q_index_intra);
1005 }
1006
1007 #[test]
1008 fn qsv_icq_monotonic_in_quality() {
1009 let (w, h) = (1920, 1080);
1010 let vl = qsv_av1_params(QualityTarget::VisuallyLossless, SpeedTier::Standard, w, h);
1011 let hi = qsv_av1_params(QualityTarget::High, SpeedTier::Standard, w, h);
1012 let std = qsv_av1_params(QualityTarget::Standard, SpeedTier::Standard, w, h);
1013 let lo = qsv_av1_params(QualityTarget::Low, SpeedTier::Standard, w, h);
1014 // Lower ICQ quality = higher visual quality, so values should
1015 // increase as the requested target drops.
1016 assert!(vl.icq_quality < hi.icq_quality);
1017 assert!(hi.icq_quality < std.icq_quality);
1018 assert!(std.icq_quality < lo.icq_quality);
1019 }
1020
1021 #[test]
1022 fn amf_archive_at_visually_lossless_uses_cqp() {
1023 let p = amf_av1_params(
1024 QualityTarget::VisuallyLossless,
1025 SpeedTier::Archive,
1026 1920,
1027 1080,
1028 );
1029 assert_eq!(p.rc_mode, AmfRateControl::Cqp);
1030 }
1031
1032 #[test]
1033 fn amf_non_vl_uses_quality_vbr() {
1034 for target in [
1035 QualityTarget::High,
1036 QualityTarget::Standard,
1037 QualityTarget::Low,
1038 ] {
1039 for tier in TIERS {
1040 let p = amf_av1_params(target, *tier, 1920, 1080);
1041 assert_eq!(p.rc_mode, AmfRateControl::QualityVbr);
1042 }
1043 }
1044 }
1045
1046 #[test]
1047 fn qsv_archive_at_visually_lossless_uses_cqp() {
1048 let p = qsv_av1_params(
1049 QualityTarget::VisuallyLossless,
1050 SpeedTier::Archive,
1051 1920,
1052 1080,
1053 );
1054 assert_eq!(p.rc_mode, QsvRateControl::Cqp);
1055 }
1056
1057 #[test]
1058 fn amf_quality_preset_tier_mapping() {
1059 // Archive → HighQuality, Standard → Quality, Draft → Balanced.
1060 // Speed preset is never selected by this service.
1061 let (w, h) = (1920, 1080);
1062 let arc = amf_av1_params(QualityTarget::Standard, SpeedTier::Archive, w, h);
1063 let std = amf_av1_params(QualityTarget::Standard, SpeedTier::Standard, w, h);
1064 let drf = amf_av1_params(QualityTarget::Standard, SpeedTier::Draft, w, h);
1065 assert_eq!(arc.quality_preset, AmfQualityPreset::HighQuality);
1066 assert_eq!(std.quality_preset, AmfQualityPreset::Quality);
1067 assert_eq!(drf.quality_preset, AmfQualityPreset::Balanced);
1068 }
1069
1070 #[test]
1071 fn qsv_target_usage_tier_ordering() {
1072 // Archive → TU 1 (best quality); Draft → TU 7 (best speed).
1073 let (w, h) = (1920, 1080);
1074 let arc = qsv_av1_params(QualityTarget::Standard, SpeedTier::Archive, w, h);
1075 let std = qsv_av1_params(QualityTarget::Standard, SpeedTier::Standard, w, h);
1076 let drf = qsv_av1_params(QualityTarget::Standard, SpeedTier::Draft, w, h);
1077 assert!(arc.target_usage < std.target_usage);
1078 assert!(std.target_usage < drf.target_usage);
1079 }
1080
1081 #[test]
1082 fn amf_tile_count_caps_at_4() {
1083 // RDNA3 VCN prefers few tiles — research §3 caps at 4 via tile_grid_hw.
1084 // tiles_per_frame = cols * rows on the shared 2×2-at-4K grid.
1085 fn tiles(w: u32, h: u32) -> usize {
1086 let (c, r) = tile_grid_hw(w, h);
1087 c * r
1088 }
1089 assert_eq!(tiles(640, 360), 1);
1090 assert_eq!(tiles(1280, 720), 1);
1091 assert_eq!(tiles(1920, 1080), 4);
1092 assert_eq!(tiles(3840, 2160), 4);
1093 }
1094
1095 #[test]
1096 fn qsv_tile_grid_caps_at_2x2() {
1097 // QSV AV1 shares `tile_grid_hw` with AMF/NVENC — capped at 2×2.
1098 assert_eq!(tile_grid_hw(640, 360), (1, 1));
1099 assert_eq!(tile_grid_hw(1280, 720), (1, 1));
1100 assert_eq!(tile_grid_hw(1920, 1080), (2, 2));
1101 assert_eq!(tile_grid_hw(3840, 2160), (2, 2));
1102 }
1103
1104 /// Regression test from codec-spec-reviewer's task #49 review: every
1105 /// tile grid produced by the adapter must fit inside AV1 Level 5.1
1106 /// limits (AV1 spec Annex A.3): ≤8 tile columns, ≤64 total tiles,
1107 /// per-tile width ≤4096 luma samples, per-tile area ≤4,230,144.
1108 /// Level 5.1 covers every resolution we ship up to 4096×2176.
1109 #[test]
1110 fn tile_grid_fits_av1_level_5_1() {
1111 const MAX_TILE_COLS_L51: u32 = 8;
1112 const MAX_TILES_L51: u32 = 64;
1113 const MAX_TILE_WIDTH_L51: u32 = 4096;
1114 const MAX_TILE_AREA_L51: u32 = 4_230_144;
1115
1116 for (w, h) in RESOLUTIONS {
1117 for (label, (cols, rows)) in [
1118 ("rav1e", tile_grid_rav1e(*w, *h)),
1119 ("nvenc", tile_grid_nvenc(*w, *h)),
1120 // AMF and QSV both share tile_grid_hw, which is today
1121 // an alias of tile_grid_nvenc — covering explicitly
1122 // so that if the alias diverges later, this regression
1123 // test catches the Level 5.1 compliance for HW paths.
1124 ("hw", tile_grid_hw(*w, *h)),
1125 ] {
1126 let cols = cols as u32;
1127 let rows = rows as u32;
1128
1129 assert!(
1130 cols <= MAX_TILE_COLS_L51,
1131 "{} {}x{} emits {} tile cols; Level 5.1 max is {}",
1132 label,
1133 w,
1134 h,
1135 cols,
1136 MAX_TILE_COLS_L51
1137 );
1138 assert!(
1139 cols * rows <= MAX_TILES_L51,
1140 "{} {}x{} emits {} total tiles; Level 5.1 max is {}",
1141 label,
1142 w,
1143 h,
1144 cols * rows,
1145 MAX_TILES_L51
1146 );
1147
1148 let tile_w = w.div_ceil(cols);
1149 let tile_h = h.div_ceil(rows);
1150 assert!(
1151 tile_w <= MAX_TILE_WIDTH_L51,
1152 "{} {}x{} per-tile width {} > Level 5.1 max {}",
1153 label,
1154 w,
1155 h,
1156 tile_w,
1157 MAX_TILE_WIDTH_L51
1158 );
1159 assert!(
1160 tile_w * tile_h <= MAX_TILE_AREA_L51,
1161 "{} {}x{} per-tile area {} > Level 5.1 max {}",
1162 label,
1163 w,
1164 h,
1165 tile_w * tile_h,
1166 MAX_TILE_AREA_L51
1167 );
1168 }
1169 }
1170 }
1171}