diff --git a/crates/platform/README.md b/crates/platform/README.md index 87bc13c8..4aecde21 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -142,8 +142,10 @@ level and flags are the encoder's own. If it is missing the export stops with th One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability query, the `hevcConfig` member of the codec-configuration union, how NAL units are told apart -(`(b0 >> 1) & 0x3f`) and the level numbering (`level × 30`, so 4.1 is 123; the export leaves the -level to the encoder). The H.264 path is unchanged: the same export is the same file, byte for byte. +(`(b0 >> 1) & 0x3f`) and the level numbering (`level × 30`, so 4.1 is 123). The level is the lowest +whose **Main tier** limits hold the picture size, sample rate and peak bitrate (H.265 Table A.8, +`nvenc::hevc::main_tier_level`): left to choose, NVENC answered 1080p30 at an 18 Mbit/s peak with +level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main tier. The H.264 path is unchanged: the same export is the same file, byte for byte. - **Choosing the format is the opt-in,** as on macOS: Export ▸ Format ▸ H.265 (HEVC) or `"format": "hevc"`. `register()` hands `nvenc::hevc_available` to diff --git a/crates/platform/src/nvenc/hevc.rs b/crates/platform/src/nvenc/hevc.rs index a93c6ea6..e0bda40f 100644 --- a/crates/platform/src/nvenc/hevc.rs +++ b/crates/platform/src/nvenc/hevc.rs @@ -20,6 +20,43 @@ pub fn level_code(level_x10: u8) -> Option { matches!(level_x10, 10 | 20 | 21 | 30 | 31 | 40 | 41 | 50 | 51 | 52 | 60 | 61 | 62).then(|| level_x10.saturating_mul(3)) } +/// The lowest HEVC level (× 10) whose **Main tier** limits take `width × height` pictures at `fps` +/// and a peak bitrate (and, with the one-second VBV buffer, a CPB) of `max_kbps` (H.265 Table A.8, +/// Main / Main 10 profiles: `MaxLumaPs`, `MaxLumaSr`, `MaxBR`, and pictures no wider or taller than +/// `sqrt(8 × MaxLumaPs)`). `None` when no Main tier level does (the encoder then chooses). +pub fn main_tier_level(width: u32, height: u32, fps: (u32, u32), max_kbps: u32) -> Option { + // (level × 10, MaxLumaPs, MaxLumaSr, Main tier MaxBR = MaxCPB in kbit/s) + const LEVELS: [(u8, u64, u64, u64); 13] = [ + (10, 36_864, 552_960, 128), + (20, 122_880, 3_686_400, 1_500), + (21, 245_760, 7_372_800, 3_000), + (30, 552_960, 16_588_800, 6_000), + (31, 983_040, 33_177_600, 10_000), + (40, 2_228_224, 66_846_720, 12_000), + (41, 2_228_224, 133_693_440, 20_000), + (50, 8_912_896, 267_386_880, 25_000), + (51, 8_912_896, 534_773_760, 40_000), + (52, 8_912_896, 1_069_547_520, 60_000), + (60, 35_651_584, 1_069_547_520, 60_000), + (61, 35_651_584, 2_139_095_040, 120_000), + (62, 35_651_584, 4_278_190_080, 240_000), + ]; + if fps.1 == 0 { + return None; + } + let ps = u64::from(width).checked_mul(u64::from(height))?; + // luma samples per second, rounded up + let sr = ps.checked_mul(u64::from(fps.0))?.div_ceil(u64::from(fps.1)); + let side = u64::from(width.max(height)); + LEVELS + .iter() + .find(|(_, max_ps, max_sr, max_br)| { + // side² ≤ 8 × MaxLumaPs: the dimension limit without a square root + ps <= *max_ps && sr <= *max_sr && u64::from(max_kbps) <= *max_br && side.saturating_mul(side) <= max_ps.saturating_mul(8) + }) + .map(|(l, ..)| *l) +} + /// Bytes of the SPS RBSP before the general profile / tier / level record: `sps_video_parameter_set_id`, /// `sps_max_sub_layers_minus1` and `sps_temporal_id_nesting_flag` share the first one. const PTL_START: usize = 1; @@ -117,6 +154,31 @@ mod tests { } } + #[test] + fn the_level_is_the_lowest_main_tier_one_that_fits() { + // size and sample rate alone + assert_eq!(main_tier_level(1280, 720, (30, 1), 5_000), Some(31)); + assert_eq!(main_tier_level(1920, 1080, (30, 1), 8_000), Some(40)); + // the bitrate moves 1080p30 up a level instead of to the High tier (what NVENC picked by itself) + assert_eq!(main_tier_level(1920, 1080, (30, 1), 18_000), Some(41)); + assert_eq!(main_tier_level(1920, 1080, (60, 1), 12_000), Some(41)); + assert_eq!(main_tier_level(3840, 2160, (30, 1), 22_500), Some(50)); + assert_eq!(main_tier_level(3840, 2160, (30, 1), 60_000), Some(52)); + assert_eq!(main_tier_level(3840, 2160, (60, 1), 60_000), Some(52)); + assert_eq!(main_tier_level(7680, 4320, (30, 1), 60_000), Some(60)); + // 29.97 rounds the sample rate up + assert_eq!(main_tier_level(1920, 1080, (30_000, 1_001), 8_000), Some(40)); + // a long thin picture is held by the dimension limit, not the area + assert_eq!(main_tier_level(8192, 64, (30, 1), 1_000), Some(50)); + // beyond every Main tier level, or nonsense: the encoder chooses + assert_eq!(main_tier_level(1920, 1080, (30, 1), 300_000), None); + assert_eq!(main_tier_level(16_384, 16_384, (30, 1), 1_000), None); + assert_eq!(main_tier_level(1920, 1080, (30, 0), 1_000), None); + assert_eq!(main_tier_level(u32::MAX, u32::MAX, (u32::MAX, 1), u32::MAX), None); + // every answer is a level the encoder takes + assert!(main_tier_level(1920, 1080, (30, 1), 18_000).and_then(level_code).is_some()); + } + #[test] fn the_record_is_the_sps_it_came_from() { let c = record(); diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index 336e0433..feb7ca4e 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -181,6 +181,12 @@ impl Nvenc { let codec = cfg.profile.codec(); let level = match (codec, cfg.level) { (Codec::Hevc, Some(l)) => Some(hevc::level_code(l).ok_or_else(|| format!("{}.{} is not an HEVC level", l / 10, l % 10))?), + // left to itself NVENC answers a bitrate above its chosen level's Main tier limit with the + // High tier, which many hardware decoders refuse: pick the lowest Main tier level instead + (Codec::Hevc, None) => { + let max_kbps = if cfg.cbr { cfg.bitrate_kbps } else { cfg.max_bitrate_kbps.max(cfg.bitrate_kbps) }; + hevc::main_tier_level(w, h, cfg.fps, max_kbps).and_then(hevc::level_code) + } (_, l) => l, }; let mut session = Session::open()?; diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs index 0eeb5c19..d1083529 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/nvenc_hevc.rs @@ -283,6 +283,23 @@ fn an_encoder_dropped_mid_stream_releases_everything() { } } +#[test] +fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // 1080p30 at a 12 / 18 Mbit/s peak: above level 4's Main tier (12), where NVENC's own choice was + // level 4 High tier, which many hardware decoders refuse + for (w, h, fps, kbps, max_kbps, level_idc) in + [(1920u32, 1080u32, 30u32, 12_000u32, 18_000u32, 123u8), (1920, 1080, 30, 4_000, 6_000, 120), (1280, 720, 30, 5_000, 7_500, 93)] + { + let cfg = Config { fps: (fps, 1), bitrate_kbps: kbps, max_bitrate_kbps: max_kbps, keyint: 30, ..config(w, h) }; + let Some((enc, packets, _)) = encode_all(&cfg, 4) else { return }; + assert_eq!(packets.len(), 4); + let record = enc.hevc_config().expect("an hvcC record"); + assert!(!record.general_tier_flag, "{w}x{h} at {max_kbps} kbit/s: Main tier"); + assert_eq!(record.general_level_idc, level_idc, "{w}x{h} at {max_kbps} kbit/s"); + } +} + #[test] fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);