M2: review fixes for NVENC H.265

- register() asks the HEVC probe on a thread of its own (the first probe is
  about half a second: D3D11 device + NVENC session), so the first draw of
  Export's format list does not wait for it; tests/nvenc_hevc_warm.rs.
- A machine with no HEVC encoder is not a declined hardware attempt: the
  factory returns None without counting `declined`.
- HEVC tests skip only when the probe says there is no HEVC encoder; on a
  machine that has one, a configuration NVENC refuses fails the test.
- Wording: NVENC is FilmCraft's only HEVC encoder on Windows (Windows itself
  ships others).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Davi Andrei
2026-10-08 16:48:43 -03:00
co-authored by Claude Opus 5.5
parent deebe2e5b2
commit 29b454ac17
8 changed files with 78 additions and 23 deletions
+6 -3
View File
@@ -147,9 +147,11 @@ level to the encoder). The H.264 path is unchanged: the same export is the same
- **Choosing the format is the opt-in,** as on macOS: Export ▸ Format ▸ H.265 (HEVC) or
`"format": "hevc"`. `register()` hands `nvenc::hevc_available` to
`filmcraft_export::register_format_probe`: one small HEVC session (a 640×360 encoder, created on
the first question and the answer kept; about half a second in a fresh process, nearly all of it
opening the Direct3D 11 device and the NVENC session, which every NVENC export pays too). The
`filmcraft_export::register_format_probe`: one small HEVC session (a 640×360 encoder, the answer
kept; about half a second in a fresh process, nearly all of it opening the Direct3D 11 device and
the NVENC session, which every NVENC export pays too). `register()` also asks it on a thread of its
own (`nvenc::warm_hevc_probe`), so the first draw of the format list does not wait for it; a
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
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
@@ -211,6 +213,7 @@ level to the encoder). The H.264 path is unchanged: the same export is the same
| `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_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 |
| `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) |
| `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 |
+4 -2
View File
@@ -68,11 +68,13 @@ pub fn register() -> Availability {
filmcraft_codecs::hw::set_hw_backend("VideoToolbox");
Availability::Available("VideoToolbox")
}
// Export ▸ H.265: NVENC is the only HEVC encoder on Windows, so the format is available when a
// small HEVC session opens (asked once, when the format list first asks)
// Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available
// when a small HEVC session opens. That takes about half a second, so it is asked on a thread of
// its own now rather than by the first draw of the format list.
#[cfg(target_os = "windows")]
{
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available);
nvenc::warm_hevc_probe();
}
#[cfg(target_os = "windows")]
{
+6 -6
View File
@@ -173,7 +173,7 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
}
}
/// The H.265 side of [`factory`]: the only HEVC encoder on Windows, so the Hardware encoding toggle
/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle
/// does not apply and a request NVENC cannot take is an error, not a fall-through.
fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
let declined = |why: &str| {
@@ -190,12 +190,12 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<R
filmcraft_export::note_hw_encode_session();
Some(Ok(Box::new(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() })))
}
Err(why) => {
log::info!("hardware H.265 encoding declined: {why}");
filmcraft_export::note_hw_encode_declined();
// no HEVC encoder here at all: the export's own "encoder not available" error
if super::hevc_available() { Some(Err(ExportError::Unsupported(format!("H.265 export with NVENC: {why}")))) } else { None }
// no HEVC encoder here at all: not a hardware attempt, the export's own "encoder not available" error
Err(why) if !super::hevc_available() => {
log::info!("no hardware H.265 encoder: {why}");
None
}
Err(why) => declined(&why),
}
}
+27 -2
View File
@@ -120,8 +120,7 @@ pub fn available() -> bool {
/// Whether this system's NVENC can encode HEVC: a session opened and an HEVC encoder created for a
/// small picture, once (the answer is kept). What makes the H.265 export format available.
pub fn hevc_available() -> bool {
static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*AVAILABLE.get_or_init(|| {
*HEVC_AVAILABLE.get_or_init(|| {
let probe = Config {
width: 640,
height: 360,
@@ -143,6 +142,32 @@ pub fn hevc_available() -> bool {
})
}
/// The answer of [`hevc_available`], once asked.
static HEVC_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// Ask [`hevc_available`] on a thread of its own (once, however often this is called), so the
/// first draw of Export's format list does not wait for the probe session. Whoever asks while it
/// runs waits for the same answer; if the thread cannot start, or the probe panics (caught), the
/// first caller asks, as before.
pub fn warm_hevc_probe() {
static STARTED: std::sync::Once = std::sync::Once::new();
STARTED.call_once(|| {
let spawned = std::thread::Builder::new().name("nvenc-hevc-probe".into()).spawn(|| {
if std::panic::catch_unwind(hevc_available).is_err() {
log::warn!("the hardware HEVC probe panicked; the format list will ask again");
}
});
if let Err(e) = spawned {
log::info!("hardware HEVC probe thread not started: {e}");
}
});
}
/// Whether [`hevc_available`] has its answer yet (diagnostics and tests).
pub fn hevc_probed() -> bool {
HEVC_AVAILABLE.get().is_some()
}
impl Nvenc {
/// Open an encoder, or say why NVENC does not take this configuration.
pub fn new(cfg: &Config) -> Result<Self, String> {
+7 -7
View File
@@ -48,14 +48,14 @@ static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
type Sources = Vec<(Vec<u8>, Vec<u8>, Vec<u8>)>;
/// `None` (the test skips) only on a machine without an HEVC encoder; on one that has it, a
/// configuration NVENC refuses is a failure, not a skip.
fn encode_all(cfg: &Config, frames: usize) -> Option<(Nvenc, Vec<Packet>, Sources)> {
let mut enc = match Nvenc::new(cfg) {
Ok(e) => e,
Err(why) => {
eprintln!("SKIPPED: {why}");
return None;
}
};
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC encoder");
return None;
}
let mut enc = Nvenc::new(cfg).unwrap_or_else(|why| panic!("NVENC HEVC is available but refused {cfg:?}: {why}"));
let (w, h) = (cfg.width as usize, cfg.height as usize);
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
let (mut packets, mut sources) = (Vec::new(), Vec::new());
+24
View File
@@ -0,0 +1,24 @@
//! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format
//! list does not open an encoder session on the UI thread. Its own test binary: anything else asking
//! `hevc_available` first would make the wait meaningless.
#![cfg(target_os = "windows")]
use std::time::{Duration, Instant};
use filmcraft_platform::nvenc::{hevc_available, hevc_probed};
#[test]
fn register_answers_the_hevc_question_in_the_background() {
assert!(!hevc_probed(), "nothing has asked yet");
filmcraft_platform::register();
let t0 = Instant::now();
while !hevc_probed() {
assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the probe");
std::thread::sleep(Duration::from_millis(10));
}
eprintln!("answered in the background after {:?}", t0.elapsed());
// the answer is kept: asking and registering again change nothing
let answer = hevc_available();
filmcraft_platform::register();
assert_eq!(hevc_available(), answer);
}
+1 -1
View File
@@ -72,7 +72,7 @@ and `filmcraft-cli`.
| `engine` | L4 | `Session`, command registry, undo history, media pool, jobs, interchange glue |
| `ui-egui` | L5 | the egui frontend: docking, panels, timeline, monitors, playback, control-channel handlers |
| `automation` | L5 | MCP server (`rmcp`, stdio), headless or bridged to the running app |
| `platform` | L5 | OS media FFI only: hardware video decoding (VideoToolbox H.264 / HEVC on macOS; Media Foundation / Direct3D 11 H.264 / HEVC on Windows; a no-op elsewhere) behind `codecs::VideoDecoder`, with transparent fallback to our decoders, and hardware H.264 encoding (VideoToolbox, opt-in) and H.265 encoding (VideoToolbox, the only H.265 encoder; the format exists only where a hardware encoder does) and NVIDIA NVENC H.264 (Windows, opt-in) and H.265 (Windows, the only H.265 encoder there) encoding behind `export::VideoEncoder`; H.264 declines to the built-in encoder for what the hardware does not take. The one crate allowed `unsafe` ([ADR 0001](adr/0001-platform-ffi.md), [README](../crates/platform/README.md)) |
| `platform` | L5 | OS media FFI only: hardware video decoding (VideoToolbox H.264 / HEVC on macOS; Media Foundation / Direct3D 11 H.264 / HEVC on Windows; a no-op elsewhere) behind `codecs::VideoDecoder`, with transparent fallback to our decoders, and hardware H.264 encoding (VideoToolbox, opt-in) and H.265 encoding (VideoToolbox, the only H.265 encoder; the format exists only where a hardware encoder does) and NVIDIA NVENC H.264 (Windows, opt-in) and H.265 (Windows, FilmCraft's only H.265 encoder there) encoding behind `export::VideoEncoder`; H.264 declines to the built-in encoder for what the hardware does not take. The one crate allowed `unsafe` ([ADR 0001](adr/0001-platform-ffi.md), [README](../crates/platform/README.md)) |
| `filmcraft` | L6 | desktop binary: eframe/wgpu window, cpal audio output, file dialogs, native macOS menu, TCP control server |
| `filmcraft-cli` | L6 | headless CLI: `exec`, `run`, `inspect`, `describe`, `commands`, `import`, `export`, `render`, `probe`, `mcp`; `--bridge` targets the running app |
| `filmcraft-web` | L6 | the browser app (wasm32): eframe web runner on WebGPU/WebGL2, Blob-backed services, OPFS recovery, WebAudio, WebCodecs, `window.filmcraft` API ([web.md](web.md)) |
+3 -2
View File
@@ -72,8 +72,9 @@ 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.
The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_hevc.rs`,
`nvenc_hevc_export.rs` and `nvenc_hevc_probe.rs` for H.265; Windows only) skip, printing `SKIPPED`,
without an NVIDIA GPU with NVENC (H.265: with an HEVC encoder). The FFI layout tests in
`nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265; Windows only) skip,
printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC
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
(`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