From 29b454ac17b2beb3258c4095e1b957d9a11083d4 Mon Sep 17 00:00:00 2001 From: Davi Andrei <67552001+SaymonMaster14@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:48:43 -0300 Subject: [PATCH] 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 --- crates/platform/README.md | 9 +++++--- crates/platform/src/lib.rs | 6 +++-- crates/platform/src/nvenc/export.rs | 12 +++++----- crates/platform/src/nvenc/mod.rs | 29 ++++++++++++++++++++++-- crates/platform/tests/nvenc_hevc.rs | 14 ++++++------ crates/platform/tests/nvenc_hevc_warm.rs | 24 ++++++++++++++++++++ docs/architecture.md | 2 +- docs/testing.md | 5 ++-- 8 files changed, 78 insertions(+), 23 deletions(-) create mode 100644 crates/platform/tests/nvenc_hevc_warm.rs diff --git a/crates/platform/README.md b/crates/platform/README.md index 3ecf22c6c..87bc13c87 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -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 | diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index e6bd9c141..45422e314 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -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")] { diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 7748b0a1e..a7733207a 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -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>> { let declined = |why: &str| { @@ -190,12 +190,12 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option { - 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), } } diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index 82b0cd42b..336e0433a 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -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 = 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 = 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 { diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs index 05be5eec1..0eeb5c199 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/nvenc_hevc.rs @@ -48,14 +48,14 @@ static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); type Sources = Vec<(Vec, Vec, Vec)>; +/// `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, 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()); diff --git a/crates/platform/tests/nvenc_hevc_warm.rs b/crates/platform/tests/nvenc_hevc_warm.rs new file mode 100644 index 000000000..33944520d --- /dev/null +++ b/crates/platform/tests/nvenc_hevc_warm.rs @@ -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); +} diff --git a/docs/architecture.md b/docs/architecture.md index d095c3b12..a72643c97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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)) | diff --git a/docs/testing.md b/docs/testing.md index 3dcf4c2ce..f6745fd36 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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