M-HW: NVENC on top of the shared hardware-encoding setting (#183); never-crash fixes

Merge origin/main into the NVENC branch. #183 already provides the
HardwareEncoding enum, ExportSettings.hardware_encoding, the
hardwareEncoding export param and the Export > Video > Hardware Encoding
checkbox, so the duplicates from this branch are dropped and NVENC uses
them. filmcraft-export is now an all-platform dependency of
filmcraft-platform (VideoToolbox and NVENC both register encoders).

Never-crash fixes in the NVENC backend:
- submit() rejects a driver input pitch narrower than a row (and unlocks
  the buffer); fill_nv12 uses get_mut instead of slicing rows.
- composition offsets use saturating math and i32::try_from; frame-rate,
  size and timestamp casts are checked; sizes above u16::MAX decline.
- Annex B scanning and NAL type checks use get()/first().
- A failed nvEncOpenEncodeSessionEx that returns a handle destroys it.

ffi.rs carries NVIDIA's MIT notice for nvEncodeAPI.h, listed in
ATTRIBUTION.md under a new "Third-party source code" section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-07 18:16:12 -04:00
co-authored by Claude Opus 5.5
102 changed files with 7655 additions and 654 deletions
+157
View File
@@ -59,6 +59,42 @@ What remains per live decoder is unchanged: the H.264 decoder publishes each mac
picture as its own allocations (`Frame::make_row`, five per row), which is where the 290 MB per
decoder comes from.
## Results (CPU compositor: layer rectangles, an opaque bottom layer and recycled images, before → after)
The CPU compositor is what export runs. In a profile of a 9:29 export it took about a third of the
busy CPU with the built-in H.264 encoder (the encoder took the other two thirds), and a faster
encoder leaves it as the largest cost of an export. It works on premultiplied linear `f32` images,
33 MB each at 1080p, and every layer was one of them: a banner that is transparent except for its
bottom 100 rows still cost a full conversion and a full mix, and the bottom layer paid for a
zero-filled canvas and a pass that mixes it over that canvas.
Apple M1 (8 cores: 4 performance + 4 efficiency), 2026-10-06, a machine that was not idle, so the
two builds ran alternately in the same session and the median is reported.
### Per frame, synthetic 1080p sources
`cargo run --release -p filmcraft-render --example frame_bench`: an 8-bit 4:2:0 camera picture and a
10-bit 4:4:4 banner with alpha only in its bottom 100 rows; `render_sequence` followed by the
conversion to 8-bit RGBA, milliseconds per frame (median of 40 frames, 5 alternating rounds):
| scenario | before | after |
|---|---|---|
| camera only | 8.9 | 6.5 |
| camera + banner | 16.2 | 7.8 |
Both builds give the same picture (same checksum).
### A real export
30 s of a 1080p25 camera clip with a ProRes 4444 banner, exported with the built-in encoder
(deterministic, byte for byte): 46.4 s before, 36.7 s after. **The two exports are the same file**
(same bytes, same MD5), so every frame of the real pipeline (camera decode, ProRes decode,
compositing, conversion) matches. The encoder is most of that time; with a faster one the
compositor's savings are a larger part of the export.
What is left in a frame without overlays is the Y'CbCr → linear float conversion of the camera
picture (4.5 ms in the synthetic case) and the conversion to 8 bits (2 ms).
## Results (GPU2: standard effects on the GPU compositor, #30, before → after)
Before = this change with clips that carry standard effects sent back to the CPU layer path in
@@ -125,6 +161,99 @@ the draft plan's box decimation of the planes, cheap next to software decoding,
CPU cost. Zero-copy upload of the decoded `CVPixelBuffer` into wgpu (no copy-out at all) is the
next step (issue #30).
## Results (HW2: VideoToolbox hardware H.264 encoding, built-in → hardware)
Apple M1 (8 cores, 16 GB), 2026-10-06, single runs on an otherwise idle machine, release build.
Hardware encoding is opt-in per export (`hardware_encoding: auto`); the built-in encoder is unchanged.
### The encoder alone (100 frames of 1920×1080 25 fps camera footage, High, one-pass VBR, keyframe every 50)
Frames go in as planar Y'CbCr and out as compressed frames (NV12 fill, `VTCompressionSessionEncodeFrame`,
flush); quality is the luma PSNR of our software decoder's output against the input.
| target | B-frames | real bitrate | luma PSNR | frames/s |
|---|---|---|---|---|
| 4 Mb/s | off | 3982 kb/s | 41.66 dB | 202 |
| 8 Mb/s | off | 7987 kb/s | 43.44 dB | 202 |
| 16 Mb/s | off | 15972 kb/s | 45.55 dB | 202 |
| 4 Mb/s | on | 4220 kb/s | 41.37 dB | 183 |
| 8 Mb/s | on | 8268 kb/s | 43.41 dB | 197 |
| 16 Mb/s | on | 16388 kb/s | 45.66 dB | 199 |
B-frames buy nothing measurable here (±0.3 dB) and overshoot the target by up to 5 %, so the hardware
encoder runs without them: frames come out in presentation order and the muxer needs no composition
offsets or edit list.
### Whole exports (1080p25 camera clip with ProRes 4444 overlays with alpha, AAC, loudness normalised to −14 LUFS)
A 9:29 timeline (569 s, 14 227 frames): the camera clip, an intro, six banners, three subscribe bars
and an outro.
| encoder | wall time | frames/s | vs real time | file |
|---|---|---|---|---|
| built-in (`filmcraft-h264enc`) | 793 s | 17.9 | 1.4× slower | 1445 MB, 20.0 Mb/s |
| hardware (VideoToolbox) | **311 s** | **45.7** | 1.8× faster | 1417 MB, 19.6 Mb/s |
2.5× faster. The first 134 s of the same timeline, where the overlays are densest (intro, a banner, a
subscribe bar and the outro in 134 s):
| encoder | wall time | frames/s | vs real time | file |
|---|---|---|---|---|
| built-in (`filmcraft-h264enc`) | 395 s | 8.5 | 2.9× slower | 340 MB, 20.0 Mb/s |
| hardware (VideoToolbox) | **84 s** | **39.9** | 1.6× faster | 312 MB, 18.3 Mb/s |
4.7× faster there. The gain depends on what else an export spends its time on: with the encoder
off the CPU, what is left is decoding the camera clip, the CPU compositor (every layer, alpha
included) and the RGBA → Y'CbCr conversion.
The pairs of files have the same frame count and duration, decode in ffmpeg without a message, and
compare at SSIM 0.990 / PSNR 46.7 dB over the 134 s (lowest frame: 45.1 dB) and SSIM 0.989–0.990 /
PSNR 46.3–46.6 dB at six 3-second spots of the full export. Audio is identical.
Where the built-in export's time goes (`sample` on the process during a 20 s export, inclusive CPU
time in the call tree; the figures overlap because work-stealing mixes the tasks): about two thirds
of the busy CPU is in the encoder, about a third in compositing and layer decoding. Moving the encoder to
the media engine removes most of the first and lets the next batch render while the previous one
encodes (the exporter renders a batch of frames in parallel, then encodes it). What is left is the
CPU compositor, the next target of #30 ("GPU export").
## Results (HW3: VideoToolbox hardware H.265 (HEVC) encoding, against hardware H.264)
Apple M1 (8 cores, 16 GB), 2026-10-07, single runs on an otherwise idle machine, release build. Both
encoders are the M1's media engine. H.265 is `Format::Hevc` (Main, 8-bit 4:2:0, one-pass VBR, no
B-frames); H.264 is the opt-in hardware encoder of HW2 (High).
### Speed
The raw encoders (ffmpeg's `hevc_videotoolbox` and `h264_videotoolbox` on a 1080p25 test pattern, 500
frames): 166 and 189 frames/s. H.265 is the codec that compresses further, not the faster one to encode.
The 9:29 timeline of HW2 (camera clip, ProRes 4444 overlays, AAC, loudness normalised to −14 LUFS), 20 Mb/s
H.264 and 10 Mb/s H.265: **204 s and 195 s** (CPU 1042 s and 1029 s), files of 1417 MB and 719 MB. The
same time: the same H.264 export took 193 s and 200 s on other runs. The export is bound by the CPU
compositor and the decoders, not by the encoder, so a different hardware codec does not change it.
### Quality per bit
30 s with a banner (15–45 s of that timeline), exported by FilmCraft as ProRes 422 HQ (the reference)
and by each encoder at several bitrates; ffmpeg's `psnr` and `ssim` of the decoded 4:2:0 pictures against
the reference. Average PSNR over the three planes, and SSIM (higher is better):
| Mb/s | H.264 | H.265 | difference | H.264 Mb/s for H.265's PSNR |
|---|---|---|---|---|
| 20 | 46.88 dB, 0.9902 | 46.91 dB, 0.9903 | +0.03 dB | beyond the range measured |
| 10 | 44.96 dB, 0.9863 | 45.09 dB, 0.9863 | +0.13 dB | 10.5 |
| 6 | 43.72 dB, 0.9833 | 44.31 dB, 0.9843 | +0.59 dB | 7.7 |
| 4 | 42.91 dB, 0.9807 | 43.64 dB, 0.9825 | +0.73 dB | 5.8 |
| 3 | 42.37 dB, 0.9786 | 43.05 dB, 0.9807 | +0.68 dB | 4.3 |
At 10 Mb/s and above the two give the same picture. At 3–6 Mb/s H.265 reaches the same PSNR with 22–31 %
less bitrate. Its worst frame is better at every bitrate (lowest per-frame PSNR 45.9 against 42.1 dB at
20 Mb/s, 43.1 against 41.5 dB at 10, 39.5 against 37.8 dB at 3).
So there is no case for a lower default bitrate: the two formats start at the same 20 Mb/s. H.265
pays off where the file size matters, at 3–6 Mb/s; that 9:29 timeline is 428 MB at 6 Mb/s.
## Results (HW2: Windows Media Foundation / Direct3D 11 hardware decoding, Off → Auto)
Same binary (release build of the HW2 commit), Settings ▸ Playback ▸ Hardware decoding switched
@@ -192,6 +321,34 @@ of 8 s of a 1080p23.976 H.264 clip (191 frames) to H.264 with the default preset
Quality at equal bitrate: see the PR description.
## Results (HW2 follow-up: Windows VP9 and AV1 hardware decoding, Off → Auto)
Same machine and method as the H.264 / HEVC results above (Xeon E5-2680 v4, RTX 5060 driver 617.14,
idle, 2026-10-07, two alternating rounds of `--hw off` / `--hw auto`, the same binary). MFTs:
`VP9VideoExtensionDecoder` and `AV1VideoExtension` (Microsoft Store codec extensions), DXVA on the
GPU's NVDEC (profiles VP9 0 / 2, AV1 main). AV1 fixtures here are libaom (`libsvtav1` is not in this
ffmpeg build; the bench falls back to it) and VP9 are libvpx-vp9, as in the bench's `dec_*` specs.
`cargo xtask bench --sections decode --only dec_vp9 --repeat 3 --hw off|auto` (likewise `dec_av1`),
`--sections playback --only "av1-2160 full" --hw off|auto`.
| codec | size | CPU ms/frame Off → **Auto** | fps Off → **Auto** (best of 3) |
|---|---|---|---|
| VP9 | 1080p | 38-43 → **3.5-3.6** | 60 → **281-283** |
| VP9 | 2160p | 165-169 → **15-16** | 16 → **70-79** |
| AV1 | 1080p | 55-56 → **3.3-3.4** | 22 → **373-374** |
| AV1 | 2160p | 219-221 → **10.9-11.3** | 5.5 → **106-107** |
Every Auto row had `hw frames` = frames × 3 repeats, 3 sessions, 0 fallbacks, 0 declined.
| playback 8 s, Full | shown/dropped Off → **Auto** | CPU ms/frame Off → **Auto** |
|---|---|---|
| VP9 2160p | 11-12/180-181 → **191/1** | 117-124 → **24-27** |
| AV1 2160p | 0/192 → **192/0** | 47.5-47.9 → **17-20** |
Software AV1 decodes 4K at 5.5 fps here, so it never plays in real time; with the hardware decoder it
plays without a drop. As on the other codecs, the hardware ignores draft mode and what is left on the
CPU is the readback and plane conversion.
## Results (GPU1: blend modes on the GPU compositor, #30, before → after)
Before = this change with the old whole-frame CPU fallback for non-Normal blend modes put back