NC Kinema · Docs

ADR 0001: unsafe OS media FFI in crates/platform, and nowhere else

Context

Kinema is pure Rust and the workspace sets unsafe_code = "forbid". Every codec has a clean-room software decoder, and those remain the reference. But decoding 4K H.264 / HEVC in software costs 85–120 ms of CPU per frame on an M4 Pro, so 4K playback drops frames under load, while every current computer has a hardware video decoder the operating system exposes (VideoToolbox on macOS; Media Foundation / D3D11 on Windows and VA-API on Linux later).

Using it needs FFI into C / Objective-C APIs, which is unsafe Rust. The bindings we use (the objc2-* crates, MIT / Apache-2.0 / Zlib) are generated declarations; nothing GPL/LGPL is linked.

Decision

Allow unsafe in exactly one crate, crates/platform (kinema-platform, layer L5), for one job: OS media FFI (hardware decoding, and hardware encoding through NVENC). Audio output, dialogs, menus and other OS integration do not go in it.

Containment rules:

  1. The crate does not use lints.workspace = true. Its own [lints] table copies the workspace lints except unsafe_code = "deny" (not forbid), and adds clippy::undocumented_unsafe_blocks = "deny". Only the FFI modules (videotoolbox, media_foundation::gpu / media_foundation::mft, nvenc::ffi / nvenc::session / nvenc::device on Windows and 64-bit Linux, and vaapi::va on Linux) carry #[allow(unsafe_code)]; the rest of the crate (the fallback logic in hybrid, the decoder logic in media_foundation, annexb, biplanar, 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.
  3. The public API is safe: no pub unsafe fn, no raw pointers or FFI types in public signatures; failures are Results. The crate keeps the never-crash deny(clippy::unwrap_used, …) attribute of every clean crate.
  4. No panic crosses the FFI boundary: the VideoToolbox output callback runs under catch_unwind and reports a panic as a failed frame. The Windows backend has no callbacks into Rust (it drives a synchronous decoder MFT with ProcessInput / ProcessOutput), so nothing can unwind into COM. NVENC loads the driver library at run time and has no callbacks into Rust either.
  5. The crate compiles on every target. OS bindings are target-specific dependencies (the objc2-* crates on macOS, the official windows crate on Windows: MIT OR Apache-2.0, already in the lockfile through wgpu / winit, with only the Win32 features the backend uses); elsewhere register() reports Unavailable and does nothing. It is not part of the wasm check (L5).

The fallback guarantee

Registering a hardware decoder never makes a file undecodable, and never changes a picture:

Consequences

Addendum (2026-10-06): hardware H.264 encoding

The crate's second use of the FFI is the one this decision named, hardware encoding: a VideoToolbox H.264 encoder behind kinema_export::VideoEncoder (videotoolbox_encode, with the same containment rules; hardware_encode is safe code). It differs from decoding in two ways, both deliberate:

Everything else in this record applies unchanged: unsafe stays in crates/platform, every block has a // SAFETY: comment, no panic crosses the FFI boundary, the public API is safe, and the crate compiles everywhere (register() is a no-op off macOS).

Addendum (2026-10-08): NVENC HEVC Main 10 (HDR)

HDR H.265 added no unsafe module either. nvenc::ffi gained data declarations (the HEVC picture parameters, NV_ENC_SEI_PAYLOAD, the 10-bit buffer format) checked by the generated layout tests, and nvenc::session gained the P010 pitch check and the SEI pointers it hands to the driver, which are heap blocks owned by the session for its whole life (// SAFETY: comments say so). The conversion from float pictures to 10-bit planes is safe code in the export crate.

Addendum (2026-10-07): hardware H.265 (HEVC) encoding

The same VideoToolbox session wrapper also creates HEVC sessions (VtProfile::HevcMain). The rules above hold; HEVC adds one difference. The crate has no software HEVC encoder to fall back to, and there will not be one (pure Rust, clean-room: x265 is GPL), so Format::Hevc exists only where the OS has a hardware encoder. Choosing the format is the opt-in, kinema_export::available asks a probe the platform crate registers, and what the hardware path does not take (two-pass, odd sizes, a machine without the encoder) is an error naming the reason instead of a different encoder.

Addendum (2026-10-08): VA-API hardware decoding on Linux

The Linux backend this decision named: H.264 and HEVC decoding through VA-API (vaapi/). The rules above hold; VA-API differs from the other two backends in three ways.

The fallback guarantee is unchanged: what the hardware path does not take is declined up front or, mid-stream, handed to the software decoder by HybridDecoder.

Addendum: Linux NVENC H.264 encoding

The existing NVENC session is shared with 64-bit Linux. Only device acquisition and driver loading are platform-specific: Windows retains the Direct3D device; Linux retains the first CUDA device's primary context through libcuda.so.1. cuDevicePrimaryCtxRetain does not push a context onto the calling thread's stack. The retained reference is released after the NVENC session and its buffers; other users' references are not reset. libnvidia-encode.so.1 stays loaded with its function table. Both libraries come from the installed NVIDIA driver, not this project. Export registration on Linux does not claim that a hardware decoder is available. The same opt-in and fallback rules apply.

Addendum (2026-10-09): NVDEC hardware decoding on Linux

NVIDIA's proprietary driver has no VA-API of its own (only through the separate libva-nvidia-driver), so Linux gets a second decoder backend, nvdec/, registered in front of VA-API's. The rules above hold: libnvcuvid.so.1 and libcuda.so.1 are loaded at run time, nvdec/ffi.rs is transcribed from NVIDIA's MIT-licensed headers and checked by nvdec/abi_tests.rs, only nvdec::cuvid carries #[allow(unsafe_code)], and the CUDA context is the one nvenc::device already retains for encoding. Unlike VA-API, NVDEC parses the stream itself; its callbacks run inside our parse call and never unwind into C (they record the first error and return 0, and the decoder then fails over to software like any other backend).