Windows: NVENC H.265 (HEVC) Main hardware encoding for Export

One NVENC session, ring and NV12 path now serve H.264 and HEVC (Codec enum,
Profile::HevcMain). HEVC Main 8-bit 4:2:0 SDR BT.709, MP4/MOV hvc1, parameter
sets only in an hvcC built from the SPS the encoder wrote. The H.265 format is
available through a cached NVENC probe; choosing it is the opt-in, declines are
counted errors. ABI asserts regenerated from nvEncodeAPI.h 12.1 (no drift) and
extended for HEVC; tests, docs and roadmap updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Davi Andrei 2026-10-08 16:28:50 -03:00
parent 5231852443
commit deebe2e5b2
19 changed files with 1498 additions and 97 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, function table and constants 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), 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) |
## Downloaded at runtime ## Downloaded at runtime

2
Cargo.lock generated
View File

@ -2097,10 +2097,12 @@ dependencies = [
name = "filmcraft-platform" name = "filmcraft-platform"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs", "filmcraft-codecs",
"filmcraft-color", "filmcraft-color",
"filmcraft-export", "filmcraft-export",
"filmcraft-frame", "filmcraft-frame",
"filmcraft-hevc",
"filmcraft-isobmff", "filmcraft-isobmff",
"filmcraft-media", "filmcraft-media",
"filmcraft-project", "filmcraft-project",

View File

@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox) and Windows (Media Foundation), with Linux to follow; H.264 export can use NVIDIA's encoder on Windows (opt-in) ([#30](https://github.com/storytold/filmcraft/issues/30)). Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox) and Windows (Media Foundation), with Linux to follow; H.264 export can use NVIDIA's encoder on Windows (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
<br> <br>
@ -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 there ([#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 (NVENC; Main 10 and HDR not yet) ([#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 delivery-codec export; no HEVC or AV1 export yet. - **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.
- **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,8 +35,8 @@ 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 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. 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 (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. |
| **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 encoder. 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. |
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. | | **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
@ -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 on NVENC (Windows, opt-in); HEVC on Windows and Linux 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) on NVENC (Windows); Main 10 / HDR HEVC, 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).
@ -154,11 +154,13 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
## Running now ## Running now
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in). Next: VA-API (Linux) decode, zero-copy upload, Lumetri on the GPU. - **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in) and H.265 export. Next: VA-API (Linux) decode, zero-copy upload, Lumetri on the GPU.
- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land. - **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land.
## Log ## Log
- **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).
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8). - **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).

View File

@ -21,10 +21,12 @@ type_complexity = "allow"
undocumented_unsafe_blocks = "deny" undocumented_unsafe_blocks = "deny"
[dependencies] [dependencies]
filmcraft-bitstream = { workspace = true }
filmcraft-codecs = { workspace = true } filmcraft-codecs = { workspace = true }
filmcraft-color = { workspace = true } filmcraft-color = { workspace = true }
filmcraft-export = { workspace = true } filmcraft-export = { workspace = true }
filmcraft-frame = { workspace = true } filmcraft-frame = { workspace = true }
filmcraft-hevc = { workspace = true }
filmcraft-isobmff = { workspace = true } filmcraft-isobmff = { workspace = true }
filmcraft-time = { workspace = true } filmcraft-time = { workspace = true }
log = { workspace = true } log = { workspace = true }

View File

@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
- After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache - After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache
always seeks after a flush, and a caller that continues from the middle of a GOP gets an error always seeks after a flush, and a caller that continues from the middle of a GOP gets an error
that `HybridDecoder` answers by replaying the run in software. that `HybridDecoder` answers by replaying the run in software.
- **Windows: NVIDIA NVENC H.264 encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's - **Windows: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
`nvEncodeAPI64.dll` (API 12.1, no CUDA or SDK) is loaded at run time, so machines without NVIDIA `nvEncodeAPI64.dll` (API 12.1, no CUDA or SDK) is loaded at run time, so machines without NVIDIA
still start. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12 still start. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
@ -69,7 +69,9 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it. distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it.
It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems
without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export
ends it with an error, since a hardware stream cannot be finished in software. ends it with an error, since a hardware stream cannot be finished in software. The same session,
ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
- **Other systems:** `register()` does nothing and returns `Availability::Unavailable`. - **Other systems:** `register()` does nothing and returns `Availability::Unavailable`.
- **`HybridDecoder`** (`hybrid.rs`, safe code): the hardware decoder plus the means to build our - **`HybridDecoder`** (`hybrid.rs`, safe code): the hardware decoder plus the means to build our
software decoder for the same `SampleEntry` (`filmcraft_codecs::software_video_decoder`). On a software decoder for the same `SampleEntry` (`filmcraft_codecs::software_video_decoder`). On a
@ -135,6 +137,51 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r
format description's sample description extension atoms, with the VPS / SPS / PPS), so profile, format description's sample description extension atoms, with the VPS / SPS / PPS), so profile,
level and flags are the encoder's own. If it is missing the export stops with that reason. level and flags are the encoder's own. If it is missing the export stops with that reason.
## Hardware H.265 (HEVC) encoding (Windows, NVENC)
One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it
for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability
query, the `hevcConfig` member of the codec-configuration union, how NAL units are told apart
(`(b0 >> 1) & 0x3f`) and the level numbering (`level × 30`, so 4.1 is 123; the export leaves the
level to the encoder). The H.264 path is unchanged: the same export is the same file, byte for byte.
- **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
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
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
reports support and the GOP is longer than two pictures (shorter ones are written without), in
MP4 (`hvc1`) or QuickTime. Pictures are converted exactly like the H.264 path's. The coded size is
a whole number of 32-pixel coding tree blocks and the SPS carries a conformance window (1080 is
coded as 1088 and cropped back); the `hvcC` is only accepted when the cropped size is the export's.
- **Samples are length-prefixed (4 bytes) with the parameter sets only in the `hvcC`:** VPS, SPS,
PPS, access unit delimiters and end-of-sequence / bitstream markers are dropped; slices and SEI
stay. `nvEncGetSequenceParams` returns all three parameter sets.
- **The `hvcC` is built from the SPS the encoder wrote** (`nvenc/hevc.rs`, parsed with
`filmcraft_hevc`): profile space, tier, profile, compatibility flags and level from the SPS's
profile / tier / level; the 48 general constraint flags and `sps_temporal_id_nesting_flag` read from
the SPS bits that carry them; chroma format, bit depths and temporal layers from the SPS; the
arrays complete (`array_completeness` 1). Nothing is a constant of ours except what the format
leaves unspecified (`avg_frame_rate`, `constant_frame_rate`, `min_spatial_segmentation_idc` and
`parallelism_type` are 0). It is built, and validated as Main 8-bit 4:2:0 of the right size, when
the encoder is created, so a stream we cannot describe is a declined export and never a default
sample entry.
- **Timestamps are the H.264 path's:** dts is the decode index minus the B-frame delay, composition
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
`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,
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
"encoder not available yet". Frames, sessions and declines are the same `export.hardware` counters
as for H.264.
- Main 10 / HDR is not done (see Not yet).
## Guarantees ## Guarantees
- **Never undecodable:** the factory declines (returns `None`, so the software decoder is used) - **Never undecodable:** the factory declines (returns `None`, so the software decoder is used)
@ -161,7 +208,11 @@ level and flags are the encoder's own. If it is missing the export stops with th
| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | | `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 |
| `tests/nvenc.rs` (Windows, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | | `tests/nvenc.rs` (Windows, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right |
| `tests/nvenc_export.rs` (Windows, NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | | `tests/nvenc_export.rs` (Windows, NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants and GUIDs against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `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) |
| `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 |
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.
@ -188,6 +239,7 @@ playback with no dropped frames at Full, 1/2 and 1/4. Details in
## Not yet ## Not yet
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 and 10-bit / HDR HEVC (Main 10) in hardware encoding; VA-API (Linux) textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in
decoders; field-coded H.264; HEVC, 10-bit and HDR encoding with NVENC, and encoders from other hardware encoding; VA-API (Linux) decoders; field-coded H.264; 10-bit and HDR encoding with NVENC
vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. (Main 10 HEVC, 4:4:4), 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.

View File

@ -6,7 +6,8 @@
//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; on other systems //! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; on other systems
//! 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`]. //! 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.
//! //!
//! Hardware decoding never makes a file undecodable: //! Hardware decoding never makes a file undecodable:
//! //!
@ -67,6 +68,12 @@ pub fn register() -> Availability {
filmcraft_codecs::hw::set_hw_backend("VideoToolbox"); filmcraft_codecs::hw::set_hw_backend("VideoToolbox");
Availability::Available("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)
#[cfg(target_os = "windows")]
{
filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available);
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
static ENCODERS: std::sync::Once = std::sync::Once::new(); static ENCODERS: std::sync::Once = std::sync::Once::new();

View File

@ -2,7 +2,9 @@
//! alignments, field offsets, constants and GUIDs. Generated by running a C program (built with //! alignments, field offsets, constants and GUIDs. Generated by running a C program (built with
//! MSVC from the NVIDIA header) that prints them; the header itself is not in this repository. //! MSVC from the NVIDIA header) that prints them; the header itself is not in this repository.
//! To regenerate, put NVIDIA's MIT-licensed `nvEncodeAPI.h` 12.1 in `ffnvcodec/` next to a program //! To regenerate, put NVIDIA's MIT-licensed `nvEncodeAPI.h` 12.1 in `ffnvcodec/` next to a program
//! printing `sizeof` / `offsetof` of the structures of `src/nvenc/ffi.rs` and the constants it uses. //! printing `sizeof` / `alignof` / `offsetof` of the structures of `src/nvenc/ffi.rs`, the constants
//! and GUIDs it uses and, for the bit-fields of the `flags` words (`offsetof` cannot take those), the
//! word with that one field set (see docs/testing.md). H.264 and HEVC are both covered.
#![allow(clippy::unreadable_literal)] #![allow(clippy::unreadable_literal)]
use super::ffi::*; use super::ffi::*;
@ -111,9 +113,38 @@ fn layouts_match_the_header() {
assert_eq!(offset_of!(NV_ENC_CONFIG_H264, numRefL1), 208, "NV_ENC_CONFIG_H264.numRefL1"); assert_eq!(offset_of!(NV_ENC_CONFIG_H264, numRefL1), 208, "NV_ENC_CONFIG_H264.numRefL1");
assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved1), 212, "NV_ENC_CONFIG_H264.reserved1"); assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved1), 212, "NV_ENC_CONFIG_H264.reserved1");
assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved2), 1280, "NV_ENC_CONFIG_H264.reserved2"); assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved2), 1280, "NV_ENC_CONFIG_H264.reserved2");
assert_eq!(
(size_of::<NV_ENC_CONFIG_HEVC_VUI_PARAMETERS>(), align_of::<NV_ENC_CONFIG_HEVC_VUI_PARAMETERS>()),
(112, 4),
"NV_ENC_CONFIG_HEVC_VUI_PARAMETERS"
);
assert_eq!((size_of::<NV_ENC_CONFIG_HEVC>(), align_of::<NV_ENC_CONFIG_HEVC>()), (1560, 8), "NV_ENC_CONFIG_HEVC");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, level), 0, "NV_ENC_CONFIG_HEVC.level");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, tier), 4, "NV_ENC_CONFIG_HEVC.tier");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, minCUSize), 8, "NV_ENC_CONFIG_HEVC.minCUSize");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxCUSize), 12, "NV_ENC_CONFIG_HEVC.maxCUSize");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, idrPeriod), 20, "NV_ENC_CONFIG_HEVC.idrPeriod");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, intraRefreshPeriod), 24, "NV_ENC_CONFIG_HEVC.intraRefreshPeriod");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, intraRefreshCnt), 28, "NV_ENC_CONFIG_HEVC.intraRefreshCnt");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxNumRefFramesInDPB), 32, "NV_ENC_CONFIG_HEVC.maxNumRefFramesInDPB");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ltrNumFrames), 36, "NV_ENC_CONFIG_HEVC.ltrNumFrames");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, vpsId), 40, "NV_ENC_CONFIG_HEVC.vpsId");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, spsId), 44, "NV_ENC_CONFIG_HEVC.spsId");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ppsId), 48, "NV_ENC_CONFIG_HEVC.ppsId");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, sliceMode), 52, "NV_ENC_CONFIG_HEVC.sliceMode");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, sliceModeData), 56, "NV_ENC_CONFIG_HEVC.sliceModeData");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxTemporalLayersMinus1), 60, "NV_ENC_CONFIG_HEVC.maxTemporalLayersMinus1");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, hevcVUIParameters), 64, "NV_ENC_CONFIG_HEVC.hevcVUIParameters");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ltrTrustMode), 176, "NV_ENC_CONFIG_HEVC.ltrTrustMode");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, useBFramesAsRef), 180, "NV_ENC_CONFIG_HEVC.useBFramesAsRef");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, numRefL0), 184, "NV_ENC_CONFIG_HEVC.numRefL0");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, numRefL1), 188, "NV_ENC_CONFIG_HEVC.numRefL1");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, reserved1), 192, "NV_ENC_CONFIG_HEVC.reserved1");
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, reserved2), 1048, "NV_ENC_CONFIG_HEVC.reserved2");
assert_eq!((size_of::<NV_ENC_CODEC_CONFIG>(), align_of::<NV_ENC_CODEC_CONFIG>()), (1792, 8), "NV_ENC_CODEC_CONFIG"); assert_eq!((size_of::<NV_ENC_CODEC_CONFIG>(), align_of::<NV_ENC_CODEC_CONFIG>()), (1792, 8), "NV_ENC_CODEC_CONFIG");
assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, h264Config), 0, "NV_ENC_CODEC_CONFIG.h264Config"); assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, h264Config), 0, "NV_ENC_CODEC_CONFIG.h264Config");
assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, reserved), 0, "NV_ENC_CODEC_CONFIG.reserved"); assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, reserved), 0, "NV_ENC_CODEC_CONFIG.reserved");
assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, hevcConfig), 0, "NV_ENC_CODEC_CONFIG.hevcConfig");
assert_eq!((size_of::<NV_ENC_CONFIG>(), align_of::<NV_ENC_CONFIG>()), (3584, 8), "NV_ENC_CONFIG"); assert_eq!((size_of::<NV_ENC_CONFIG>(), align_of::<NV_ENC_CONFIG>()), (3584, 8), "NV_ENC_CONFIG");
assert_eq!(offset_of!(NV_ENC_CONFIG, version), 0, "NV_ENC_CONFIG.version"); assert_eq!(offset_of!(NV_ENC_CONFIG, version), 0, "NV_ENC_CONFIG.version");
assert_eq!(offset_of!(NV_ENC_CONFIG, profileGUID), 4, "NV_ENC_CONFIG.profileGUID"); assert_eq!(offset_of!(NV_ENC_CONFIG, profileGUID), 4, "NV_ENC_CONFIG.profileGUID");
@ -353,6 +384,21 @@ fn constants_match_the_header() {
assert_eq!(NV_ENC_LOCK_BITSTREAM_VER as i64, 4043374604, "NV_ENC_LOCK_BITSTREAM_VER"); assert_eq!(NV_ENC_LOCK_BITSTREAM_VER as i64, 4043374604, "NV_ENC_LOCK_BITSTREAM_VER");
assert_eq!(NV_ENC_LOCK_INPUT_BUFFER_VER as i64, 1895890956, "NV_ENC_LOCK_INPUT_BUFFER_VER"); assert_eq!(NV_ENC_LOCK_INPUT_BUFFER_VER as i64, 1895890956, "NV_ENC_LOCK_INPUT_BUFFER_VER");
assert_eq!(NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER as i64, 1895890956, "NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER"); assert_eq!(NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER as i64, 1895890956, "NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER");
assert_eq!(NV_ENC_TIER_HEVC_MAIN as i64, 0, "NV_ENC_TIER_HEVC_MAIN");
assert_eq!(NV_ENC_HEVC_CUSIZE_AUTOSELECT as i64, 0, "NV_ENC_HEVC_CUSIZE_AUTOSELECT");
assert_eq!(NV_ENC_LEVEL_HEVC_1 as i64, 30, "NV_ENC_LEVEL_HEVC_1");
assert_eq!(NV_ENC_LEVEL_HEVC_2 as i64, 60, "NV_ENC_LEVEL_HEVC_2");
assert_eq!(NV_ENC_LEVEL_HEVC_21 as i64, 63, "NV_ENC_LEVEL_HEVC_21");
assert_eq!(NV_ENC_LEVEL_HEVC_3 as i64, 90, "NV_ENC_LEVEL_HEVC_3");
assert_eq!(NV_ENC_LEVEL_HEVC_31 as i64, 93, "NV_ENC_LEVEL_HEVC_31");
assert_eq!(NV_ENC_LEVEL_HEVC_4 as i64, 120, "NV_ENC_LEVEL_HEVC_4");
assert_eq!(NV_ENC_LEVEL_HEVC_41 as i64, 123, "NV_ENC_LEVEL_HEVC_41");
assert_eq!(NV_ENC_LEVEL_HEVC_5 as i64, 150, "NV_ENC_LEVEL_HEVC_5");
assert_eq!(NV_ENC_LEVEL_HEVC_51 as i64, 153, "NV_ENC_LEVEL_HEVC_51");
assert_eq!(NV_ENC_LEVEL_HEVC_52 as i64, 156, "NV_ENC_LEVEL_HEVC_52");
assert_eq!(NV_ENC_LEVEL_HEVC_6 as i64, 180, "NV_ENC_LEVEL_HEVC_6");
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!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION"); assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION");
} }
@ -364,4 +410,22 @@ fn guids_match_the_header() {
assert_eq!(NV_ENC_H264_PROFILE_HIGH_GUID.to_u128(), 0xe7cbc3094f7a4b89af2ad537c92be310u128, "NV_ENC_H264_PROFILE_HIGH_GUID"); assert_eq!(NV_ENC_H264_PROFILE_HIGH_GUID.to_u128(), 0xe7cbc3094f7a4b89af2ad537c92be310u128, "NV_ENC_H264_PROFILE_HIGH_GUID");
assert_eq!(NV_ENC_PRESET_P4_GUID.to_u128(), 0x90a7b826df064862b9d2cd6d73a08681u128, "NV_ENC_PRESET_P4_GUID"); assert_eq!(NV_ENC_PRESET_P4_GUID.to_u128(), 0x90a7b826df064862b9d2cd6d73a08681u128, "NV_ENC_PRESET_P4_GUID");
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_HEVC_PROFILE_MAIN_GUID.to_u128(), 0xb514c39ab55b40fa878ff1253b4dfdecu128, "NV_ENC_HEVC_PROFILE_MAIN_GUID");
}
/// The bit-field masks of the `flags` words (the C compiler's layout of each bit-field set alone).
#[test]
fn bit_field_masks_match_the_header() {
assert_eq!(H264_OUTPUT_AUD, 0x40, "NV_ENC_CONFIG_H264.outputAUD");
assert_eq!(H264_REPEAT_SPSPPS, 0x1000, "NV_ENC_CONFIG_H264.repeatSPSPPS");
assert_eq!(HEVC_OUTPUT_AUD, 0x10, "NV_ENC_CONFIG_HEVC.outputAUD");
assert_eq!(HEVC_DISABLE_SPSPPS, 0x40, "NV_ENC_CONFIG_HEVC.disableSPSPPS");
assert_eq!(HEVC_REPEAT_SPSPPS, 0x80, "NV_ENC_CONFIG_HEVC.repeatSPSPPS");
assert_eq!(HEVC_ENABLE_INTRA_REFRESH, 0x100, "NV_ENC_CONFIG_HEVC.enableIntraRefresh");
assert_eq!(HEVC_CHROMA_FORMAT_MASK, 0x600, "NV_ENC_CONFIG_HEVC.chromaFormatIDC");
assert_eq!(HEVC_PIXEL_BIT_DEPTH_MASK, 0x3800, "NV_ENC_CONFIG_HEVC.pixelBitDepthMinus8");
assert_eq!(HEVC_OUTPUT_RECOVERY_POINT_SEI, 0x40000, "NV_ENC_CONFIG_HEVC.outputRecoveryPointSEI");
assert_eq!(HEVC_CHROMA_FORMAT_MASK >> HEVC_CHROMA_FORMAT_SHIFT, 3, "chromaFormatIDC is 2 bits");
assert_eq!(HEVC_PIXEL_BIT_DEPTH_MASK >> HEVC_PIXEL_BIT_DEPTH_SHIFT, 7, "pixelBitDepthMinus8 is 3 bits");
} }

View File

@ -1,7 +1,14 @@
//! NVENC as an Export encoder: [`factory`] is registered in front of the software H.264 encoder //! NVENC as an Export encoder: [`factory`] is registered in front of the software H.264 encoder
//! (`filmcraft_export::register_encoder`) and takes an export only when Export ▸ Hardware encoding //! (`filmcraft_export::register_encoder`).
//! is Auto and NVENC can do what the settings ask; otherwise it returns `None` and the software //!
//! encoder runs, exactly as before. //! - 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.
//! - H.265 (HEVC Main, 8-bit 4:2:0, SDR BT.709, progressive): there is no software encoder, so the
//! Hardware encoding toggle does not apply (as with VideoToolbox) and choosing the format is the
//! opt-in. What NVENC cannot do (HDR, interlaced, two-pass, non-square pixels, sizes outside its
//! 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
//! returns `None` (the export's own "encoder not available" error).
//! //!
//! A hardware encoder that fails in the middle of an export (a lost device) ends the export with an //! A hardware encoder that fails in the middle of an export (a lost device) ends the export with an
//! error naming it; unlike a decoder it cannot be replayed into the software encoder, because the //! error naming it; unlike a decoder it cannot be replayed into the software encoder, because the
@ -13,11 +20,11 @@ use filmcraft_export::{
use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_isobmff::{AvcConfig, SampleEntry};
use filmcraft_time::FrameRate; use filmcraft_time::FrameRate;
use super::{Config, NvencH264, Profile}; use super::{Codec, Config, Nvenc, Profile};
/// The NVENC export encoder. /// The NVENC export encoder.
struct NvencEncoder { struct NvencEncoder {
enc: NvencH264, enc: Nvenc,
w: u32, w: u32,
h: u32, h: u32,
rate: FrameRate, rate: FrameRate,
@ -46,7 +53,11 @@ impl VideoEncoder for NvencEncoder {
let (sps, pps) = self.enc.parameter_sets(); let (sps, pps) = self.enc.parameter_sets();
// `config` declines sizes above u16::MAX // `config` declines sizes above u16::MAX
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));
SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h) match self.enc.codec() {
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 => SampleEntry::hevc(self.enc.hevc_config().cloned().unwrap_or_default(), w, h),
}
} }
fn timescale(&self) -> u32 { fn timescale(&self) -> u32 {
@ -54,6 +65,9 @@ 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)));
} }
@ -77,17 +91,27 @@ impl VideoEncoder for NvencEncoder {
} }
} }
/// Why NVENC does not take this export (the software encoder does), or the encoder's configuration. /// Why NVENC does not take this export (the software encoder does, for H.264), or the encoder's
/// configuration.
fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> std::result::Result<Config, String> { fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> std::result::Result<Config, String> {
if format != Format::H264 || s.format.is_mxf() { let hevc = format == Format::Hevc;
return Err("only MP4 / MOV H.264 exports".into()); if !(format == Format::H264 || hevc) || s.format.is_mxf() {
return Err("only MP4 / MOV H.264 and H.265 exports".into());
} }
if s.signal.is_hdr() { if s.signal.is_hdr() {
return Err("HDR (8-bit H.264 here carries it with software signalling)".into()); return Err(if hevc {
"HDR (hardware H.265 here is 8-bit SDR Main)".into()
} else {
"HDR (8-bit H.264 here carries it with software signalling)".into()
});
} }
if !matches!(s.h264_pass, H264Pass::Single) { 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());
} }
// the hardware path writes the aspect ratio of H.264 only
if hevc && s.pixel_aspect.is_some_and(|(n, d)| n != d) {
return Err("non-square pixels".into());
}
if s.field_order != FieldOrder::Progressive { if s.field_order != FieldOrder::Progressive {
return Err("interlaced output".into()); return Err("interlaced output".into());
} }
@ -110,12 +134,14 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps / 2 * 3), max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps / 2 * 3),
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 s.h264_profile { profile: match (hevc, s.h264_profile) {
H264Profile::Baseline => Profile::Baseline, (true, _) => Profile::HevcMain,
H264Profile::Main => Profile::Main, (false, H264Profile::Baseline) => Profile::Baseline,
H264Profile::High => Profile::High, (false, H264Profile::Main) => Profile::Main,
(false, H264Profile::High) => Profile::High,
}, },
level: s.h264_level, // the H.264 level setting means nothing for H.265: the encoder picks the level
level: if hevc { None } else { s.h264_level },
sar: s.pixel_aspect, sar: s.pixel_aspect,
bframes: true, bframes: true,
}) })
@ -123,6 +149,9 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
/// The Export encoder factory (see the module documentation). /// The Export encoder factory (see the module documentation).
pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> { pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
if format == Format::Hevc {
return hevc_factory(w, h, rate, s);
}
if s.hardware_encoding != HardwareEncoding::Auto || format != Format::H264 { if s.hardware_encoding != HardwareEncoding::Auto || format != Format::H264 {
return None; return None;
} }
@ -135,7 +164,7 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
Ok(c) => c, Ok(c) => c,
Err(why) => return declined(&why), Err(why) => return declined(&why),
}; };
match NvencH264::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(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() })))
@ -144,6 +173,32 @@ 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
/// 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| {
log::info!("hardware H.265 encoding declined: {why}");
filmcraft_export::note_hw_encode_declined();
Some(Err(ExportError::Unsupported(format!("H.265 export with NVENC: {why}"))))
};
let cfg = match config(Format::Hevc, w, h, rate, s) {
Ok(c) => c,
Err(why) => return declined(&why),
};
match Nvenc::new(&cfg) {
Ok(enc) => {
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 }
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -157,6 +212,37 @@ mod tests {
assert_eq!(composition_offset(1 << 40, 0, 1), i32::MAX); assert_eq!(composition_offset(1 << 40, 0, 1), i32::MAX);
} }
#[test]
fn hevc_takes_what_nvenc_can_write_and_declines_the_rest() {
let ok = ExportSettings { format: Format::Hevc, bitrate_kbps: 4000, keyframe_distance: Some(48), ..Default::default() };
let c = config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &ok).unwrap();
assert_eq!((c.profile, c.level, c.keyint, c.fps), (Profile::HevcMain, None, 48, (24, 1)));
// the H.264 level and profile settings mean nothing to HEVC
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);
// the Hardware encoding toggle does not matter for HEVC (it has no software encoder)
let off = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() };
assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &off).is_ok());
for (what, s) in [
("HDR", ExportSettings { signal: filmcraft_export::ColorSignal::PQ, ..ok.clone() }),
("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }),
("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }),
("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }),
("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }),
("MXF", ExportSettings { format: Format::MxfOp1a, ..ok.clone() }),
] {
assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &s).is_err(), "{what}");
}
// hostile sizes and frame rates
assert!(config(Format::Hevc, 70_000, 64, FrameRate::FPS_24, &ok).is_err());
assert!(config(Format::Hevc, 64, 70_000, FrameRate::FPS_24, &ok).is_err());
for rate in [FrameRate { num: 0, den: 1 }, FrameRate { num: 24, den: 0 }, FrameRate { num: -24, den: 1 }, FrameRate { num: i64::MAX, den: 1 }] {
assert!(config(Format::Hevc, 1280, 720, rate, &ok).is_err(), "{rate:?}");
}
// other formats are not NVENC's
assert!(config(Format::ProRes, 1280, 720, FrameRate::FPS_24, &ok).is_err());
}
#[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

@ -30,7 +30,7 @@
//! offsets are checked against a C compiler's by the generated `abi_tests.rs`. //! offsets are checked against a C compiler's by the generated `abi_tests.rs`.
//! //!
//! API 12.1 is targeted on purpose: it is understood by every driver since 531.x, and the encoder //! API 12.1 is targeted on purpose: it is understood by every driver since 531.x, and the encoder
//! features used here (H.264, presets, rate control) are the same in later versions. //! features used here (H.264 and HEVC, presets, rate control) are the same in later versions.
//! //!
//! FFI module (docs/adr/0001-platform-ffi.md): plain `repr(C)` data, no logic. //! FFI module (docs/adr/0001-platform-ffi.md): plain `repr(C)` data, no logic.
@ -82,6 +82,22 @@ pub const NV_ENC_PIC_TYPE_I: u32 = 2;
pub const NV_ENC_PIC_TYPE_IDR: u32 = 3; pub const NV_ENC_PIC_TYPE_IDR: u32 = 3;
pub const NV_ENC_PIC_FLAG_EOS: u32 = 0x8; pub const NV_ENC_PIC_FLAG_EOS: u32 = 0x8;
pub const NV_ENC_LEVEL_AUTOSELECT: u32 = 0; pub const NV_ENC_LEVEL_AUTOSELECT: u32 = 0;
/// HEVC levels are `level × 30` (4.1 is 123), unlike H.264's `level × 10`.
pub const NV_ENC_LEVEL_HEVC_1: u32 = 30;
pub const NV_ENC_LEVEL_HEVC_2: u32 = 60;
pub const NV_ENC_LEVEL_HEVC_21: u32 = 63;
pub const NV_ENC_LEVEL_HEVC_3: u32 = 90;
pub const NV_ENC_LEVEL_HEVC_31: u32 = 93;
pub const NV_ENC_LEVEL_HEVC_4: u32 = 120;
pub const NV_ENC_LEVEL_HEVC_41: u32 = 123;
pub const NV_ENC_LEVEL_HEVC_5: u32 = 150;
pub const NV_ENC_LEVEL_HEVC_51: u32 = 153;
pub const NV_ENC_LEVEL_HEVC_52: u32 = 156;
pub const NV_ENC_LEVEL_HEVC_6: u32 = 180;
pub const NV_ENC_LEVEL_HEVC_61: u32 = 183;
pub const NV_ENC_LEVEL_HEVC_62: u32 = 186;
pub const NV_ENC_TIER_HEVC_MAIN: u32 = 0;
pub const NV_ENC_HEVC_CUSIZE_AUTOSELECT: u32 = 0;
pub const NV_ENC_H264_ENTROPY_CODING_MODE_CABAC: u32 = 1; pub const NV_ENC_H264_ENTROPY_CODING_MODE_CABAC: u32 = 1;
pub const NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED: u32 = 5; 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;
@ -97,9 +113,11 @@ pub const NV_ENC_CAPS_WIDTH_MIN: u32 = 45;
pub const NV_ENC_CAPS_HEIGHT_MIN: u32 = 46; pub const NV_ENC_CAPS_HEIGHT_MIN: u32 = 46;
pub const NV_ENC_CODEC_H264_GUID: GUID = GUID::from_values(0x6bc82762, 0x4e63, 0x4ca4, [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf]); pub const NV_ENC_CODEC_H264_GUID: GUID = GUID::from_values(0x6bc82762, 0x4e63, 0x4ca4, [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf]);
pub const NV_ENC_CODEC_HEVC_GUID: GUID = GUID::from_values(0x790cdc88, 0x4522, 0x4d7b, [0x94, 0x25, 0xbd, 0xa9, 0x97, 0x5f, 0x76, 0x03]);
pub const NV_ENC_H264_PROFILE_BASELINE_GUID: GUID = GUID::from_values(0x0727bcaa, 0x78c4, 0x4c83, [0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a]); pub const NV_ENC_H264_PROFILE_BASELINE_GUID: GUID = GUID::from_values(0x0727bcaa, 0x78c4, 0x4c83, [0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a]);
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_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]);
@ -235,10 +253,58 @@ pub const H264_OUTPUT_AUD: u32 = 1 << 6;
pub const H264_REPEAT_SPSPPS: u32 = 1 << 12; pub const H264_REPEAT_SPSPPS: u32 = 1 << 12;
pub const H264_OUTPUT_RECOVERY_POINT_SEI: u32 = 1 << 9; pub const H264_OUTPUT_RECOVERY_POINT_SEI: u32 = 1 << 9;
/// `NV_ENC_CONFIG_HEVC_VUI_PARAMETERS` is a typedef of the H.264 VUI structure.
pub type NV_ENC_CONFIG_HEVC_VUI_PARAMETERS = NV_ENC_CONFIG_H264_VUI_PARAMETERS;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct NV_ENC_CONFIG_HEVC {
pub level: u32,
pub tier: u32,
pub minCUSize: u32,
pub maxCUSize: u32,
/// useConstrainedIntraPred:1, disableDeblockAcrossSliceBoundary:1, outputBufferingPeriodSEI:1,
/// outputPictureTimingSEI:1, outputAUD:1, enableLTR:1, disableSPSPPS:1, repeatSPSPPS:1,
/// enableIntraRefresh:1, chromaFormatIDC:2, pixelBitDepthMinus8:3, enableFillerDataInsertion:1,
/// enableConstrainedEncoding:1, enableAlphaLayerEncoding:1, singleSliceIntraRefresh:1,
/// outputRecoveryPointSEI:1, outputTimeCodeSEI:1, reserved:12.
pub flags: u32,
pub idrPeriod: u32,
pub intraRefreshPeriod: u32,
pub intraRefreshCnt: u32,
pub maxNumRefFramesInDPB: u32,
pub ltrNumFrames: u32,
pub vpsId: u32,
pub spsId: u32,
pub ppsId: u32,
pub sliceMode: u32,
pub sliceModeData: u32,
pub maxTemporalLayersMinus1: u32,
pub hevcVUIParameters: NV_ENC_CONFIG_HEVC_VUI_PARAMETERS,
pub ltrTrustMode: u32,
pub useBFramesAsRef: u32,
pub numRefL0: u32,
pub numRefL1: u32,
pub reserved1: [u32; 214],
pub reserved2: [*mut c_void; 64],
}
pub const HEVC_OUTPUT_AUD: u32 = 1 << 4;
pub const HEVC_DISABLE_SPSPPS: u32 = 1 << 6;
pub const HEVC_REPEAT_SPSPPS: u32 = 1 << 7;
pub const HEVC_ENABLE_INTRA_REFRESH: u32 = 1 << 8;
/// `chromaFormatIDC` (2 bits) and `pixelBitDepthMinus8` (3 bits) share the flags word.
pub const HEVC_CHROMA_FORMAT_SHIFT: u32 = 9;
pub const HEVC_CHROMA_FORMAT_MASK: u32 = 3 << HEVC_CHROMA_FORMAT_SHIFT;
pub const HEVC_PIXEL_BIT_DEPTH_SHIFT: u32 = 11;
pub const HEVC_PIXEL_BIT_DEPTH_MASK: u32 = 7 << HEVC_PIXEL_BIT_DEPTH_SHIFT;
pub const HEVC_OUTPUT_RECOVERY_POINT_SEI: u32 = 1 << 18;
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub union NV_ENC_CODEC_CONFIG { pub union NV_ENC_CODEC_CONFIG {
pub h264Config: NV_ENC_CONFIG_H264, pub h264Config: NV_ENC_CONFIG_H264,
pub hevcConfig: NV_ENC_CONFIG_HEVC,
pub reserved: [u32; 320], pub reserved: [u32; 320],
align: [*mut c_void; 0], align: [*mut c_void; 0],
} }

View File

@ -0,0 +1,194 @@
//! 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.
//!
//! 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
//! general constraint flags and `sps_temporal_id_nesting_flag` are read from the SPS bits that carry
//! them (`parse_ptl` skips the flags and `Sps` does not keep the nesting flag). The array
//! completeness flags are 1 and the parameter sets are only in the record, never in the samples
//! (the `hvc1` sample entry).
use filmcraft_bitstream::unescape_rbsp;
use filmcraft_hevc::params::Sps;
use filmcraft_isobmff::{HevcConfig, HevcNalArray};
use super::{Codec, nal_type};
/// `NV_ENC_LEVEL_HEVC_*` for a level × 10 (4.1 is 41): HEVC levels are numbered `level × 30`.
/// `None` for a number that is not an HEVC level.
pub fn level_code(level_x10: u8) -> Option<u8> {
matches!(level_x10, 10 | 20 | 21 | 30 | 31 | 40 | 41 | 50 | 51 | 52 | 60 | 61 | 62).then(|| level_x10.saturating_mul(3))
}
/// Bytes of the SPS RBSP before the general profile / tier / level record: `sps_video_parameter_set_id`,
/// `sps_max_sub_layers_minus1` and `sps_temporal_id_nesting_flag` share the first one.
const PTL_START: usize = 1;
/// General profile space / tier / profile (1 byte), compatibility flags (4), constraint flags (6), level (1).
const PTL_LEN: usize = 12;
/// 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
/// says why the stream is not one this backend writes: HEVC Main, 8-bit 4:2:0.
pub fn hevc_config(vps: &[u8], sps: &[u8], pps: &[u8], size: (u32, u32)) -> Result<HevcConfig, String> {
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 {
return Err(format!("the encoder's {name} is not a {name} NAL unit"));
}
// the record stores each length in 16 bits
if u16::try_from(nal.len()).is_err() {
return Err(format!("the {name} is too long for an hvcC record"));
}
}
let rbsp = unescape_rbsp(sps.get(2..).unwrap_or_default());
let parsed = Sps::parse(&rbsp).map_err(|e| format!("unreadable HEVC SPS: {e}"))?;
let ptl_bytes = rbsp.get(PTL_START..PTL_START + PTL_LEN).ok_or("the HEVC SPS is truncated before its profile / tier / level")?;
// bytes 5..11 of the record are the 48 general constraint indicator flags
let constraints = ptl_bytes.get(5..11).ok_or("the HEVC SPS is truncated before its constraint flags")?;
let general_constraint_indicator_flags = constraints.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b));
let temporal_id_nested = rbsp.first().is_some_and(|b| b & 1 != 0);
let ptl = &parsed.ptl;
if ptl.profile_space != 0 || ptl.profile_idc != 1 {
return Err(format!("the encoder wrote HEVC profile {} (space {}), not Main", ptl.profile_idc, ptl.profile_space));
}
if parsed.chroma_format_idc != 1 || parsed.bit_depth_luma != 8 || parsed.bit_depth_chroma != 8 {
return Err(format!(
"the encoder wrote chroma format {} at {}/{} bits, not 8-bit 4:2:0",
parsed.chroma_format_idc, parsed.bit_depth_luma, parsed.bit_depth_chroma
));
}
let (_, _, w, h) = parsed.crop_rect();
if (w, h) != size {
return Err(format!("the encoder wrote {w}x{h} pictures for a {}x{} export", size.0, size.1));
}
Ok(HevcConfig {
general_profile_space: ptl.profile_space,
general_tier_flag: ptl.tier,
general_profile_idc: ptl.profile_idc,
general_profile_compatibility_flags: ptl.compatibility,
general_constraint_indicator_flags,
general_level_idc: ptl.level_idc,
// 0: not specified, which is a valid value for both
min_spatial_segmentation_idc: 0,
parallelism_type: 0,
chroma_format_idc: u8::try_from(parsed.chroma_format_idc).map_err(|_| "chroma format".to_string())?,
bit_depth_luma: u8::try_from(parsed.bit_depth_luma).map_err(|_| "bit depth".to_string())?,
bit_depth_chroma: u8::try_from(parsed.bit_depth_chroma).map_err(|_| "bit depth".to_string())?,
// 0: unspecified average rate, and the frame rate may not be constant
avg_frame_rate: 0,
constant_frame_rate: 0,
num_temporal_layers: u8::try_from(parsed.max_sub_layers_minus1.saturating_add(1)).map_err(|_| "temporal layers".to_string())?,
temporal_id_nested,
length_size: 4,
arrays: vec![
HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vps.to_vec()] },
HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps.to_vec()] },
HevcNalArray { completeness: true, nal_type: 34, nalus: vec![pps.to_vec()] },
],
})
}
#[cfg(test)]
mod tests {
use super::*;
// The parameter sets NVENC wrote for a 1280x720, 24 fps, B-frame HEVC Main stream (RTX 5060, driver
// 617.14): the coded size is 1280x736 and a conformance window crops it to 1280x720.
const VPS: &str = "40010c01ffff01600000030090000003000003005d9940c0000003004000000614";
const SPS: &str = "42010101600000030090000003000003005da00280802e1f1396654a421191bff0c05a8080808a0000030002000003003010";
const PPS: &str = "4401c1937c0cc9";
fn unhex(h: &str) -> Vec<u8> {
(0..h.len()).step_by(2).filter_map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok()).collect()
}
fn record() -> HevcConfig {
hevc_config(&unhex(VPS), &unhex(SPS), &unhex(PPS), (1280, 720)).unwrap()
}
#[test]
fn hevc_levels_are_thirty_times_the_level() {
assert_eq!(level_code(41), Some(123));
assert_eq!(level_code(10), Some(30));
assert_eq!(level_code(21), Some(63));
assert_eq!(level_code(62), Some(186));
for bad in [0, 11, 42, 53, 70, 255] {
assert_eq!(level_code(bad), None, "{bad}");
}
}
#[test]
fn the_record_is_the_sps_it_came_from() {
let c = record();
let sps = unhex(SPS);
let rbsp = unescape_rbsp(&sps[2..]);
// profile / tier / compatibility / constraint flags / level are the SPS's own bytes
assert_eq!(&c.to_bytes()[1..13], &rbsp[1..13]);
assert_eq!((c.general_profile_space, c.general_tier_flag, c.general_profile_idc), (0, false, 1));
assert_eq!(c.general_profile_compatibility_flags, 0x6000_0000, "Main and Main 10");
// progressive_source_flag and frame_only_constraint_flag
assert_eq!(c.general_constraint_indicator_flags, 0x9000_0000_0000);
assert_eq!(c.general_level_idc, 93, "level 3.1");
assert_eq!((c.chroma_format_idc, c.bit_depth_luma, c.bit_depth_chroma), (1, 8, 8));
assert_eq!((c.num_temporal_layers, c.temporal_id_nested, c.length_size), (1, true, 4));
assert_eq!(c.arrays.iter().map(|a| (a.nal_type, a.completeness, a.nalus.len())).collect::<Vec<_>>(), vec![(32, true, 1), (33, true, 1), (34, true, 1)]);
assert_eq!((c.vps(), c.sps(), c.pps()), (vec![&unhex(VPS)[..]], vec![&sps[..]], vec![&unhex(PPS)[..]]));
assert_eq!(HevcConfig::parse(&c.to_bytes()).unwrap(), c);
}
#[test]
fn the_picture_size_has_to_match_after_cropping() {
let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS));
for wrong in [(1280, 736), (1920, 1080), (1280, 719), (0, 0)] {
let e = hevc_config(&v, &s, &p, wrong).unwrap_err();
assert!(e.contains("1280x720"), "{e}");
}
}
#[test]
fn a_stream_that_is_not_main_8_bit_420_is_refused() {
// The same SPS with the general profile changed (bits of profile_idc in the first PTL byte),
// the chroma format or the bit depth changed would be other streams: edit the PTL byte only
// (it sits before any emulation-prevention byte).
let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS));
let mut other_profile = s.clone();
other_profile[3] = (other_profile[3] & !0x1f) | 2; // Main 10
assert!(hevc_config(&v, &other_profile, &p, (1280, 720)).unwrap_err().contains("not Main"));
let mut other_space = s.clone();
other_space[3] |= 0x40;
assert!(hevc_config(&v, &other_space, &p, (1280, 720)).is_err());
}
#[test]
fn truncated_and_corrupt_parameter_sets_are_errors_never_panics() {
let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS));
// every truncation of the SPS
for n in 0..s.len() {
assert!(hevc_config(&v, &s[..n], &p, (1280, 720)).is_err(), "SPS cut at {n}");
}
// a bit flip anywhere in the SPS: an error or a record, but never a panic
for byte in 0..s.len() {
for bit in 0..8 {
let mut m = s.clone();
m[byte] ^= 1 << bit;
let r = std::panic::catch_unwind(|| hevc_config(&v, &m, &p, (1280, 720)));
assert!(r.is_ok(), "panic with bit {bit} of byte {byte} flipped");
}
}
// wrong NAL types, empty and one-byte NAL units
let cases: [(&[u8], &[u8], &[u8]); 5] = [
(&[], &[], &[]),
(&[0x40], &[0x42], &[0x44]),
(&[0x40, 0x01, 0xAA], &[0x42, 0x01], &[0x44, 0x01, 0xBB]),
(&[0x42, 0x01, 0xAA], &s, &p),
(&v, &p, &s),
];
for (v, s, p) in cases {
assert!(hevc_config(v, s, p, (64, 64)).is_err(), "{v:?} {s:?} {p:?}");
}
// a parameter set too long for the record's 16-bit lengths
let mut huge = s.clone();
huge.resize(70_000, 0x55);
assert!(hevc_config(&v, &huge, &p, (1280, 720)).is_err());
}
}

View File

@ -1,23 +1,27 @@
//! NVIDIA NVENC H.264 hardware encoding (Windows). //! NVIDIA NVENC hardware encoding, H.264 and H.265 (HEVC) Main (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
//! comes back as length-prefixed H.264 samples with the parameter sets split out for the `avcC`. //! comes back as length-prefixed samples with the parameter sets split out for the `avcC` (H.264)
//! [`export`] plugs it into Export as an alternative to the software encoder. //! or the `hvcC` (HEVC, see [`hevc`]). One session, ring and NV12 path serves both codecs; the
//! codec only chooses the GUIDs, the codec configuration and how NAL units are told apart.
//! [`export`] plugs it into Export: as an alternative to the software encoder for H.264, and as the
//! only encoder of the H.265 format (which has no software encoder).
//! //!
//! ```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 //! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC / hvcC
//! ``` //! ```
//! //!
//! `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
//! code. Hardware encoding never replaces an export that works in software: the factory declines //! code. Hardware H.264 encoding never replaces an export that works in software: the factory
//! what NVENC cannot do (no NVIDIA GPU or driver, sizes, HDR, two-pass, MXF...) and the software //! declines what NVENC cannot do (no NVIDIA GPU or driver, sizes, HDR, two-pass, MXF...) and the
//! encoder takes over. //! software encoder takes over. A declined HEVC export is an error that says why.
pub mod export; pub mod export;
#[allow(unsafe_code)] #[allow(unsafe_code)]
mod ffi; mod ffi;
pub mod hevc;
#[allow(unsafe_code)] #[allow(unsafe_code)]
mod session; mod session;
@ -26,15 +30,29 @@ mod abi_tests;
use std::collections::VecDeque; use std::collections::VecDeque;
pub use self::session::{Caps, Params}; use filmcraft_isobmff::HevcConfig;
pub use self::session::{Caps, Codec, Params};
use self::session::{Locked, Session, Submitted}; use self::session::{Locked, Session, Submitted};
/// H.264 profile (`profile_idc` 66 / 77 / 100). /// H.264 profile (`profile_idc` 66 / 77 / 100), or HEVC Main (8-bit 4:2:0).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Profile { pub enum Profile {
Baseline, Baseline,
Main, Main,
High, High,
/// H.265 Main: chooses the HEVC codec (like `VtProfile::HevcMain` for VideoToolbox).
HevcMain,
}
impl Profile {
/// The codec this profile belongs to.
pub fn codec(self) -> Codec {
match self {
Profile::HevcMain => Codec::Hevc,
_ => Codec::H264,
}
}
} }
/// What to encode. /// What to encode.
@ -50,7 +68,7 @@ pub struct Config {
/// Frames between IDR pictures. /// Frames between IDR pictures.
pub keyint: u32, pub keyint: u32,
pub profile: Profile, pub profile: Profile,
/// Level × 10 (41 = 4.1); `None` lets the encoder pick. /// Level × 10 (41 = 4.1), for either codec; `None` lets the encoder pick.
pub level: Option<u8>, pub level: Option<u8>,
/// Sample aspect ratio. /// Sample aspect ratio.
pub sar: Option<(u32, u32)>, pub sar: Option<(u32, u32)>,
@ -71,11 +89,16 @@ 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 H.264 encoder. /// An NVENC encoder (H.264, or HEVC when the profile is [`Profile::HevcMain`]).
pub struct NvencH264 { pub struct Nvenc {
session: Session, session: Session,
codec: Codec,
/// Empty for H.264.
vps: Vec<u8>,
sps: Vec<u8>, sps: Vec<u8>,
pps: Vec<u8>, pps: Vec<u8>,
/// The `hvcC` record (HEVC only), built from the parameter sets the encoder wrote.
hvcc: Option<HevcConfig>,
/// Frames the output is delayed by reordering (0 or 1). /// Frames the output is delayed by reordering (0 or 1).
delay: u32, delay: u32,
free: Vec<usize>, free: Vec<usize>,
@ -86,12 +109,41 @@ pub struct NvencH264 {
size: (u32, u32), size: (u32, u32),
} }
/// The H.264 encoder (the name it had before HEVC joined it).
pub type NvencH264 = Nvenc;
/// Whether this system has an NVIDIA GPU with a driver that has NVENC. /// Whether this system has an NVIDIA GPU with a driver that has NVENC.
pub fn available() -> bool { pub fn available() -> bool {
Session::open().is_ok() Session::open().is_ok()
} }
impl NvencH264 { /// 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(|| {
let probe = Config {
width: 640,
height: 360,
fps: (30, 1),
bitrate_kbps: 2000,
max_bitrate_kbps: 3000,
cbr: false,
keyint: 30,
profile: Profile::HevcMain,
level: None,
sar: None,
bframes: false,
};
let result = Nvenc::new(&probe);
if let Err(why) = &result {
log::info!("no hardware HEVC encoder: {why}");
}
result.is_ok()
})
}
impl Nvenc {
/// Open an encoder, or say why NVENC does not take this configuration. /// Open an encoder, 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> {
let (w, h) = (cfg.width, cfg.height); let (w, h) = (cfg.width, cfg.height);
@ -101,13 +153,22 @@ impl NvencH264 {
if cfg.fps.0 == 0 || cfg.fps.1 == 0 { if cfg.fps.0 == 0 || cfg.fps.1 == 0 {
return Err("frame rate".into()); return Err("frame rate".into());
} }
let codec = cfg.profile.codec();
let level = match (codec, cfg.level) {
(Codec::Hevc, Some(l)) => Some(hevc::level_code(l).ok_or_else(|| format!("{}.{} is not an HEVC level", l / 10, l % 10))?),
(_, l) => l,
};
let mut session = Session::open()?; let mut session = Session::open()?;
let caps = session.caps()?; let caps = session.caps(codec)?;
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));
} }
let bframes = u32::from(cfg.bframes && cfg.profile != Profile::Baseline && caps.max_bframes >= 1); // NVENC wants a GOP longer than the B-frame pattern: HEVC with a keyframe every one or two
// 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 bframes = u32::from(cfg.bframes && cfg.profile != Profile::Baseline && caps.max_bframes >= 1 && room_for_bframes);
let params = Params { let params = Params {
codec,
width: w, width: w,
height: h, height: h,
fps: cfg.fps, fps: cfg.fps,
@ -117,17 +178,47 @@ impl NvencH264 {
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 => 1, Profile::Main | Profile::HevcMain => 1,
Profile::High => 2, Profile::High => 2,
}, },
level: cfg.level, level,
sar: cfg.sar, sar: cfg.sar,
bframes, bframes,
}; };
session.initialize(&params, RING)?; session.initialize(&params, RING)?;
let (sps, pps) = split_parameter_sets(&session.sequence_params()?)?; let params = session.sequence_params()?;
let (vps, sps, pps, hvcc) = match codec {
Codec::H264 => {
let (sps, pps) = split_parameter_sets(&params)?;
(Vec::new(), sps, pps, None)
}
Codec::Hevc => {
let (vps, sps, pps) = split_hevc_parameter_sets(&params)?;
// validated now: a stream whose parameter sets we cannot describe is a declined export
let record = hevc::hevc_config(&vps, &sps, &pps, (w, h))?;
(vps, sps, pps, Some(record))
}
};
let slots = session.slots(); let slots = session.slots();
Ok(Self { session, sps, pps, delay: bframes, free: (0..slots).rev().collect(), pending: VecDeque::new(), ready: 0, emitted: 0, size: (w, h) }) Ok(Self {
session,
codec,
vps,
sps,
pps,
hvcc,
delay: bframes,
free: (0..slots).rev().collect(),
pending: VecDeque::new(),
ready: 0,
emitted: 0,
size: (w, h),
})
}
/// The codec this encoder writes.
pub fn codec(&self) -> Codec {
self.codec
} }
/// The sequence and picture parameter sets (NAL units without start codes). /// The sequence and picture parameter sets (NAL units without start codes).
@ -135,6 +226,16 @@ impl NvencH264 {
(&self.sps, &self.pps) (&self.sps, &self.pps)
} }
/// The video parameter set (HEVC; empty for H.264).
pub fn vps(&self) -> &[u8] {
&self.vps
}
/// The `hvcC` record of an HEVC stream (`None` for H.264).
pub fn hevc_config(&self) -> Option<&HevcConfig> {
self.hvcc.as_ref()
}
/// Frames the decoding time runs behind the presentation time (B-frame reordering). /// Frames the decoding time runs behind the presentation time (B-frame reordering).
pub fn delay(&self) -> u32 { pub fn delay(&self) -> u32 {
self.delay self.delay
@ -193,7 +294,7 @@ impl NvencH264 {
let r = self.session.read(slot); let r = self.session.read(slot);
self.free.push(slot); self.free.push(slot);
let o = r?; let o = r?;
let data = annex_b_to_length_prefixed(&o.data)?; let data = annex_b_to_length_prefixed_for(self.codec, &o.data)?;
let k = self.emitted; let k = self.emitted;
self.emitted = self.emitted.saturating_add(1); self.emitted = self.emitted.saturating_add(1);
let pts = i64::try_from(o.pts).unwrap_or(i64::MAX); let pts = i64::try_from(o.pts).unwrap_or(i64::MAX);
@ -256,16 +357,41 @@ pub fn annex_b_nals(data: &[u8]) -> Vec<&[u8]> {
/// Annex B to 4-byte length-prefixed NAL units, dropping parameter sets (they live in the `avcC`) /// Annex B to 4-byte length-prefixed NAL units, dropping parameter sets (they live in the `avcC`)
/// and access unit delimiters. Errors on a stream without NAL units. /// and access unit delimiters. Errors on a stream without NAL units.
pub fn annex_b_to_length_prefixed(data: &[u8]) -> Result<Vec<u8>, String> { pub fn annex_b_to_length_prefixed(data: &[u8]) -> Result<Vec<u8>, String> {
annex_b_to_length_prefixed_for(Codec::H264, data)
}
/// The NAL unit type of a NAL unit (`None` when it is too short to have a header).
pub fn nal_type(codec: Codec, nal: &[u8]) -> Option<u8> {
match codec {
Codec::H264 => nal.first().map(|b| b & 0x1f),
// two header bytes: forbidden_zero_bit, nal_unit_type (6), layer id (6), temporal id plus 1 (3)
Codec::Hevc => match nal {
[b, _, ..] => Some((b >> 1) & 0x3f),
_ => None,
},
}
}
/// [`annex_b_to_length_prefixed`] for a codec: H.264 drops SPS / PPS / AUD (types 7 to 9); HEVC drops
/// VPS / SPS / PPS / AUD (32 to 35) and end-of-sequence / end-of-bitstream markers (36, 37), since
/// the file is one coded video sequence. A NAL unit too short for its header is dropped.
pub fn annex_b_to_length_prefixed_for(codec: Codec, data: &[u8]) -> Result<Vec<u8>, String> {
let nals = annex_b_nals(data); let nals = annex_b_nals(data);
if nals.is_empty() { if nals.is_empty() {
return Err("the encoder produced no NAL units".into()); return Err("the encoder produced no NAL units".into());
} }
let mut out = Vec::with_capacity(data.len()); let mut out = Vec::with_capacity(data.len());
for n in nals { for n in nals {
if n.first().is_none_or(|b| matches!(b & 0x1f, 7..=9)) { let dropped = match (codec, nal_type(codec, n)) {
(_, None) => true,
(Codec::H264, Some(t)) => matches!(t, 7..=9),
(Codec::Hevc, Some(t)) => matches!(t, 32..=37),
};
if dropped {
continue; continue;
} }
out.extend_from_slice(&(n.len() as u32).to_be_bytes()); let len = u32::try_from(n.len()).map_err(|_| "a NAL unit longer than 4 GiB".to_string())?;
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(n); out.extend_from_slice(n);
} }
if out.is_empty() { if out.is_empty() {
@ -284,6 +410,16 @@ pub fn split_parameter_sets(data: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
} }
} }
/// The VPS, SPS and PPS NAL units of an Annex B byte string from an HEVC encoder.
pub fn split_hevc_parameter_sets(data: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
let nals = annex_b_nals(data);
let find = |t: u8| nals.iter().find(|n| nal_type(Codec::Hevc, n) == Some(t)).map(|n| n.to_vec());
match (find(32), find(33), find(34)) {
(Some(v), Some(s), Some(p)) => Ok((v, s, p)),
_ => Err("the encoder returned no VPS / SPS / PPS".into()),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -336,4 +472,89 @@ mod tests {
assert!(annex_b_to_length_prefixed(&[0, 0, 1, 0, 0, 0, 1]).is_err()); assert!(annex_b_to_length_prefixed(&[0, 0, 1, 0, 0, 0, 1]).is_err());
assert!(split_parameter_sets(&[0, 0, 1]).is_err()); assert!(split_parameter_sets(&[0, 0, 1]).is_err());
} }
// HEVC NAL unit headers: type in bits 1..=6 of the first byte (VPS 0x40, SPS 0x42, PPS 0x44, AUD 0x46,
// EOS 0x48, EOB 0x4a, IDR_W_RADL 0x26, IDR_N_LP 0x28, TRAIL_R 0x02, prefix SEI 0x4e, suffix SEI 0x50)
#[test]
fn hevc_nal_types_come_from_the_second_header_bit_range() {
assert_eq!(nal_type(Codec::Hevc, &[0x40, 0x01]), Some(32));
assert_eq!(nal_type(Codec::Hevc, &[0x26, 0x01, 9]), Some(19));
assert_eq!(nal_type(Codec::Hevc, &[0x28, 0x01]), Some(20));
assert_eq!(nal_type(Codec::Hevc, &[0x4e, 0x01]), Some(39));
assert_eq!(nal_type(Codec::Hevc, &[0x50, 0x01]), Some(40));
// the H.264 reading of the same bytes is different (and wrong for HEVC)
assert_eq!(nal_type(Codec::H264, &[0x28, 0x01]), Some(8));
// no header, no type
assert_eq!(nal_type(Codec::Hevc, &[]), None);
assert_eq!(nal_type(Codec::Hevc, &[0x40]), None);
assert_eq!(nal_type(Codec::H264, &[]), None);
}
#[test]
fn hevc_samples_keep_slices_and_sei_and_lose_parameter_sets_and_delimiters() {
let s = [
&[0, 0, 0, 1, 0x46, 0x01, 0x50][..], // AUD
&[0, 0, 0, 1, 0x40, 0x01, 1, 2], // VPS
&[0, 0, 0, 1, 0x42, 0x01, 3], // SPS
&[0, 0, 1, 0x44, 0x01, 4], // PPS
&[0, 0, 1, 0x4e, 0x01, 5, 5], // prefix SEI
&[0, 0, 0, 1, 0x28, 0x01, 6, 6, 6], // IDR_N_LP: its header byte is 0x28, a PPS to an H.264 reader
&[0, 0, 1, 0x26, 0x01, 7], // IDR_W_RADL
&[0, 0, 1, 0x02, 0x01, 8], // TRAIL_R
&[0, 0, 1, 0x50, 0x01, 9], // suffix SEI
&[0, 0, 1, 0x48, 0x01], // end of sequence
&[0, 0, 1, 0x4a, 0x01], // end of bitstream
]
.concat();
let out = annex_b_to_length_prefixed_for(Codec::Hevc, &s).unwrap();
let expected: Vec<u8> = [
&[0, 0, 0, 4, 0x4e, 0x01, 5, 5][..],
&[0, 0, 0, 5, 0x28, 0x01, 6, 6, 6],
&[0, 0, 0, 3, 0x26, 0x01, 7],
&[0, 0, 0, 3, 0x02, 0x01, 8],
&[0, 0, 0, 3, 0x50, 0x01, 9],
]
.concat();
assert_eq!(out, expected);
// the same stream read as H.264 loses and keeps the wrong NAL units
assert_ne!(annex_b_to_length_prefixed(&s).unwrap(), expected);
}
#[test]
fn hevc_parameter_sets_are_found_by_type() {
let s = [0, 0, 0, 1, 0x40, 0x01, 1, 0, 0, 0, 1, 0x42, 0x01, 2, 0, 0, 1, 0x44, 0x01, 3, 0, 0, 1, 0x26, 0x01, 4];
assert_eq!(split_hevc_parameter_sets(&s).unwrap(), (vec![0x40, 1, 1], vec![0x42, 1, 2], vec![0x44, 1, 3]));
// an H.264 SPS / PPS pair has none of them; one missing is an error
assert!(split_hevc_parameter_sets(&[0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3]).is_err());
assert!(split_hevc_parameter_sets(&[0, 0, 0, 1, 0x40, 0x01, 1, 0, 0, 1, 0x42, 0x01, 2]).is_err());
}
#[test]
fn hostile_hevc_streams_are_errors() {
// empty, no start code, only parameter sets / delimiters / markers, one-byte NAL units
for s in [
&[][..],
&[0, 0, 1],
&[1, 2, 3],
&[0, 0, 1, 0x46, 0x01, 0x50],
&[0, 0, 0, 1, 0x40, 0x01],
&[0, 0, 1, 0x26],
&[0, 0, 1, 0x48, 0x01],
&[0, 0, 1, 0xff],
] {
assert!(annex_b_to_length_prefixed_for(Codec::Hevc, s).is_err(), "{s:?}");
}
// truncated slices are kept as they are (the decoder reports them), never panic
assert!(annex_b_to_length_prefixed_for(Codec::Hevc, &[0, 0, 1, 0x26, 0x01]).is_ok());
assert!(split_hevc_parameter_sets(&[]).is_err());
assert!(split_hevc_parameter_sets(&[0, 0, 1, 0x40]).is_err());
}
#[test]
fn the_hevc_profile_picks_the_hevc_codec() {
assert_eq!(Profile::HevcMain.codec(), Codec::Hevc);
for p in [Profile::Baseline, Profile::Main, Profile::High] {
assert_eq!(p.codec(), Codec::H264);
}
}
} }

View File

@ -1,4 +1,4 @@
//! An NVENC H.264 encode session: the driver library, the Direct3D 11 device it is opened on, the //! An NVENC (H.264 or HEVC) encode session: the driver library, the Direct3D 11 device it is opened on, the
//! encoder configuration, and the input / output buffers (every call into the driver). //! encoder configuration, and the input / output buffers (every call into the driver).
//! //!
//! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the //! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the
@ -99,7 +99,23 @@ fn nvidia_device() -> Result<ID3D11Device, String> {
Err("no NVIDIA adapter".into()) Err("no NVIDIA adapter".into())
} }
/// What the encoder says it can do for H.264. /// The codec a session encodes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Codec {
H264,
Hevc,
}
impl Codec {
fn guid(self) -> windows::core::GUID {
match self {
Codec::H264 => NV_ENC_CODEC_H264_GUID,
Codec::Hevc => NV_ENC_CODEC_HEVC_GUID,
}
}
}
/// What the encoder says it can do for a codec.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Caps { pub struct Caps {
pub max_bframes: u32, pub max_bframes: u32,
@ -110,6 +126,7 @@ pub struct Caps {
/// Encoder settings (already validated by the caller). /// Encoder settings (already validated by the caller).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Params { pub struct Params {
pub codec: Codec,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub fps: (u32, u32), pub fps: (u32, u32),
@ -117,8 +134,9 @@ pub struct Params {
pub max_bitrate: u32, pub max_bitrate: u32,
pub cbr: bool, pub cbr: bool,
pub gop: u32, pub gop: u32,
/// 0 baseline, 1 main, 2 high. /// H.264 only: 0 baseline, 1 main, 2 high (HEVC is always Main).
pub profile: u8, pub profile: u8,
/// 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)>,
/// B-frames between references (0 or 1). /// B-frames between references (0 or 1).
@ -181,7 +199,8 @@ fn status(what: &str, s: NvStatus, enc: Handle, api: &Api) -> String {
}) })
}) })
.flatten() .flatten()
.filter(|d| !d.is_empty()); // the driver's text is sometimes not text at all (HEVC parameter errors): keep it only when readable
.filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_graphic() || c == ' '));
match detail { match detail {
Some(d) => format!("{what} failed ({s}): {d}"), Some(d) => format!("{what} failed ({s}): {d}"),
None => format!("{what} failed ({s})"), None => format!("{what} failed ({s})"),
@ -221,8 +240,8 @@ impl Session {
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 })
} }
/// The encoder's limits for H.264. /// The encoder's limits for `codec` (an error when the GPU cannot encode it).
pub fn caps(&self) -> Result<Caps, String> { pub fn caps(&self, codec: Codec) -> Result<Caps, String> {
let get = |cap: u32| -> Result<u32, String> { let get = |cap: u32| -> Result<u32, String> {
let f = self.api.list.nvEncGetEncodeCaps.ok_or("no nvEncGetEncodeCaps")?; let f = self.api.list.nvEncGetEncodeCaps.ok_or("no nvEncGetEncodeCaps")?;
// SAFETY: a valid session, a zeroed parameter block with its version, one int out. // SAFETY: a valid session, a zeroed parameter block with its version, one int out.
@ -231,9 +250,9 @@ impl Session {
p.version = NV_ENC_CAPS_PARAM_VER; p.version = NV_ENC_CAPS_PARAM_VER;
p.capsToQuery = cap; p.capsToQuery = cap;
let mut v = 0i32; let mut v = 0i32;
let s = f(self.enc, NV_ENC_CODEC_H264_GUID, &mut p, &mut v); let s = f(self.enc, codec.guid(), &mut p, &mut v);
if s != NV_ENC_SUCCESS { if s != NV_ENC_SUCCESS {
return Err(status("querying the encoder", s, self.enc, self.api)); return Err(status(if codec == Codec::Hevc { "querying the HEVC encoder" } else { "querying the encoder" }, s, self.enc, self.api));
} }
Ok(v.max(0) as u32) Ok(v.max(0) as u32)
} }
@ -256,16 +275,17 @@ impl Session {
let mut preset: Box<NV_ENC_PRESET_CONFIG> = Box::new(std::mem::zeroed()); let mut preset: Box<NV_ENC_PRESET_CONFIG> = Box::new(std::mem::zeroed());
preset.version = NV_ENC_PRESET_CONFIG_VER; preset.version = NV_ENC_PRESET_CONFIG_VER;
preset.presetCfg.version = NV_ENC_CONFIG_VER; preset.presetCfg.version = NV_ENC_CONFIG_VER;
let s = preset_fn(self.enc, NV_ENC_CODEC_H264_GUID, NV_ENC_PRESET_P5_GUID, NV_ENC_TUNING_INFO_HIGH_QUALITY, &mut *preset); let s = preset_fn(self.enc, p.codec.guid(), NV_ENC_PRESET_P5_GUID, NV_ENC_TUNING_INFO_HIGH_QUALITY, &mut *preset);
if s != NV_ENC_SUCCESS { if s != NV_ENC_SUCCESS {
return Err(status("reading the encoder preset", s, self.enc, self.api)); return Err(status("reading the encoder preset", s, self.enc, self.api));
} }
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.profile { cfg.profileGUID = match (p.codec, p.profile) {
0 => NV_ENC_H264_PROFILE_BASELINE_GUID, (Codec::Hevc, _) => NV_ENC_HEVC_PROFILE_MAIN_GUID,
1 => NV_ENC_H264_PROFILE_MAIN_GUID, (Codec::H264, 0) => NV_ENC_H264_PROFILE_BASELINE_GUID,
_ => NV_ENC_H264_PROFILE_HIGH_GUID, (Codec::H264, 1) => NV_ENC_H264_PROFILE_MAIN_GUID,
(Codec::H264, _) => NV_ENC_H264_PROFILE_HIGH_GUID,
}; };
cfg.gopLength = p.gop.max(1); cfg.gopLength = p.gop.max(1);
cfg.frameIntervalP = 1 + p.bframes as i32; cfg.frameIntervalP = 1 + p.bframes as i32;
@ -277,28 +297,62 @@ impl Session {
// a one-second buffer, the usual for streaming-style rate control // a one-second buffer, the usual for streaming-style rate control
rc.vbvBufferSize = rc.maxBitRate; rc.vbvBufferSize = rc.maxBitRate;
rc.vbvInitialDelay = rc.vbvBufferSize; rc.vbvInitialDelay = rc.vbvBufferSize;
let h = &mut cfg.encodeCodecConfig.h264Config; match p.codec {
h.idrPeriod = p.gop.max(1); Codec::H264 => {
h.level = p.level.map_or(NV_ENC_LEVEL_AUTOSELECT, u32::from); let h = &mut cfg.encodeCodecConfig.h264Config;
h.flags &= !(H264_OUTPUT_AUD | H264_REPEAT_SPSPPS); h.idrPeriod = p.gop.max(1);
h.entropyCodingMode = if p.profile == 0 { 2 } else { NV_ENC_H264_ENTROPY_CODING_MODE_CABAC }; h.level = p.level.map_or(NV_ENC_LEVEL_AUTOSELECT, u32::from);
h.maxNumRefFrames = 0; h.flags &= !(H264_OUTPUT_AUD | H264_REPEAT_SPSPPS);
// BT.709 limited range, like our own encoder's default signalling h.entropyCodingMode = if p.profile == 0 { 2 } else { NV_ENC_H264_ENTROPY_CODING_MODE_CABAC };
let vui = &mut h.h264VUIParameters; h.maxNumRefFrames = 0;
vui.videoSignalTypePresentFlag = 1; // BT.709 limited range, like our own encoder's default signalling
vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED; let vui = &mut h.h264VUIParameters;
vui.videoFullRangeFlag = 0; vui.videoSignalTypePresentFlag = 1;
vui.colourDescriptionPresentFlag = 1; vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED;
vui.colourPrimaries = NV_ENC_VUI_COLOR_PRIMARIES_BT709; vui.videoFullRangeFlag = 0;
vui.transferCharacteristics = NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709; vui.colourDescriptionPresentFlag = 1;
vui.colourMatrix = NV_ENC_VUI_MATRIX_COEFFS_BT709; vui.colourPrimaries = NV_ENC_VUI_COLOR_PRIMARIES_BT709;
vui.timingInfoPresentFlag = 1; vui.transferCharacteristics = NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709;
vui.numUnitInTicks = p.fps.1; vui.colourMatrix = NV_ENC_VUI_MATRIX_COEFFS_BT709;
vui.timeScale = p.fps.0.saturating_mul(2); vui.timingInfoPresentFlag = 1;
vui.numUnitInTicks = p.fps.1;
vui.timeScale = p.fps.0.saturating_mul(2);
}
Codec::Hevc => {
let h = &mut cfg.encodeCodecConfig.hevcConfig;
h.level = p.level.map_or(NV_ENC_LEVEL_AUTOSELECT, u32::from);
h.tier = NV_ENC_TIER_HEVC_MAIN;
h.minCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT;
h.maxCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT;
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
h.flags &= !(HEVC_OUTPUT_AUD
| HEVC_DISABLE_SPSPPS
| HEVC_REPEAT_SPSPPS
| HEVC_ENABLE_INTRA_REFRESH
| HEVC_CHROMA_FORMAT_MASK
| HEVC_PIXEL_BIT_DEPTH_MASK);
h.flags |= 1 << HEVC_CHROMA_FORMAT_SHIFT;
h.maxNumRefFramesInDPB = 0;
// BT.709 limited range, like the H.264 path; an HEVC VUI counts frames, not fields:
// frame rate = time_scale / num_units_in_tick
let vui = &mut h.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = NV_ENC_VUI_COLOR_PRIMARIES_BT709;
vui.transferCharacteristics = NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709;
vui.colourMatrix = NV_ENC_VUI_MATRIX_COEFFS_BT709;
vui.timingInfoPresentFlag = 1;
vui.numUnitInTicks = p.fps.1;
vui.timeScale = p.fps.0;
}
}
let mut init: Box<NV_ENC_INITIALIZE_PARAMS> = Box::new(std::mem::zeroed()); let mut init: Box<NV_ENC_INITIALIZE_PARAMS> = Box::new(std::mem::zeroed());
init.version = NV_ENC_INITIALIZE_PARAMS_VER; init.version = NV_ENC_INITIALIZE_PARAMS_VER;
init.encodeGUID = NV_ENC_CODEC_H264_GUID; init.encodeGUID = p.codec.guid();
init.presetGUID = NV_ENC_PRESET_P5_GUID; init.presetGUID = NV_ENC_PRESET_P5_GUID;
init.encodeWidth = p.width; init.encodeWidth = p.width;
init.encodeHeight = p.height; init.encodeHeight = p.height;
@ -363,7 +417,7 @@ impl Session {
self.slots.len() self.slots.len()
} }
/// The SPS and PPS as one Annex B byte string. /// The parameter sets as one Annex B byte string (SPS and PPS; VPS, SPS and PPS for HEVC).
pub fn sequence_params(&self) -> Result<Vec<u8>, String> { pub fn sequence_params(&self) -> Result<Vec<u8>, String> {
let f = self.api.list.nvEncGetSequenceParams.ok_or("no nvEncGetSequenceParams")?; let f = self.api.list.nvEncGetSequenceParams.ok_or("no nvEncGetSequenceParams")?;
let mut buf = vec![0u8; 1024]; let mut buf = vec![0u8; 1024];

View File

@ -0,0 +1,306 @@
//! NVENC H.265 (HEVC Main) encoding (Windows, NVIDIA GPU with an HEVC encoder): the stream decodes
//! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the
//! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips
//! without an NVIDIA GPU / driver with HEVC encoding.
#![cfg(target_os = "windows")]
use filmcraft_bitstream::unescape_rbsp;
use filmcraft_hevc::params::Sps;
use filmcraft_isobmff::{HevcConfig, SampleEntry};
use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, annex_b_to_length_prefixed_for, hevc_available, nal_type};
/// A moving test picture: gradient background, a moving box, a little texture.
fn rgba(w: usize, h: usize, i: usize) -> Vec<u8> {
let mut v = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let o = (y * w + x) * 4;
let (bx, by) = ((i * 7) % (w - 64), (i * 3) % (h - 64));
let in_box = x >= bx && x < bx + 64 && y >= by && y < by + 64;
let tex = ((x * 31 + y * 17 + i * 5) % 23) as u8;
v[o] = if in_box { 230 } else { (x * 200 / w) as u8 + tex };
v[o + 1] = if in_box { 40 } else { (y * 200 / h) as u8 + tex };
v[o + 2] = if in_box { 60 } else { 90 + tex };
v[o + 3] = 255;
}
}
v
}
fn config(w: u32, h: u32) -> Config {
Config {
width: w,
height: h,
fps: (24, 1),
bitrate_kbps: 6000,
max_bitrate_kbps: 9000,
cbr: false,
keyint: 24,
profile: Profile::HevcMain,
level: None,
sar: None,
bframes: true,
}
}
/// NVENC sessions are a limited resource on consumer GPUs: one test at a time.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
type Sources = Vec<(Vec<u8>, Vec<u8>, Vec<u8>)>;
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;
}
};
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());
for i in 0..frames {
filmcraft_export::rgba_to_yuv420_8(&rgba(w, h, i), w, h, &mut y, &mut u, &mut v);
sources.push((y.clone(), u.clone(), v.clone()));
packets.extend(enc.encode(&y, &u, &v, i as u64).unwrap());
}
packets.extend(enc.flush().unwrap());
Some((enc, packets, sources))
}
fn psnr(a: &[u8], b: &[u8]) -> 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 * (255.0f64 * 255.0 / mse).log10() }
}
/// The NAL unit types of a length-prefixed sample.
fn sample_nal_types(sample: &[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"));
pos += n;
}
assert_eq!(pos, sample.len(), "samples are made of whole NAL units");
out
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn the_stream_decodes_to_the_source() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let (w, h) = (1280u32, 720u32);
let Some((enc, packets, sources)) = encode_all(&config(w, h), 72) else { return };
assert_eq!(enc.codec(), Codec::Hevc);
assert_eq!(packets.len(), 72, "one packet per picture");
// timestamps: decoding order, presentation = frame number, dts = k - delay
let delay = i64::from(enc.delay());
for (k, p) in packets.iter().enumerate() {
assert_eq!(p.dts, k as i64 - delay, "dts of packet {k}");
assert!(p.pts >= p.dts, "pts {} before dts {}", p.pts, p.dts);
}
assert!(packets.windows(2).all(|p| p[0].dts < p[1].dts), "dts strictly increases");
let mut pts: Vec<i64> = packets.iter().map(|p| p.pts).collect();
pts.sort_unstable();
assert_eq!(pts, (0..72).collect::<Vec<_>>(), "every picture once");
// keyframes every 24 pictures (IDR), nothing else flagged
let keys: Vec<i64> = packets.iter().filter(|p| p.key).map(|p| p.pts).collect();
assert_eq!(keys, vec![0, 24, 48], "IDR pictures");
eprintln!("B-frame delay {delay}");
// no parameter sets, delimiters or end markers in the samples; the keyframes are IDR slices
for (k, p) in packets.iter().enumerate() {
let types = sample_nal_types(&p.data);
assert!(!types.is_empty(), "packet {k} has no NAL units");
assert!(types.iter().all(|t| !(32..=37).contains(t)), "packet {k} carries NAL types {types:?}");
if p.key {
assert!(types.iter().any(|t| matches!(t, 19 | 20)), "keyframe {k}: {types:?}");
}
}
// the record describes the SPS it came from
let hvcc = enc.hevc_config().expect("an hvcC record").clone();
let (sps_nal, vps_nal) = (enc.parameter_sets().0.to_vec(), enc.vps().to_vec());
eprintln!("VPS {}\nSPS {}\nPPS {}", hex(&vps_nal), hex(&sps_nal), hex(enc.parameter_sets().1));
let rbsp = unescape_rbsp(&sps_nal[2..]);
let sps = Sps::parse(&rbsp).unwrap();
// the coded size is a whole number of coding tree blocks; the conformance window crops it back
assert!(sps.width >= w && sps.height >= h);
let (_, _, shown_w, shown_h) = sps.crop_rect();
assert_eq!((shown_w, shown_h), (w, h));
assert!(sps.max_num_reorder <= enc.delay(), "SPS reorder {} but the dts shift is {}", sps.max_num_reorder, enc.delay());
assert_eq!(&hvcc.to_bytes()[1..13], &rbsp[1..13], "the record's profile / tier / level bytes are the SPS's");
assert_eq!(HevcConfig::parse(&hvcc.to_bytes()).unwrap(), hvcc);
assert_eq!((hvcc.general_profile_idc, hvcc.general_tier_flag, hvcc.chroma_format_idc, hvcc.bit_depth_luma, hvcc.bit_depth_chroma), (1, false, 1, 8, 8));
assert_eq!(usize::from(hvcc.num_temporal_layers), sps.max_sub_layers_minus1 as usize + 1);
assert!(hvcc.arrays.iter().all(|a| a.completeness && a.nalus.len() == 1));
assert_eq!(hvcc.arrays.iter().map(|a| a.nal_type).collect::<Vec<_>>(), vec![32, 33, 34]);
eprintln!(
"hvcC: level {} compat {:#010x} constraints {:#014x} nested {} layers {}",
hvcc.general_level_idc,
hvcc.general_profile_compatibility_flags,
hvcc.general_constraint_indicator_flags,
hvcc.temporal_id_nested,
hvcc.num_temporal_layers
);
// BT.709 limited range, timing of the frame rate (a frame count per tick, not fields)
let vui = sps.vui.clone().expect("a VUI");
assert_eq!((vui.colour_primaries, vui.transfer_characteristics, vui.matrix_coefficients, vui.full_range), (1, 1, 1, false));
assert_eq!(vui.timing, Some((1, 24)), "num_units_in_tick, time_scale");
// our own decoder decodes the samples
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(), 72);
let mut worst = 99.0f64;
let mut worst_chroma = 99.0f64;
for f in &out {
let src = &sources[f.pts as usize];
let filmcraft_frame::PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("8-bit planes") };
worst = worst.min(psnr(&planes[0], &src.0));
worst_chroma = worst_chroma.min(psnr(&planes[1], &src.1)).min(psnr(&planes[2], &src.2));
}
eprintln!("worst luma PSNR {worst:.1} dB, worst chroma {worst_chroma:.1} dB, {} bytes", packets.iter().map(|p| p.data.len()).sum::<usize>());
assert!(worst > 43.0, "luma PSNR {worst:.1} dB");
assert!(worst_chroma > 45.0, "chroma PSNR {worst_chroma:.1} dB");
}
#[test]
fn nothing_to_encode_with_is_an_error_not_a_crash() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC here");
return;
}
// sizes NVENC cannot take, a zero frame rate, an HEVC level that does not exist
for (w, h) in [(0, 0), (641, 360), (640, 361), (16, 16), (u32::MAX, u32::MAX), (100_000, 64)] {
assert!(Nvenc::new(&config(w, h)).is_err(), "{w}x{h}");
}
let mut c = config(640, 360);
c.fps = (0, 1);
assert!(Nvenc::new(&c).is_err());
c.fps = (24, 0);
assert!(Nvenc::new(&c).is_err());
c = config(640, 360);
c.level = Some(42);
assert!(Nvenc::new(&c).is_err());
// a level that exists is taken
c.level = Some(41);
let enc = Nvenc::new(&c).expect("level 4.1");
assert_eq!(enc.hevc_config().map(|h| h.general_level_idc), Some(123));
// pictures smaller than the encoder's size
let mut enc = Nvenc::new(&config(640, 360)).unwrap();
assert!(enc.encode(&[0; 10], &[0; 10], &[0; 10], 0).is_err());
}
#[test]
fn hostile_annex_b_never_panics() {
for s in [&[][..], &[0, 0, 1], &[1, 2, 3], &[0, 0, 1, 0x46, 0x01, 0x50], &[0, 0, 0, 1, 0x40], &[0, 0, 0, 1, 0x40, 0x01]] {
assert!(annex_b_to_length_prefixed_for(Codec::Hevc, s).is_err(), "{s:?}");
}
}
/// 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 hostile_configurations_give_errors_not_crashes() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC here");
return;
}
let base = config(640, 360);
let variants: Vec<(&str, Config)> = vec![
("zero bitrate", Config { bitrate_kbps: 0, max_bitrate_kbps: 0, ..base.clone() }),
("huge bitrate", Config { bitrate_kbps: u32::MAX, max_bitrate_kbps: u32::MAX, ..base.clone() }),
("max below target", Config { bitrate_kbps: 6000, max_bitrate_kbps: 1, ..base.clone() }),
("zero cbr", Config { cbr: true, bitrate_kbps: 0, ..base.clone() }),
("cbr", Config { cbr: true, bitrate_kbps: 4000, ..base.clone() }),
("zero keyint", Config { keyint: 0, ..base.clone() }),
("keyint 2", Config { keyint: 2, ..base.clone() }),
("huge keyint", Config { keyint: u32::MAX, ..base.clone() }),
("keyint 1 (all intra)", Config { keyint: 1, ..base.clone() }),
("huge frame rate", Config { fps: (u32::MAX, 1), ..base.clone() }),
("huge frame duration", Config { fps: (1, u32::MAX), ..base.clone() }),
("huge aspect ratio", Config { sar: Some((u32::MAX, u32::MAX)), ..base.clone() }),
("zero aspect ratio", Config { sar: Some((0, 0)), ..base.clone() }),
("no B-frames", Config { bframes: false, ..base.clone() }),
("smallest size", Config { width: 130, height: 34, ..base.clone() }),
("odd size", Config { width: 321, height: 181, ..base.clone() }),
];
for (name, c) in variants {
match calm(name, || Nvenc::new(&c)) {
Ok(mut enc) => {
let (w, h) = (c.width as usize, c.height as usize);
let (cw, ch) = (w / 2, h / 2);
assert!(calm(name, || enc.encode(&[], &[], &[], 0)).is_err() || w == 0, "{name}: empty planes");
assert!(calm(name, || enc.encode(&vec![0; w * h - 1], &vec![128; cw * ch], &vec![128; cw * ch], 0)).is_err(), "{name}: short luma");
// a few real pictures, then the end; whatever comes out, nothing panics
for i in 0..3u8 {
let _ = calm(name, || enc.encode(&vec![16 + i; w * h], &vec![128; cw * ch], &vec![128; cw * ch], i as u64));
}
let _ = calm(name, || enc.flush());
}
Err(why) => eprintln!("{name}: declined ({why})"),
}
}
}
#[test]
fn an_encoder_dropped_mid_stream_releases_everything() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC here");
return;
}
// more sessions than a consumer GPU allows at once would fail the later ones: drop each early
for round in 0..12 {
let mut enc = Nvenc::new(&config(640, 360)).unwrap_or_else(|e| panic!("round {round}: {e}"));
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
for i in 0..5 {
filmcraft_export::rgba_to_yuv420_8(&rgba(640, 360, i), 640, 360, &mut y, &mut u, &mut v);
enc.encode(&y, &u, &v, i as u64).unwrap();
}
// dropped with pictures in flight and no flush
}
}
#[test]
fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// NVENC wants a GOP longer than the B-frame pattern: short GOPs drop the B-frames, they do not fail
for keyint in [1u32, 2] {
let cfg = Config { keyint, ..config(640, 360) };
let Some((enc, packets, _)) = encode_all(&cfg, 12) else { return };
assert_eq!(enc.delay(), 0, "keyint {keyint}");
assert_eq!(packets.len(), 12);
assert!(packets.iter().enumerate().all(|(k, p)| p.pts == k as i64 && p.dts == p.pts), "keyint {keyint}: no reordering");
let keys: Vec<i64> = packets.iter().filter(|p| p.key).map(|p| p.pts).collect();
assert_eq!(keys, (0..12).step_by(keyint as usize).collect::<Vec<i64>>(), "keyint {keyint}");
let entry = SampleEntry::hevc(enc.hevc_config().expect("an hvcC record").clone(), 640, 360);
let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap();
let mut n = 0;
for p in &packets {
n += dec.decode(&p.data, p.pts).unwrap().len();
}
assert_eq!(n + dec.flush().len(), 12, "keyint {keyint}");
}
}

View File

@ -0,0 +1,303 @@
//! Export as H.265 (HEVC Main, NVENC) through the real export pipeline: MP4 and QuickTime files that
//! ffprobe (an external oracle only) describes as `hvc1` Main 8-bit 4:2:0 BT.709, that ffmpeg decodes
//! without errors and that our own decoder decodes to the pictures of the software H.264 export; the
//! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test
//! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with
//! an HEVC encoder.
#![cfg(target_os = "windows")]
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use filmcraft_export::{
BitrateMode, ColorSignal, ExportSettings, FieldOrder, Format, H264Pass, HardwareEncoding, Multiplexer, 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;
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(());
fn project(w: u32, h: u32, frames: i64) -> (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 seq = p.new_sequence("s", SequenceSettings { width: w, height: h, frame_rate: rate, ..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-hevc-{}", 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 luma_psnr(a: &filmcraft_frame::VideoFrame, b: &filmcraft_frame::VideoFrame) -> f64 {
let (x, y) = (a.luma8(), b.luma8());
let mse = x.iter().zip(&y).map(|(p, q)| (f64::from(*p) - f64::from(*q)).powi(2)).sum::<f64>() / x.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }
}
fn hevc(path: String) -> ExportSettings {
ExportSettings { format: Format::Hevc, path, include_audio: false, bitrate_kbps: 4000, bitrate_mode: BitrateMode::Vbr1Pass, ..Default::default() }
}
/// `key=value` lines of `ffprobe -show_entries` for the first video stream.
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,r_frame_rate,start_time,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()
}
/// Presentation times (in frames at 24 fps) of the packets ffprobe flags as keyframes.
fn keyframes(ffprobe: &Path, path: &str) -> Vec<i64> {
let out = Command::new(ffprobe)
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", path])
.output()
.unwrap();
let mut keys: Vec<i64> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_once(','))
.filter(|(_, flags)| flags.starts_with('K'))
.filter_map(|(t, _)| t.parse::<f64>().ok())
.map(|t| (t * 24.0).round() as i64)
.collect();
keys.sort_unstable();
keys
}
/// ffmpeg decodes the whole file with `-xerror` and says nothing.
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));
}
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)
}
#[test]
fn hevc_exports_play_everywhere() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
assert_eq!(filmcraft_export::available(Format::Hevc), hevc_available(), "the format is available iff NVENC can encode HEVC");
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC here");
return;
}
let (p, seq, m) = project(1280, 720, 72);
// the software H.264 export of the same frames is the reference for our decoder's pictures
let soft_path = tmp("soft.mp4");
let soft = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Off, ..hevc(soft_path.clone()) };
let before = hw_encode_stats();
export(&p, seq, &soft, &m, &Progress::default()).unwrap();
assert_eq!(delta(before), (0, 0, 0), "H.264 with hardware encoding Off never touches NVENC");
let reference = decode(&soft_path);
let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hevc export");
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("nvenc hevc export");
for (name, multiplexer, toggle) in [
("hevc.mp4", Multiplexer::Mp4, HardwareEncoding::Auto),
("hevc.mov", Multiplexer::Mov, HardwareEncoding::Auto),
// HEVC has no software encoder: the toggle does not apply
("hevc-off.mp4", Multiplexer::Mp4, HardwareEncoding::Off),
] {
let path = tmp(name);
let s = ExportSettings { multiplexer, hardware_encoding: toggle, ..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, 72, "{name}");
assert_eq!(delta(before), (72, 1, 0), "{name}: every picture through one NVENC session, nothing declined");
// our own decoder: 72 pictures close to the software export's
let pictures = decode(&path);
assert_eq!(pictures.len(), 72, "{name}");
let worst = pictures.iter().zip(&reference).map(|(a, b)| luma_psnr(a, b)).fold(99.0, f64::min);
eprintln!(
"{name}: {} bytes (software H.264 {}), worst luma PSNR against the software export {worst:.1} dB",
std::fs::metadata(&path).unwrap().len(),
std::fs::metadata(&soft_path).unwrap().len()
);
assert!(worst > 48.0, "{name}: {worst:.1} dB");
let (Some(ffprobe), Some(ffmpeg)) = (&ffprobe, &ffmpeg) else { continue };
let v = probe(ffprobe, &path);
let get = |k: &str| v.get(k).map(String::as_str).unwrap_or("?");
assert_eq!((get("codec_name"), get("profile"), get("codec_tag_string"), get("pix_fmt")), ("hevc", "Main", "hvc1", "yuv420p"), "{name}: {v:?}");
assert_eq!((get("color_space"), get("color_transfer"), get("color_primaries"), get("color_range")), ("bt709", "bt709", "bt709", "tv"), "{name}: {v:?}");
assert_eq!((get("width"), get("height"), get("nb_frames"), get("r_frame_rate")), ("1280", "720", "72", "24/1"), "{name}: {v:?}");
assert!(get("start_time").parse::<f64>().is_ok_and(|t| t.abs() < 1e-3), "{name}: starts at {}", get("start_time"));
assert_eq!(keyframes(ffprobe, &path), vec![0, 48], "{name}: keyframes every 2 s (the default distance)");
assert_ffmpeg_decodes(ffmpeg, &path);
eprintln!("{name}: ffprobe {v:?}, ffmpeg -xerror decodes it cleanly");
}
}
#[test]
fn hevc_export_with_audio() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC here");
return;
}
let (p, seq, m) = project(640, 360, 48);
let path = tmp("hevc-audio.mp4");
let s = ExportSettings { include_audio: true, keyframe_distance: Some(24), ..hevc(path.clone()) };
export(&p, seq, &s, &m, &Progress::default()).expect("export");
let bytes = std::fs::read(&path).unwrap();
let file = filmcraft_isobmff::open(bytes.as_slice()).unwrap();
assert!(file.track_of_kind(filmcraft_isobmff::TrackKind::Video).is_some());
let at = file.track_of_kind(filmcraft_isobmff::TrackKind::Audio).expect("an audio track");
assert!(matches!(&file.tracks[at].entries[0].codec, filmcraft_isobmff::CodecConfig::Aac(_)));
if let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("nvenc hevc export"), filmcraft_testkit::ffmpeg_or_skip("nvenc hevc export")) {
let out = Command::new(&ffprobe)
.args(["-v", "error", "-show_entries", "stream=codec_name,codec_type,codec_tag_string", "-of", "csv=p=0", &path])
.output()
.unwrap();
let streams = String::from_utf8_lossy(&out.stdout).to_string();
assert!(streams.contains("hevc,video,hvc1") && streams.contains("aac,audio,mp4a"), "{streams}");
assert_ffmpeg_decodes(&ffmpeg, &path);
assert_eq!(keyframes(&ffprobe, &path), vec![0, 24]);
}
}
#[test]
fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC here");
return;
}
// 1080 is not a multiple of the 32-pixel coding tree block (the SPS carries a conformance window);
// 642x362 is even but a multiple of nothing
for (w, h) in [(1920u32, 1080u32), (642, 362)] {
let (p, seq, m) = project(w, h, 12);
let path = tmp(&format!("hevc-{w}x{h}.mp4"));
let before = hw_encode_stats();
export(&p, seq, &hevc(path.clone()), &m, &Progress::default()).unwrap_or_else(|e| panic!("{w}x{h}: {e}"));
assert_eq!(delta(before), (12, 1, 0), "{w}x{h}");
let pictures = decode(&path);
assert_eq!(pictures.len(), 12);
assert!(pictures.iter().all(|f| (f.width, f.height) == (w, h)), "{w}x{h}: decoded {}x{}", pictures[0].width, pictures[0].height);
if let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("nvenc hevc export"), filmcraft_testkit::ffmpeg_or_skip("nvenc hevc export"))
{
let v = probe(&ffprobe, &path);
assert_eq!((v["width"].as_str(), v["height"].as_str()), (w.to_string().as_str(), h.to_string().as_str()), "{v:?}");
assert_ffmpeg_decodes(&ffmpeg, &path);
}
}
}
#[test]
fn what_nvenc_cannot_do_is_an_error_that_says_why() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
let rate = FrameRate::FPS_24;
let base = hevc(tmp("declined.mp4"));
// (what, settings, size): each declined by the configuration alone, so even a machine without
// NVENC counts and explains it
let cases: Vec<(&str, ExportSettings, (u32, u32))> = vec![
("HDR (PQ)", ExportSettings { signal: ColorSignal::PQ, ..base.clone() }, (1280, 720)),
("HDR (HLG)", ExportSettings { signal: ColorSignal::HLG, ..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)),
("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..base.clone() }, (1280, 720)),
("MXF", ExportSettings { format: Format::MxfOp1a, ..base.clone() }, (1280, 720)),
("interlaced output", ExportSettings { field_order: FieldOrder::UpperFirst, ..base.clone() }, (1280, 720)),
("wider than an MP4 sample entry", base.clone(), (70_000, 64)),
("taller than an MP4 sample entry", base.clone(), (64, 70_000)),
];
for (what, s, (w, h)) in cases {
let before = hw_encode_stats();
let r = factory(Format::Hevc, w, h, rate, &s);
let Some(Err(e)) = r else { panic!("{what}: expected a declined request, got {}", if r.is_some() { "an encoder" } else { "None" }) };
assert!(e.to_string().contains("NVENC"), "{what}: {e}");
assert_eq!(delta(before), (0, 0, 1), "{what}: counted as declined, no session");
}
// interlaced output is refused by the export's own validation, before any encoder is asked
let (p, seq, m) = project(640, 360, 6);
let before = hw_encode_stats();
let s = ExportSettings { field_order: FieldOrder::UpperFirst, ..hevc(tmp("interlaced.mp4")) };
assert!(export(&p, seq, &s, &m, &Progress::default()).is_err());
assert_eq!(delta(before), (0, 0, 0));
if !hevc_available() {
// no HEVC encoder: the export's own "encoder not available" error, nothing written, no panic
let err = export(&p, seq, &hevc(tmp("none.mp4")), &m, &Progress::default()).expect_err("no encoder").to_string();
assert!(err.contains("encoder not available"), "{err}");
eprintln!("SKIPPED the NVENC-limit cases: no NVENC HEVC here");
return;
}
// sizes outside what the GPU's HEVC encoder takes, through the real export
let (p, seq, m) = project(640, 360, 6);
for (w, h) in [(64u32, 64u32), (16, 16), (10_000, 64)] {
let path = tmp(&format!("small-{w}x{h}.mp4"));
let s = ExportSettings { frame_size: Some((w, h)), ..hevc(path.clone()) };
let before = hw_encode_stats();
let r = export(&p, seq, &s, &m, &Progress::default());
match r {
Err(e) => {
let e = e.to_string();
eprintln!("{w}x{h}: {e}");
assert!(e.contains("NVENC"), "{w}x{h}: {e}");
assert_eq!(delta(before), (0, 0, 1), "{w}x{h}");
}
// a GPU that does take the size: a good file
Ok(_) => assert_eq!(delta(before).1, 1, "{w}x{h}"),
}
}
// the factory itself: an odd size and a size outside the limits are declined, a good one is taken
let before = hw_encode_stats();
assert!(matches!(factory(Format::Hevc, 641, 360, rate, &base), Some(Err(_))));
assert!(matches!(factory(Format::Hevc, 20_000, 20_000, rate, &base), Some(Err(_))));
assert_eq!(delta(before), (0, 0, 2));
let before = hw_encode_stats();
assert!(matches!(factory(Format::Hevc, 640, 360, rate, &base), Some(Ok(_))));
assert_eq!(delta(before), (0, 1, 0));
// H.264 with hardware encoding Off is still not NVENC's business, HEVC's toggle does not matter
let before = hw_encode_stats();
let off = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Off, ..base.clone() };
assert!(factory(Format::H264, 640, 360, rate, &off).is_none());
assert!(matches!(factory(Format::Hevc, 640, 360, rate, &ExportSettings { hardware_encoding: HardwareEncoding::Off, ..base }), Some(Ok(_))));
assert_eq!(delta(before), (0, 1, 0));
}

View File

@ -0,0 +1,29 @@
//! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own
//! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC
//! session and encoder initialisation all included).
#![cfg(target_os = "windows")]
use std::time::{Duration, Instant};
use filmcraft_export::Format;
use filmcraft_platform::nvenc::hevc_available;
#[test]
fn the_probe_runs_once_and_registers_the_format() {
let t0 = Instant::now();
let first = hevc_available();
let cold = t0.elapsed();
let t1 = Instant::now();
let again = hevc_available();
let warm = t1.elapsed();
eprintln!("first HEVC probe in a fresh process: {cold:?} (available: {first}); the cached answer: {warm:?}");
assert_eq!(first, again);
assert!(warm < Duration::from_millis(50), "the answer is cached: {warm:?}");
// `register` makes `available(Hevc)` follow the probe; registering again changes nothing
assert!(!filmcraft_export::available(Format::Hevc) || first);
filmcraft_platform::register();
filmcraft_platform::register();
assert_eq!(filmcraft_export::available(Format::Hevc), first);
assert!(filmcraft_export::available(Format::H264));
}

View File

@ -28,7 +28,9 @@ Containment rules:
`clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`, and `clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`, and
`nvenc::ffi` / `nvenc::session` on Windows) carry `#[allow(unsafe_code)]`; the rest of the crate `nvenc::ffi` / `nvenc::session` on Windows) carry `#[allow(unsafe_code)]`; the rest of the crate
(the fallback logic in `hybrid`, the decoder logic in `media_foundation`, `annexb`, `biplanar`, (the fallback logic in `hybrid`, the decoder logic in `media_foundation`, `annexb`, `biplanar`,
the encoder logic in `nvenc`) has no `unsafe`. the encoder logic in `nvenc`, H.264 and H.265 alike) has no `unsafe`. NVENC's H.265 support
added no `unsafe` module: it reuses `nvenc::ffi` and `nvenc::session` (new data declarations and
a second codec-configuration member of an existing union).
2. Every `unsafe` block has a `// SAFETY:` comment saying why it is sound. 2. Every `unsafe` block has a `// SAFETY:` comment saying why it is sound.
3. The public API is safe: no `pub unsafe fn`, no raw pointers or FFI types in public signatures; 3. The public API is safe: no `pub unsafe fn`, no raw pointers or FFI types in public signatures;
failures are `Result`s. The crate keeps the never-crash `deny(clippy::unwrap_used, …)` failures are `Result`s. The crate keeps the never-crash `deny(clippy::unwrap_used, …)`

View File

@ -72,7 +72,7 @@ and `filmcraft-cli`.
| `engine` | L4 | `Session`, command registry, undo history, media pool, jobs, interchange glue | | `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 | | `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 | | `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 encoding (Windows, opt-in) 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, 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)) |
| `filmcraft` | L6 | desktop binary: eframe/wgpu window, cpal audio output, file dialogs, native macOS menu, TCP control server | | `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-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)) | | `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)) |
@ -273,7 +273,11 @@ 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. stream. The same NVENC session also encodes H.265 (HEVC Main, 8-bit 4:2:0, SDR; the `hvcC` is built
from the SPS the encoder wrote): `register()` hands `platform::nvenc::hevc_available` to
`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
H.265 encoder, what NVENC declines is counted and ends the export with an error that says why.
- **Compositor.** `render` is the reference for monitors, thumbnails and export. `render::plan` - **Compositor.** `render` is the reference for monitors, thumbnails and export. `render::plan`
turns a frame into GPU layers, each with its opacity and blend mode. All 27 blend modes run on the turns a frame into GPU layers, each with its opacity and blend mode. All 27 blend modes run on the
GPU (`filmcraft-gpu`): Normal and Dissolve with fixed-function "over" blending, the others by GPU (`filmcraft-gpu`): Normal and Dissolve with fixed-function "over" blending, the others by

View File

@ -71,11 +71,18 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2).
without a Direct3D 11 video device or the HEVC / VP9 / AV1 codec extensions of the Microsoft Store. The VP9 and without a Direct3D 11 video device or the HEVC / VP9 / AV1 codec extensions of the Microsoft Store. The VP9 and
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`, Windows only) skip without an The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_hevc.rs`,
NVIDIA GPU with NVENC. The FFI layout tests in `crates/platform/src/nvenc/abi_tests.rs` were generated `nvenc_hevc_export.rs` and `nvenc_hevc_probe.rs` for H.265; Windows only) skip, printing `SKIPPED`,
from a C program built with MSVC against NVIDIA's MIT-licensed `nvEncodeAPI.h` (12.1); to regenerate without an NVIDIA GPU with NVENC (H.265: with an HEVC encoder). The FFI layout tests in
them, print the sizes, alignments, offsets, constants and GUIDs of `src/nvenc/ffi.rs` from that `crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC
program and update the asserts. (`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
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
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.
Compare the numbers with the asserts (a difference is drift, which must be understood before
anything is "fixed" to match) and add asserts for anything new.
### Pass criteria per codec ### Pass criteria per codec