Files
filmcraft/crates/platform/README.md
Brandon ThomasandClaude Opus 5.5 f114b5f10c Revert #783 (Linux H.265 through NVENC): intermittent heap corruption in the CLI
With NVENC registered on Linux, `filmcraft-cli --test cli` aborts in 2 of 45 runs on an RTX 3090
(`malloc(): mismatching next->prev_size`, `malloc(): unaligned fastbin chunk detected`) right
after an export finishes; 0 of 30 runs fail with this reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:15 -04:00

360 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# filmcraft-platform
OS media integration for FilmCraft (layer L5): hardware video decoding through the operating
system's codecs, behind `filmcraft_codecs::VideoDecoder`, and hardware H.264 and H.265 (HEVC)
encoding, behind `filmcraft_export::VideoEncoder`. It holds OS media FFI and nothing else.
It is the one crate of the workspace allowed to contain `unsafe`, under the rules of
[ADR 0001](../../docs/adr/0001-platform-ffi.md) and [AGENTS.md](../../AGENTS.md) §0.3.
```rust
// at startup (the desktop app, filmcraft-cli, the bench)
let availability = filmcraft_platform::register(); // Available("VideoToolbox") on macOS, Available("Media Foundation") on Windows, Available("VA-API") on Linux with a driver
```
## What it does
- **macOS: VideoToolbox H.264 (`avcC`) and HEVC (`hvcC`)**, 8- and 10-bit, 4:2:0 and 4:2:2
(`videotoolbox.rs`). The session is created from the sample entry's parameter sets with a
hardware decoder *required*; samples go in as `CMSampleBuffer`s with asynchronous decompression
(two access units in flight); the output callback copies each NV12 / P010-style biplanar
`CVPixelBuffer` into planar `Yuv8` / `Yuv16` (chroma deinterleaved, 10-bit samples shifted down
from the high bits, cropped to the conformance window when the buffer is the coded size). A
reorder buffer of the stream's own depth (`max_num_reorder_frames` /
`sps_max_num_reorder_pics`) restores presentation order; a run starting at an HEVC CRA leaves
out its RASL pictures, as our decoder does. Each seek (`reset`) starts a fresh session.
- **Windows: Media Foundation + Direct3D 11 / DXVA, H.264 (`avcC`), HEVC (`hvcC`), VP9 (`vpcC`) and
AV1 (`av1C`)**, 8-bit 4:2:0 (H.264 Baseline / Main / High, HEVC Main, VP9 profile 0, AV1 main) and
10-bit 4:2:0 (HEVC Main 10, VP9 profile 2, AV1 main 10)
(`media_foundation/`). A Direct3D-aware decoder MFT (Microsoft's H.264 decoder, the HEVC Video
Extensions' decoder, or a vendor's synchronous hardware MFT) is driven at the level of single
access units, so there is no Source Reader and no second demuxer: the container samples FilmCraft
already read go in as Annex B (`annexb.rs`; parameter sets are put in front of the first sample of
a run). The process-wide Direct3D 11 device (`D3D11_CREATE_DEVICE_VIDEO_SUPPORT`, multithread
protected) is handed to the MFT through an `IMFDXGIDeviceManager`, which makes it decode with DXVA
on the GPU's video engine and return NV12 / P010 Direct3D 11 textures. Each picture is then read
back through a staging texture (**the one GPU to CPU copy**, `gpu.rs` `Readback`) and turned into
planar `Yuv8` / `Yuv16` (`biplanar.rs`: chroma deinterleaved, P010 shifted down, cropped to the
conformance window). The MFT returns pictures in presentation order; a run starting at an HEVC CRA
leaves out its RASL pictures, as our decoder does; `reset` (a seek) flushes the MFT.
- *Hardware is verified, not assumed.* An MFT that is not Direct3D-aware, asynchronous, or does not
provide its own output samples is not used; the GPU's DXVA decoder must list the profile, format
and size (`ID3D11VideoDevice::CheckVideoDecoderFormat` / `GetVideoDecoderConfigCount`); and a
picture that is not a Direct3D 11 texture (what Microsoft's decoders return when they fall back
to software inside the MFT) fails the stream, so `HybridDecoder` continues with our decoder.
Windows' own software decoding is never used in place of ours.
- *Declined up front* (our decoder is used): field-coded H.264, H.264 profiles other than
Baseline / Main / High, 10-bit H.264, 4:2:2 / 4:4:4 / monochrome, HEVC profiles other than
Main / Main Still Picture / Main 10, VP9 profiles 1 / 3 (4:2:2 / 4:4:4 / RGB) and 12-bit, AV1
profiles 1 / 2 and 12-bit, larger than 8192×8192, no Direct3D 11 video device, no DXVA decoder
for the stream on this GPU, no decoder MFT (HEVC, VP9 and AV1 need the *HEVC Video Extensions*,
*VP9 Video Extensions* and *AV1 Video Extension* from the Microsoft Store, which the Windows "N"
editions and some installs lack). Several GPUs' DXVA lacks AV1 profiles 1 / 2 as well.
- *VP9 and AV1* (`codecs::hw::FrameStreamInfo`, `media_foundation/stream.rs`): the container
sample goes in as it is (a VP9 frame or superframe, an AV1 temporal unit; the `av1C` sequence
header goes first after a seek). The MFT outputs only shown pictures, in presentation order, so
hidden alt-ref frames and `show_existing_frame` need nothing special. Picture size and colour
are read from the bitstream the way the software decoders do (`hw_frame::vp9_color` /
`av1_color` are shared with them). A VP9 key frame of another size or format, or an AV1 sequence
header unlike `av1C`'s, hands the stream to the software decoder (`HybridDecoder`).
- `mfplat.dll` is loaded at run time (`mft.rs`), not linked: Windows "N" editions without the Media
Feature Pack still start FilmCraft, which then decodes in software.
- 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
that `HybridDecoder` answers by replaying the run in software.
- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
`nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time,
so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary
CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed.
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go
in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709
limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver
that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on
the encode thread, where it competed with the render of the next frames for the thread pool and
held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe
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
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. The same session,
ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264
8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at
run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the
stream as Annex B access units, so there is no host-side DPB: `cuvid::Session` creates the decoder
in the parser's sequence callback, decodes in its picture callback, and reads each displayed
picture back (NV12 / P016) through the CUDA driver into the same planes as the other backends.
The factory goes in front of VA-API's: what NVDEC declines still reaches VA-API (another GPU),
then the software decoders. With both registered, `perf.stats` names NVDEC as the hardware
backend, also for the streams VA-API decodes. The callback state is a `Box::into_raw` allocation
the parser and `Session` reach through the same pointer, freed in `Drop`. After a start at a CRA picture its RASL pictures are not fed (no
decoder outputs them, and the parser would otherwise show the CRA picture with the last one's
timestamp). A seek recreates the parser and keeps the decoder.
- **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained
Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2`
are loaded at run time (`vaapi/va.rs`; no libva headers needed to build), and each decoder opens
its own display on the first DRM render node (`/dev/dri/renderD128`…) with a working driver.
VA-API decoding is *stateless*: the host parses the stream and keeps the decoded picture buffer,
and the GPU decodes one picture's slice data at a time into a surface. That host side
(`vaapi/h264.rs`, `vaapi/hevc.rs`, safe code) is the software decoders' own: their parameter-set
and slice-header parsers, POC computation and DPBs (`filmcraft_h264::dpb`, `filmcraft_hevc::dpb`,
generic over what a picture is: H.264 reference marking including MMCOs and reference lists with
modifications; HEVC reference picture sets and lists, RASL pictures of a CRA the run starts at
left out; output order). The two decoders therefore make
the same decisions and output the same pictures in the same order, and the parity tests are
bit-exact. Each picture is sent as one picture-parameter buffer, the scaling matrices, and a slice
parameter + slice data buffer per slice (the NAL unit as stored, emulation prevention included).
HEVC pictures are sent the same way per slice segment (scaling lists only when enabled, converted
to raster order). Pictures the DPB outputs are read back right away (`vaGetImage` into an NV12 or
P010 image, then `biplanar.rs`), **the one GPU to CPU copy**. Surfaces: the stream's DPB size plus two.
- *frame_num gaps* (and every start at a non-IDR picture: an open-GOP seek) get "non-existing"
frames like the software decoder's: copies of the latest decoded frame, mid-gray when there is
none (`vaPutImage`). Pictures predicted from them (the leading pictures of an open GOP after a
seek) are concealment in both decoders and can differ: the hardware also reads motion data the
stand-ins do not have. Everything from the seek point on is bit-exact.
- *Declined up front* (our decoder is used): VP9 and AV1 (not through VA-API yet), H.264 profiles
other than Baseline / Main / High, 10-bit H.264, HEVC range extensions and profiles other than
Main / Main 10 / Main Still Picture, 4:2:2 / 4:4:4, field / MBAFF coding, no libva, no render
node or driver, a profile or size the driver does not decode. *Mid-stream errors*
(`HybridDecoder` continues in software): FMO, SP / SI slices, data partitioning, a size or DPB
change, a missing reference picture (HEVC: one the RPS names that was never decoded), more than
15 HEVC reference frames, any driver error.
- An HEVC `flush` ends the stream as in software (references are dropped and the next CRA
leaves out its RASL pictures); the GOP cache always seeks after a flush.
- **Other systems:** `register()` does nothing and returns `Availability::Unavailable`.
- **`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
mid-stream failure (decode error, invalidated session, changed in-band parameter sets) it replays
the samples since the last restart point (IDR / IRAP; for an HEVC CRA the one before, so its RASL
pictures decode) through the software decoder, drops pictures already returned, keeps the ones
the hardware had decoded but not returned, and stays in software for that instance. The replay
log is bounded (600 samples / 256 MB); beyond it one error is returned and the next seek restarts
in software. Streams our decoders cannot decode (HEVC 4:2:2) have no fallback: the error stands.
## Hardware H.264 encoding (macOS)
`videotoolbox_encode.rs` (FFI) wraps a VideoToolbox compression session with a hardware encoder
*required*; `hardware_encode.rs` (safe code) is the `VideoEncoder` adapter and the factory that
`register()` puts in front of the built-in encoders (`filmcraft_export::register_encoder`).
- **Opt-in per export:** `ExportSettings::hardware_encoding` (`Off` | `Auto`; Export ▸ Video ▸
Hardware Encoding, `"hardwareEncoding": "off|auto"` in `file.exportMedia`), off by default. The built-in encoder's
output is byte-identical on every machine; a hardware encoder's depends on the machine.
- **What it takes:** H.264 in MP4 / MOV, 8-bit SDR, even picture sizes up to 8192, Baseline / Main /
High (the level is chosen by the OS), constant or one-pass variable bitrate with the settings'
target and ceiling, the keyframe distance (closed GOPs: every keyframe is an IDR picture).
Pictures are converted exactly like the built-in encoder's (BT.709, limited range), 4:2:0 NV12.
- **No B-frames.** On real 1080p footage the quality is the same with and without them (±0.3 dB at
equal bitrate) and the bitrate lands closer to the target without them, so frame reordering is
off: compressed frames come out in presentation order, with no composition offsets or edit list.
- **What it declines** (the built-in encoder is used, nothing fails): the setting off, other formats,
MXF (Annex B), two-pass VBR, HDR, odd sizes (4:2:0 cannot crop an odd number of samples),
non-square pixels, and any configuration VideoToolbox cannot create a hardware session for
(logged at `info`).
- **A hardware encoder that fails in the middle of an export is an error**, unlike the decoder:
an encoder cannot hand a half-written stream to another one, so the export stops with the reason.
- The first frame is completed straight away: the SPS / PPS the container needs come with the first
compressed frame, and the muxer asks for them after the first group of pictures.
## Hardware H.265 (HEVC) encoding (macOS)
The same session wrapper, created for `kCMVideoCodecType_HEVC` with the HEVC Main profile
(`VtProfile::HevcMain`: the profile also says which codec the session is). `Format::Hevc` has no
built-in encoder, which changes three things from H.264:
- **Choosing the format is the opt-in.** No `hardware_encoding` setting: Export ▸ Format ▸ H.265
(HEVC), or `"format": "hevc"` in `file.exportMedia` (`h265` is accepted too). The format is
listed as available only on a machine with a hardware HEVC encoder: `register()` hands
`hardware_encode::hevc_available` (one small hardware session, about 0.1 s) to
`filmcraft_export::register_format_probe`, and asks it right away on a thread of its own
(`warm_hevc_probe`), so the first draw of the format list does not create the session on the UI
thread.
- **There is no fallback encoder.** What the hardware path does not take is an error, not a
different encoder: two-pass VBR is refused up front (`ExportSettings::validate`), HDR sequences
are exported as SDR (the H.265 path is 8-bit), and odd sizes, non-square pixels or a machine
without the encoder end in "H.265 (HEVC) encoder not available yet".
The sessions are created with a hardware encoder *required*, and `VtEncoder::uses_hardware()` asks
VideoToolbox whether it agrees (`UsingHardwareAcceleratedVideoEncoder`; a test checks it for H.264 and
HEVC), so a requirement dropped by accident could never turn into a silent software encode. Export
mode says "Encoder: Hardware" for H.265, and the summary line "HEVC Main (hardware encoder)".
What it takes is H.264's list: MP4 (`hvc1`, the tag QuickTime reads) or QuickTime, AAC
audio, 8-bit 4:2:0 BT.709 limited range, even sizes up to 8192, constant or one-pass variable
bitrate, the keyframe distance (closed GOPs, IDR pictures), and no B-frames. Keyframes are the
random access points of types 16–21 (BLA, IDR, CRA).
The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (read from the
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.
## 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 level is the lowest
whose **Main tier** limits hold the picture size, sample rate and peak bitrate (H.265 Table A.8,
`nvenc::hevc::main_tier_level`): left to choose, NVENC answered 1080p30 at an 18 Mbit/s peak with
level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main tier. 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, the answer
kept; about half a second in a fresh process, nearly all of it opening the Direct3D 11 device and
the NVENC session, which every NVENC export pays too). `register()` also asks it on a thread of its
own (`nvenc::warm_hevc_probe`), so the first draw of the format list does not wait for it; a
caller that asks while it runs waits for the same answer. The
Hardware encoding toggle governs H.264 only: with H.265 selected NVENC is used whether it is Auto
or Off, because nothing else can encode it.
- **What it writes:** HEVC Main, 8-bit 4:2:0, SDR BT.709 limited range (Main 10 HDR: see below) (the VUI says so, with the
frame rate as `time_scale / num_units_in_tick`), progressive, preset P5 with high-quality tuning,
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:
10-bit on a GPU without it (HDR), interlaced output, two-pass (`ExportSettings::validate` refuses it first), non-square pixels,
MXF, sizes above 65535, odd sizes, sizes outside the GPU's limits (129×33 to 8192×8192 on the RTX
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 (PQ / HLG).** An HDR sequence (Rec. 2100 PQ or HLG working space) exports as HEVC
Main 10 when `settings.sdr` is off and this GPU has a 10-bit HEVC encoder. `register()` hands
`nvenc::hevc_hdr_available` (a Main 10 session, cached, asked on the same background thread as the
8-bit probe and short-circuiting when HEVC itself is missing) to `filmcraft_export::register_hdr_probe`,
and the export job makes the export HDR for `Format::Hevc` only when
`filmcraft_export::hdr_available(Format::Hevc)` says so: on macOS (VideoToolbox is 8-bit) and on GPUs
without 10-bit an HDR sequence is tone-mapped to 8-bit SDR HEVC exactly as before, and `settings.sdr`
still forces that. The pixels are real: `EncoderFrame::hdr` (encoded BT.2020 R'G'B', 3 floats per
pixel) goes through `filmcraft_export::rgbf_to_yuv420_10` (BT.2020 non-constant-luminance matrix,
Y = 64 + 876·Y', C = 512 + 896·C, 2x2 chroma average, NaN counts as 0, values clamp to 0..1 and codes
to 4..1019, wrong-length input is an error) and into a **P010** input buffer (`code << 6`,
little-endian, chroma interleaved after the luma rows, pitch in bytes; a driver pitch under 2 bytes
per pixel is refused). One buffer format (`NV_ENC_BUFFER_FORMAT_YUV420_10BIT`) is used for the
initialisation, the input buffers and every picture; `pixelBitDepthMinus8` is 2, the profile GUID
is Main 10, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` is queried first and a GPU without it declines
with "no 10-bit (HEVC Main 10) support". The VUI says BT.2020 primaries, transfer 16 (PQ) or 18
(HLG), matrix 9, limited range. The `hvcC` is checked against what was asked (`hevc_config(…, 10)`:
profile 2, 10-bit 4:2:0). NVENC chose the High tier for level 4 at 12 Mb/s; the record just says so.
Pictures of the wrong depth for the encoder (an `hdr` picture to a Main encoder, an RGBA one to
Main 10, or a float slice of the wrong length) are errors, never panics.
- **HDR10 static metadata is SEI, not an API.** NVENC 12.1 has no mastering-display field, so PQ
streams get the mastering display colour volume (payload 137) and content light level (144)
messages through `NV_ENC_PIC_PARAMS_HEVC::seiPayloadArray` on every IDR picture, with the values
of `ColorSignal::static_metadata` (BT.2020/D65, 1000 / 0.0001 cd/m², MaxCLL/MaxFALL 0), the same
that the software H.264 encoder's SEI and the `mdcv` / `clli` boxes carry. NVENC writes them as
two prefix SEI NAL units (type 39) in front of the IDR slice, adding the emulation-prevention
byte its `00 00 00 01` needs; nothing is attached to other pictures; HLG has no static metadata and
gets none. The payload bytes and the descriptor array are heap blocks owned by the `Session` for
its whole life (it is moved by value, and the driver may queue a picture), and IDR pictures are
known from a submission counter (`n % gop == 0`). The sample entry carries `colr` (nclx BT.2020,
PQ / HLG, limited), and for PQ `mdcv` and `clli`; 8-bit SDR HEVC is unchanged and has no `colr`
(the VUI carries its description, as in the software H.264 path).
## Guarantees
- **Never undecodable:** the factory declines (returns `None`, so the software decoder is used)
when Settings ▸ Playback ▸ Hardware decoding is Off, for formats it does not take (field-coded
H.264, bit depths other than 8 / 10, 4:4:4 or monochrome, luma / chroma depth mismatch, larger
than 8192×8192; on Windows also 4:2:2 and the profiles listed above; on Linux everything but
8-bit 4:2:0 progressive H.264 and 4:2:0 HEVC Main / Main 10) and when the OS cannot create a hardware session (VideoToolbox), a
GPU-backed decoder (Media Foundation) or a VA-API configuration and context (Linux).
- **Interchangeable:** colour, pixel aspect, pts, presentation order, `is_random_access` and
`is_disposable` come from the software decoders' own helpers (`filmcraft_codecs::hw`,
`video::vui_color`, `sar_par`).
- **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback (and
libva's error-message callback) runs under `catch_unwind`; every `unsafe` block has a `// SAFETY:` comment; the public API is safe.
- **Counted:** `perf.stats` `decode.hardware` (frames, software frames, sessions, declined,
fallbacks; `filmcraft_codecs::hw::hw_stats`) and `backend` (the registered backend's name,
`filmcraft_codecs::hw::hw_backend`). `export.hardware` counts the NVENC encoder's frames, sessions and declines.
## Tests
| test | what |
|---|---|
| `tests/videotoolbox.rs` (macOS) | H.264 High, HEVC Main (open GOP: CRA + RASL) and HEVC Main 10, 640×360 (coded 368: cropping) with B-frames: every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample, mid-stream `flush`, and a full pass after resets; forced mid-stream failures (`VtDecoder::fail_after`) at five points continue with the software decoder's exact output; seeded mutation of samples and parameter sets (bit flips, truncation, corrupt length prefixes) never panics or hangs; HEVC 4:2:2 10-bit is bit-exact with ffmpeg's decode |
| `tests/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software |
| `tests/setting.rs` | Hardware decoding Off gives the software decoder through `make_video_decoder` and the media stack (no hardware frames); Auto gives VideoToolbox where available |
| `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 / Linux, 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 / Linux; fallback also tested without 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_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_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors |
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
| `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity |
| `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages |
| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` |
| `tests/nvdec.rs` (Linux, NVIDIA driver) | H.264 High, HEVC Main (open GOP: CRA + RASL) and Main 10 at 640x360, H.264 and HEVC Main 10 at 1080p: bit-exact with the software decoders over the whole stream, after `reset` + reseek to every sync sample and again after the resets; a forced mid-stream failure continues in software with identical output; interlaced and 10-bit H.264 and 4:2:2 HEVC are declined, as is everything while Hardware decoding is Off; damaged samples (bit flips, truncation, corrupt lengths) never crash or hang |
| `src/nvdec/abi_tests.rs` | FFI structs' sizes, alignments, field offsets and constants against a C compiler's view of NVIDIA's `cuviddec.h` / `nvcuvid.h` (nv-codec-headers) |
| `tests/vaapi.rs` (Linux, VA-API) | HEVC Main (open GOP: CRA + RASL), Main 10, scaling lists, 3 slices with WPP (10-bit), weighted prediction with 4 references, transform skip + AMP + B-pyramid (10-bit, open GOP), Main / Main 10 at 1080p and 2160p (compared picture by picture, little memory); H.264 High (B-pyramid), Constrained Baseline with 3 slices per picture, Main with explicit weighted prediction (P and B) and 4 references, temporal direct, custom scaling matrices (JVT) with the 8x8 transform and 2 slices, an open-GOP stream, and 1080p / 2160p, 640×360 (coded 368: cropping): every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample (after an open-GOP I picture, from the seek point on), a mid-stream `flush` with decoding carrying on, and a full pass after resets; forced mid-stream failures (`VaDecoder::fail_after`) at five points continue with the software decoder's exact output; interlaced and 10-bit H.264, 4:2:2 HEVC and the Off setting are declined; an HEVC mid-GOP flush is not checked (it ends the stream, as in software); seeded mutation of samples (through the hybrid and straight into the hardware decoder) and of `avcC` records never panics or hangs; 40 decoders created and dropped; concurrent decoders; a decoder moving between threads |
| `src/vaapi/tests.rs` (every OS) | The H.264 and HEVC front ends with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct) and x265 streams (open GOP with RASL, B-pyramid, Main 10 with 2 slices and weighted prediction): output order and pts are the software decoder's, also after seeks and a mid-stream flush; every buffer is consistent (current surface free, references decoded and listed once, reference lists of the active length, slice data offsets inside the slice); damaged samples are errors |
| `src/vaapi/abi_tests.rs` | FFI structs' sizes, alignments, field offsets, constants and bit-field positions against a C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23) |
Fixtures are made with ffmpeg into `target/fixtures/platform/` (generator only, never linked);
tests skip without ffmpeg or without a hardware decoder.
## Performance
Hardware H.264 encoding, Apple M1 (8 cores), single runs: 1080p25 camera footage through the
encoder alone 200 fps at 4, 8 and 16 Mb/s (8× real time); a 9:29 timeline (camera clip, ProRes 4444
overlays, AAC, loudness) exported in 311 s against 793 s with the built-in encoder (84 s against 395 s
for its densest 134 s), the outputs at SSIM 0.990 / PSNR 46.5 dB. Details in
[docs/performance.md](../../docs/performance.md).
Hardware H.265 encoding, same machine: no faster than H.264 (the 9:29 timeline in 195 s against
204 s, both bound by the CPU compositor); same picture quality at 10 Mb/s and above, and 22–31 % less
bitrate for the same PSNR at 3–6 Mb/s ([docs/performance.md](../../docs/performance.md), HW3).
Hardware decoding, M4 Pro:
M4 Pro, load 150–190 (`cargo xtask bench --hw off|auto`): CPU per decoded frame H.264 2160p
119 → 4.2 ms, HEVC 2160p 86 → 3.7 ms; decode 35 → 107 fps and 49 → 217 fps; 4K H.264 and HEVC
playback with no dropped frames at Full, 1/2 and 1/4. Details in
[docs/performance.md](../../docs/performance.md).
Hardware decoding, Linux (Intel Iris Xe, iHD driver; `cargo xtask bench --hw off|auto`): CPU per
decoded frame H.264 1080p 104 → 2.3 ms, 2160p 417 → 11.4 ms; HEVC 1080p 46 → 2.3 ms, 2160p
203 → 10.1 ms, Main 10 2160p 212 → 18.4 ms.
Details in [docs/performance.md](../../docs/performance.md).
## Not yet
Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on macOS, Direct3D 11
textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in
VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR
H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from
the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders
from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.
### Linux NVENC sources and limits
The device binding uses NVIDIA's [CUDA Driver API 12.1 primary-context reference](https://docs.nvidia.com/cuda/archive/12.1.0/cuda-driver-api/group__CUDA__PRIMARY__CTX.html)
and the existing [Video Codec SDK 12.1 encode API](https://docs.nvidia.com/video-technologies/video-codec-sdk/12.1/nvenc-video-encoder-api-prog-guide/index.html).
It uses the first CUDA device. On Linux this backend handles H.264 only (H.265 through NVENC is
Windows-only for now); other formats and unsupported H.264 settings use the existing encoder selection.
Linux registers the export factory even without a driver; `register()`'s return value and
`registered()` still describe hardware **decoding**, which this backend does not provide.
Absent / old drivers decline once per export, with an informational log and
`perf.stats` → `export.hardware.declined`; hardware encoding Off never opens the driver.