Windows: NVENC H.265 Main 10 HDR (PQ / HLG) export

HDR sequences export as real 10-bit HEVC Main 10 through NVENC where the GPU
reports 10-bit HEVC: float BT.2020 R'G'B' -> rgbf_to_yuv420_10 -> P010 input;
VUI BT.2020 + PQ/HLG; HDR10 mastering-display / content-light as SEI on every
IDR (PQ); colr/mdcv/clli in the sample entry. New register_hdr_probe /
hdr_available in the export crate; macOS and GPUs without 10-bit keep
tone-mapping HDR sequences to 8-bit SDR HEVC. ABI asserts extended and
re-verified against nvEncodeAPI.h 12.1 (0 drift). 8-bit SDR HEVC unchanged;
the two #328 tests that asserted HDR is declined now expect Main 10.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Davi Andrei 2026-10-08 22:18:03 -03:00
parent a8f91e3ffe
commit dbae11b843
23 changed files with 1705 additions and 110 deletions

View File

@ -146,7 +146,7 @@ the original copyright and permission notice in its header.
| File | Author | Source | Licence | | File | Author | Source | Licence |
|---|---|---|---| |---|---|---|---|
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures (including `NV_ENC_CONFIG_HEVC` and its bit-field masks), function table, codec / profile GUIDs (H.264, HEVC Main) and constants (`NV_ENC_LEVEL_HEVC_*`, tier, CU size) transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) | | `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures (including `NV_ENC_CONFIG_HEVC` and its bit-field masks, `NV_ENC_PIC_PARAMS_HEVC`, `NV_ENC_SEI_PAYLOAD`, `NV_ENC_TIME_CODE`), function table, codec / profile GUIDs (H.264, HEVC Main, HEVC Main 10) and constants (`NV_ENC_LEVEL_HEVC_*`, tier, CU size, `NV_ENC_BUFFER_FORMAT_YUV420_10BIT`, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, BT.2020 / PQ / HLG VUI codes) transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
## Downloaded at runtime ## Downloaded at runtime

View File

@ -287,9 +287,9 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
The biggest gaps today: The biggest gaps today:
- **Speed on big footage.** Hardware decoding works on macOS and Windows; Linux has no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (NVENC; Main 10 and HDR not yet) ([#30](https://github.com/storytold/filmcraft/issues/30)). - **Speed on big footage.** Hardware decoding works on macOS and Windows; Linux has no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **No plugins.** No VST3 / Audio Units or OpenFX hosting. - **No plugins.** No VST3 / Audio Units or OpenFX hosting.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 (8-bit Main, SDR) exports only through a hardware encoder (NVIDIA on Windows, VideoToolbox on macOS); no AV1 export yet. - **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
- **AI features.** Few so far; speech to text is optional and off by default. - **AI features.** Few so far; speech to text is optional and off by default.

View File

@ -35,7 +35,7 @@ before choosing work.
| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. | | **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. |
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. | | **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. | | **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
| **Export** | ~92% | **~70%** | Delivery: H.264, plus H.265 (8-bit Main, SDR) through a hardware encoder only (NVENC on Windows, VideoToolbox on macOS); ProRes, DNxHR and image sequences cover mastering. No AV1 export, no HDR or 10-bit HEVC. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. | | **Export** | ~92% | **~70%** | Delivery: H.264, plus H.265 through a hardware encoder only (NVENC on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: Main 8-bit SDR); ProRes, DNxHR and image sequences cover mastering. No AV1 export, no 10-bit / HDR HEVC on macOS. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects and export rendering are CPU; H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists there only, through the same hardware encoders (macOS and Windows). Linux has no hardware path. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. | | **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects and export rendering are CPU; H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists there only, through the same hardware encoders (macOS and Windows). Linux has no hardware path. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. | | **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. | | **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
@ -51,7 +51,7 @@ In priority order. Agents should prefer this work over adding more checklist ite
- hardware decode on Linux (VA-API); - hardware decode on Linux (VA-API);
- zero-copy decoded frames into wgpu; - zero-copy decoded frames into wgpu;
- the remaining effects on the GPU: Lumetri, keys, Vignette, Video Limiter, masks (31 common effects are done); - the remaining effects on the GPU: Lumetri, keys, Vignette, Video Limiter, masks (31 common effects are done);
- GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 and H.265 (Main, 8-bit) on NVENC (Windows); Main 10 / HDR HEVC, Linux and other Windows vendors remain. - GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 and H.265 (Main 8-bit, and Main 10 HDR PQ / HLG) on NVENC (Windows); Main 10 / HDR HEVC on VideoToolbox, Linux and other Windows vendors remain.
2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus, 2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus,
commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in
`plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub). `plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub).
@ -63,7 +63,7 @@ In priority order. Agents should prefer this work over adding more checklist ite
through XML / AAF. through XML / AAF.
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export 5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
smoke test on both, not just release builds. Fix the Linux audio path (#23). smoke test on both, not just release builds. Fix the Linux audio path (#23).
6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder); still to do: HEVC elsewhere, 10-bit / HDR HEVC, and AV1 (`rav1e`, BSD-2). 6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder) and on Windows (NVENC, 8-bit and Main 10 HDR); still to do: HEVC elsewhere, 10-bit / HDR HEVC on macOS, and AV1 (`rav1e`, BSD-2).
7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated 7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated
`unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting. `unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting.
8. **AI features** with openly licensed local models: transcription on by default, text-based 8. **AI features** with openly licensed local models: transcription on by default, text-based
@ -159,6 +159,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
## Log ## Log
- **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged.
- **2026-10-08:** H.265 (HEVC Main, 8-bit 4:2:0, SDR) export on Windows through NVENC (#30): the NVENC session, ring and NV12 path of the H.264 encoder now serve both codecs (the H.264 export is byte-for-byte unchanged); the format is available where the GPU has an HEVC encoder, choosing it is the opt-in, and `hvcC` is built from the SPS the encoder wrote. ffprobe reads `hevc` / `Main` / `hvc1` / `yuv420p` / BT.709; our own HEVC decoder decodes it at 48.9 dB luma PSNR (RTX 5060). Main 10 and HDR remain. - **2026-10-08:** H.265 (HEVC Main, 8-bit 4:2:0, SDR) export on Windows through NVENC (#30): the NVENC session, ring and NV12 path of the H.264 encoder now serve both codecs (the H.264 export is byte-for-byte unchanged); the format is available where the GPU has an HEVC encoder, choosing it is the opt-in, and `hvcC` is built from the SPS the encoder wrote. ffprobe reads `hevc` / `Main` / `hvc1` / `yuv420p` / BT.709; our own HEVC decoder decodes it at 48.9 dB luma PSNR (RTX 5060). Main 10 and HDR remain.
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28). - **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).

View File

@ -0,0 +1,201 @@
//! HDR plumbing shared by hardware encoders: the 10-bit 4:2:0 converter, the HDR probe registry, the
//! decision whether an export is HDR, and the one definition of the HDR10 static metadata.
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
const BT2020: (f32, f32) = (0.2627, 0.0593);
fn convert(rgb: &[f32], w: usize, h: usize) -> (Vec<u16>, Vec<u16>, Vec<u16>) {
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
rgbf_to_yuv420_10(rgb, w, h, BT2020.0, BT2020.1, &mut y, &mut u, &mut v).unwrap();
(y, u, v)
}
fn flat(w: usize, h: usize, px: [f32; 3]) -> Vec<f32> {
px.iter().copied().cycle().take(w * h * 3).collect()
}
#[test]
fn black_white_and_grey_have_the_limited_range_codes() {
for (px, code) in [([0.0; 3], 64u16), ([1.0; 3], 940), ([0.5; 3], 502)] {
let (y, u, v) = convert(&flat(4, 4, px), 4, 4);
assert!(y.iter().all(|c| *c == code), "{px:?}: {y:?}");
assert!(u.iter().chain(&v).all(|c| *c == 512), "{px:?}: neutral chroma");
assert_eq!((y.len(), u.len(), v.len()), (16, 4, 4));
}
}
#[test]
fn bt2020_primaries_have_exact_codes() {
// (R', G', B') -> (Y, Cb, Cr) with the BT.2020 non-constant-luminance matrix, 10-bit limited range
for (px, want) in [
([1.0, 0.0, 0.0], (294u16, 387u16, 960u16)),
([0.0, 1.0, 0.0], (658, 189, 100)),
([0.0, 0.0, 1.0], (116, 960, 476)),
([1.0, 0.5, 0.0], (591, 225, 754)),
] {
let (y, u, v) = convert(&flat(2, 2, px), 2, 2);
assert_eq!((y[0], u[0], v[0]), want, "{px:?}");
}
}
#[test]
fn out_of_range_and_non_finite_values_clamp_instead_of_wrapping() {
// above 1, below 0 and infinities clamp to the legal range; NaN counts as 0 (black, not code 0)
let mut px = vec![0.0f32; 4 * 3];
px[..3].copy_from_slice(&[2.0, 2.0, 2.0]);
px[3..6].copy_from_slice(&[-1.0, -5.0, -0.5]);
px[6..9].copy_from_slice(&[f32::INFINITY, f32::INFINITY, f32::INFINITY]);
px[9..].copy_from_slice(&[f32::NAN, f32::NEG_INFINITY, f32::NAN]);
let (y, u, v) = convert(&px, 4, 1);
assert_eq!(y, vec![940, 64, 940, 64]);
assert!(u.iter().chain(&v).all(|c| (64..=960).contains(c)), "{u:?} {v:?}");
// all NaN: black
let (y, u, v) = convert(&[f32::NAN; 2 * 2 * 3], 2, 2);
assert_eq!((y, u, v), (vec![64; 4], vec![512], vec![512]));
}
#[test]
fn odd_sizes_average_the_pixels_they_have() {
// 3x3: the chroma planes are 2x2; the right column and bottom row average one or two pixels
let (y, u, v) = convert(&flat(3, 3, [1.0, 0.0, 0.0]), 3, 3);
assert_eq!((y.len(), u.len(), v.len()), (9, 4, 4));
assert!(y.iter().all(|c| *c == 294) && u.iter().all(|c| *c == 387) && v.iter().all(|c| *c == 960));
// 1x1
let (y, u, v) = convert(&[0.0, 0.0, 1.0], 1, 1);
assert_eq!((y, u, v), (vec![116], vec![960], vec![476]));
// chroma is the mean over the 2x2 block: red and blue pixels give the mean of their Cb and Cr
let rgb = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
let (y, u, v) = convert(&rgb, 2, 2);
assert_eq!(y, vec![294, 116, 294, 116]);
assert_eq!((u[0], v[0]), ((387u16 + 960) / 2, (960u16 + 476) / 2));
}
#[test]
fn a_ramp_has_every_code_a_10_bit_picture_can_have() {
// a smooth horizontal ramp: 877 distinct luma codes in 64..=940, far more than 8 bits hold
let w = 1024usize;
let rgb: Vec<f32> = (0..w).flat_map(|x| [x as f32 / (w - 1) as f32; 3]).collect();
let (y, _, _) = convert(&rgb, w, 1);
let mut codes: Vec<u16> = y.clone();
codes.dedup();
assert_eq!((y[0], y[w - 1]), (64, 940));
assert_eq!(codes.len(), 877, "{} distinct codes", codes.len());
assert!(y.windows(2).all(|p| p[0] <= p[1]));
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
let mut run = |rgb: &[f32], w: usize, h: usize| rgbf_to_yuv420_10(rgb, w, h, 0.2627, 0.0593, &mut y, &mut u, &mut v);
assert!(run(&[], 0, 0).is_err());
assert!(run(&[0.0; 3], 0, 1).is_err());
assert!(run(&[0.0; 3], 1, 0).is_err());
assert!(run(&[0.0; 11], 2, 2).is_err(), "short");
assert!(run(&[0.0; 13], 2, 2).is_err(), "long");
assert!(run(&[0.0; 6], 2, 2).is_err());
assert!(run(&[0.0; 3], usize::MAX, 2).is_err(), "w*h overflows");
assert!(run(&[0.0; 3], usize::MAX / 2, 3).is_err(), "w*h*3 overflows");
assert!(run(&[0.0; 3], 1 << 40, 1 << 40).is_err());
}
#[test]
fn the_hdr_probe_registry_follows_its_probes() {
static HERE: AtomicBool = AtomicBool::new(false);
fn probe() -> bool {
HERE.load(Ordering::SeqCst)
}
// a format nothing registered a probe for is never HDR; registering twice is harmless
assert!(!hdr_available(Format::ProRes));
register_hdr_probe(Format::ProRes, probe);
register_hdr_probe(Format::ProRes, probe);
assert!(!hdr_available(Format::ProRes));
HERE.store(true, Ordering::SeqCst);
assert!(hdr_available(Format::ProRes));
assert!(!hdr_available(Format::DnxHr), "probes are per format");
HERE.store(false, Ordering::SeqCst);
assert!(!hdr_available(Format::ProRes));
}
#[test]
fn which_exports_are_hdr() {
use crate::job::hdr_output;
let never = |_: Format| false;
let hevc_only = |f: Format| f == Format::Hevc;
// H.264, ProRes, DNxHR and APV follow the working space, whatever the probes say
for f in [Format::H264, Format::ProRes, Format::DnxHr, Format::Apv] {
assert!(hdr_output(true, false, f, never), "{f:?}");
assert!(!hdr_output(false, false, f, hevc_only), "{f:?}: SDR working space");
assert!(!hdr_output(true, true, f, hevc_only), "{f:?}: SDR asked for");
}
// H.265 only where a registered encoder writes HDR
assert!(!hdr_output(true, false, Format::Hevc, never), "no probe: tone-mapped SDR, as with VideoToolbox");
assert!(hdr_output(true, false, Format::Hevc, hevc_only));
assert!(!hdr_output(true, true, Format::Hevc, hevc_only), "settings.sdr forces SDR");
assert!(!hdr_output(false, false, Format::Hevc, hevc_only));
// formats without HDR output
assert!(!hdr_output(true, false, Format::Mjpeg, |_| true));
}
#[test]
fn only_pq_has_static_metadata_and_it_is_what_h264_writes() {
assert!(ColorSignal::HLG.static_metadata().is_none());
assert!(ColorSignal::default().static_metadata().is_none());
let (md, cll) = ColorSignal::PQ.static_metadata().unwrap();
assert_eq!(cll, (0, 0));
assert_eq!(md, filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001));
assert_eq!(md.to_bytes().len(), 24);
// the box on the sample entry is the same value
let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64);
ColorSignal::PQ.apply_to(&mut e, false);
let v = e.video.as_ref().unwrap();
assert_eq!((v.mastering_display, v.content_light), (Some(md), Some((0, 0))));
let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64);
ColorSignal::HLG.apply_to(&mut e, false);
let v = e.video.as_ref().unwrap();
assert!(v.color.is_some() && v.mastering_display.is_none() && v.content_light.is_none());
}
/// Remove emulation-prevention bytes (`00 00 03` -> `00 00`).
fn unescape(b: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut zeros = 0;
for &x in b {
if zeros >= 2 && x == 3 {
zeros = 0;
continue;
}
zeros = if x == 0 { zeros + 1 } else { 0 };
out.push(x);
}
out
}
#[test]
fn the_software_h264_sei_is_the_static_metadata_laid_out_as_messages() {
let s = ExportSettings { format: Format::H264, signal: ColorSignal::PQ, ..Default::default() };
let mut enc = h264_factory(Format::H264, 64, 64, FrameRate::FPS_24, &s).unwrap().unwrap();
let rgb = vec![0.5f32; 64 * 64 * 3];
let mut packets = enc.encode(&EncoderFrame { width: 64, height: 64, rgba: &[], hdr: Some(&rgb), index: 0 }).unwrap();
packets.extend(enc.flush().unwrap());
// find the SEI NAL unit (type 6) of the length-prefixed first access unit
let data = &packets[0].data;
let (mut pos, mut sei) = (0usize, None);
while let Some(len) = data.get(pos..pos + 4) {
let n = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize;
let nal = &data[pos + 4..pos + 4 + n];
if nal[0] & 0x1f == 6 {
sei = Some(unescape(&nal[1..]));
}
pos += 4 + n;
}
let sei = sei.expect("an SEI NAL unit in the first access unit");
let (md, (cll, fall)) = ColorSignal::PQ.static_metadata().unwrap();
let mut want = vec![137u8, 24];
want.extend(md.to_bytes());
want.extend([144u8, 4]);
want.extend(cll.to_be_bytes());
want.extend(fall.to_be_bytes());
want.push(0x80);
assert_eq!(sei, want);
}

View File

@ -85,6 +85,18 @@ pub fn stepped(format: Format) -> bool {
matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom) matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
} }
/// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder
/// writes HDR (`hdr_available` says so for H.265, which has no built-in encoder).
pub(crate) fn hdr_output(working_hdr: bool, sdr: bool, format: Format, hdr_available: impl Fn(Format) -> bool) -> bool {
working_hdr
&& !sdr
&& match format {
Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv => true,
Format::Hevc => hdr_available(Format::Hevc),
_ => false,
}
}
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> { fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
video_factories() video_factories()
.read() .read()
@ -102,9 +114,10 @@ impl Exporter {
} }
settings.validate()?; settings.validate()?;
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?; let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
// HDR sequences export HDR (H.264 / ProRes / DNxHR / APV) unless SDR is asked for (H.265 export is 8-bit SDR) // HDR sequences export HDR (H.264 / ProRes / DNxHR / APV, and H.265 where a registered encoder
// writes Main 10) unless SDR is asked for; elsewhere H.265 is tone-mapped 8-bit SDR
let pipe = q.settings.color; let pipe = q.settings.color;
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv); let hdr_out = hdr_output(pipe.working.is_hdr(), settings.sdr, settings.video_format(), crate::hdr_available);
let mut settings = settings.clone(); let mut settings = settings.clone();
settings.signal = match (hdr_out, pipe.working) { settings.signal = match (hdr_out, pipe.working) {
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ, (true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,

View File

@ -64,7 +64,9 @@ pub enum Format {
H264, H264,
/// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.265 / HEVC Main (8-bit) video + AAC audio. There is no /// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.265 / HEVC Main (8-bit) video + AAC audio. There is no
/// built-in encoder: a platform hardware encoder registers one ([`register_encoder`]) and says so /// built-in encoder: a platform hardware encoder registers one ([`register_encoder`]) and says so
/// with [`register_format_probe`]; [`available`] is false without it. /// with [`register_format_probe`]; [`available`] is false without it. HDR sequences export as HDR
/// (Main 10, PQ / HLG) only where an encoder also says so with [`register_hdr_probe`]
/// ([`hdr_available`]); elsewhere they are tone-mapped to SDR.
#[serde(rename = "hevc", alias = "Hevc")] #[serde(rename = "hevc", alias = "Hevc")]
Hevc, Hevc,
/// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM. /// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM.
@ -420,12 +422,18 @@ impl ColorSignal {
} else { } else {
filmcraft_isobmff::ColorInfo::Nclx { primaries: p, transfer: t, matrix: m, full_range: false } filmcraft_isobmff::ColorInfo::Nclx { primaries: p, transfer: t, matrix: m, full_range: false }
}); });
if self.transfer == 16 { if let Some((md, cll)) = self.static_metadata() {
v.mastering_display = Some(filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001)); v.mastering_display = Some(md);
v.content_light = Some((0, 0)); v.content_light = Some(cll);
} }
} }
} }
/// HDR10 static metadata of a PQ signal: the mastering display (BT.2020/D65, 1000 / 0.0001 cd/m²)
/// and (MaxCLL, MaxFALL) = (0, 0), unknown. `None` for HLG and SDR. The one definition of these
/// values: the `mdcv` / `clli` boxes and the SEI messages of every encoder come from here.
pub fn static_metadata(&self) -> Option<(filmcraft_isobmff::MasteringDisplay, (u16, u16))> {
(self.transfer == 16).then(|| (filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001), (0, 0)))
}
} }
impl Default for ExportSettings { impl Default for ExportSettings {
@ -692,6 +700,29 @@ pub fn register_format_probe(format: Format, probe: fn() -> bool) {
} }
} }
fn hdr_probes() -> &'static RwLock<Vec<FormatProbe>> {
static P: OnceLock<RwLock<Vec<FormatProbe>>> = OnceLock::new();
P.get_or_init(|| RwLock::new(Vec::new()))
}
/// Say how to find out whether the registered encoder of `format` can write HDR (PQ / HLG) on this
/// machine: `probe` runs when [`hdr_available`] asks (it should cache its answer). Only H.265 needs
/// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence
/// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless.
pub fn register_hdr_probe(format: Format, probe: fn() -> bool) {
let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner());
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
g.push((format, probe));
}
}
/// Whether a registered probe says the encoder of `format` writes HDR here (false without one).
pub fn hdr_available(format: Format) -> bool {
// the probes run outside the lock: one may take a while
let probes: Vec<fn() -> bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect();
probes.into_iter().any(|p| p())
}
/// Whether a format can currently be exported: it has a built-in encoder, or a registered probe /// Whether a format can currently be exported: it has a built-in encoder, or a registered probe
/// says its encoder works here. /// says its encoder works here.
pub fn available(format: Format) -> bool { pub fn available(format: Format) -> bool {
@ -1160,6 +1191,60 @@ pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &m
}); });
} }
/// Limited-range 10-bit 4:2:0 (code values 0..1023, not MSB-aligned) from encoded R'G'B' floats with
/// matrix (Kr, Kb): Y = 64 + 876·Y', C = 512 + 896·C, chroma averaged over each 2x2 block (one pixel
/// or pair at an odd edge). `rgb` is 3 floats per pixel; NaN counts as 0, infinities and
/// out-of-range values clamp to 0..1 first, and codes clamp to 4..1019 like [`rgbf_to_yuv422_10`]
/// (so black is 64, peak white 940 and the chroma of any colour stays within 64..960).
/// The planes are resized to `w * h` and `ceil(w / 2) * ceil(h / 2)`. A `rgb` slice of the wrong
/// length, a zero size or a size whose arithmetic overflows is an error.
#[allow(clippy::too_many_arguments)]
pub fn rgbf_to_yuv420_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut Vec<u16>, u: &mut Vec<u16>, v: &mut Vec<u16>) -> Result<()> {
let bad = || ExportError::Encode(format!("a {w}x{h} HDR picture needs w*h*3 floats, got {}", rgb.len()));
let px = w.checked_mul(h).ok_or_else(bad)?;
if w == 0 || h == 0 || px.checked_mul(3) != Some(rgb.len()) {
return Err(bad());
}
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let kg = 1.0 - kr - kb;
let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr));
y.clear();
y.resize(px, 0);
u.clear();
u.resize(cw * ch, 0);
v.clear();
v.resize(cw * ch, 0);
let unit = |x: f32| if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
let code = |x: f32| x.round().clamp(4.0, 1019.0) as u16;
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let rows = yr.len() / w;
let mut us = vec![0f32; cw];
let mut vs = vec![0f32; cw];
let mut cnt = vec![0f32; cw];
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate().take(rows) {
let row = cy * 2 + dy;
let Some(src) = rgb.get(row * w * 3..(row + 1) * w * 3) else { continue };
for (x, (p, yo)) in src.chunks_exact(3).zip(yrow.iter_mut()).enumerate() {
let &[r, g, b] = p else { continue };
let (r, g, b) = (unit(r), unit(g), unit(b));
let yy = kr * r + kg * g + kb * b;
*yo = code(64.0 + 876.0 * yy);
if let (Some(a), Some(c), Some(n)) = (us.get_mut(x / 2), vs.get_mut(x / 2), cnt.get_mut(x / 2)) {
*a += (b - yy) / sb;
*c += (r - yy) / sr;
*n += 1.0;
}
}
}
for (cx, (uo, vo)) in ur.iter_mut().zip(vr.iter_mut()).enumerate() {
let n = cnt.get(cx).copied().unwrap_or(1.0).max(1.0);
*uo = code(512.0 + 896.0 * us.get(cx).copied().unwrap_or(0.0) / n);
*vo = code(512.0 + 896.0 * vs.get(cx).copied().unwrap_or(0.0) / n);
}
});
Ok(())
}
/// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core /// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core
/// count, which would make the stream (and every decoded picture) depend on the machine; this is /// count, which would make the stream (and every decoded picture) depend on the machine; this is
/// what it picks on a machine with enough cores. /// what it picks on a machine with enough cores.
@ -1199,12 +1284,11 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
} }
if s.signal.is_hdr() { if s.signal.is_hdr() {
cfg.color = filmcraft_h264enc::ColorConfig { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false }; cfg.color = filmcraft_h264enc::ColorConfig { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false };
if s.signal.transfer == 16 { if let Some((md, cll)) = s.signal.static_metadata() {
let md = filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001).to_bytes();
let mut b = [0u8; 24]; let mut b = [0u8; 24];
b.copy_from_slice(&md); b.copy_from_slice(&md.to_bytes());
cfg.mastering_display = Some(b); cfg.mastering_display = Some(b);
cfg.content_light = Some((0, 0)); cfg.content_light = Some(cll);
} }
} }
Some( Some(
@ -1404,3 +1488,6 @@ mod mxf_tests;
#[cfg(test)] #[cfg(test)]
mod determinism_tests; mod determinism_tests;
#[cfg(test)]
mod hdr_tests;

View File

@ -509,7 +509,9 @@ impl ExportSettings {
v += &format!(", keyframe every {} frames", r.keyint); v += &format!(", keyframe every {} frames", r.keyint);
} }
Format::Hevc => { Format::Hevc => {
v += &format!(", HEVC Main (hardware encoder), {}", self.bitrate_mode.label()); // Main 10 only where a registered encoder writes HDR (NVENC); VideoToolbox is 8-bit
let profile = if crate::hdr_available(Format::Hevc) { "HEVC Main / Main 10 for HDR" } else { "HEVC Main" };
v += &format!(", {profile} (hardware encoder), {}", self.bitrate_mode.label());
v += &match self.bitrate_mode { v += &match self.bitrate_mode {
BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)), BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)),
_ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)), _ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)),

View File

@ -156,7 +156,7 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
caller that asks while it runs waits for the same answer. The caller that asks while it runs waits for the same answer. The
Hardware encoding toggle governs H.264 only: with H.265 selected NVENC is used whether it is Auto Hardware encoding toggle governs H.264 only: with H.265 selected NVENC is used whether it is Auto
or Off, because nothing else can encode it. or Off, because nothing else can encode it.
- **What it writes:** HEVC Main, 8-bit 4:2:0, SDR BT.709 limited range (the VUI says so, with the - **What it writes:** HEVC Main, 8-bit 4:2:0, SDR BT.709 limited range (Main 10 HDR: see below) (the VUI says so, with the
frame rate as `time_scale / num_units_in_tick`), progressive, preset P5 with high-quality tuning, frame rate as `time_scale / num_units_in_tick`), progressive, preset P5 with high-quality tuning,
constant or one-pass variable bitrate, an IDR at every keyframe distance, one B-frame when the GPU constant or one-pass variable bitrate, an IDR at every keyframe distance, one B-frame when the GPU
reports support and the GOP is longer than two pictures (shorter ones are written without), in reports support and the GOP is longer than two pictures (shorter ones are written without), in
@ -179,12 +179,43 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
offsets are `pts - dts` in frame durations, and the media starts `delay` frames early (edit list). offsets are `pts - dts` in frame durations, and the media starts `delay` frames early (edit list).
- **There is no fallback encoder.** A request NVENC cannot take is counted in - **There is no fallback encoder.** A request NVENC cannot take is counted in
`export.hardware.declined` and ends the export with "H.265 export with NVENC: …" and the reason: `export.hardware.declined` and ends the export with "H.265 export with NVENC: …" and the reason:
HDR, interlaced output, two-pass (`ExportSettings::validate` refuses it first), non-square pixels, 10-bit on a GPU without it (HDR), interlaced output, two-pass (`ExportSettings::validate` refuses it first), non-square pixels,
MXF, sizes above 65535, odd sizes, sizes outside the GPU's limits (129×33 to 8192×8192 on the RTX MXF, sizes above 65535, odd sizes, sizes outside the GPU's limits (129×33 to 8192×8192 on the RTX
5060). On a machine without an HEVC encoder the format is not available and an export fails with 5060). On a machine without an HEVC encoder the format is not available and an export fails with
"encoder not available yet". Frames, sessions and declines are the same `export.hardware` counters "encoder not available yet". Frames, sessions and declines are the same `export.hardware` counters
as for H.264. as for H.264.
- Main 10 / HDR is not done (see Not yet). - **Main 10 HDR (PQ / HLG).** An HDR sequence (Rec. 2100 PQ or HLG working space) exports as HEVC
Main 10 when `settings.sdr` is off and this GPU has a 10-bit HEVC encoder. `register()` hands
`nvenc::hevc_hdr_available` (a Main 10 session, cached, asked on the same background thread as the
8-bit probe and short-circuiting when HEVC itself is missing) to `filmcraft_export::register_hdr_probe`,
and the export job makes the export HDR for `Format::Hevc` only when
`filmcraft_export::hdr_available(Format::Hevc)` says so: on macOS (VideoToolbox is 8-bit) and on GPUs
without 10-bit an HDR sequence is tone-mapped to 8-bit SDR HEVC exactly as before, and `settings.sdr`
still forces that. The pixels are real: `EncoderFrame::hdr` (encoded BT.2020 R'G'B', 3 floats per
pixel) goes through `filmcraft_export::rgbf_to_yuv420_10` (BT.2020 non-constant-luminance matrix,
Y = 64 + 876·Y', C = 512 + 896·C, 2x2 chroma average, NaN counts as 0, values clamp to 0..1 and codes
to 4..1019, wrong-length input is an error) and into a **P010** input buffer (`code << 6`,
little-endian, chroma interleaved after the luma rows, pitch in bytes; a driver pitch under 2 bytes
per pixel is refused). One buffer format (`NV_ENC_BUFFER_FORMAT_YUV420_10BIT`) is used for the
initialisation, the input buffers and every picture; `pixelBitDepthMinus8` is 2, the profile GUID
is Main 10, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` is queried first and a GPU without it declines
with "no 10-bit (HEVC Main 10) support". The VUI says BT.2020 primaries, transfer 16 (PQ) or 18
(HLG), matrix 9, limited range. The `hvcC` is checked against what was asked (`hevc_config(…, 10)`:
profile 2, 10-bit 4:2:0). NVENC chose the High tier for level 4 at 12 Mb/s; the record just says so.
Pictures of the wrong depth for the encoder (an `hdr` picture to a Main encoder, an RGBA one to
Main 10, or a float slice of the wrong length) are errors, never panics.
- **HDR10 static metadata is SEI, not an API.** NVENC 12.1 has no mastering-display field, so PQ
streams get the mastering display colour volume (payload 137) and content light level (144)
messages through `NV_ENC_PIC_PARAMS_HEVC::seiPayloadArray` on every IDR picture, with the values
of `ColorSignal::static_metadata` (BT.2020/D65, 1000 / 0.0001 cd/m², MaxCLL/MaxFALL 0), the same
that the software H.264 encoder's SEI and the `mdcv` / `clli` boxes carry. NVENC writes them as
two prefix SEI NAL units (type 39) in front of the IDR slice, adding the emulation-prevention
byte its `00 00 00 01` needs; nothing is attached to other pictures; HLG has no static metadata and
gets none. The payload bytes and the descriptor array are heap blocks owned by the `Session` for
its whole life (it is moved by value, and the driver may queue a picture), and IDR pictures are
known from a submission counter (`n % gop == 0`). The sample entry carries `colr` (nclx BT.2020,
PQ / HLG, limited), and for PQ `mdcv` and `clli`; 8-bit SDR HEVC is unchanged and has no `colr`
(the VUI carries its description, as in the software H.264 path).
## Guarantees ## Guarantees
@ -215,9 +246,12 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | | `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | | `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | | `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC question on a thread of its own, before anyone asks; the answer is kept | | `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264 and HEVC) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
| `src/nvenc/{mod,hevc}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS, every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels | | `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity |
| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages |
| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` |
Fixtures are made with ffmpeg into `target/fixtures/platform/` (generator only, never linked); Fixtures are made with ffmpeg into `target/fixtures/platform/` (generator only, never linked);
tests skip without ffmpeg or without a hardware decoder. tests skip without ffmpeg or without a hardware decoder.
@ -245,6 +279,7 @@ playback with no dropped frames at Full, 1/2 and 1/4. Details in
Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on macOS, Direct3D 11 Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on macOS, Direct3D 11
textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in
hardware encoding; VA-API (Linux) decoders; field-coded H.264; 10-bit and HDR encoding with NVENC VideoToolbox; VA-API (Linux) decoders; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR
(Main 10 HEVC, 4:4:4), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from
the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders
from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.

View File

@ -7,7 +7,8 @@
//! registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! registration does nothing and reports [`Availability::Unavailable`]. It also registers a
//! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an
//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on //! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on
//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available. //! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows
//! (Main 10) lets HDR sequences export as HDR H.265.
//! //!
//! Hardware decoding never makes a file undecodable: //! Hardware decoding never makes a file undecodable:
//! //!
@ -74,6 +75,9 @@ pub fn register() -> Availability {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available);
// HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere
// (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before
filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available);
nvenc::warm_hevc_probe(); nvenc::warm_hevc_probe();
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]

View File

@ -336,6 +336,41 @@ fn layouts_match_the_header() {
assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncRestoreEncoderState), 336, "NV_ENCODE_API_FUNCTION_LIST.nvEncRestoreEncoderState"); assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncRestoreEncoderState), 336, "NV_ENCODE_API_FUNCTION_LIST.nvEncRestoreEncoderState");
assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncLookaheadPicture), 344, "NV_ENCODE_API_FUNCTION_LIST.nvEncLookaheadPicture"); assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncLookaheadPicture), 344, "NV_ENCODE_API_FUNCTION_LIST.nvEncLookaheadPicture");
assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, reserved2), 352, "NV_ENCODE_API_FUNCTION_LIST.reserved2"); assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, reserved2), 352, "NV_ENCODE_API_FUNCTION_LIST.reserved2");
assert_eq!((size_of::<NV_ENC_SEI_PAYLOAD>(), align_of::<NV_ENC_SEI_PAYLOAD>()), (16, 8), "NV_ENC_SEI_PAYLOAD");
assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payloadSize), 0, "NV_ENC_SEI_PAYLOAD.payloadSize");
assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payloadType), 4, "NV_ENC_SEI_PAYLOAD.payloadType");
assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payload), 8, "NV_ENC_SEI_PAYLOAD.payload");
assert_eq!((size_of::<NV_ENC_CLOCK_TIMESTAMP_SET>(), align_of::<NV_ENC_CLOCK_TIMESTAMP_SET>()), (8, 4), "NV_ENC_CLOCK_TIMESTAMP_SET");
assert_eq!(offset_of!(NV_ENC_CLOCK_TIMESTAMP_SET, timeOffset), 4, "NV_ENC_CLOCK_TIMESTAMP_SET.timeOffset");
assert_eq!((size_of::<NV_ENC_TIME_CODE>(), align_of::<NV_ENC_TIME_CODE>()), (28, 4), "NV_ENC_TIME_CODE");
assert_eq!(offset_of!(NV_ENC_TIME_CODE, displayPicStruct), 0, "NV_ENC_TIME_CODE.displayPicStruct");
assert_eq!(offset_of!(NV_ENC_TIME_CODE, clockTimestamp), 4, "NV_ENC_TIME_CODE.clockTimestamp");
assert_eq!((size_of::<NV_ENC_PIC_PARAMS_HEVC>(), align_of::<NV_ENC_PIC_PARAMS_HEVC>()), (1536, 8), "NV_ENC_PIC_PARAMS_HEVC");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, displayPOCSyntax), 0, "NV_ENC_PIC_PARAMS_HEVC.displayPOCSyntax");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, refPicFlag), 4, "NV_ENC_PIC_PARAMS_HEVC.refPicFlag");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, temporalId), 8, "NV_ENC_PIC_PARAMS_HEVC.temporalId");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, forceIntraRefreshWithFrameCnt), 12, "NV_ENC_PIC_PARAMS_HEVC.forceIntraRefreshWithFrameCnt");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceTypeData), 24, "NV_ENC_PIC_PARAMS_HEVC.sliceTypeData");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceTypeArrayCnt), 32, "NV_ENC_PIC_PARAMS_HEVC.sliceTypeArrayCnt");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceMode), 36, "NV_ENC_PIC_PARAMS_HEVC.sliceMode");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceModeData), 40, "NV_ENC_PIC_PARAMS_HEVC.sliceModeData");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrMarkFrameIdx), 44, "NV_ENC_PIC_PARAMS_HEVC.ltrMarkFrameIdx");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrUseFrameBitmap), 48, "NV_ENC_PIC_PARAMS_HEVC.ltrUseFrameBitmap");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrUsageMode), 52, "NV_ENC_PIC_PARAMS_HEVC.ltrUsageMode");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, seiPayloadArrayCnt), 56, "NV_ENC_PIC_PARAMS_HEVC.seiPayloadArrayCnt");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved), 60, "NV_ENC_PIC_PARAMS_HEVC.reserved");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, seiPayloadArray), 64, "NV_ENC_PIC_PARAMS_HEVC.seiPayloadArray");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, timeCode), 72, "NV_ENC_PIC_PARAMS_HEVC.timeCode");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved2), 100, "NV_ENC_PIC_PARAMS_HEVC.reserved2");
assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved3), 1048, "NV_ENC_PIC_PARAMS_HEVC.reserved3");
assert_eq!(offset_of!(NV_ENC_CLOCK_TIMESTAMP_SET, bits), 0, "NV_ENC_CLOCK_TIMESTAMP_SET.bits");
assert_eq!(offset_of!(NV_ENC_CODEC_PIC_PARAMS, hevcPicParams), 0, "NV_ENC_CODEC_PIC_PARAMS.hevcPicParams");
assert_eq!(offset_of!(NV_ENC_CODEC_PIC_PARAMS, reserved), 0, "NV_ENC_CODEC_PIC_PARAMS.reserved");
assert_eq!(
offset_of!(NV_ENC_PIC_PARAMS_HEVC, flags),
16,
"NV_ENC_PIC_PARAMS_HEVC.flags (the C program sets constrainedFrame / ltrUseFrames and reads the word at 16: 0x1 / 0x8)"
);
} }
#[test] #[test]
@ -400,6 +435,12 @@ fn constants_match_the_header() {
assert_eq!(NV_ENC_LEVEL_HEVC_61 as i64, 183, "NV_ENC_LEVEL_HEVC_61"); assert_eq!(NV_ENC_LEVEL_HEVC_61 as i64, 183, "NV_ENC_LEVEL_HEVC_61");
assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62"); assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62");
assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION"); assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION");
assert_eq!(NV_ENC_BUFFER_FORMAT_YUV420_10BIT as i64, 65536, "NV_ENC_BUFFER_FORMAT_YUV420_10BIT");
assert_eq!(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE as i64, 39, "NV_ENC_CAPS_SUPPORT_10BIT_ENCODE");
assert_eq!(NV_ENC_VUI_COLOR_PRIMARIES_BT2020 as i64, 9, "NV_ENC_VUI_COLOR_PRIMARIES_BT2020");
assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084 as i64, 16, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084");
assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67 as i64, 18, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67");
assert_eq!(NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL as i64, 9, "NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL");
} }
#[test] #[test]
@ -412,6 +453,7 @@ fn guids_match_the_header() {
assert_eq!(NV_ENC_PRESET_P5_GUID.to_u128(), 0x21c6e6b4297a4cba998fb6cbde72ade3u128, "NV_ENC_PRESET_P5_GUID"); assert_eq!(NV_ENC_PRESET_P5_GUID.to_u128(), 0x21c6e6b4297a4cba998fb6cbde72ade3u128, "NV_ENC_PRESET_P5_GUID");
assert_eq!(NV_ENC_CODEC_HEVC_GUID.to_u128(), 0x790cdc8845224d7b9425bda9975f7603u128, "NV_ENC_CODEC_HEVC_GUID"); assert_eq!(NV_ENC_CODEC_HEVC_GUID.to_u128(), 0x790cdc8845224d7b9425bda9975f7603u128, "NV_ENC_CODEC_HEVC_GUID");
assert_eq!(NV_ENC_HEVC_PROFILE_MAIN_GUID.to_u128(), 0xb514c39ab55b40fa878ff1253b4dfdecu128, "NV_ENC_HEVC_PROFILE_MAIN_GUID"); assert_eq!(NV_ENC_HEVC_PROFILE_MAIN_GUID.to_u128(), 0xb514c39ab55b40fa878ff1253b4dfdecu128, "NV_ENC_HEVC_PROFILE_MAIN_GUID");
assert_eq!(NV_ENC_HEVC_PROFILE_MAIN10_GUID.to_u128(), 0xfa4d2b6c3a5b411a80180a3f5e3c9be5u128, "NV_ENC_HEVC_PROFILE_MAIN10_GUID");
} }
/// The bit-field masks of the `flags` words (the C compiler's layout of each bit-field set alone). /// The bit-field masks of the `flags` words (the C compiler's layout of each bit-field set alone).

View File

@ -3,9 +3,12 @@
//! //!
//! - H.264: it takes an export only when Export ▸ Hardware encoding is Auto and NVENC can do what //! - H.264: it takes an export only when Export ▸ Hardware encoding is Auto and NVENC can do what
//! the settings ask; otherwise it returns `None` and the software encoder runs, exactly as before. //! the settings ask; otherwise it returns `None` and the software encoder runs, exactly as before.
//! - H.265 (HEVC Main, 8-bit 4:2:0, SDR BT.709, progressive): there is no software encoder, so the //! - H.265 (HEVC Main 8-bit 4:2:0 SDR BT.709, or Main 10 HDR: 10-bit 4:2:0, BT.2020, PQ or HLG, limited
//! Hardware encoding toggle does not apply (as with VideoToolbox) and choosing the format is the //! range; progressive): there is no software encoder, so the Hardware encoding toggle does not apply
//! opt-in. What NVENC cannot do (HDR, interlaced, two-pass, non-square pixels, sizes outside its //! (as with VideoToolbox) and choosing the format is the opt-in. The export crate hands HDR pictures
//! over (`EncoderFrame::hdr`) only where [`super::hevc_hdr_available`] said this GPU has a 10-bit
//! encoder, otherwise an HDR sequence is tone-mapped to SDR as before. What NVENC cannot do
//! (interlaced, two-pass, non-square pixels, 10-bit on a GPU without it, sizes outside its
//! limits...) is counted as declined and ends the export with an error that says why; when this //! limits...) is counted as declined and ends the export with an error that says why; when this
//! machine has no HEVC encoder at all, [`filmcraft_export::available`] says so and the factory //! machine has no HEVC encoder at all, [`filmcraft_export::available`] says so and the factory
//! returns `None` (the export's own "encoder not available" error). //! returns `None` (the export's own "encoder not available" error).
@ -15,12 +18,13 @@
//! two write different streams. //! two write different streams.
use filmcraft_export::{ use filmcraft_export::{
BitrateMode, EncodedPacket, EncoderFrame, ExportError, ExportSettings, FieldOrder, Format, H264Pass, H264Profile, HardwareEncoding, Result, VideoEncoder, BitrateMode, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, FieldOrder, Format, H264Pass, H264Profile, HardwareEncoding, Result,
VideoEncoder,
}; };
use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_isobmff::{AvcConfig, SampleEntry};
use filmcraft_time::FrameRate; use filmcraft_time::FrameRate;
use super::{Codec, Config, Nvenc, Profile}; use super::{Codec, Config, Nvenc, Profile, Sei, Signal};
/// The NVENC export encoder. /// The NVENC export encoder.
struct NvencEncoder { struct NvencEncoder {
@ -28,9 +32,38 @@ struct NvencEncoder {
w: u32, w: u32,
h: u32, h: u32,
rate: FrameRate, rate: FrameRate,
/// The stream's colour signal (`colr` / `mdcv` / `clli` on the sample entry for HDR).
signal: ColorSignal,
y: Vec<u8>, y: Vec<u8>,
u: Vec<u8>, u: Vec<u8>,
v: Vec<u8>, v: Vec<u8>,
/// Main 10: the 10-bit planes of the current picture.
y16: Vec<u16>,
u16: Vec<u16>,
v16: Vec<u16>,
}
/// SEI payload types of the HDR10 static metadata (H.265 Annex D): mastering display colour volume
/// and content light level information.
const SEI_MASTERING_DISPLAY: u32 = 137;
const SEI_CONTENT_LIGHT_LEVEL: u32 = 144;
/// The encoder's signal for an export's colour signal: the VUI code points and, for PQ, the HDR10
/// static metadata as SEI messages (the same values and bytes the `mdcv` / `clli` boxes and the software
/// H.264 encoder's SEI carry: [`ColorSignal::static_metadata`]). HLG has no static metadata.
pub fn nvenc_signal(signal: &ColorSignal) -> Signal {
let mut sei = Vec::new();
if let Some((md, (max_cll, max_fall))) = signal.static_metadata() {
sei.push(Sei { payload_type: SEI_MASTERING_DISPLAY, payload: md.to_bytes() });
let mut cll = max_cll.to_be_bytes().to_vec();
cll.extend_from_slice(&max_fall.to_be_bytes());
sei.push(Sei { payload_type: SEI_CONTENT_LIGHT_LEVEL, payload: cll });
}
Signal { primaries: signal.primaries, transfer: signal.transfer, matrix: signal.matrix, sei }
}
fn new_encoder(enc: Nvenc, w: u32, h: u32, rate: FrameRate, signal: ColorSignal) -> NvencEncoder {
NvencEncoder { enc, w, h, rate, signal, y: Vec::new(), u: Vec::new(), v: Vec::new(), y16: Vec::new(), u16: Vec::new(), v16: Vec::new() }
} }
impl NvencEncoder { impl NvencEncoder {
@ -55,8 +88,14 @@ impl VideoEncoder for NvencEncoder {
let (w, h) = (u16::try_from(self.w).unwrap_or(u16::MAX), u16::try_from(self.h).unwrap_or(u16::MAX)); let (w, h) = (u16::try_from(self.w).unwrap_or(u16::MAX), u16::try_from(self.h).unwrap_or(u16::MAX));
match self.enc.codec() { match self.enc.codec() {
Codec::H264 => SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h), Codec::H264 => SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h),
// `Nvenc::new` built and validated the record, so there is always one for HEVC Codec::Hevc => {
Codec::Hevc => SampleEntry::hevc(self.enc.hevc_config().cloned().unwrap_or_default(), w, h), // `Nvenc::new` built and validated the record, so there is always one for HEVC
let mut e = SampleEntry::hevc(self.enc.hevc_config().cloned().unwrap_or_default(), w, h);
// HDR: `colr` (BT.2020 + PQ / HLG) and, for PQ, `mdcv` / `clli`, as the software H.264 encoder does
// (nclx, whatever the container: readers take it from either). SDR stays as it was, with the VUI only.
self.signal.apply_to(&mut e, false);
e
}
} }
} }
@ -65,17 +104,25 @@ impl VideoEncoder for NvencEncoder {
} }
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> { fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
if f.hdr.is_some() {
return Err(ExportError::Unsupported("NVENC does not take HDR pictures here".into()));
}
if f.width != self.w || f.height != self.h { if f.width != self.w || f.height != self.h {
return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h))); return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h)));
} }
filmcraft_export::rgba_to_yuv420_8(f.rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v); let (w, h) = (self.w as usize, self.h as usize);
let ps = self let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" };
.enc let ps = match (self.enc.is_ten_bit(), f.hdr) {
.encode(&self.y, &self.u, &self.v, f.index) // Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix)
.map_err(|e| ExportError::Encode(format!("NVENC: {e} (turn Export ▸ Hardware encoding off to use the software encoder)")))?; (true, Some(rgb)) => {
let (kr, kb) = self.signal.kr_kb();
filmcraft_export::rgbf_to_yuv420_10(rgb, w, h, kr, kb, &mut self.y16, &mut self.u16, &mut self.v16)?;
self.enc.encode_10(&self.y16, &self.u16, &self.v16, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?
}
(true, None) => return Err(ExportError::Unsupported("NVENC Main 10 needs the HDR picture (EncoderFrame::hdr)".into())),
(false, Some(_)) => return Err(ExportError::Unsupported("NVENC Main (8-bit) does not take HDR pictures".into())),
(false, None) => {
filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v);
self.enc.encode(&self.y, &self.u, &self.v, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?
}
};
filmcraft_export::note_hw_encode_frame(); filmcraft_export::note_hw_encode_frame();
Ok(self.packets(ps)) Ok(self.packets(ps))
} }
@ -98,12 +145,13 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
if !(format == Format::H264 || hevc) || s.format.is_mxf() { if !(format == Format::H264 || hevc) || s.format.is_mxf() {
return Err("only MP4 / MOV H.264 and H.265 exports".into()); return Err("only MP4 / MOV H.264 and H.265 exports".into());
} }
if s.signal.is_hdr() { let hdr = s.signal.is_hdr();
return Err(if hevc { if hdr && !hevc {
"HDR (hardware H.265 here is 8-bit SDR Main)".into() return Err("HDR (8-bit H.264 here carries it with software signalling)".into());
} else { }
"HDR (8-bit H.264 here carries it with software signalling)".into() // HEVC: BT.709 SDR (Main) or BT.2020 NCL with PQ / HLG (Main 10); any other description is not written
}); if hevc && (hdr && (s.signal.primaries != 9 || s.signal.matrix != 9) || !hdr && s.signal != ColorSignal::default()) {
return Err(format!("the colour signal {:?} (H.265 here is BT.709, or BT.2020 with PQ / HLG)", s.signal));
} }
if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) { if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) {
return Err("two-pass VBR".into()); return Err("two-pass VBR".into());
@ -135,6 +183,7 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
cbr: s.bitrate_mode == BitrateMode::Cbr, cbr: s.bitrate_mode == BitrateMode::Cbr,
keyint: s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (f64::from(fps.0) / f64::from(fps.1) * 2.0).round().max(1.0) as u32), keyint: s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (f64::from(fps.0) / f64::from(fps.1) * 2.0).round().max(1.0) as u32),
profile: match (hevc, s.h264_profile) { profile: match (hevc, s.h264_profile) {
(true, _) if hdr => Profile::HevcMain10,
(true, _) => Profile::HevcMain, (true, _) => Profile::HevcMain,
(false, H264Profile::Baseline) => Profile::Baseline, (false, H264Profile::Baseline) => Profile::Baseline,
(false, H264Profile::Main) => Profile::Main, (false, H264Profile::Main) => Profile::Main,
@ -167,7 +216,7 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
match Nvenc::new(&cfg) { match Nvenc::new(&cfg) {
Ok(enc) => { Ok(enc) => {
filmcraft_export::note_hw_encode_session(); filmcraft_export::note_hw_encode_session();
Some(Ok(Box::new(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() }))) Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal))))
} }
Err(why) => declined(&why), Err(why) => declined(&why),
} }
@ -185,10 +234,10 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<R
Ok(c) => c, Ok(c) => c,
Err(why) => return declined(&why), Err(why) => return declined(&why),
}; };
match Nvenc::new(&cfg) { match Nvenc::with_signal(&cfg, &nvenc_signal(&s.signal)) {
Ok(enc) => { Ok(enc) => {
filmcraft_export::note_hw_encode_session(); filmcraft_export::note_hw_encode_session();
Some(Ok(Box::new(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() }))) Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal))))
} }
// no HEVC encoder here at all: not a hardware attempt, the export's own "encoder not available" error // no HEVC encoder here at all: not a hardware attempt, the export's own "encoder not available" error
Err(why) if !super::hevc_available() => { Err(why) if !super::hevc_available() => {
@ -220,11 +269,18 @@ mod tests {
// the H.264 level and profile settings mean nothing to HEVC // the H.264 level and profile settings mean nothing to HEVC
let h = ExportSettings { h264_level: Some(41), h264_profile: H264Profile::Baseline, ..ok.clone() }; let h = ExportSettings { h264_level: Some(41), h264_profile: H264Profile::Baseline, ..ok.clone() };
assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &h).unwrap().level, None); assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &h).unwrap().level, None);
// HDR (PQ / HLG, BT.2020) is Main 10, SDR stays Main; H.264 still declines HDR
for signal in [ColorSignal::PQ, ColorSignal::HLG] {
let hdr = ExportSettings { signal, ..ok.clone() };
assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &hdr).unwrap().profile, Profile::HevcMain10);
assert!(config(Format::H264, 1280, 720, FrameRate::FPS_24, &ExportSettings { format: Format::H264, ..hdr }).is_err());
}
// the Hardware encoding toggle does not matter for HEVC (it has no software encoder) // the Hardware encoding toggle does not matter for HEVC (it has no software encoder)
let off = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() }; let off = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() };
assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &off).is_ok()); assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &off).is_ok());
for (what, s) in [ for (what, s) in [
("HDR", ExportSettings { signal: filmcraft_export::ColorSignal::PQ, ..ok.clone() }), ("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..ok.clone() }),
("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..ok.clone() }),
("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }), ("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }),
("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }), ("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }),
("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }), ("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }),
@ -243,6 +299,34 @@ mod tests {
assert!(config(Format::ProRes, 1280, 720, FrameRate::FPS_24, &ok).is_err()); assert!(config(Format::ProRes, 1280, 720, FrameRate::FPS_24, &ok).is_err());
} }
#[test]
fn the_sei_messages_are_the_hdr10_static_metadata_the_software_h264_path_writes() {
let pq = nvenc_signal(&ColorSignal::PQ);
assert_eq!((pq.primaries, pq.transfer, pq.matrix), (9, 16, 9));
assert_eq!(pq.sei.len(), 2);
// mastering display colour volume (137): the 24-byte payload of ST 2086, the `mdcv` box's bytes
let (md, _) = ColorSignal::PQ.static_metadata().unwrap();
assert_eq!((pq.sei[0].payload_type, pq.sei[0].payload.as_slice()), (137, md.to_bytes().as_slice()));
assert_eq!(pq.sei[0].payload.len(), 24);
// BT.2020 primaries (G, B, R order), D65, 1000 / 0.0001 cd/m2 in 0.0001 units
assert_eq!(
pq.sei[0].payload,
[0x21, 0x34, 0x9B, 0xAA, 0x19, 0x96, 0x08, 0xFC, 0x8A, 0x48, 0x39, 0x08, 0x3D, 0x13, 0x40, 0x42, 0x00, 0x98, 0x96, 0x80, 0x00, 0x00, 0x00, 0x01]
);
// content light level (144): MaxCLL 0, MaxFALL 0 (unknown)
assert_eq!((pq.sei[1].payload_type, pq.sei[1].payload.as_slice()), (144, &[0u8, 0, 0, 0][..]));
// laid out as one SEI RBSP (type, size, payload, ...) these are the bytes the software H.264 encoder
// writes for the same metadata (checked against its real output in the export crate's hdr_tests)
let rbsp: Vec<u8> =
pq.sei.iter().flat_map(|m| [m.payload_type as u8, m.payload.len() as u8].into_iter().chain(m.payload.iter().copied())).chain([0x80]).collect();
assert_eq!(&rbsp[..2], &[137, 24]);
assert_eq!(&rbsp[26..], &[144, 4, 0, 0, 0, 0, 0x80]);
// HLG: the colour description only; SDR: BT.709, no messages
let hlg = nvenc_signal(&ColorSignal::HLG);
assert_eq!((hlg.primaries, hlg.transfer, hlg.matrix, hlg.sei.len()), (9, 18, 9, 0));
assert_eq!(nvenc_signal(&ColorSignal::default()), Signal::default());
}
#[test] #[test]
fn oversized_pictures_are_declined() { fn oversized_pictures_are_declined() {
let s = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Auto, ..Default::default() }; let s = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Auto, ..Default::default() };

View File

@ -72,6 +72,8 @@ pub const NV_ENC_ERR_LOCK_BUSY: NvStatus = 13;
pub const NV_ENC_DEVICE_TYPE_DIRECTX: u32 = 0; pub const NV_ENC_DEVICE_TYPE_DIRECTX: u32 = 0;
pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2; pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2;
pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1; pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1;
/// P010: 10-bit 4:2:0, 16-bit little-endian samples with the value in the high 10 bits.
pub const NV_ENC_BUFFER_FORMAT_YUV420_10BIT: u32 = 0x10000;
pub const NV_ENC_PARAMS_RC_VBR: u32 = 1; pub const NV_ENC_PARAMS_RC_VBR: u32 = 1;
pub const NV_ENC_PARAMS_RC_CBR: u32 = 2; pub const NV_ENC_PARAMS_RC_CBR: u32 = 2;
pub const NV_ENC_TUNING_INFO_HIGH_QUALITY: u32 = 1; pub const NV_ENC_TUNING_INFO_HIGH_QUALITY: u32 = 1;
@ -103,10 +105,15 @@ pub const NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED: u32 = 5;
pub const NV_ENC_VUI_COLOR_PRIMARIES_BT709: u32 = 1; pub const NV_ENC_VUI_COLOR_PRIMARIES_BT709: u32 = 1;
pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709: u32 = 1; pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709: u32 = 1;
pub const NV_ENC_VUI_MATRIX_COEFFS_BT709: u32 = 1; pub const NV_ENC_VUI_MATRIX_COEFFS_BT709: u32 = 1;
pub const NV_ENC_VUI_COLOR_PRIMARIES_BT2020: u32 = 9;
pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084: u32 = 16;
pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67: u32 = 18;
pub const NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL: u32 = 9;
/// `NV_ENC_CAPS` values (the enum counts from zero in the header's order). /// `NV_ENC_CAPS` values (the enum counts from zero in the header's order).
pub const NV_ENC_CAPS_NUM_MAX_BFRAMES: u32 = 0; pub const NV_ENC_CAPS_NUM_MAX_BFRAMES: u32 = 0;
pub const NV_ENC_CAPS_SUPPORTED_RATECONTROL_MODES: u32 = 1; pub const NV_ENC_CAPS_SUPPORTED_RATECONTROL_MODES: u32 = 1;
pub const NV_ENC_CAPS_SUPPORT_10BIT_ENCODE: u32 = 39;
pub const NV_ENC_CAPS_WIDTH_MAX: u32 = 16; pub const NV_ENC_CAPS_WIDTH_MAX: u32 = 16;
pub const NV_ENC_CAPS_HEIGHT_MAX: u32 = 17; pub const NV_ENC_CAPS_HEIGHT_MAX: u32 = 17;
pub const NV_ENC_CAPS_WIDTH_MIN: u32 = 45; pub const NV_ENC_CAPS_WIDTH_MIN: u32 = 45;
@ -118,6 +125,7 @@ pub const NV_ENC_H264_PROFILE_BASELINE_GUID: GUID = GUID::from_values(0x0727bcaa
pub const NV_ENC_H264_PROFILE_MAIN_GUID: GUID = GUID::from_values(0x60b5c1d4, 0x67fe, 0x4790, [0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d]); pub const NV_ENC_H264_PROFILE_MAIN_GUID: GUID = GUID::from_values(0x60b5c1d4, 0x67fe, 0x4790, [0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d]);
pub const NV_ENC_H264_PROFILE_HIGH_GUID: GUID = GUID::from_values(0xe7cbc309, 0x4f7a, 0x4b89, [0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10]); pub const NV_ENC_H264_PROFILE_HIGH_GUID: GUID = GUID::from_values(0xe7cbc309, 0x4f7a, 0x4b89, [0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10]);
pub const NV_ENC_HEVC_PROFILE_MAIN_GUID: GUID = GUID::from_values(0xb514c39a, 0xb55b, 0x40fa, [0x87, 0x8f, 0xf1, 0x25, 0x3b, 0x4d, 0xfd, 0xec]); pub const NV_ENC_HEVC_PROFILE_MAIN_GUID: GUID = GUID::from_values(0xb514c39a, 0xb55b, 0x40fa, [0x87, 0x8f, 0xf1, 0x25, 0x3b, 0x4d, 0xfd, 0xec]);
pub const NV_ENC_HEVC_PROFILE_MAIN10_GUID: GUID = GUID::from_values(0xfa4d2b6c, 0x3a5b, 0x411a, [0x80, 0x18, 0x0a, 0x3f, 0x5e, 0x3c, 0x9b, 0xe5]);
pub const NV_ENC_PRESET_P4_GUID: GUID = GUID::from_values(0x90a7b826, 0xdf06, 0x4862, [0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81]); pub const NV_ENC_PRESET_P4_GUID: GUID = GUID::from_values(0x90a7b826, 0xdf06, 0x4862, [0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81]);
pub const NV_ENC_PRESET_P5_GUID: GUID = GUID::from_values(0x21c6e6b4, 0x297a, 0x4cba, [0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3]); pub const NV_ENC_PRESET_P5_GUID: GUID = GUID::from_values(0x21c6e6b4, 0x297a, 0x4cba, [0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3]);
@ -401,10 +409,62 @@ pub struct NV_ENC_CREATE_BITSTREAM_BUFFER {
pub reserved2: [*mut c_void; 64], pub reserved2: [*mut c_void; 64],
} }
/// `NV_ENC_CODEC_PIC_PARAMS`: a union of codec structures, all zero here (defaults). /// `NV_ENC_SEI_PAYLOAD`: a user SEI message (payload type and bytes) for a picture.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct NV_ENC_CODEC_PIC_PARAMS { pub struct NV_ENC_SEI_PAYLOAD {
pub payloadSize: u32,
pub payloadType: u32,
pub payload: *mut u8,
}
/// `NV_ENC_CLOCK_TIMESTAMP_SET`: countingType:1, discontinuityFlag:1, cntDroppedFrames:1, nFrames:8,
/// secondsValue:6, minutesValue:6, hoursValue:5, reserved2:4 in `bits`, then the time offset.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct NV_ENC_CLOCK_TIMESTAMP_SET {
pub bits: u32,
pub timeOffset: u32,
}
/// `NV_ENC_TIME_CODE` (`MAX_NUM_CLOCK_TS` is 3).
#[repr(C)]
#[derive(Clone, Copy)]
pub struct NV_ENC_TIME_CODE {
pub displayPicStruct: u32,
pub clockTimestamp: [NV_ENC_CLOCK_TIMESTAMP_SET; 3],
}
/// `NV_ENC_PIC_PARAMS_HEVC`: the per-picture HEVC parameters; all zero but the SEI payload array here.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct NV_ENC_PIC_PARAMS_HEVC {
pub displayPOCSyntax: u32,
pub refPicFlag: u32,
pub temporalId: u32,
pub forceIntraRefreshWithFrameCnt: u32,
/// constrainedFrame:1, sliceModeDataUpdate:1, ltrMarkFrame:1, ltrUseFrames:1, reservedBitFields:28.
pub flags: u32,
pub sliceTypeData: *mut u8,
pub sliceTypeArrayCnt: u32,
pub sliceMode: u32,
pub sliceModeData: u32,
pub ltrMarkFrameIdx: u32,
pub ltrUseFrameBitmap: u32,
pub ltrUsageMode: u32,
pub seiPayloadArrayCnt: u32,
pub reserved: u32,
pub seiPayloadArray: *mut NV_ENC_SEI_PAYLOAD,
pub timeCode: NV_ENC_TIME_CODE,
pub reserved2: [u32; 237],
pub reserved3: [*mut c_void; 61],
}
/// `NV_ENC_CODEC_PIC_PARAMS`: a union of codec structures (H.264 and AV1 stay opaque inside `reserved`).
#[repr(C)]
#[derive(Clone, Copy)]
pub union NV_ENC_CODEC_PIC_PARAMS {
pub hevcPicParams: NV_ENC_PIC_PARAMS_HEVC,
pub reserved: [u32; 388], pub reserved: [u32; 388],
align: [*mut c_void; 0], align: [*mut c_void; 0],
} }

View File

@ -1,6 +1,9 @@
//! What is specific to HEVC in the NVENC backend (safe code): the level codes, and the `hvcC` //! What is specific to HEVC in the NVENC backend (safe code): the level codes, and the `hvcC`
//! record built from the parameter sets the encoder wrote. //! record built from the parameter sets the encoder wrote.
//! //!
//! Main (8-bit) and Main 10 (10-bit) are both written; the caller says which it asked the encoder for
//! and the record is refused if the SPS says otherwise.
//!
//! Nothing in the record is invented: the profile, tier, compatibility flags, level, chroma format, //! Nothing in the record is invented: the profile, tier, compatibility flags, level, chroma format,
//! bit depths and temporal layers come from the SPS (parsed with `filmcraft_hevc`), and the 48 //! bit depths and temporal layers come from the SPS (parsed with `filmcraft_hevc`), and the 48
//! general constraint flags and `sps_temporal_id_nesting_flag` are read from the SPS bits that carry //! general constraint flags and `sps_temporal_id_nesting_flag` are read from the SPS bits that carry
@ -65,8 +68,14 @@ const PTL_LEN: usize = 12;
/// The `hvcC` record of a stream of `size` pictures (width, height) whose parameter sets are `vps`, /// The `hvcC` record of a stream of `size` pictures (width, height) whose parameter sets are `vps`,
/// `sps` and `pps` (NAL units with their headers, without start codes or length prefixes). An error /// `sps` and `pps` (NAL units with their headers, without start codes or length prefixes). An error
/// says why the stream is not one this backend writes: HEVC Main, 8-bit 4:2:0. /// says why the stream is not the one that was asked for: `bit_depth` 8 is HEVC Main (profile 1),
pub fn hevc_config(vps: &[u8], sps: &[u8], pps: &[u8], size: (u32, u32)) -> Result<HevcConfig, String> { /// 10 is Main 10 (profile 2), both 4:2:0 with equal luma and chroma depth; any other depth is refused.
pub fn hevc_config(vps: &[u8], sps: &[u8], pps: &[u8], size: (u32, u32), bit_depth: u8) -> Result<HevcConfig, String> {
let (profile_idc, profile_name) = match bit_depth {
8 => (1u8, "Main"),
10 => (2, "Main 10"),
other => return Err(format!("{other}-bit HEVC is not written by this backend")),
};
for (nal, kind, name) in [(vps, 32u8, "VPS"), (sps, 33, "SPS"), (pps, 34, "PPS")] { for (nal, kind, name) in [(vps, 32u8, "VPS"), (sps, 33, "SPS"), (pps, 34, "PPS")] {
if nal_type(Codec::Hevc, nal) != Some(kind) || nal.len() <= 2 { if nal_type(Codec::Hevc, nal) != Some(kind) || nal.len() <= 2 {
return Err(format!("the encoder's {name} is not a {name} NAL unit")); return Err(format!("the encoder's {name} is not a {name} NAL unit"));
@ -85,12 +94,13 @@ pub fn hevc_config(vps: &[u8], sps: &[u8], pps: &[u8], size: (u32, u32)) -> Resu
let temporal_id_nested = rbsp.first().is_some_and(|b| b & 1 != 0); let temporal_id_nested = rbsp.first().is_some_and(|b| b & 1 != 0);
let ptl = &parsed.ptl; let ptl = &parsed.ptl;
if ptl.profile_space != 0 || ptl.profile_idc != 1 { if ptl.profile_space != 0 || ptl.profile_idc != profile_idc {
return Err(format!("the encoder wrote HEVC profile {} (space {}), not Main", ptl.profile_idc, ptl.profile_space)); return Err(format!("the encoder wrote HEVC profile {} (space {}), not {profile_name}", ptl.profile_idc, ptl.profile_space));
} }
if parsed.chroma_format_idc != 1 || parsed.bit_depth_luma != 8 || parsed.bit_depth_chroma != 8 { let depth = u32::from(bit_depth);
if parsed.chroma_format_idc != 1 || parsed.bit_depth_luma != depth || parsed.bit_depth_chroma != depth {
return Err(format!( return Err(format!(
"the encoder wrote chroma format {} at {}/{} bits, not 8-bit 4:2:0", "the encoder wrote chroma format {} at {}/{} bits, not {bit_depth}-bit 4:2:0",
parsed.chroma_format_idc, parsed.bit_depth_luma, parsed.bit_depth_chroma parsed.chroma_format_idc, parsed.bit_depth_luma, parsed.bit_depth_chroma
)); ));
} }
@ -140,7 +150,7 @@ mod tests {
} }
fn record() -> HevcConfig { fn record() -> HevcConfig {
hevc_config(&unhex(VPS), &unhex(SPS), &unhex(PPS), (1280, 720)).unwrap() hevc_config(&unhex(VPS), &unhex(SPS), &unhex(PPS), (1280, 720), 8).unwrap()
} }
#[test] #[test]
@ -202,7 +212,7 @@ mod tests {
fn the_picture_size_has_to_match_after_cropping() { fn the_picture_size_has_to_match_after_cropping() {
let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS)); let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS));
for wrong in [(1280, 736), (1920, 1080), (1280, 719), (0, 0)] { for wrong in [(1280, 736), (1920, 1080), (1280, 719), (0, 0)] {
let e = hevc_config(&v, &s, &p, wrong).unwrap_err(); let e = hevc_config(&v, &s, &p, wrong, 8).unwrap_err();
assert!(e.contains("1280x720"), "{e}"); assert!(e.contains("1280x720"), "{e}");
} }
} }
@ -215,10 +225,10 @@ mod tests {
let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS)); let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS));
let mut other_profile = s.clone(); let mut other_profile = s.clone();
other_profile[3] = (other_profile[3] & !0x1f) | 2; // Main 10 other_profile[3] = (other_profile[3] & !0x1f) | 2; // Main 10
assert!(hevc_config(&v, &other_profile, &p, (1280, 720)).unwrap_err().contains("not Main")); assert!(hevc_config(&v, &other_profile, &p, (1280, 720), 8).unwrap_err().contains("not Main"));
let mut other_space = s.clone(); let mut other_space = s.clone();
other_space[3] |= 0x40; other_space[3] |= 0x40;
assert!(hevc_config(&v, &other_space, &p, (1280, 720)).is_err()); assert!(hevc_config(&v, &other_space, &p, (1280, 720), 8).is_err());
} }
#[test] #[test]
@ -226,14 +236,14 @@ mod tests {
let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS)); let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS));
// every truncation of the SPS // every truncation of the SPS
for n in 0..s.len() { for n in 0..s.len() {
assert!(hevc_config(&v, &s[..n], &p, (1280, 720)).is_err(), "SPS cut at {n}"); assert!(hevc_config(&v, &s[..n], &p, (1280, 720), 8).is_err(), "SPS cut at {n}");
} }
// a bit flip anywhere in the SPS: an error or a record, but never a panic // a bit flip anywhere in the SPS: an error or a record, but never a panic
for byte in 0..s.len() { for byte in 0..s.len() {
for bit in 0..8 { for bit in 0..8 {
let mut m = s.clone(); let mut m = s.clone();
m[byte] ^= 1 << bit; m[byte] ^= 1 << bit;
let r = std::panic::catch_unwind(|| hevc_config(&v, &m, &p, (1280, 720))); let r = std::panic::catch_unwind(|| hevc_config(&v, &m, &p, (1280, 720), 8));
assert!(r.is_ok(), "panic with bit {bit} of byte {byte} flipped"); assert!(r.is_ok(), "panic with bit {bit} of byte {byte} flipped");
} }
} }
@ -246,11 +256,59 @@ mod tests {
(&v, &p, &s), (&v, &p, &s),
]; ];
for (v, s, p) in cases { for (v, s, p) in cases {
assert!(hevc_config(v, s, p, (64, 64)).is_err(), "{v:?} {s:?} {p:?}"); assert!(hevc_config(v, s, p, (64, 64), 8).is_err(), "{v:?} {s:?} {p:?}");
} }
// a parameter set too long for the record's 16-bit lengths // a parameter set too long for the record's 16-bit lengths
let mut huge = s.clone(); let mut huge = s.clone();
huge.resize(70_000, 0x55); huge.resize(70_000, 0x55);
assert!(hevc_config(&v, &huge, &p, (1280, 720)).is_err()); assert!(hevc_config(&v, &huge, &p, (1280, 720), 8).is_err());
}
// The parameter sets NVENC wrote for a 1280x720, 24 fps HEVC Main 10 stream with the PQ signal (VUI: BT.2020
// primaries, SMPTE ST 2084, BT.2020 NCL matrix, limited range) on the same GPU.
const VPS10: &str = "40010c01ffff2220000003009000000300000300789940c0000003004000000614";
const SPS10: &str = "420101222000000300900000030000030078a00280802e1f12d96654a421191bff0c05a8488048a000000300200000030301";
const PPS10: &str = "4401c1933c0cc9";
#[test]
fn a_main_10_stream_is_accepted_as_main_10_and_refused_as_main() {
let (v, s, p) = (unhex(VPS10), unhex(SPS10), unhex(PPS10));
let c = hevc_config(&v, &s, &p, (1280, 720), 10).unwrap();
assert_eq!((c.general_profile_space, c.general_tier_flag, c.general_profile_idc), (0, true, 2), "NVENC picked the High tier for level 4 at 12 Mbps");
assert_eq!(c.general_profile_compatibility_flags, 0x2000_0000, "Main 10 only");
assert_eq!(c.general_constraint_indicator_flags, 0x9000_0000_0000);
assert_eq!(c.general_level_idc, 120, "level 4");
assert_eq!((c.chroma_format_idc, c.bit_depth_luma, c.bit_depth_chroma), (1, 10, 10));
assert_eq!(HevcConfig::parse(&c.to_bytes()).unwrap(), c);
// the same stream where Main (8-bit) was asked for, and the 8-bit stream where Main 10 was asked for
let e = hevc_config(&v, &s, &p, (1280, 720), 8).unwrap_err();
assert!(e.contains("not Main") && !e.contains("Main 10"), "{e}");
let e = hevc_config(&unhex(VPS), &unhex(SPS), &unhex(PPS), (1280, 720), 10).unwrap_err();
assert!(e.contains("not Main 10"), "{e}");
// no other depth
for depth in [0, 7, 9, 12, 16, 255] {
assert!(hevc_config(&v, &s, &p, (1280, 720), depth).is_err(), "{depth}");
}
// a Main 10 profile byte with 8-bit depth (or the reverse) is refused whichever is asked for
let mut forged = s.clone();
forged[3] = (forged[3] & !0x1f) | 1; // profile_idc 1 (Main) but a 10-bit SPS
for depth in [8, 10] {
assert!(hevc_config(&v, &forged, &p, (1280, 720), depth).is_err(), "{depth}");
}
}
#[test]
fn truncated_and_flipped_main_10_parameter_sets_never_panic() {
let (v, s, p) = (unhex(VPS10), unhex(SPS10), unhex(PPS10));
for n in 0..s.len() {
assert!(hevc_config(&v, &s[..n], &p, (1280, 720), 10).is_err(), "SPS cut at {n}");
}
for byte in 0..s.len() {
for bit in 0..8 {
let mut m = s.clone();
m[byte] ^= 1 << bit;
assert!(std::panic::catch_unwind(|| hevc_config(&v, &m, &p, (1280, 720), 10)).is_ok(), "panic with bit {bit} of byte {byte} flipped");
}
}
} }
} }

View File

@ -1,4 +1,4 @@
//! NVIDIA NVENC hardware encoding, H.264 and H.265 (HEVC) Main (Windows). //! NVIDIA NVENC hardware encoding, H.264 and H.265 (HEVC) Main / Main 10 (Windows).
//! //!
//! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` (API 12.1, //! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` (API 12.1,
//! [`ffi`]; no CUDA, no SDK to install): pictures go in as NV12 input buffers, the Annex B output //! [`ffi`]; no CUDA, no SDK to install): pictures go in as NV12 input buffers, the Annex B output
@ -11,6 +11,10 @@
//! ```text //! ```text
//! RGBA (the export pipeline) ──► BT.709 limited 4:2:0 (the software encoder's conversion) //! RGBA (the export pipeline) ──► BT.709 limited 4:2:0 (the software encoder's conversion)
//! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC / hvcC //! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC / hvcC
//!
//! HDR (PQ / HLG) HEVC Main 10:
//! encoded BT.2020 R'G'B' floats ──► 10-bit limited 4:2:0 (`filmcraft_export::rgbf_to_yuv420_10`)
//! ──► P010 input buffer ──NVENC──► same path; VUI BT.2020 + transfer, HDR10 SEI on every IDR (PQ)
//! ``` //! ```
//! //!
//! `unsafe` is confined to `ffi` (data) and `session` (every driver call); this module is safe //! `unsafe` is confined to `ffi` (data) and `session` (every driver call); this module is safe
@ -32,10 +36,10 @@ use std::collections::VecDeque;
use filmcraft_isobmff::HevcConfig; use filmcraft_isobmff::HevcConfig;
pub use self::session::{Caps, Codec, Params}; pub use self::session::{Caps, Codec, Params, Sei, Signal};
use self::session::{Locked, Session, Submitted}; use self::session::{Locked, Session, Submitted};
/// H.264 profile (`profile_idc` 66 / 77 / 100), or HEVC Main (8-bit 4:2:0). /// H.264 profile (`profile_idc` 66 / 77 / 100), or HEVC Main (8-bit 4:2:0) / Main 10 (10-bit 4:2:0).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Profile { pub enum Profile {
Baseline, Baseline,
@ -43,13 +47,15 @@ pub enum Profile {
High, High,
/// H.265 Main: chooses the HEVC codec (like `VtProfile::HevcMain` for VideoToolbox). /// H.265 Main: chooses the HEVC codec (like `VtProfile::HevcMain` for VideoToolbox).
HevcMain, HevcMain,
/// H.265 Main 10: 10-bit 4:2:0 (P010 input), for HDR (PQ / HLG) and 10-bit SDR.
HevcMain10,
} }
impl Profile { impl Profile {
/// The codec this profile belongs to. /// The codec this profile belongs to.
pub fn codec(self) -> Codec { pub fn codec(self) -> Codec {
match self { match self {
Profile::HevcMain => Codec::Hevc, Profile::HevcMain | Profile::HevcMain10 => Codec::Hevc,
_ => Codec::H264, _ => Codec::H264,
} }
} }
@ -89,7 +95,7 @@ pub struct Packet {
/// Pictures in flight: input / output buffer pairs. /// Pictures in flight: input / output buffer pairs.
const RING: usize = 8; const RING: usize = 8;
/// An NVENC encoder (H.264, or HEVC when the profile is [`Profile::HevcMain`]). /// An NVENC encoder (H.264, or HEVC when the profile is [`Profile::HevcMain`] or [`Profile::HevcMain10`]).
pub struct Nvenc { pub struct Nvenc {
session: Session, session: Session,
codec: Codec, codec: Codec,
@ -107,6 +113,8 @@ pub struct Nvenc {
ready: usize, ready: usize,
emitted: i64, emitted: i64,
size: (u32, u32), size: (u32, u32),
/// Main 10: pictures go in as 10-bit P010 ([`Nvenc::encode_10`]), otherwise as 8-bit NV12 ([`Nvenc::encode`]).
ten_bit: bool,
} }
/// The H.264 encoder (the name it had before HEVC joined it). /// The H.264 encoder (the name it had before HEVC joined it).
@ -156,6 +164,9 @@ pub fn warm_hevc_probe() {
if std::panic::catch_unwind(hevc_available).is_err() { if std::panic::catch_unwind(hevc_available).is_err() {
log::warn!("the hardware HEVC probe panicked; the format list will ask again"); log::warn!("the hardware HEVC probe panicked; the format list will ask again");
} }
if std::panic::catch_unwind(hevc_hdr_available).is_err() {
log::warn!("the hardware HEVC Main 10 probe panicked; the export will ask again");
}
}); });
if let Err(e) = spawned { if let Err(e) = spawned {
log::info!("hardware HEVC probe thread not started: {e}"); log::info!("hardware HEVC probe thread not started: {e}");
@ -163,14 +174,58 @@ pub fn warm_hevc_probe() {
}); });
} }
/// Whether this system's NVENC can encode HEVC Main 10 (10-bit): HEVC works and a Main 10 session
/// opened for a small picture, once (the answer is kept). What makes HDR H.265 exports possible:
/// `filmcraft_export::hdr_available(Format::Hevc)`.
pub fn hevc_hdr_available() -> bool {
*HEVC_HDR_AVAILABLE.get_or_init(|| {
if !hevc_available() {
return false;
}
let probe = Config {
width: 640,
height: 360,
fps: (30, 1),
bitrate_kbps: 2000,
max_bitrate_kbps: 3000,
cbr: false,
keyint: 30,
profile: Profile::HevcMain10,
level: None,
sar: None,
bframes: false,
};
let result = Nvenc::with_signal(&probe, &Signal::default());
if let Err(why) = &result {
log::info!("no hardware HEVC Main 10 encoder: {why}");
}
result.is_ok()
})
}
/// The answer of [`hevc_hdr_available`], once asked.
static HEVC_HDR_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// Whether [`hevc_hdr_available`] has its answer yet (diagnostics and tests).
pub fn hevc_hdr_probed() -> bool {
HEVC_HDR_AVAILABLE.get().is_some()
}
/// Whether [`hevc_available`] has its answer yet (diagnostics and tests). /// Whether [`hevc_available`] has its answer yet (diagnostics and tests).
pub fn hevc_probed() -> bool { pub fn hevc_probed() -> bool {
HEVC_AVAILABLE.get().is_some() HEVC_AVAILABLE.get().is_some()
} }
impl Nvenc { impl Nvenc {
/// Open an encoder, or say why NVENC does not take this configuration. /// Open an encoder with the default BT.709 signal, or say why NVENC does not take this configuration.
pub fn new(cfg: &Config) -> Result<Self, String> { pub fn new(cfg: &Config) -> Result<Self, String> {
Self::with_signal(cfg, &Signal::default())
}
/// Open an encoder whose HEVC stream carries `signal` (colour description in the VUI, SEI messages
/// on every IDR picture), or say why NVENC does not take this configuration. H.264 takes the
/// default signal only.
pub fn with_signal(cfg: &Config, signal: &Signal) -> Result<Self, String> {
let (w, h) = (cfg.width, cfg.height); let (w, h) = (cfg.width, cfg.height);
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 { if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
return Err(format!("{w}x{h}: NVENC needs even dimensions")); return Err(format!("{w}x{h}: NVENC needs even dimensions"));
@ -179,6 +234,9 @@ impl Nvenc {
return Err("frame rate".into()); return Err("frame rate".into());
} }
let codec = cfg.profile.codec(); let codec = cfg.profile.codec();
if codec == Codec::H264 && *signal != Signal::default() {
return Err("H.264 here is BT.709 without SEI messages".into());
}
let level = match (codec, cfg.level) { 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))?), (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 // left to itself NVENC answers a bitrate above its chosen level's Main tier limit with the
@ -191,6 +249,7 @@ impl Nvenc {
}; };
let mut session = Session::open()?; let mut session = Session::open()?;
let caps = session.caps(codec)?; let caps = session.caps(codec)?;
check_caps(&caps, cfg)?;
if w < caps.min_size.0 || h < caps.min_size.1 || w > caps.max_size.0 || h > caps.max_size.1 { if w < caps.min_size.0 || h < caps.min_size.1 || w > caps.max_size.0 || h > caps.max_size.1 {
return Err(format!("{w}x{h} is outside NVENC's {}x{} - {}x{}", caps.min_size.0, caps.min_size.1, caps.max_size.0, caps.max_size.1)); return Err(format!("{w}x{h} is outside NVENC's {}x{} - {}x{}", caps.min_size.0, caps.min_size.1, caps.max_size.0, caps.max_size.1));
} }
@ -198,6 +257,7 @@ impl Nvenc {
// pictures is simply written without B-frames (H.264 keeps declining those, to the software encoder) // pictures is simply written without B-frames (H.264 keeps declining those, to the software encoder)
let room_for_bframes = codec == Codec::H264 || cfg.keyint > 2; let room_for_bframes = codec == Codec::H264 || cfg.keyint > 2;
let bframes = u32::from(cfg.bframes && cfg.profile != Profile::Baseline && caps.max_bframes >= 1 && room_for_bframes); let bframes = u32::from(cfg.bframes && cfg.profile != Profile::Baseline && caps.max_bframes >= 1 && room_for_bframes);
let ten_bit = cfg.profile == Profile::HevcMain10;
let params = Params { let params = Params {
codec, codec,
width: w, width: w,
@ -209,9 +269,11 @@ impl Nvenc {
gop: cfg.keyint.max(1), gop: cfg.keyint.max(1),
profile: match cfg.profile { profile: match cfg.profile {
Profile::Baseline => 0, Profile::Baseline => 0,
Profile::Main | Profile::HevcMain => 1, Profile::Main | Profile::HevcMain | Profile::HevcMain10 => 1,
Profile::High => 2, Profile::High => 2,
}, },
ten_bit,
signal: signal.clone(),
level, level,
sar: cfg.sar, sar: cfg.sar,
bframes, bframes,
@ -226,7 +288,7 @@ impl Nvenc {
Codec::Hevc => { Codec::Hevc => {
let (vps, sps, pps) = split_hevc_parameter_sets(&params)?; let (vps, sps, pps) = split_hevc_parameter_sets(&params)?;
// validated now: a stream whose parameter sets we cannot describe is a declined export // validated now: a stream whose parameter sets we cannot describe is a declined export
let record = hevc::hevc_config(&vps, &sps, &pps, (w, h))?; let record = hevc::hevc_config(&vps, &sps, &pps, (w, h), if ten_bit { 10 } else { 8 })?;
(vps, sps, pps, Some(record)) (vps, sps, pps, Some(record))
} }
}; };
@ -244,6 +306,7 @@ impl Nvenc {
ready: 0, ready: 0,
emitted: 0, emitted: 0,
size: (w, h), size: (w, h),
ten_bit,
}) })
} }
@ -272,13 +335,39 @@ impl Nvenc {
self.delay self.delay
} }
/// Encode picture `index` from planar 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes). /// Whether this encoder takes 10-bit pictures ([`Nvenc::encode_10`]) instead of 8-bit ones ([`Nvenc::encode`]).
/// Returns the pictures that came out (usually the oldest; none while the ring fills). pub fn is_ten_bit(&self) -> bool {
self.ten_bit
}
/// Encode picture `index` from planar 8-bit 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes).
/// Returns the pictures that came out (usually the oldest; none while the ring fills). An error
/// for a Main 10 encoder.
pub fn encode(&mut self, y: &[u8], u: &[u8], v: &[u8], index: u64) -> Result<Vec<Packet>, String> { pub fn encode(&mut self, y: &[u8], u: &[u8], v: &[u8], index: u64) -> Result<Vec<Packet>, String> {
if self.ten_bit {
return Err("an 8-bit picture for a Main 10 encoder".into());
}
let (w, h) = (self.size.0 as usize, self.size.1 as usize); let (w, h) = (self.size.0 as usize, self.size.1 as usize);
if y.len() < w * h || u.len() < w / 2 * (h / 2) || v.len() < w / 2 * (h / 2) { if y.len() < w * h || u.len() < w / 2 * (h / 2) || v.len() < w / 2 * (h / 2) {
return Err("the picture is smaller than the encoder's size".into()); return Err("the picture is smaller than the encoder's size".into());
} }
self.submit_picture(index, |l| fill_nv12(l, y, u, v, w, h))
}
/// Encode picture `index` from planar 10-bit 4:2:0 (code values 0..=1023 in `u16`, `u`, `v` at
/// half size). The pictures that came out, as for [`Nvenc::encode`]. An error for a Main encoder.
pub fn encode_10(&mut self, y: &[u16], u: &[u16], v: &[u16], index: u64) -> Result<Vec<Packet>, String> {
if !self.ten_bit {
return Err("a 10-bit picture for a Main (8-bit) encoder".into());
}
let (w, h) = (self.size.0 as usize, self.size.1 as usize);
if y.len() < w * h || u.len() < w / 2 * (h / 2) || v.len() < w / 2 * (h / 2) {
return Err("the picture is smaller than the encoder's size".into());
}
self.submit_picture(index, |l| fill_p010(l, y, u, v, w, h))
}
fn submit_picture(&mut self, index: u64, fill: impl FnOnce(Locked<'_>)) -> Result<Vec<Packet>, String> {
let mut out = Vec::new(); let mut out = Vec::new();
let slot = match self.free.pop() { let slot = match self.free.pop() {
Some(s) => s, Some(s) => s,
@ -291,7 +380,7 @@ impl Nvenc {
self.free.pop().ok_or("no free encoder buffer")? self.free.pop().ok_or("no free encoder buffer")?
} }
}; };
let st = self.session.submit(slot, index, |l| fill_nv12(l, y, u, v, w, h)); let st = self.session.submit(slot, index, fill);
let st = match st { let st = match st {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@ -359,6 +448,44 @@ fn fill_nv12(l: Locked<'_>, y: &[u8], u: &[u8], v: &[u8], w: usize, h: usize) {
} }
} }
/// Copy planar 10-bit 4:2:0 into a P010 input buffer: each sample is a little-endian `u16` with the
/// 10-bit code in its high bits (`code << 6`), the chroma plane interleaved (Cb, Cr) after the luma rows,
/// `pitch` in bytes. Codes above 10 bits are masked; a row the pitch cannot hold is skipped, never overrun.
fn fill_p010(l: Locked<'_>, y: &[u16], u: &[u16], v: &[u16], w: usize, h: usize) {
let pitch = l.pitch;
let (luma, chroma) = l.data.split_at_mut(pitch.saturating_mul(h).min(l.data.len()));
if w == 0 || pitch == 0 {
return;
}
let put = |dst: &mut [u8], code: u16| dst.copy_from_slice(&((code & 0x3ff) << 6).to_le_bytes());
for (row, dst) in y.chunks_exact(w).zip(luma.chunks_exact_mut(pitch)).take(h) {
let Some(dst) = dst.get_mut(..w.saturating_mul(2)) else { continue };
for (d, c) in dst.chunks_exact_mut(2).zip(row) {
put(d, *c);
}
}
let cw = w / 2;
if cw == 0 {
return;
}
for ((ur, vr), dst) in u.chunks_exact(cw).zip(v.chunks_exact(cw)).zip(chroma.chunks_exact_mut(pitch)).take(h / 2) {
let Some(dst) = dst.get_mut(..w.saturating_mul(2)) else { continue };
for ((d, a), b) in dst.chunks_exact_mut(4).zip(ur).zip(vr) {
let (du, dv) = d.split_at_mut(2);
put(du, *a);
put(dv, *b);
}
}
}
/// Why this GPU's encoder cannot take `cfg` (beyond its size limits), or `Ok`: Main 10 needs 10-bit support.
fn check_caps(caps: &Caps, cfg: &Config) -> Result<(), String> {
if cfg.profile == Profile::HevcMain10 && !caps.ten_bit {
return Err("this GPU's encoder has no 10-bit (HEVC Main 10) support".into());
}
Ok(())
}
/// The NAL units of an Annex B byte stream (start codes `00 00 01` / `00 00 00 01`). /// The NAL units of an Annex B byte stream (start codes `00 00 01` / `00 00 00 01`).
pub fn annex_b_nals(data: &[u8]) -> Vec<&[u8]> { pub fn annex_b_nals(data: &[u8]) -> Vec<&[u8]> {
let mut starts = Vec::new(); let mut starts = Vec::new();
@ -497,6 +624,69 @@ mod tests {
fill_nv12(Locked { data: &mut buf, pitch }, &[1], &[], &[], 1, 1); fill_nv12(Locked { data: &mut buf, pitch }, &[1], &[], &[], 1, 1);
} }
#[test]
fn p010_is_shifted_little_endian_with_interleaved_chroma_in_bytes() {
let (w, h, pitch) = (4usize, 2usize, 12usize); // 2 bytes per sample + 4 bytes of padding
let y: Vec<u16> = vec![0, 1, 64, 1023, 940, 512, 4, 1019];
let (u, v) = (vec![512, 960], vec![64, 1023]);
let mut buf = vec![0xAAu8; pitch * h * 3 / 2];
fill_p010(Locked { data: &mut buf, pitch }, &y, &u, &v, w, h);
let sample = |at: usize| u16::from_le_bytes([buf[at], buf[at + 1]]);
assert_eq!((0..4).map(|x| sample(x * 2)).collect::<Vec<_>>(), vec![0, 1 << 6, 64 << 6, 1023 << 6]);
assert_eq!((0..4).map(|x| sample(pitch + x * 2)).collect::<Vec<_>>(), vec![940 << 6, 512 << 6, 4 << 6, 1019 << 6]);
// chroma after pitch * h bytes: Cb Cr Cb Cr
let c = pitch * h;
assert_eq!((0..4).map(|x| sample(c + x * 2)).collect::<Vec<_>>(), vec![512 << 6, 64 << 6, 960 << 6, 1023 << 6]);
// the low 6 bits of every sample are zero and the padding is untouched
assert!((0..4).all(|x| sample(x * 2) & 0x3f == 0));
assert_eq!(&buf[8..12], &[0xAA; 4]);
// a code beyond 10 bits is masked, not shifted into the next field
let mut b2 = vec![0u8; pitch * h * 3 / 2];
fill_p010(Locked { data: &mut b2, pitch }, &[0xFFFF; 8], &[0xFFFF; 2], &[0xFFFF; 2], w, h);
assert_eq!(u16::from_le_bytes([b2[0], b2[1]]), 1023 << 6);
}
#[test]
fn p010_never_writes_past_a_narrow_pitch_or_short_buffer() {
// a pitch narrower than 2 bytes per pixel (rejected by `submit`) must not panic here either
let (w, h) = (8usize, 4usize);
for pitch in [0usize, 1, 7, 8, 15, 16] {
let mut buf = vec![0u8; pitch * h * 3 / 2];
let y = vec![1000u16; w * h];
let (u, v) = (vec![500u16; w * h / 4], vec![600u16; w * h / 4]);
fill_p010(Locked { data: &mut buf, pitch }, &y, &u, &v, w, h);
}
// empty and short inputs, a buffer shorter than the pitch says
fill_p010(Locked { data: &mut [], pitch: 0 }, &[], &[], &[], 0, 0);
fill_p010(Locked { data: &mut [0u8; 5], pitch: 64 }, &[1; 64], &[1; 16], &[1; 16], 8, 8);
let mut buf = vec![0u8; 64];
fill_p010(Locked { data: &mut buf, pitch: 16 }, &[1], &[], &[], 1, 1);
}
#[test]
fn main_10_needs_a_gpu_with_10_bit_support() {
let cfg = |profile| Config {
width: 640,
height: 360,
fps: (24, 1),
bitrate_kbps: 1000,
max_bitrate_kbps: 1500,
cbr: false,
keyint: 24,
profile,
level: None,
sar: None,
bframes: false,
};
let caps = |ten_bit| Caps { ten_bit, max_bframes: 2, min_size: (130, 128), max_size: (8192, 8192) };
assert!(check_caps(&caps(true), &cfg(Profile::HevcMain10)).is_ok());
let e = check_caps(&caps(false), &cfg(Profile::HevcMain10)).unwrap_err();
assert!(e.contains("10-bit"), "{e}");
for p in [Profile::HevcMain, Profile::Main, Profile::High, Profile::Baseline] {
assert!(check_caps(&caps(false), &cfg(p)).is_ok(), "{p:?} does not need 10-bit");
}
}
#[test] #[test]
fn empty_nal_units_are_skipped() { fn empty_nal_units_are_skipped() {
assert!(annex_b_nals(&[0, 0, 1, 0, 0, 1]).is_empty()); assert!(annex_b_nals(&[0, 0, 1, 0, 0, 1]).is_empty());
@ -584,6 +774,7 @@ mod tests {
#[test] #[test]
fn the_hevc_profile_picks_the_hevc_codec() { fn the_hevc_profile_picks_the_hevc_codec() {
assert_eq!(Profile::HevcMain.codec(), Codec::Hevc); assert_eq!(Profile::HevcMain.codec(), Codec::Hevc);
assert_eq!(Profile::HevcMain10.codec(), Codec::Hevc);
for p in [Profile::Baseline, Profile::Main, Profile::High] { for p in [Profile::Baseline, Profile::Main, Profile::High] {
assert_eq!(p.codec(), Codec::H264); assert_eq!(p.codec(), Codec::H264);
} }

View File

@ -115,9 +115,34 @@ impl Codec {
} }
} }
/// One SEI message the encoder writes in front of every IDR picture (HEVC): its payload type and bytes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Sei {
pub payload_type: u32,
pub payload: Vec<u8>,
}
/// The colour description of a stream (ITU-T H.273 code points, limited range) and the SEI messages
/// that go with it. The default is BT.709 without SEI.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Signal {
pub primaries: u8,
pub transfer: u8,
pub matrix: u8,
pub sei: Vec<Sei>,
}
impl Default for Signal {
fn default() -> Self {
Signal { primaries: 1, transfer: 1, matrix: 1, sei: Vec::new() }
}
}
/// What the encoder says it can do for a codec. /// What the encoder says it can do for a codec.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Caps { pub struct Caps {
/// 10-bit encoding (Main 10) is supported.
pub ten_bit: bool,
pub max_bframes: u32, pub max_bframes: u32,
pub min_size: (u32, u32), pub min_size: (u32, u32),
pub max_size: (u32, u32), pub max_size: (u32, u32),
@ -134,8 +159,12 @@ pub struct Params {
pub max_bitrate: u32, pub max_bitrate: u32,
pub cbr: bool, pub cbr: bool,
pub gop: u32, pub gop: u32,
/// H.264 only: 0 baseline, 1 main, 2 high (HEVC is always Main). /// H.264 only: 0 baseline, 1 main, 2 high (HEVC is Main, or Main 10 with `ten_bit`).
pub profile: u8, pub profile: u8,
/// HEVC Main 10: 10-bit 4:2:0 pictures (P010), otherwise 8-bit (NV12).
pub ten_bit: bool,
/// HEVC: the VUI colour description and the SEI messages of every IDR picture (H.264 keeps BT.709).
pub signal: Signal,
/// The codec's own level code: `level × 10` for H.264, `level × 30` for HEVC. /// The codec's own level code: `level × 10` for H.264, `level × 30` for HEVC.
pub level: Option<u8>, pub level: Option<u8>,
pub sar: Option<(u32, u32)>, pub sar: Option<(u32, u32)>,
@ -143,7 +172,7 @@ pub struct Params {
pub bframes: u32, pub bframes: u32,
} }
/// An input buffer locked for writing: NV12 rows go to `data` at `pitch` bytes per row, the /// An input buffer locked for writing: NV12 (or P010) rows go to `data` at `pitch` bytes per row, the
/// chroma plane after `pitch * height` bytes. /// chroma plane after `pitch * height` bytes.
pub struct Locked<'a> { pub struct Locked<'a> {
pub data: &'a mut [u8], pub data: &'a mut [u8],
@ -182,6 +211,17 @@ pub struct Session {
slots: Vec<Slot>, slots: Vec<Slot>,
size: (u32, u32), size: (u32, u32),
out_size: u32, out_size: u32,
/// `NV_ENC_BUFFER_FORMAT_*` of the input buffers and pictures, and its bytes per sample.
fmt: u32,
bytes_per_sample: usize,
/// The SEI messages of every IDR picture. The payload bytes and the array of descriptors live on
/// the heap, owned by the session for its whole life, so the pointers handed to the driver stay
/// valid however the `Session` value is moved and however long the driver keeps a picture queued.
sei_payloads: Vec<Box<[u8]>>,
sei_array: Box<[NV_ENC_SEI_PAYLOAD]>,
/// Pictures submitted, and the distance between IDR pictures (to know which pictures get the SEI).
submitted: u64,
gop: u64,
} }
// SAFETY: an encoder session and its buffers are driver objects used from one thread at a time (the // SAFETY: an encoder session and its buffers are driver objects used from one thread at a time (the
@ -237,7 +277,20 @@ impl Session {
} }
enc enc
}; };
Ok(Session { api, enc, _device: device, slots: Vec::new(), size: (0, 0), out_size: 0 }) Ok(Session {
api,
enc,
_device: device,
slots: Vec::new(),
size: (0, 0),
out_size: 0,
fmt: NV_ENC_BUFFER_FORMAT_NV12,
bytes_per_sample: 1,
sei_payloads: Vec::new(),
sei_array: Box::new([]),
submitted: 0,
gop: 1,
})
} }
/// The encoder's limits for `codec` (an error when the GPU cannot encode it). /// The encoder's limits for `codec` (an error when the GPU cannot encode it).
@ -258,6 +311,8 @@ impl Session {
} }
}; };
Ok(Caps { Ok(Caps {
// a failing query means no 10-bit encoder
ten_bit: get(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE).unwrap_or(0) != 0,
max_bframes: get(NV_ENC_CAPS_NUM_MAX_BFRAMES)?, max_bframes: get(NV_ENC_CAPS_NUM_MAX_BFRAMES)?,
min_size: (get(NV_ENC_CAPS_WIDTH_MIN)?, get(NV_ENC_CAPS_HEIGHT_MIN)?), min_size: (get(NV_ENC_CAPS_WIDTH_MIN)?, get(NV_ENC_CAPS_HEIGHT_MIN)?),
max_size: (get(NV_ENC_CAPS_WIDTH_MAX)?, get(NV_ENC_CAPS_HEIGHT_MAX)?), max_size: (get(NV_ENC_CAPS_WIDTH_MAX)?, get(NV_ENC_CAPS_HEIGHT_MAX)?),
@ -267,6 +322,24 @@ impl Session {
/// Configure the encoder (preset P5, high quality tuning, the settings' rate control) and /// Configure the encoder (preset P5, high quality tuning, the settings' rate control) and
/// create `ring` pairs of input / output buffers. /// create `ring` pairs of input / output buffers.
pub fn initialize(&mut self, p: &Params, ring: usize) -> Result<(), String> { pub fn initialize(&mut self, p: &Params, ring: usize) -> Result<(), String> {
if p.ten_bit && p.codec != Codec::Hevc {
return Err("10-bit encoding is HEVC Main 10 here".into());
}
if !p.signal.sei.is_empty() && p.codec != Codec::Hevc {
return Err("SEI messages are written for HEVC only".into());
}
self.fmt = if p.ten_bit { NV_ENC_BUFFER_FORMAT_YUV420_10BIT } else { NV_ENC_BUFFER_FORMAT_NV12 };
self.bytes_per_sample = if p.ten_bit { 2 } else { 1 };
self.gop = u64::from(p.gop.max(1));
self.submitted = 0;
// the SEI payloads: heap blocks that outlive every picture (see the field documentation)
self.sei_payloads = p.signal.sei.iter().map(|m| m.payload.clone().into_boxed_slice()).collect();
let mut array = Vec::with_capacity(self.sei_payloads.len());
for (m, bytes) in p.signal.sei.iter().zip(self.sei_payloads.iter_mut()) {
let size = u32::try_from(bytes.len()).map_err(|_| "an SEI payload longer than 4 GiB".to_string())?;
array.push(NV_ENC_SEI_PAYLOAD { payloadSize: size, payloadType: m.payload_type, payload: bytes.as_mut_ptr() });
}
self.sei_array = array.into_boxed_slice();
let preset_fn = self.api.list.nvEncGetEncodePresetConfigEx.ok_or("no nvEncGetEncodePresetConfigEx")?; let preset_fn = self.api.list.nvEncGetEncodePresetConfigEx.ok_or("no nvEncGetEncodePresetConfigEx")?;
let init_fn = self.api.list.nvEncInitializeEncoder.ok_or("no nvEncInitializeEncoder")?; let init_fn = self.api.list.nvEncInitializeEncoder.ok_or("no nvEncInitializeEncoder")?;
// SAFETY: zeroed parameter blocks with their versions; the preset call fills `preset`; the // SAFETY: zeroed parameter blocks with their versions; the preset call fills `preset`; the
@ -282,6 +355,7 @@ impl Session {
let mut cfg: Box<NV_ENC_CONFIG> = Box::new(preset.presetCfg); let mut cfg: Box<NV_ENC_CONFIG> = Box::new(preset.presetCfg);
cfg.version = NV_ENC_CONFIG_VER; cfg.version = NV_ENC_CONFIG_VER;
cfg.profileGUID = match (p.codec, p.profile) { cfg.profileGUID = match (p.codec, p.profile) {
(Codec::Hevc, _) if p.ten_bit => NV_ENC_HEVC_PROFILE_MAIN10_GUID,
(Codec::Hevc, _) => NV_ENC_HEVC_PROFILE_MAIN_GUID, (Codec::Hevc, _) => NV_ENC_HEVC_PROFILE_MAIN_GUID,
(Codec::H264, 0) => NV_ENC_H264_PROFILE_BASELINE_GUID, (Codec::H264, 0) => NV_ENC_H264_PROFILE_BASELINE_GUID,
(Codec::H264, 1) => NV_ENC_H264_PROFILE_MAIN_GUID, (Codec::H264, 1) => NV_ENC_H264_PROFILE_MAIN_GUID,
@ -325,7 +399,7 @@ impl Session {
h.minCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT; h.minCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT;
h.maxCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT; h.maxCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT;
h.idrPeriod = p.gop.max(1); h.idrPeriod = p.gop.max(1);
// parameter sets only in the first IDR (and the hvcC), no access unit delimiters, 8-bit 4:2:0 // parameter sets only in the first IDR (and the hvcC), no access unit delimiters, 4:2:0 at 8 or 10 bits
h.flags &= !(HEVC_OUTPUT_AUD h.flags &= !(HEVC_OUTPUT_AUD
| HEVC_DISABLE_SPSPPS | HEVC_DISABLE_SPSPPS
| HEVC_REPEAT_SPSPPS | HEVC_REPEAT_SPSPPS
@ -333,17 +407,20 @@ impl Session {
| HEVC_CHROMA_FORMAT_MASK | HEVC_CHROMA_FORMAT_MASK
| HEVC_PIXEL_BIT_DEPTH_MASK); | HEVC_PIXEL_BIT_DEPTH_MASK);
h.flags |= 1 << HEVC_CHROMA_FORMAT_SHIFT; h.flags |= 1 << HEVC_CHROMA_FORMAT_SHIFT;
if p.ten_bit {
h.flags |= 2 << HEVC_PIXEL_BIT_DEPTH_SHIFT;
}
h.maxNumRefFramesInDPB = 0; h.maxNumRefFramesInDPB = 0;
// BT.709 limited range, like the H.264 path; an HEVC VUI counts frames, not fields: // the stream's colour description (BT.709 unless the signal says otherwise), limited
// frame rate = time_scale / num_units_in_tick // range; an HEVC VUI counts frames, not fields: frame rate = time_scale / num_units_in_tick
let vui = &mut h.hevcVUIParameters; let vui = &mut h.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1; vui.videoSignalTypePresentFlag = 1;
vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED; vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED;
vui.videoFullRangeFlag = 0; vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1; vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = NV_ENC_VUI_COLOR_PRIMARIES_BT709; vui.colourPrimaries = u32::from(p.signal.primaries);
vui.transferCharacteristics = NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709; vui.transferCharacteristics = u32::from(p.signal.transfer);
vui.colourMatrix = NV_ENC_VUI_MATRIX_COEFFS_BT709; vui.colourMatrix = u32::from(p.signal.matrix);
vui.timingInfoPresentFlag = 1; vui.timingInfoPresentFlag = 1;
vui.numUnitInTicks = p.fps.1; vui.numUnitInTicks = p.fps.1;
vui.timeScale = p.fps.0; vui.timeScale = p.fps.0;
@ -364,7 +441,7 @@ impl Session {
init.enablePTD = 1; init.enablePTD = 1;
init.encodeConfig = &mut *cfg; init.encodeConfig = &mut *cfg;
init.tuningInfo = NV_ENC_TUNING_INFO_HIGH_QUALITY; init.tuningInfo = NV_ENC_TUNING_INFO_HIGH_QUALITY;
init.bufferFormat = NV_ENC_BUFFER_FORMAT_NV12; init.bufferFormat = self.fmt;
let s = init_fn(self.enc, &mut *init); let s = init_fn(self.enc, &mut *init);
if s != NV_ENC_SUCCESS { if s != NV_ENC_SUCCESS {
return Err(status("initialising the encoder", s, self.enc, self.api)); return Err(status("initialising the encoder", s, self.enc, self.api));
@ -391,7 +468,7 @@ impl Session {
i.width = self.size.0; i.width = self.size.0;
i.height = self.size.1; i.height = self.size.1;
i.memoryHeap = NV_ENC_MEMORY_HEAP_SYSMEM_CACHED; i.memoryHeap = NV_ENC_MEMORY_HEAP_SYSMEM_CACHED;
i.bufferFmt = NV_ENC_BUFFER_FORMAT_NV12; i.bufferFmt = self.fmt;
let s = create_in(self.enc, &mut i); let s = create_in(self.enc, &mut i);
if s != NV_ENC_SUCCESS { if s != NV_ENC_SUCCESS {
return Err(status("creating an input buffer", s, self.enc, self.api)); return Err(status("creating an input buffer", s, self.enc, self.api));
@ -451,8 +528,8 @@ impl Session {
); );
let (w, h) = self.size; let (w, h) = self.size;
// SAFETY: the buffer handle is one this session created. `LockInputBuffer` returns a // SAFETY: the buffer handle is one this session created. `LockInputBuffer` returns a
// writable block of `pitch * h * 3 / 2` bytes (NV12: luma rows then chroma rows) that stays // writable block of `pitch * h * 3 / 2` bytes (NV12 or P010: luma rows then chroma rows, the pitch
// valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`. // in bytes) that stays valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`.
let pitch = unsafe { let pitch = unsafe {
let mut l: NV_ENC_LOCK_INPUT_BUFFER = std::mem::zeroed(); let mut l: NV_ENC_LOCK_INPUT_BUFFER = std::mem::zeroed();
l.version = NV_ENC_LOCK_INPUT_BUFFER_VER; l.version = NV_ENC_LOCK_INPUT_BUFFER_VER;
@ -461,11 +538,11 @@ impl Session {
if st != NV_ENC_SUCCESS || l.bufferDataPtr.is_null() || l.pitch == 0 { if st != NV_ENC_SUCCESS || l.bufferDataPtr.is_null() || l.pitch == 0 {
return Err(status("locking an input buffer", st, self.enc, self.api)); return Err(status("locking an input buffer", st, self.enc, self.api));
} }
// a driver pitch narrower than a row would make `fill` write past each row // a driver pitch narrower than a row (in bytes: P010 samples are two) would make `fill` write past each row
if (l.pitch as usize) < w as usize { if (l.pitch as usize) < (w as usize).saturating_mul(self.bytes_per_sample) {
// the lock succeeded, so release it before giving up (its status no longer matters) // the lock succeeded, so release it before giving up (its status no longer matters)
let _ = unlock(self.enc, input); let _ = unlock(self.enc, input);
return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows", l.pitch)); return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows of {}-byte samples", l.pitch, self.bytes_per_sample));
} }
let len = (l.pitch as usize).saturating_mul(h as usize).saturating_mul(3) / 2; let len = (l.pitch as usize).saturating_mul(h as usize).saturating_mul(3) / 2;
fill(Locked { data: std::slice::from_raw_parts_mut(l.bufferDataPtr.cast::<u8>(), len), pitch: l.pitch as usize }); fill(Locked { data: std::slice::from_raw_parts_mut(l.bufferDataPtr.cast::<u8>(), len), pitch: l.pitch as usize });
@ -475,16 +552,27 @@ impl Session {
} }
l.pitch l.pitch
}; };
// SAFETY: a zeroed parameter block with its version; the buffers are this session's. // IDR pictures (every `gop` pictures from the first, in submission = display order) carry the SEI messages
let idr = !self.sei_array.is_empty() && self.submitted.is_multiple_of(self.gop);
self.submitted = self.submitted.saturating_add(1);
// SAFETY: a zeroed parameter block with its version; the buffers are this session's. The SEI
// descriptor array and the payload bytes it points to are heap blocks owned by `self`, which
// stay allocated (and unmoved) until the session is dropped, so they outlive this call and
// whatever time the driver keeps the picture queued.
let st = unsafe { let st = unsafe {
let mut e: NV_ENC_PIC_PARAMS = std::mem::zeroed(); let mut e: NV_ENC_PIC_PARAMS = std::mem::zeroed();
if idr {
let h = &mut e.codecPicParams.hevcPicParams;
h.seiPayloadArrayCnt = u32::try_from(self.sei_array.len()).unwrap_or(0);
h.seiPayloadArray = self.sei_array.as_mut_ptr();
}
e.version = NV_ENC_PIC_PARAMS_VER; e.version = NV_ENC_PIC_PARAMS_VER;
e.inputWidth = w; e.inputWidth = w;
e.inputHeight = h; e.inputHeight = h;
e.inputPitch = pitch; e.inputPitch = pitch;
e.inputBuffer = input; e.inputBuffer = input;
e.outputBitstream = output; e.outputBitstream = output;
e.bufferFmt = NV_ENC_BUFFER_FORMAT_NV12; e.bufferFmt = self.fmt;
e.pictureStruct = NV_ENC_PIC_STRUCT_FRAME; e.pictureStruct = NV_ENC_PIC_STRUCT_FRAME;
e.inputTimeStamp = pts; e.inputTimeStamp = pts;
e.inputDuration = 1; e.inputDuration = 1;

View File

@ -237,8 +237,10 @@ fn what_nvenc_cannot_do_is_an_error_that_says_why() {
// (what, settings, size): each declined by the configuration alone, so even a machine without // (what, settings, size): each declined by the configuration alone, so even a machine without
// NVENC counts and explains it // NVENC counts and explains it
let cases: Vec<(&str, ExportSettings, (u32, u32))> = vec![ let cases: Vec<(&str, ExportSettings, (u32, u32))> = vec![
("HDR (PQ)", ExportSettings { signal: ColorSignal::PQ, ..base.clone() }, (1280, 720)), // HDR is Main 10 since the HDR round (see nvenc_hevc_hdr_export.rs); what stays declined is a colour description
("HDR (HLG)", ExportSettings { signal: ColorSignal::HLG, ..base.clone() }, (1280, 720)), // this backend does not write
("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..base.clone() }, (1280, 720)),
("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..base.clone() }, (1280, 720)),
("an analysis pass", ExportSettings { h264_pass: H264Pass::First, ..base.clone() }, (1280, 720)), ("an analysis pass", ExportSettings { h264_pass: H264Pass::First, ..base.clone() }, (1280, 720)),
("two-pass VBR", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..base.clone() }, (1280, 720)), ("two-pass VBR", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..base.clone() }, (1280, 720)),
("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..base.clone() }, (1280, 720)), ("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..base.clone() }, (1280, 720)),
@ -254,6 +256,19 @@ fn what_nvenc_cannot_do_is_an_error_that_says_why() {
assert!(e.to_string().contains("NVENC"), "{what}: {e}"); assert!(e.to_string().contains("NVENC"), "{what}: {e}");
assert_eq!(delta(before), (0, 0, 1), "{what}: counted as declined, no session"); assert_eq!(delta(before), (0, 0, 1), "{what}: counted as declined, no session");
} }
// HDR (PQ / HLG) is taken as Main 10 where the GPU has 10-bit HEVC, declined (counted, with the reason) where it has not
for signal in [ColorSignal::PQ, ColorSignal::HLG] {
let before = hw_encode_stats();
let r = factory(Format::Hevc, 640, 360, rate, &ExportSettings { signal, ..base.clone() });
if filmcraft_platform::nvenc::hevc_hdr_available() {
assert!(matches!(r, Some(Ok(_))), "{signal:?}");
assert_eq!(delta(before), (0, 1, 0), "{signal:?}");
} else if hevc_available() {
let Some(Err(e)) = r else { panic!("{signal:?}: expected a declined request") };
assert!(e.to_string().contains("10-bit"), "{signal:?}: {e}");
assert_eq!(delta(before), (0, 0, 1), "{signal:?}");
}
}
// interlaced output is refused by the export's own validation, before any encoder is asked // interlaced output is refused by the export's own validation, before any encoder is asked
let (p, seq, m) = project(640, 360, 6); let (p, seq, m) = project(640, 360, 6);
let before = hw_encode_stats(); let before = hw_encode_stats();

View File

@ -0,0 +1,362 @@
//! HDR export as H.265 Main 10 (NVENC) through the real export pipeline: an HDR (Rec. 2100 PQ / HLG)
//! sequence gives MP4 / QuickTime files that ffprobe (an external oracle only) describes as `hvc1`
//! Main 10, yuv420p10le, BT.2020 with the right transfer, limited range, with HDR10 static metadata for
//! PQ; that ffmpeg decodes without errors to the same 10-bit pictures our own decoder makes; that
//! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same
//! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder
//! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder.
#![cfg(target_os = "windows")]
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use filmcraft_color::{ColorPipeline, WorkingSpace};
use filmcraft_export::{BitrateMode, ColorSignal, EncoderFrame, ExportSettings, Format, Progress, export, hw_encode_stats};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, FrameRequest, Generator, MediaSource};
use filmcraft_platform::nvenc::export::factory;
use filmcraft_platform::nvenc::{hevc_available, hevc_hdr_available};
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
use filmcraft_render::SourceMap;
use filmcraft_time::{FrameRate, Tick, TimeRange};
/// The encoder counters are process-wide: one test at a time.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
const W: u32 = 1280;
const H: u32 = 720;
const FRAMES: i64 = 24;
fn project(working: WorkingSpace) -> (Arc<Project>, filmcraft_project::ItemId, SourceMap) {
let rate = FrameRate::FPS_24;
let mut p = Project::new("x");
let g = GeneratorSource::new(Generator::Demo(DemoScene::OceanSunset), W, H, rate, rate.tick_of(FRAMES));
let info = g.info().clone();
let clip = p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(g.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let color = ColorPipeline { working, ..ColorPipeline::REC709 };
let seq = p.new_sequence("s", SequenceSettings { width: W, height: H, frame_rate: rate, color, ..Default::default() }, 1, 1, None);
let v = p.make_track_item(clip, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(FRAMES)), rate).unwrap();
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v);
let mut m = SourceMap::default();
m.0.insert(clip, Arc::new(g));
(Arc::new(p), seq, m)
}
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("fc-nvenc-hdr-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
fn decode(path: &str) -> Vec<Arc<filmcraft_frame::VideoFrame>> {
let bytes: Arc<[u8]> = std::fs::read(path).unwrap().into();
let src = filmcraft_codecs::open_bytes(path, bytes).unwrap();
let rate = src.info().frame_rate();
let n = rate.frame_at(src.info().duration);
(0..n).map(|i| src.video_frame(FrameRequest::full(rate.tick_of(i))).unwrap()).collect()
}
fn hevc(path: String) -> ExportSettings {
ExportSettings { format: Format::Hevc, path, include_audio: false, bitrate_kbps: 10_000, bitrate_mode: BitrateMode::Vbr1Pass, ..Default::default() }
}
fn probe(ffprobe: &Path, path: &str) -> std::collections::HashMap<String, String> {
let entries = "stream=codec_name,profile,codec_tag_string,pix_fmt,color_space,color_transfer,color_primaries,color_range,width,height,nb_frames,level";
let out = Command::new(ffprobe).args(["-v", "error", "-select_streams", "v:0", "-show_entries", entries, "-of", "default=nw=1", path]).output().unwrap();
assert!(out.status.success(), "ffprobe: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
/// The side data ffprobe reports for the first frame.
fn first_frame_side_data(ffprobe: &Path, path: &str) -> String {
let out = Command::new(ffprobe)
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_frames",
"-show_entries",
"frame=side_data_list",
"-of",
"default=nw=1",
path,
])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).to_string()
}
fn assert_ffmpeg_decodes(ffmpeg: &Path, path: &str) {
let out = Command::new(ffmpeg).args(["-v", "error", "-xerror", "-i", path, "-f", "null", "-"]).output().unwrap();
assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr));
}
/// The first picture as ffmpeg decodes it to yuv420p10le: (Y, Cb, Cr) code values.
fn ffmpeg_first_picture(ffmpeg: &Path, path: &str, w: usize, h: usize) -> (Vec<u16>, Vec<u16>, Vec<u16>) {
let out = Command::new(ffmpeg).args(["-v", "error", "-i", path, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "yuv420p10le", "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr));
let samples: Vec<u16> = out.stdout.chunks_exact(2).map(|b| u16::from_le_bytes([b[0], b[1]])).collect();
assert_eq!(samples.len(), w * h * 3 / 2, "one yuv420p10le picture");
let (y, c) = samples.split_at(w * h);
let (u, v) = c.split_at(c.len() / 2);
(y.to_vec(), u.to_vec(), v.to_vec())
}
fn frame<'a>(rgba: &'a [u8], hdr: Option<&'a [f32]>, index: u64) -> EncoderFrame<'a> {
EncoderFrame { width: 640, height: 360, rgba, hdr, index }
}
fn psnr10(a: &[u16], b: &[u16]) -> f64 {
let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (1023.0f64 * 1023.0 / mse).log10() }
}
fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) {
let after = hw_encode_stats();
(after.frames - before.frames, after.sessions - before.sessions, after.declined - before.declined)
}
fn yuv16(f: &filmcraft_frame::VideoFrame) -> ([Arc<Vec<u16>>; 3], u32) {
match &f.data {
filmcraft_frame::PixelData::Yuv16 { planes, bits, .. } => (planes.clone(), *bits),
_ => panic!("16-bit planes expected, got {}", f.format_label()),
}
}
#[test]
fn hdr_sequences_export_as_main_10() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
assert_eq!(filmcraft_export::hdr_available(Format::Hevc), hevc_hdr_available(), "HDR H.265 is available iff NVENC can encode Main 10");
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 here (hevc: {})", hevc_available());
return;
}
let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hdr export");
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("nvenc hdr export");
for (ws, ext, ff_transfer, pq) in [
(WorkingSpace::Rec2100Pq, "mp4", "smpte2084", true),
(WorkingSpace::Rec2100Hlg, "mp4", "arib-std-b67", false),
(WorkingSpace::Rec2100Pq, "mov", "smpte2084", true),
] {
let name = format!("{}.{ext}", ws.id());
let (p, seq, m) = project(ws);
// the ProRes HDR export of the same sequence is the reference for the pixels (10-bit BT.2020, same matrix and range)
let ref_path = tmp(&format!("ref-{}.mov", ws.id()));
let before = hw_encode_stats();
export(
&p,
seq,
&ExportSettings { format: Format::ProRes, path: ref_path.clone(), include_audio: false, ..Default::default() },
&m,
&Progress::default(),
)
.unwrap();
assert_eq!(delta(before), (0, 0, 0), "ProRes never touches NVENC");
let reference = decode(&ref_path);
let path = tmp(&name);
let s = hevc(path.clone());
let before = hw_encode_stats();
let report = export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(report.frames, FRAMES as u64, "{name}");
assert_eq!(delta(before), (FRAMES as u64, 1, 0), "{name}: every picture through one NVENC session, nothing declined");
// our importer: PQ / HLG, BT.2020, 10-bit planes
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let src = filmcraft_codecs::open_bytes(&path, bytes.clone()).unwrap();
let v = src.info().video.clone().unwrap();
let (want_transfer, want_hdr) = if pq { (filmcraft_color::Transfer::Pq, true) } else { (filmcraft_color::Transfer::Hlg, false) };
assert_eq!(
(v.color.transfer, v.color.primaries, v.color.matrix),
(want_transfer, filmcraft_color::Primaries::Bt2020, filmcraft_color::Matrix::Bt2020Ncl),
"{name}"
);
if want_hdr {
let hdr = v.hdr.unwrap_or_else(|| panic!("{name}: no HDR metadata"));
assert_eq!((hdr.mastering_max_nits, hdr.max_cll, hdr.peak_nits()), (Some(1000.0), None, Some(1000.0)), "{name}");
}
// and the sample entry boxes
let file = filmcraft_isobmff::open(&bytes[..]).unwrap();
let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).expect("a video track");
let ve = file.tracks[vt].entries[0].video.clone().expect("a video sample entry");
assert!(matches!(ve.color, Some(filmcraft_isobmff::ColorInfo::Nclx { primaries: 9, matrix: 9, full_range: false, .. })), "{name}: {:?}", ve.color);
assert_eq!(ve.mastering_display.is_some() && ve.content_light == Some((0, 0)), pq, "{name}: mdcv / clli");
let pictures = decode(&path);
assert_eq!(pictures.len(), FRAMES as usize, "{name}");
let (mut worst, mut best_levels) = (99.0f64, usize::MAX);
let (mut peak, mut ref_peak) = (0u16, 0u16);
for (a, b) in pictures.iter().zip(&reference) {
let (pa, bits) = yuv16(a);
let (pb, _) = yuv16(b);
assert_eq!(bits, 10, "{name}: decoded bit depth");
assert!(pa[0].iter().all(|c| *c < 1024));
worst = worst.min(psnr10(&pa[0], &pb[0]));
peak = peak.max(pa[0].iter().copied().max().unwrap_or(0));
ref_peak = ref_peak.max(pb[0].iter().copied().max().unwrap_or(0));
let mut levels: Vec<u16> = pa[0].to_vec();
levels.sort_unstable();
levels.dedup();
best_levels = best_levels.min(levels.len());
}
eprintln!(
"{name}: {} bytes, worst luma PSNR against the ProRes HDR export {worst:.1} dB, {best_levels} distinct luma levels per picture (min), peak luma code {peak} (ProRes {ref_peak})",
report.bytes
);
assert!(worst > 35.0, "{name}: {worst:.1} dB against the ProRes HDR export");
assert!(best_levels > 256, "{name}: {best_levels} distinct luma levels: not 10-bit");
assert!(peak.abs_diff(ref_peak) < 24, "{name}: peak {peak} against {ref_peak}");
let (Some(ffprobe), Some(ffmpeg)) = (&ffprobe, &ffmpeg) else { continue };
let f = probe(ffprobe, &path);
let get = |k: &str| f.get(k).map(String::as_str).unwrap_or("?");
assert_eq!((get("codec_name"), get("profile"), get("codec_tag_string"), get("pix_fmt")), ("hevc", "Main 10", "hvc1", "yuv420p10le"), "{name}: {f:?}");
assert_eq!(
(get("color_primaries"), get("color_transfer"), get("color_space"), get("color_range")),
("bt2020", ff_transfer, "bt2020nc", "tv"),
"{name}: {f:?}"
);
assert_eq!((get("width"), get("height"), get("nb_frames")), ("1280", "720", "24"), "{name}: {f:?}");
let side = first_frame_side_data(ffprobe, &path);
if pq {
assert!(
side.contains("Mastering display metadata") && side.contains("max_luminance=10000000/10000") && side.contains("min_luminance=1/10000"),
"{name}: {side}"
);
assert!(side.contains("Content light level metadata") && side.contains("max_content=0") && side.contains("max_average=0"), "{name}: {side}");
} else {
assert!(!side.contains("Mastering display") && !side.contains("Content light level"), "{name}: {side}");
}
assert_ffmpeg_decodes(ffmpeg, &path);
// ffmpeg's yuv420p10le picture is our decoder's picture
let (fy, fu, fv) = ffmpeg_first_picture(ffmpeg, &path, W as usize, H as usize);
let (ours, _) = yuv16(&pictures[0]);
let diff = |a: &[u16], b: &[u16]| a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
eprintln!(
"{name}: ffprobe {f:?}\n{name}: first-frame side data {:?}\n{name}: ffmpeg vs our decoder, max code difference Y {} Cb {} Cr {}",
side.lines().collect::<Vec<_>>(),
diff(&fy, &ours[0]),
diff(&fu, &ours[1]),
diff(&fv, &ours[2])
);
assert_eq!(
(diff(&fy, &ours[0]), diff(&fu, &ours[1]), diff(&fv, &ours[2])),
(0, 0, 0),
"{name}: our HEVC decoder and ffmpeg disagree on the 10-bit pictures"
);
}
}
#[test]
fn sdr_stays_main_8_bit_and_the_sdr_setting_tone_maps_hdr_sequences() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 here");
return;
}
let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hdr export");
// an HDR sequence with SDR asked for, and a plain SDR sequence: Main, 8-bit, BT.709
for (what, ws, sdr) in [
("HDR sequence, settings.sdr", WorkingSpace::Rec2100Pq, true),
("HLG sequence, settings.sdr", WorkingSpace::Rec2100Hlg, true),
("SDR sequence", ColorPipeline::REC709.working, false),
] {
let (p, seq, m) = project(ws);
let path = tmp(&format!("sdr-{}.mp4", what.replace([' ', ','], "-")));
let s = ExportSettings { sdr, ..hevc(path.clone()) };
let before = hw_encode_stats();
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(delta(before), (FRAMES as u64, 1, 0), "{what}");
let pictures = decode(&path);
assert!(matches!(&pictures[0].data, filmcraft_frame::PixelData::Yuv8 { .. }), "{what}: 8-bit planes");
assert_eq!(pictures[0].color.transfer, filmcraft_color::Transfer::Bt709, "{what}");
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let file = filmcraft_isobmff::open(&bytes[..]).unwrap();
let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
let ve = file.tracks[vt].entries[0].video.clone().unwrap();
assert!(ve.mastering_display.is_none() && ve.content_light.is_none(), "{what}: no HDR boxes");
if let Some(ffprobe) = &ffprobe {
let f = probe(ffprobe, &path);
let get = |k: &str| f.get(k).map(String::as_str).unwrap_or("?");
assert_eq!(
(get("profile"), get("pix_fmt"), get("color_primaries"), get("color_transfer"), get("color_space"), get("color_range")),
("Main", "yuv420p", "bt709", "bt709", "bt709", "tv"),
"{what}: {f:?}"
);
assert!(!first_frame_side_data(ffprobe, &path).contains("Mastering"), "{what}");
}
}
}
#[test]
fn the_factory_rejects_mismatched_pictures_and_clamps_hostile_floats() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 here");
return;
}
let rate = FrameRate::FPS_24;
let (w, h) = (640u32, 360u32);
let rgba = vec![128u8; (w * h * 4) as usize];
let hdr_settings = ExportSettings { signal: ColorSignal::PQ, ..hevc(tmp("x.mp4")) };
let sdr_settings = hevc(tmp("y.mp4"));
let Some(Ok(mut ten)) = factory(Format::Hevc, w, h, rate, &hdr_settings) else { panic!("a Main 10 encoder") };
let Some(Ok(mut eight)) = factory(Format::Hevc, w, h, rate, &sdr_settings) else { panic!("a Main encoder") };
let good = vec![0.5f32; (w * h * 3) as usize];
// the HDR encoder wants the HDR picture, the SDR encoder the RGBA one
assert!(ten.encode(&frame(&rgba, None, 0)).is_err());
assert!(eight.encode(&frame(&rgba, Some(&good), 0)).is_err());
// wrong-length floats (short, long, empty) and a wrong picture size: errors, nothing submitted
for bad in [&good[..good.len() - 1], &[][..], &vec![0.5f32; good.len() + 3][..]] {
assert!(ten.encode(&frame(&rgba, Some(bad), 0)).is_err(), "{} floats", bad.len());
}
assert!(ten.encode(&EncoderFrame { width: w + 2, height: h, rgba: &rgba, hdr: Some(&good), index: 0 }).is_err());
// NaN, infinities and out-of-range values clamp: the pictures encode
let mut hostile = vec![0.25f32; good.len()];
for (i, x) in hostile.iter_mut().enumerate() {
*x = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 7.0, 0.5][i % 6];
}
let mut packets = 0;
for i in 0..4 {
packets += ten.encode(&frame(&rgba, Some(&hostile), i)).unwrap().len();
}
packets += ten.flush().unwrap().len();
assert_eq!(packets, 4);
// the sample entry of the HDR encoder carries colr + mdcv + clli, the SDR one nothing HDR
let e = ten.sample_entry();
let v = e.video.as_ref().unwrap();
assert!(v.color.is_some() && v.mastering_display.is_some() && v.content_light == Some((0, 0)));
assert!(eight.sample_entry().video.as_ref().unwrap().color.is_none());
// HLG: colr only
let Some(Ok(hlg)) = factory(Format::Hevc, w, h, rate, &ExportSettings { signal: ColorSignal::HLG, ..sdr_settings.clone() }) else {
panic!("an HLG encoder")
};
let v = hlg.sample_entry().video.unwrap();
assert!(v.color.is_some() && v.mastering_display.is_none() && v.content_light.is_none());
// H.264 still declines HDR (software signalling), and says why
let h264 = ExportSettings { format: Format::H264, signal: ColorSignal::PQ, ..sdr_settings };
assert!(factory(Format::H264, w, h, rate, &h264).is_none());
}

View File

@ -0,0 +1,324 @@
//! NVENC H.265 Main 10 (HDR PQ / HLG) encoding (Windows, NVIDIA GPU with 10-bit HEVC encoding): the
//! pictures that go in are the 10-bit codes `rgbf_to_yuv420_10` makes of float PQ / HLG R'G'B', the
//! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more
//! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and
//! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder.
#![cfg(target_os = "windows")]
use filmcraft_bitstream::unescape_rbsp;
use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10};
use filmcraft_hevc::params::Sps;
use filmcraft_isobmff::SampleEntry;
use filmcraft_platform::nvenc::export::nvenc_signal;
use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, hevc_available, hevc_hdr_available, nal_type};
const W: usize = 1280;
const H: usize = 720;
const FRAMES: usize = 48;
/// NVENC sessions are a limited resource on consumer GPUs: one test at a time.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn config(w: u32, h: u32) -> Config {
Config {
width: w,
height: h,
fps: (24, 1),
bitrate_kbps: 12000,
max_bitrate_kbps: 18000,
cbr: false,
keyint: 24,
profile: Profile::HevcMain10,
level: None,
sar: None,
bframes: true,
}
}
/// A float HDR test picture (encoded R'G'B', 3 per pixel): the top half is a smooth grey ramp from black to
/// peak (all of it, so PQ passes 1000 nits near 75 % of the way), a box moves across it; the bottom half
/// is bars of saturated BT.2020 colours (the primaries and secondaries at 0.75, beyond BT.709) and dark steps.
fn picture(i: usize) -> Vec<f32> {
let mut v = vec![0f32; W * H * 3];
let bars: [[f32; 3]; 8] =
[[0.75, 0.0, 0.0], [0.0, 0.75, 0.0], [0.0, 0.0, 0.75], [0.0, 0.75, 0.75], [0.75, 0.0, 0.75], [0.75, 0.75, 0.0], [1.0, 1.0, 1.0], [0.05, 0.05, 0.05]];
let (bx, by) = ((i * 11) % (W - 80), (i * 5) % (H / 2 - 80));
for y in 0..H {
for x in 0..W {
let o = (y * W + x) * 3;
let px = if y < H / 2 {
if x >= bx && x < bx + 80 && y >= by && y < by + 80 { [0.9, 0.3, 0.1] } else { [x as f32 / (W - 1) as f32; 3] }
} else {
bars[x * 8 / W]
};
v[o..o + 3].copy_from_slice(&px);
}
}
v
}
/// The converter's 10-bit planes of a picture.
fn planes(rgb: &[f32], signal: &ColorSignal) -> (Vec<u16>, Vec<u16>, Vec<u16>) {
let (kr, kb) = signal.kr_kb();
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
rgbf_to_yuv420_10(rgb, W, H, kr, kb, &mut y, &mut u, &mut v).expect("convert");
(y, u, v)
}
type Sources = Vec<(Vec<u16>, Vec<u16>, Vec<u16>)>;
fn encode_all(cfg: &Config, signal: &ColorSignal, frames: usize) -> Option<(Nvenc, Vec<Packet>, Sources)> {
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder");
return None;
}
let mut enc = Nvenc::with_signal(cfg, &nvenc_signal(signal)).unwrap_or_else(|why| panic!("NVENC Main 10 is available but refused {cfg:?}: {why}"));
let (mut packets, mut sources) = (Vec::new(), Vec::new());
for i in 0..frames {
let src = planes(&picture(i), signal);
packets.extend(enc.encode_10(&src.0, &src.1, &src.2, i as u64).unwrap());
sources.push(src);
}
packets.extend(enc.flush().unwrap());
Some((enc, packets, sources))
}
fn psnr10(a: &[u16], b: &[u16]) -> f64 {
let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (1023.0f64 * 1023.0 / mse).log10() }
}
/// The (NAL type, bytes after the 2-byte header) of every NAL unit of a length-prefixed sample.
fn sample_nals(sample: &[u8]) -> Vec<(u8, Vec<u8>)> {
let mut out = Vec::new();
let mut pos = 0;
while let Some(len) = sample.get(pos..pos + 4) {
let n = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize;
pos += 4;
let nal = sample.get(pos..pos + n).expect("a NAL unit inside the sample");
out.push((nal_type(Codec::Hevc, nal).expect("a NAL header"), nal[2..].to_vec()));
pos += n;
}
assert_eq!(pos, sample.len(), "samples are made of whole NAL units");
out
}
/// The SEI messages (type, payload) of a prefix SEI NAL unit's RBSP.
fn sei_messages(nal_body: &[u8]) -> Vec<(u32, Vec<u8>)> {
let rbsp = unescape_rbsp(nal_body);
let (mut out, mut i) = (Vec::new(), 0usize);
// the RBSP ends with the stop bit byte 0x80 (rbsp_trailing_bits)
while i < rbsp.len() && rbsp[i] != 0x80 {
let mut t = 0u32;
while rbsp[i] == 0xff {
t += 255;
i += 1;
}
t += u32::from(rbsp[i]);
i += 1;
let mut n = 0usize;
while rbsp[i] == 0xff {
n += 255;
i += 1;
}
n += usize::from(rbsp[i]);
i += 1;
out.push((t, rbsp[i..i + n].to_vec()));
i += n;
}
out
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn round_trip(name: &str, signal: ColorSignal, want_sei: bool) {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((enc, packets, sources)) = encode_all(&config(W as u32, H as u32), &signal, FRAMES) else { return };
assert!(enc.is_ten_bit());
assert_eq!(packets.len(), FRAMES, "{name}: one packet per picture");
let keys: Vec<i64> = packets.iter().filter(|p| p.key).map(|p| p.pts).collect();
assert_eq!(keys, vec![0, 24], "{name}: IDR pictures");
// where NVENC put the SEI: every IDR sample has prefix SEI NAL units (type 39) before its slices; nothing else does
let expected = nvenc_signal(&signal).sei;
for (k, p) in packets.iter().enumerate() {
let nals = sample_nals(&p.data);
let types: Vec<u8> = nals.iter().map(|n| n.0).collect();
assert!(types.iter().all(|t| !(32..=37).contains(t)), "{name}: packet {k} carries parameter sets {types:?}");
let seis: Vec<(u32, Vec<u8>)> = nals.iter().filter(|n| n.0 == 39).flat_map(|n| sei_messages(&n.1)).collect();
if p.key {
if k < 2 {
eprintln!("{name}: IDR sample {k} NAL types in order {types:?}");
}
let got: Vec<(u32, Vec<u8>)> = seis.clone();
let want: Vec<(u32, Vec<u8>)> = expected.iter().map(|m| (m.payload_type, m.payload.clone())).collect();
assert_eq!(got, want, "{name}: SEI messages of the IDR sample {k}");
let slice_at = types.iter().position(|t| matches!(t, 19 | 20)).expect("an IDR slice");
assert!(types.iter().take(slice_at).filter(|t| **t == 39).count() >= usize::from(want_sei), "{name}: prefix SEI before the slice");
assert!(types.iter().all(|t| *t != 40), "{name}: no suffix SEI");
} else {
assert!(seis.is_empty(), "{name}: packet {k} (not an IDR) carries SEI {seis:?}");
}
}
assert_eq!(expected.is_empty(), !want_sei);
// the record and the SPS: Main 10, 10-bit 4:2:0, BT.2020 + the transfer, limited range
let hvcc = enc.hevc_config().expect("an hvcC record").clone();
let sps_nal = enc.parameter_sets().0.to_vec();
eprintln!("{name}: VPS {}\n{name}: SPS {}\n{name}: PPS {}", hex(enc.vps()), hex(&sps_nal), hex(enc.parameter_sets().1));
assert_eq!((hvcc.general_profile_idc, hvcc.chroma_format_idc, hvcc.bit_depth_luma, hvcc.bit_depth_chroma), (2, 1, 10, 10), "{name}");
eprintln!(
"{name}: hvcC level {} compat {:#010x} constraints {:#014x}",
hvcc.general_level_idc, hvcc.general_profile_compatibility_flags, hvcc.general_constraint_indicator_flags
);
let rbsp = unescape_rbsp(&sps_nal[2..]);
let sps = Sps::parse(&rbsp).unwrap();
let vui = sps.vui.clone().expect("a VUI");
assert_eq!((vui.colour_primaries, vui.transfer_characteristics, vui.matrix_coefficients, vui.full_range), (9, signal.transfer, 9, false), "{name}");
// our decoder: 10-bit planes
let entry = SampleEntry::hevc(hvcc, W as u16, H as u16);
let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap();
let mut out = Vec::new();
for p in &packets {
out.extend(dec.decode(&p.data, p.pts).unwrap());
}
out.extend(dec.flush());
assert_eq!(out.len(), FRAMES, "{name}");
let (mut worst_y, mut worst_c) = (99.0f64, 99.0f64);
let (mut max_code, mut max_ramp_code) = (0u16, 0u16);
let mut distinct_min = usize::MAX;
for f in &out {
let filmcraft_frame::PixelData::Yuv16 { planes: p, bits, .. } = &f.frame.data else { panic!("{name}: 16-bit planes, got {}", f.frame.format_label()) };
assert_eq!(*bits, 10, "{name}: decoded bit depth");
assert_eq!((f.frame.width as usize, f.frame.height as usize), (W, H));
assert!(p.iter().all(|pl| pl.iter().all(|c| *c < 1024)), "{name}: codes are 10-bit values, not MSB-aligned");
let src = &sources[f.pts as usize];
worst_y = worst_y.min(psnr10(&p[0], &src.0));
worst_c = worst_c.min(psnr10(&p[1], &src.1)).min(psnr10(&p[2], &src.2));
max_code = max_code.max(p[0].iter().copied().max().unwrap_or(0));
// the ramp: one row of the top half, away from the moving box (row 340 is below it)
let row = &p[0][340 * W..341 * W];
max_ramp_code = max_ramp_code.max(row.iter().copied().max().unwrap_or(0));
let mut levels: Vec<u16> = row.to_vec();
levels.sort_unstable();
levels.dedup();
distinct_min = distinct_min.min(levels.len());
}
let src_max = sources.iter().map(|s| s.0[340 * W..341 * W].iter().copied().max().unwrap_or(0)).max().unwrap_or(0);
eprintln!(
"{name}: worst luma PSNR {worst_y:.1} dB, worst chroma {worst_c:.1} dB (10-bit scale), ramp row: {distinct_min} distinct decoded levels (min over frames), \
max decoded ramp code {max_ramp_code} (source {src_max}), max luma code {max_code}, {} bytes",
packets.iter().map(|p| p.data.len()).sum::<usize>()
);
assert!(worst_y > 60.0, "{name}: luma PSNR {worst_y:.1} dB");
assert!(worst_c > 60.0, "{name}: chroma PSNR {worst_c:.1} dB");
assert!(distinct_min > 700, "{name}: only {distinct_min} distinct levels on a smooth ramp: not 10-bit");
assert!(max_ramp_code >= src_max.saturating_sub(4) && max_ramp_code > 900, "{name}: bright codes lost: {max_ramp_code} (source {src_max})");
}
#[test]
fn pq_decodes_to_the_10_bit_source() {
round_trip("PQ", ColorSignal::PQ, true);
}
#[test]
fn hlg_decodes_to_the_10_bit_source_without_static_metadata() {
round_trip("HLG", ColorSignal::HLG, false);
}
/// Run `f`, failing the test (not the process) if it panics.
fn calm<T>(what: &str, f: impl FnOnce() -> T) -> T {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(v) => v,
Err(_) => panic!("{what}: panicked"),
}
}
#[test]
fn main_10_and_main_encoders_refuse_each_others_pictures() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder");
return;
}
let (w, h) = (640usize, 360usize);
let mut ten = Nvenc::with_signal(&config(w as u32, h as u32), &nvenc_signal(&ColorSignal::PQ)).unwrap();
let mut eight = Nvenc::new(&Config { profile: Profile::HevcMain, ..config(w as u32, h as u32) }).unwrap();
assert!(ten.is_ten_bit() && !eight.is_ten_bit());
let (y8, c8) = (vec![16u8; w * h], vec![128u8; w * h / 4]);
let (y10, c10) = (vec![64u16; w * h], vec![512u16; w * h / 4]);
assert!(calm("8-bit into Main 10", || ten.encode(&y8, &c8, &c8, 0)).err().unwrap().contains("Main 10"));
assert!(calm("10-bit into Main", || eight.encode_10(&y10, &c10, &c10, 0)).err().unwrap().contains("8-bit"));
// wrong-length planes
assert!(calm("short luma", || ten.encode_10(&y10[..w * h - 1], &c10, &c10, 0)).is_err());
assert!(calm("short chroma", || ten.encode_10(&y10, &c10[..c10.len() - 1], &c10, 0)).is_err());
assert!(calm("empty", || ten.encode_10(&[], &[], &[], 0)).is_err());
// nothing was submitted by the refusals: the encoders still work
ten.encode_10(&y10, &c10, &c10, 0).unwrap();
eight.encode(&y8, &c8, &c8, 0).unwrap();
assert!(!ten.flush().unwrap().is_empty() && !eight.flush().unwrap().is_empty());
// H.264 takes no colour signal, no 10-bit profile
let h264 = Config { profile: Profile::Main, ..config(w as u32, h as u32) };
assert!(Nvenc::with_signal(&h264, &nvenc_signal(&ColorSignal::PQ)).is_err());
assert!(Nvenc::with_signal(&h264, &Signal::default()).is_ok() || !filmcraft_platform::nvenc::available());
}
#[test]
fn hostile_main_10_configurations_give_errors_not_crashes() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder");
return;
}
let base = config(640, 360);
let sig = nvenc_signal(&ColorSignal::PQ);
for (w, h) in [(0, 0), (641, 360), (640, 361), (16, 16), (u32::MAX, u32::MAX), (100_000, 64)] {
assert!(calm("size", || Nvenc::with_signal(&config(w, h), &sig)).is_err(), "{w}x{h}");
}
let variants: Vec<(&str, Config, Signal)> = vec![
("zero bitrate", Config { bitrate_kbps: 0, max_bitrate_kbps: 0, ..base.clone() }, sig.clone()),
("huge bitrate", Config { bitrate_kbps: u32::MAX, max_bitrate_kbps: u32::MAX, ..base.clone() }, sig.clone()),
("keyint 1", Config { keyint: 1, ..base.clone() }, sig.clone()),
("zero keyint", Config { keyint: 0, ..base.clone() }, sig.clone()),
("odd signal codes", base.clone(), Signal { primaries: 255, transfer: 255, matrix: 255, sei: vec![] }),
(
"a huge SEI payload",
base.clone(),
Signal { sei: vec![filmcraft_platform::nvenc::Sei { payload_type: 137, payload: vec![0xAB; 100_000] }], ..sig.clone() },
),
("an empty SEI payload", base.clone(), Signal { sei: vec![filmcraft_platform::nvenc::Sei { payload_type: 144, payload: vec![] }], ..sig.clone() }),
];
for (name, c, s) in variants {
match calm(name, || Nvenc::with_signal(&c, &s)) {
Ok(mut enc) => {
let (y, u) = (vec![300u16; 640 * 360], vec![512u16; 640 * 360 / 4]);
for i in 0..3u64 {
let _ = calm(name, || enc.encode_10(&y, &u, &u, i));
}
let _ = calm(name, || enc.flush());
}
Err(why) => eprintln!("{name}: declined ({why})"),
}
}
// dropped mid-stream with SEI buffers in flight, repeatedly
for round in 0..8 {
let mut enc = Nvenc::with_signal(&base, &sig).unwrap_or_else(|e| panic!("round {round}: {e}"));
let (y, u) = (vec![300u16; 640 * 360], vec![512u16; 640 * 360 / 4]);
for i in 0..5 {
enc.encode_10(&y, &u, &u, i).unwrap();
}
}
}
#[test]
fn the_probe_follows_the_encoder() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let hdr = hevc_hdr_available();
assert!(!hdr || hevc_available(), "Main 10 implies HEVC");
assert_eq!(hevc_hdr_available(), hdr, "the answer is kept");
let made = Nvenc::with_signal(&config(640, 360), &Signal::default());
assert_eq!(made.is_ok(), hdr, "{:?}", made.err());
}

View File

@ -5,18 +5,26 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use filmcraft_platform::nvenc::{hevc_available, hevc_probed}; use filmcraft_platform::nvenc::{hevc_available, hevc_hdr_available, hevc_hdr_probed, hevc_probed};
#[test] #[test]
fn register_answers_the_hevc_question_in_the_background() { fn register_answers_the_hevc_question_in_the_background() {
assert!(!hevc_probed(), "nothing has asked yet"); assert!(!hevc_probed() && !hevc_hdr_probed(), "nothing has asked yet");
filmcraft_platform::register(); filmcraft_platform::register();
let t0 = Instant::now(); let t0 = Instant::now();
while !hevc_probed() { while !hevc_probed() {
assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the probe"); assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the probe");
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
// the Main 10 probe runs on the same thread, right after
while !hevc_hdr_probed() {
assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the Main 10 probe");
std::thread::sleep(Duration::from_millis(10));
}
eprintln!("answered in the background after {:?}", t0.elapsed()); eprintln!("answered in the background after {:?}", t0.elapsed());
let hdr = hevc_hdr_available();
assert!(!hdr || hevc_available(), "Main 10 implies HEVC");
assert_eq!(filmcraft_export::hdr_available(filmcraft_export::Format::Hevc), hdr);
// the answer is kept: asking and registering again change nothing // the answer is kept: asking and registering again change nothing
let answer = hevc_available(); let answer = hevc_available();
filmcraft_platform::register(); filmcraft_platform::register();

View File

@ -389,6 +389,8 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
.map(|q| (q.settings.width, q.settings.height, q.settings.frame_rate, q.settings.sample_rate)) .map(|q| (q.settings.width, q.settings.height, q.settings.frame_rate, q.settings.sample_rate))
.unwrap_or((1920, 1080, FrameRate::FPS_24, 48_000)); .unwrap_or((1920, 1080, FrameRate::FPS_24, 48_000));
let has_captions = app.session.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty()); let has_captions = app.session.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty());
// an HDR sequence exports HDR (H.265 Main 10 where the hardware encoder has it) unless SDR is asked for
let seq_hdr = app.session.active_sequence().is_some_and(|q| q.settings.color.working.is_hdr());
let all_presets = app.session.export_presets.all(); let all_presets = app.session.export_presets.all();
let favs: Vec<String> = all_presets.iter().filter(|p| app.session.export_presets.is_favorite(&p.name)).map(|p| p.name.clone()).collect(); let favs: Vec<String> = all_presets.iter().filter(|p| app.session.export_presets.is_favorite(&p.name)).map(|p| p.name.clone()).collect();
let mut pick_folder = false; let mut pick_folder = false;
@ -463,7 +465,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.add_space(8.0); ui.add_space(8.0);
let s = &mut ex.settings; let s = &mut ex.settings;
if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) { if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) {
video_section(ui, &mut reg, s, &t, seq_w, seq_h); video_section(ui, &mut reg, s, &t, seq_w, seq_h, seq_hdr);
} }
if (s.has_audio() if (s.has_audio()
|| !s.has_video() || !s.has_video()
@ -545,7 +547,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
reg.flush(app); reg.flush(app);
} }
fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_w: u32, seq_h: u32) { fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_w: u32, seq_h: u32, seq_hdr: bool) {
// frame size // frame size
let mut match_size = s.frame_size.is_none(); let mut match_size = s.frame_size.is_none();
row(ui, t, "Frame Size", |ui| { row(ui, t, "Frame Size", |ui| {
@ -602,8 +604,13 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
f @ (Format::H264 | Format::Hevc) => { f @ (Format::H264 | Format::Hevc) => {
let hevc = f == Format::Hevc; let hevc = f == Format::Hevc;
if hevc { if hevc {
// the hardware encoder writes one profile: Main, 8-bit 4:2:0, level chosen by the encoder // the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder
row(ui, t, "Profile", |ui| ui.label("Main (8-bit)")); let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) {
(true, true) => "Main 10 (10-bit, HDR)",
(true, false) => "Main (8-bit; Main 10 for HDR sequences)",
(false, _) => "Main (8-bit)",
};
row(ui, t, "Profile", |ui| ui.label(label));
row(ui, t, "Encoder", |ui| ui.label("Hardware (H.265 has no software encoder)")); row(ui, t, "Encoder", |ui| ui.label("Hardware (H.265 has no software encoder)"));
} else { } else {
row(ui, t, "Profile", |ui| { row(ui, t, "Profile", |ui| {

View File

@ -95,6 +95,14 @@ Everything else in this record applies unchanged: `unsafe` stays in `crates/plat
has a `// SAFETY:` comment, no panic crosses the FFI boundary, the public API is safe, and the crate has a `// SAFETY:` comment, no panic crosses the FFI boundary, the public API is safe, and the crate
compiles everywhere (`register()` is a no-op off macOS). compiles everywhere (`register()` is a no-op off macOS).
## Addendum (2026-10-08): NVENC HEVC Main 10 (HDR)
HDR H.265 added no `unsafe` module either. `nvenc::ffi` gained data declarations (the HEVC picture
parameters, `NV_ENC_SEI_PAYLOAD`, the 10-bit buffer format) checked by the generated layout tests, and
`nvenc::session` gained the P010 pitch check and the SEI pointers it hands to the driver, which are
heap blocks owned by the session for its whole life (`// SAFETY:` comments say so). The conversion from
float pictures to 10-bit planes is safe code in the export crate.
## Addendum (2026-10-07): hardware H.265 (HEVC) encoding ## Addendum (2026-10-07): hardware H.265 (HEVC) encoding
The same VideoToolbox session wrapper also creates HEVC sessions (`VtProfile::HevcMain`). The rules The same VideoToolbox session wrapper also creates HEVC sessions (`VtProfile::HevcMain`). The rules

View File

@ -273,8 +273,12 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see
(the software encoder runs, counted in `export.hardware.declined`) two-pass VBR, HDR, MXF, (the software encoder runs, counted in `export.hardware.declined`) two-pass VBR, HDR, MXF,
interlaced output, sizes outside NVENC's limits, and systems without an NVIDIA GPU or driver. interlaced output, sizes outside NVENC's limits, and systems without an NVIDIA GPU or driver.
A failure during an export ends it with an error: the software encoder cannot take over a hardware A failure during an export ends it with an error: the software encoder cannot take over a hardware
stream. The same NVENC session also encodes H.265 (HEVC Main, 8-bit 4:2:0, SDR; the `hvcC` is built stream. The same NVENC session also encodes H.265 (HEVC Main 8-bit 4:2:0 SDR, or Main 10 HDR: PQ / HLG,
from the SPS the encoder wrote): `register()` hands `platform::nvenc::hevc_available` to BT.2020, limited range, from the float R'G'B' pictures through `rgbf_to_yuv420_10` into P010 input
buffers, with the HDR10 static metadata as SEI on every IDR for PQ; the `hvcC` is built from the SPS
the encoder wrote). An HDR sequence exports as HDR H.265 only where a registered probe says so
(`filmcraft_export::register_hdr_probe`, `hdr_available`; NVENC with 10-bit support), otherwise it is
tone-mapped to SDR as on macOS: `register()` hands `platform::nvenc::hevc_available` to
`filmcraft_export::register_format_probe`, so the H.265 format exists where an HEVC encoder does, `filmcraft_export::register_format_probe`, so the H.265 format exists where an HEVC encoder does,
and choosing it is the opt-in (the Hardware encoding toggle governs H.264 only). With no software and choosing it is the opt-in (the Hardware encoding toggle governs H.264 only). With no software
H.265 encoder, what NVENC declines is counted and ends the export with an error that says why. H.265 encoder, what NVENC declines is counted and ends the export with an error that says why.

View File

@ -72,14 +72,15 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2).
AV1 fixtures (libvpx-vp9, libaom-av1; 360p, 1080p, 2160p, hidden alt-ref frames, two GOPs) need an ffmpeg with those encoders. AV1 fixtures (libvpx-vp9, libaom-av1; 360p, 1080p, 2160p, hidden alt-ref frames, two GOPs) need an ffmpeg with those encoders.
The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_hevc.rs`, The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_hevc.rs`,
`nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265; Windows only) skip, `nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and
printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC `nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU
encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says
there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in
`crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC `crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC
(`cl` after `vcvars64.bat`) against NVIDIA's MIT-licensed `nvEncodeAPI.h` (12.1); the header and the (`cl` after `vcvars64.bat`) against NVIDIA's MIT-licensed `nvEncodeAPI.h` (12.1); the header and the
program are not in the repository. To regenerate them, put the header in a scratch directory and program are not in the repository. To regenerate them, put the header in a scratch directory and
write a program that prints, for every type, field, constant and GUID of `src/nvenc/ffi.rs` that the write a program that prints, for every type, field, constant and GUID of `src/nvenc/ffi.rs` that the
asserts name (H.264 and HEVC), `sizeof` / `alignof` / `offsetof`, the enum and macro values cast to asserts name (H.264, HEVC and the HEVC picture parameters with their SEI payload array), `sizeof` / `alignof` / `offsetof`, the enum and macro values cast to
64-bit integers and the GUIDs as 128-bit hex; for the bit-fields of the `flags` words, which 64-bit integers and the GUIDs as 128-bit hex; for the bit-fields of the `flags` words, which
`offsetof` cannot take, zero a structure, set that one field to its maximum and print the 32-bit word. `offsetof` cannot take, zero a structure, set that one field to its maximum and print the 32-bit word.
Compare the numbers with the asserts (a difference is drift, which must be understood before Compare the numbers with the asserts (a difference is drift, which must be understood before