HW5: Linux VA-API H.264 hardware decoding (#30)

Stateless VA-API decoding with FilmCraft's own H.264 host side: the
software decoder's SPS/PPS/slice-header parsers and its DPB (now generic
over the picture type in filmcraft-h264) drive the GPU, so reference
marking, reference lists and output order are shared and the pictures are
bit-exact with the software decoder. libva is loaded at run time; no libva
or driver means software decoding as before. frame_num gaps (open-GOP
seeks) get the software decoder's stand-in frames. HybridDecoder keeps the
transparent software fallback.

Tests: hardware parity on 7 H.264 streams incl. 1080p/2160p, seeks,
flushes, forced failures, fuzzing, concurrency; front end against a
recording stand-in on every OS; ABI checks against va.h. Docs: platform
README, ADR 0001 addendum, architecture, performance (H.264 2160p CPU per
frame 417 -> 11 ms on Iris Xe), roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JordyUSA 2026-10-09 08:35:57 +07:00
parent 5231852443
commit 3496e553e7
24 changed files with 2497 additions and 90 deletions

View File

@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason. code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is 3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware `crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
video decoding through VideoToolbox and Media Foundation / Direct3D 11; later encoding and Linux) and does nothing else. It video decoding through VideoToolbox, Media Foundation / Direct3D 11 and VA-API; hardware encoding) and does nothing else. It
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the `// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)). pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).

3
Cargo.lock generated
View File

@ -2097,16 +2097,19 @@ 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-h264",
"filmcraft-isobmff", "filmcraft-isobmff",
"filmcraft-media", "filmcraft-media",
"filmcraft-project", "filmcraft-project",
"filmcraft-render", "filmcraft-render",
"filmcraft-testkit", "filmcraft-testkit",
"filmcraft-time", "filmcraft-time",
"libloading",
"log", "log",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-media", "objc2-core-media",

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: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows (opt-in) ([#30](https://github.com/storytold/filmcraft/issues/30)).
<br> <br>
@ -287,7 +287,7 @@ 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, and for H.264 on Linux (VA-API; HEVC, VP9 and AV1 decode in software there). 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)).
- **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 delivery-codec export; no HEVC or 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.

View File

@ -36,7 +36,7 @@ before choosing work.
| **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 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. |
| **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), and for H.264 on Linux (VA-API: 4K H.264 at 11 ms of CPU per frame instead of 417 ms). 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 decodes only H.264 in hardware (HEVC, VP9 and AV1 there are software) and has no hardware encoder. 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. |
@ -48,7 +48,7 @@ before choosing work.
In priority order. Agents should prefer this work over adding more checklist items. In priority order. Agents should prefer this work over adding more checklist items.
1. **Hardware acceleration** (#30), the most visible gap to users: 1. **Hardware acceleration** (#30), the most visible gap to users:
- hardware decode on Linux (VA-API); - hardware decode on Linux (VA-API): H.264 is in; HEVC, VP9 and AV1 remain;
- 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 on NVENC (Windows, opt-in); HEVC on Windows and Linux remain.
@ -100,7 +100,7 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% | | Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% | | Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% | | Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Linux, and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% | | Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; only H.264 decode in hardware on Linux (VA-API), and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% |
| **Weighted total** | | | **~87%** | | **Weighted total** | | | **~87%** |
## Estimate to parity ## Estimate to parity
@ -154,7 +154,7 @@ 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). Linux: VA-API H.264 decode. Next: VA-API HEVC / VP9 / AV1, 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

View File

@ -388,7 +388,10 @@ mod format_tests {
#[test] #[test]
fn startup_registers_the_hardware_decoders() { fn startup_registers_the_hardware_decoders() {
let hardware = super::register_hardware_decoders(); let hardware = super::register_hardware_decoders();
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}"); // always on macOS and Windows; on Linux when a VA-API driver is there
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
} }
#[test] #[test]

View File

@ -277,6 +277,9 @@ mod tests {
fn startup_registers_the_hardware_decoders_without_a_logger() { fn startup_registers_the_hardware_decoders_without_a_logger() {
assert!(!log::log_enabled!(log::Level::Info)); assert!(!log::log_enabled!(log::Level::Info));
let hardware = super::register_hardware_decoders(); let hardware = super::register_hardware_decoders();
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}"); // always on macOS and Windows; on Linux when a VA-API driver is there
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
} }
} }

View File

@ -955,7 +955,7 @@ static CATEGORIES: &[Category] = &[
b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true), b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true),
f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true), f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true),
Row::Note( Row::Note(
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS) for the H.264 and HEVC streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.", "Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
), ),
], ],
}, },

View File

@ -6,11 +6,11 @@
//! jobs of later pictures block per row on the reference data they need, so several pictures decode //! jobs of later pictures block per row on the reference data they need, so several pictures decode
//! concurrently (frame-level parallelism). //! concurrently (frame-level parallelism).
use crate::dpb::{Dpb, Output, OutputMeta}; use crate::dpb::{Dpb, Output, OutputMeta, RefMark};
use crate::error::{Error, Result, ensure, invalid, unsupported}; use crate::error::{Error, Result, ensure, invalid, unsupported};
use crate::params::{Pps, Sps}; use crate::params::{Pps, Sps};
use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic}; use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
use crate::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, nal_type}; use crate::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, is_new_picture, nal_type};
use crate::slicedec::{PicState, SliceDecoder}; use crate::slicedec::{PicState, SliceDecoder};
use crate::transform::LevelScale; use crate::transform::LevelScale;
use crate::{ColorInfo, Picture}; use crate::{ColorInfo, Picture};
@ -81,7 +81,7 @@ pub struct Decoder {
spss: Vec<Option<Arc<Sps>>>, spss: Vec<Option<Arc<Sps>>>,
ppss: Vec<Option<Arc<Pps>>>, ppss: Vec<Option<Arc<Pps>>>,
nal_length_size: Option<usize>, nal_length_size: Option<usize>,
dpb: Dpb, dpb: Dpb<FrameRef>,
poc_state: PocState, poc_state: PocState,
prev_ref_frame_num: u32, prev_ref_frame_num: u32,
pending: Option<PendingPic>, pending: Option<PendingPic>,
@ -89,7 +89,7 @@ pub struct Decoder {
active_sps: Option<Arc<Sps>>, active_sps: Option<Arc<Sps>>,
ls_cache: Vec<(Arc<Pps>, Arc<LevelScale>)>, ls_cache: Vec<(Arc<Pps>, Arc<LevelScale>)>,
/// Pictures leaving the DPB, converted once their decoding job has finished. /// Pictures leaving the DPB, converted once their decoding job has finished.
out_queue: VecDeque<Output>, out_queue: VecDeque<Output<FrameRef>>,
shared: Arc<Shared>, shared: Arc<Shared>,
#[cfg(feature = "threads")] #[cfg(feature = "threads")]
pool: Option<rayon::ThreadPool>, pool: Option<rayon::ThreadPool>,
@ -106,27 +106,6 @@ impl Default for Decoder {
} }
} }
/// Is `sh` the first slice of a new primary coded picture relative to `prev` (7.4.1.2.4)?
fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool {
if sh.frame_num != prev.frame_num
|| sh.pps_id != prev.pps_id
|| sh.field_pic != prev.field_pic
|| sh.bottom_field != prev.bottom_field
|| (sh.nal_ref_idc == 0) != (prev.nal_ref_idc == 0)
|| sh.idr != prev.idr
|| (sh.idr && prev.idr && sh.idr_pic_id != prev.idr_pic_id)
{
return true;
}
if sps.pic_order_cnt_type == 0 && (sh.pic_order_cnt_lsb != prev.pic_order_cnt_lsb || sh.delta_pic_order_cnt_bottom != prev.delta_pic_order_cnt_bottom) {
return true;
}
if sps.pic_order_cnt_type == 1 && sh.delta_pic_order_cnt != prev.delta_pic_order_cnt {
return true;
}
false
}
/// The worker threads [`Decoder::new`] uses: the available cores (at most 16) with the `threads` /// The worker threads [`Decoder::new`] uses: the available cores (at most 16) with the `threads`
/// feature on native targets, 1 otherwise. /// feature on native targets, 1 otherwise.
pub fn default_threads() -> usize { pub fn default_threads() -> usize {
@ -384,7 +363,9 @@ impl Decoder {
let Some(pending) = self.pending.as_mut() else { let Some(pending) = self.pending.as_mut() else {
return invalid("slice without a started picture"); return invalid("slice without a started picture");
}; };
let refs = self.dpb.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num())?; let refs = self
.dpb
.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num(), |e| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long })?;
if !sh.slice_type.is_intra() { if !sh.slice_type.is_intra() {
ensure!(!refs[0].is_empty(), "no reference pictures available for inter slice"); ensure!(!refs[0].is_empty(), "no reference pictures available for inter slice");
if sh.slice_type.is_b() { if sh.slice_type.is_b() {
@ -464,7 +445,7 @@ impl Decoder {
} }
_ => Planes::gray(mb_w * 16, mb_h * 16), _ => Planes::gray(mb_w * 16, mb_h * 16),
}; };
(Arc::new(Frame::from_planes(id, 0, &planes)), meta.clone()) (Arc::new(Frame::from_planes(id, 0, &planes)), 0, meta.clone())
}; };
let mut upd = |fnum: u32| poc_state.update_gap_frame(fnum, sps); let mut upd = |fnum: u32| poc_state.update_gap_frame(fnum, sps);
self.shared.stats.lock().map(|mut s| s.frame_num_gaps += 1).ok(); self.shared.stats.lock().map(|mut s| s.frame_num_gaps += 1).ok();
@ -516,7 +497,7 @@ impl Decoder {
run_job(frame, slices, &shared, draft); run_job(frame, slices, &shared, draft);
} }
fn emit(&mut self, outs: Vec<Output>) { fn emit(&mut self, outs: Vec<Output<FrameRef>>) {
self.out_queue.extend(outs); self.out_queue.extend(outs);
} }
} }
@ -613,7 +594,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta {
} }
} }
fn make_picture(o: &Output) -> Picture { fn make_picture(o: &Output<FrameRef>) -> Picture {
let (cx, cy, cw, ch) = o.meta.crop; let (cx, cy, cw, ch) = o.meta.crop;
let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize); let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize);
let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch)); let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch));

View File

@ -1,8 +1,11 @@
//! Decoded picture buffer: reference marking (8.2.5), reference list construction (8.2.4) and output //! Decoded picture buffer: reference marking (8.2.5), reference list construction (8.2.4) and output
//! ordering / bumping (C.4). //! ordering / bumping (C.4).
//!
//! The buffer only does bookkeeping on picture headers; what a picture is (`F`: the software
//! decoder's [`crate::picture::FrameRef`], a hardware decoder's surface) is up to the decoder, so
//! hardware decoders driven by the same headers (VA-API) share this logic and output order.
use crate::error::{Result, ensure}; use crate::error::{Result, ensure};
use crate::picture::{FrameRef, RefPic};
use crate::slice::{Mmco, SliceHeader, SliceType}; use crate::slice::{Mmco, SliceHeader, SliceType};
use std::sync::Arc; use std::sync::Arc;
@ -28,8 +31,8 @@ pub struct OutputMeta {
pub draft: bool, pub draft: bool,
} }
pub struct DpbEntry { pub struct DpbEntry<F> {
pub frame: FrameRef, pub frame: F,
pub poc: i32, pub poc: i32,
pub frame_num: u32, pub frame_num: u32,
pub long_term_frame_idx: u32, pub long_term_frame_idx: u32,
@ -39,8 +42,8 @@ pub struct DpbEntry {
pub meta: Arc<OutputMeta>, pub meta: Arc<OutputMeta>,
} }
pub struct Dpb { pub struct Dpb<F> {
pub entries: Vec<DpbEntry>, pub entries: Vec<DpbEntry<F>>,
/// None = "no long-term frame indices". /// None = "no long-term frame indices".
pub max_long_term_frame_idx: Option<u32>, pub max_long_term_frame_idx: Option<u32>,
pub capacity: usize, pub capacity: usize,
@ -48,18 +51,18 @@ pub struct Dpb {
} }
/// A picture leaving the DPB for output. /// A picture leaving the DPB for output.
pub struct Output { pub struct Output<F> {
pub frame: FrameRef, pub frame: F,
pub poc: i32, pub poc: i32,
pub meta: Arc<OutputMeta>, pub meta: Arc<OutputMeta>,
} }
impl Dpb { impl<F: Clone> Dpb<F> {
pub fn new() -> Self { pub fn new() -> Self {
Dpb { entries: Vec::new(), max_long_term_frame_idx: None, capacity: 16, max_reorder: 16 } Dpb { entries: Vec::new(), max_long_term_frame_idx: None, capacity: 16, max_reorder: 16 }
} }
fn frame_num_wrap(e: &DpbEntry, cur_frame_num: u32, max_frame_num: u32) -> i32 { fn frame_num_wrap(e: &DpbEntry<F>, cur_frame_num: u32, max_frame_num: u32) -> i32 {
if e.frame_num > cur_frame_num { e.frame_num as i32 - max_frame_num as i32 } else { e.frame_num as i32 } if e.frame_num > cur_frame_num { e.frame_num as i32 - max_frame_num as i32 } else { e.frame_num as i32 }
} }
@ -73,7 +76,7 @@ impl Dpb {
} }
/// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits. /// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits.
fn bump(&mut self, out: &mut Vec<Output>) -> bool { fn bump(&mut self, out: &mut Vec<Output<F>>) -> bool {
let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else { let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else {
return false; return false;
}; };
@ -85,13 +88,13 @@ impl Dpb {
} }
/// Output everything in POC order and clear non-reference entries. /// Output everything in POC order and clear non-reference entries.
pub fn flush(&mut self, out: &mut Vec<Output>) { pub fn flush(&mut self, out: &mut Vec<Output<F>>) {
while self.bump(out) {} while self.bump(out) {}
self.prune(); self.prune();
} }
/// IDR: mark all references unused; output (or drop) prior pictures. /// IDR: mark all references unused; output (or drop) prior pictures.
pub fn idr(&mut self, no_output_of_prior_pics: bool, out: &mut Vec<Output>) { pub fn idr(&mut self, no_output_of_prior_pics: bool, out: &mut Vec<Output<F>>) {
for e in &mut self.entries { for e in &mut self.entries {
e.mark = RefMark::Unused; e.mark = RefMark::Unused;
} }
@ -122,7 +125,8 @@ impl Dpb {
self.prune(); self.prune();
} }
/// Insert "non-existing" frames for a frame_num gap (8.2.5.2). /// Insert "non-existing" frames for a frame_num gap (8.2.5.2). `make_frame` gives each one's
/// picture and POC.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn fill_frame_num_gap( pub fn fill_frame_num_gap(
&mut self, &mut self,
@ -130,17 +134,17 @@ impl Dpb {
frame_num: u32, frame_num: u32,
max_frame_num: u32, max_frame_num: u32,
max_num_ref_frames: usize, max_num_ref_frames: usize,
make_frame: &mut dyn FnMut(u32) -> (FrameRef, Arc<OutputMeta>), make_frame: &mut dyn FnMut(u32) -> (F, i32, Arc<OutputMeta>),
poc_state: &mut dyn FnMut(u32), poc_state: &mut dyn FnMut(u32),
) { ) {
let mut unused = (prev_ref_frame_num + 1) % max_frame_num; let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
let mut guard = 0; let mut guard = 0;
while unused != frame_num && guard < max_frame_num { while unused != frame_num && guard < max_frame_num {
self.sliding_window(unused, max_frame_num, max_num_ref_frames); self.sliding_window(unused, max_frame_num, max_num_ref_frames);
let (frame, meta) = make_frame(unused); let (frame, poc, meta) = make_frame(unused);
poc_state(unused); poc_state(unused);
self.entries.push(DpbEntry { self.entries.push(DpbEntry {
poc: frame.poc, poc,
frame, frame,
frame_num: unused, frame_num: unused,
long_term_frame_idx: 0, long_term_frame_idx: 0,
@ -154,26 +158,26 @@ impl Dpb {
} }
} }
/// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice. /// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice, each
pub fn build_ref_lists(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32) -> Result<[Vec<RefPic>; 2]> { /// entry made by `to_ref` from the DPB entry it refers to.
let mut lists: [Vec<RefPic>; 2] = [Vec::new(), Vec::new()]; pub fn build_ref_lists<R>(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32, to_ref: impl Fn(&DpbEntry<F>) -> R) -> Result<[Vec<R>; 2]> {
let mut lists: [Vec<R>; 2] = [Vec::new(), Vec::new()];
if sh.slice_type.is_intra() { if sh.slice_type.is_intra() {
return Ok(lists); return Ok(lists);
} }
let cur_fn = sh.frame_num; let cur_fn = sh.frame_num;
let pic_num = |e: &DpbEntry| Self::frame_num_wrap(e, cur_fn, max_frame_num); let pic_num = |e: &DpbEntry<F>| Self::frame_num_wrap(e, cur_fn, max_frame_num);
let short: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect(); let short: Vec<&DpbEntry<F>> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect();
let mut long: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect(); let mut long: Vec<&DpbEntry<F>> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect();
long.sort_by_key(|e| e.long_term_frame_idx); long.sort_by_key(|e| e.long_term_frame_idx);
let to_ref = |e: &&DpbEntry| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long }; let mut entry_lists: [Vec<&DpbEntry<F>>; 2] = [Vec::new(), Vec::new()];
let mut entry_lists: [Vec<&DpbEntry>; 2] = [Vec::new(), Vec::new()];
if sh.slice_type != SliceType::B { if sh.slice_type != SliceType::B {
let mut s = short.clone(); let mut s = short.clone();
s.sort_by_key(|e| std::cmp::Reverse(pic_num(e))); s.sort_by_key(|e| std::cmp::Reverse(pic_num(e)));
entry_lists[0] = s.into_iter().chain(long.iter().copied()).collect(); entry_lists[0] = s.into_iter().chain(long.iter().copied()).collect();
} else { } else {
let mut before: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc < cur_poc).collect(); let mut before: Vec<&DpbEntry<F>> = short.iter().copied().filter(|e| e.poc < cur_poc).collect();
let mut after: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc > cur_poc).collect(); let mut after: Vec<&DpbEntry<F>> = short.iter().copied().filter(|e| e.poc > cur_poc).collect();
before.sort_by_key(|e| std::cmp::Reverse(e.poc)); before.sort_by_key(|e| std::cmp::Reverse(e.poc));
after.sort_by_key(|e| e.poc); after.sort_by_key(|e| e.poc);
entry_lists[0] = before.iter().chain(after.iter()).chain(long.iter()).copied().collect(); entry_lists[0] = before.iter().chain(after.iter()).chain(long.iter()).copied().collect();
@ -188,7 +192,7 @@ impl Dpb {
let nlists = if sh.slice_type == SliceType::B { 2 } else { 1 }; let nlists = if sh.slice_type == SliceType::B { 2 } else { 1 };
for l in 0..nlists { for l in 0..nlists {
let n = sh.num_ref_idx_active[l] as usize; let n = sh.num_ref_idx_active[l] as usize;
let mut list: Vec<Option<&DpbEntry>> = entry_lists[l].iter().copied().map(Some).collect(); let mut list: Vec<Option<&DpbEntry<F>>> = entry_lists[l].iter().copied().map(Some).collect();
list.truncate(n); list.truncate(n);
list.resize(n, None); list.resize(n, None);
if !sh.ref_pic_list_mod[l].is_empty() { if !sh.ref_pic_list_mod[l].is_empty() {
@ -199,7 +203,7 @@ impl Dpb {
for m in &sh.ref_pic_list_mod[l] { for m in &sh.ref_pic_list_mod[l] {
ensure!(ref_idx < n, "too many ref_pic_list_modification entries"); ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
list.push(None); // temporarily one longer list.push(None); // temporarily one longer
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc { let (target, is_long): (Option<&DpbEntry<F>>, bool) = match m.idc {
0 | 1 => { 0 | 1 => {
let d = m.value as i32 + 1; let d = m.value as i32 + 1;
let no_wrap = if m.idc == 0 { let no_wrap = if m.idc == 0 {
@ -249,7 +253,7 @@ impl Dpb {
} }
// Fill missing entries (should not happen in conforming streams) with the first valid entry. // Fill missing entries (should not happen in conforming streams) with the first valid entry.
let fallback = list.iter().flatten().next().copied().or_else(|| self.entries.last()); let fallback = list.iter().flatten().next().copied().or_else(|| self.entries.last());
let out: Vec<RefPic> = list.iter().filter_map(|e| e.or(fallback)).map(|e| to_ref(&e)).collect(); let out: Vec<R> = list.iter().filter_map(|e| e.or(fallback)).map(&to_ref).collect();
lists[l] = out; lists[l] = out;
} }
Ok(lists) Ok(lists)
@ -327,12 +331,12 @@ impl Dpb {
pub fn store_picture( pub fn store_picture(
&mut self, &mut self,
sh: &SliceHeader, sh: &SliceHeader,
frame: FrameRef, frame: F,
poc: i32, poc: i32,
max_frame_num: u32, max_frame_num: u32,
max_num_ref_frames: usize, max_num_ref_frames: usize,
meta: Arc<OutputMeta>, meta: Arc<OutputMeta>,
out: &mut Vec<Output>, out: &mut Vec<Output<F>>,
) { ) {
let mut mark = RefMark::Unused; let mut mark = RefMark::Unused;
let mut long_idx = 0; let mut long_idx = 0;
@ -404,7 +408,7 @@ impl Dpb {
} }
} }
impl Default for Dpb { impl<F: Clone> Default for Dpb<F> {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }

View File

@ -28,7 +28,7 @@ mod cavlc;
mod cavlc_tables; mod cavlc_tables;
mod deblock; mod deblock;
mod decoder; mod decoder;
mod dpb; pub mod dpb;
mod error; mod error;
mod inter; mod inter;
mod intra; mod intra;

View File

@ -387,6 +387,27 @@ pub struct PocState {
prev_frame_num: u32, prev_frame_num: u32,
} }
/// Is `sh` the first slice of a new primary coded picture relative to `prev` (7.4.1.2.4)?
pub fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool {
if sh.frame_num != prev.frame_num
|| sh.pps_id != prev.pps_id
|| sh.field_pic != prev.field_pic
|| sh.bottom_field != prev.bottom_field
|| (sh.nal_ref_idc == 0) != (prev.nal_ref_idc == 0)
|| sh.idr != prev.idr
|| (sh.idr && prev.idr && sh.idr_pic_id != prev.idr_pic_id)
{
return true;
}
if sps.pic_order_cnt_type == 0 && (sh.pic_order_cnt_lsb != prev.pic_order_cnt_lsb || sh.delta_pic_order_cnt_bottom != prev.delta_pic_order_cnt_bottom) {
return true;
}
if sps.pic_order_cnt_type == 1 && sh.delta_pic_order_cnt != prev.delta_pic_order_cnt {
return true;
}
false
}
/// Output of POC computation. /// Output of POC computation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Poc { pub struct Poc {

View File

@ -1,6 +1,6 @@
[package] [package]
name = "filmcraft-platform" name = "filmcraft-platform"
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS), with transparent fallback to the pure-Rust decoders" description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API on Linux), with transparent fallback to the pure-Rust decoders"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@ -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-h264 = { workspace = true }
filmcraft-isobmff = { workspace = true } filmcraft-isobmff = { workspace = true }
filmcraft-time = { workspace = true } filmcraft-time = { workspace = true }
log = { workspace = true } log = { workspace = true }
@ -49,6 +51,11 @@ windows = { version = "0.62", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
] } ] }
# Linux: libva (VA-API) is loaded at run time with libloading (ISC; already in the lockfile through
# wgpu), so building needs no libva headers and a system without libva simply decodes in software.
[target.'cfg(target_os = "linux")'.dependencies]
libloading = "0.8"
[dev-dependencies] [dev-dependencies]
filmcraft-testkit = { workspace = true } filmcraft-testkit = { workspace = true }
filmcraft-media = { workspace = true } filmcraft-media = { workspace = true }

View File

@ -8,7 +8,7 @@ It is the one crate of the workspace allowed to contain `unsafe`, under the rule
```rust ```rust
// at startup (the desktop app, filmcraft-cli, the bench) // at startup (the desktop app, filmcraft-cli, the bench)
let availability = filmcraft_platform::register(); // Available("VideoToolbox") on macOS, Available("Media Foundation") on Windows 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 ## What it does
@ -70,6 +70,30 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
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.
- **Linux: VA-API, H.264 (`avcC`)**, 8-bit 4:2:0 progressive, Constrained Baseline / Main / High
(`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`, safe code) is the software decoder's own: its SPS / PPS / slice-header parsers,
POC computation and DPB (`filmcraft_h264::dpb`, generic over what a picture is: reference marking
including MMCOs, reference lists with modifications, 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).
Pictures the DPB outputs are read back right away (`vaGetImage` into an NV12 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): HEVC, VP9 and AV1 (not through VA-API yet), H.264
profiles other than Baseline / Main / High, 10-bit, 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, any driver error.
- **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
@ -140,13 +164,14 @@ level and flags are the encoder's own. If it is missing the export stops with th
- **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)
when Settings ▸ Playback ▸ Hardware decoding is Off, for formats it does not take (field-coded 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 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) and when the OS cannot than 8192×8192; on Windows also 4:2:2 and the profiles listed above; on Linux everything but
create a hardware session (VideoToolbox) or a GPU-backed decoder (Media Foundation). 8-bit 4:2:0 progressive H.264) 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 - **Interchangeable:** colour, pixel aspect, pts, presentation order, `is_random_access` and
`is_disposable` come from the software decoders' own helpers (`filmcraft_codecs::hw`, `is_disposable` come from the software decoders' own helpers (`filmcraft_codecs::hw`,
`video::vui_color`, `sar_par`). `video::vui_color`, `sar_par`).
- **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback runs under - **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback (and
`catch_unwind`; every `unsafe` block has a `// SAFETY:` comment; the public API is safe. 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, - **Counted:** `perf.stats` `decode.hardware` (frames, software frames, sessions, declined,
fallbacks; `filmcraft_codecs::hw::hw_stats`) and `backend` (the registered backend's name, 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. `filmcraft_codecs::hw::hw_backend`). `export.hardware` counts the NVENC encoder's frames, sessions and declines.
@ -162,6 +187,9 @@ level and flags are the encoder's own. If it is missing the export stops with th
| `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) | | `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/vaapi.rs` (Linux, VA-API) | 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, HEVC and the Off setting are declined; 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 front end with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct): 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` (VA-API 1.23) |
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.
@ -185,9 +213,13 @@ M4 Pro, load 150–190 (`cargo xtask bench --hw off|auto`): CPU per decoded fram
playback with no dropped frames at Full, 1/2 and 1/4. Details in playback with no dropped frames at Full, 1/2 and 1/4. Details in
[docs/performance.md](../../docs/performance.md). [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; decode 133 → 263 fps and 32 → 56 fps.
Details in [docs/performance.md](../../docs/performance.md).
## 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 and 10-bit / HDR HEVC (Main 10) in hardware encoding; HEVC, VP9 and
decoders; field-coded H.264; HEVC, 10-bit and HDR encoding with NVENC, and encoders from other AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; HEVC, 10-bit and HDR encoding with NVENC, and encoders from other
vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.

View File

@ -3,8 +3,9 @@
//! //!
//! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders //! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders
//! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264 //! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264
//! (`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; Media Foundation / DXVA on
//! registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! Windows; VA-API on Linux for H.264 ([`vaapi`]); on other systems (and on Linux without a VA-API
//! driver) 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`].
//! //!
@ -25,10 +26,11 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
// Used by the Windows decoder only; compiled everywhere so their tests run on every system. // Used by the Windows (and, for `biplanar`, Linux) decoders; compiled everywhere so their tests run
// on every system.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))] #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
mod annexb; mod annexb;
#[cfg_attr(not(target_os = "windows"), allow(dead_code))] #[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
mod biplanar; mod biplanar;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod hardware_encode; pub mod hardware_encode;
@ -37,6 +39,8 @@ pub mod hybrid;
pub mod media_foundation; pub mod media_foundation;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub mod nvenc; pub mod nvenc;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub mod vaapi;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[allow(unsafe_code)] #[allow(unsafe_code)]
pub mod videotoolbox; pub mod videotoolbox;
@ -77,7 +81,23 @@ pub fn register() -> Availability {
filmcraft_codecs::hw::set_hw_backend("Media Foundation"); filmcraft_codecs::hw::set_hw_backend("Media Foundation");
Availability::Available("Media Foundation") Availability::Available("Media Foundation")
} }
#[cfg(not(any(target_os = "macos", target_os = "windows")))] #[cfg(target_os = "linux")]
{
match vaapi::va::probe() {
Ok(driver) => {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| log::info!("hardware decoding through VA-API: {driver}"));
filmcraft_codecs::register_video_decoder(vaapi_factory);
filmcraft_codecs::hw::set_hw_backend("VA-API");
Availability::Available("VA-API")
}
Err(why) => {
log::info!("no VA-API hardware decoding: {why}");
Availability::Unavailable("no VA-API driver (libva and a DRM render node)")
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{ {
Availability::Unavailable("no hardware video decoder for this system yet") Availability::Unavailable("no hardware video decoder for this system yet")
} }
@ -93,7 +113,11 @@ pub fn registered() -> bool {
{ {
filmcraft_codecs::video_decoder_registered(media_foundation_factory) filmcraft_codecs::video_decoder_registered(media_foundation_factory)
} }
#[cfg(not(any(target_os = "macos", target_os = "windows")))] #[cfg(target_os = "linux")]
{
filmcraft_codecs::video_decoder_registered(vaapi_factory)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{ {
false false
} }
@ -110,7 +134,11 @@ pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool {
{ {
media_foundation::stream_info(entry).is_some_and(|info| media_foundation::MfDecoder::new(info).is_ok()) media_foundation::stream_info(entry).is_some_and(|info| media_foundation::MfDecoder::new(info).is_ok())
} }
#[cfg(not(any(target_os = "macos", target_os = "windows")))] #[cfg(target_os = "linux")]
{
filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| vaapi::VaDecoder::new(info).is_ok())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{ {
let _ = entry; let _ = entry;
false false
@ -153,3 +181,24 @@ pub fn media_foundation_factory(entry: &filmcraft_isobmff::SampleEntry) -> Optio
} }
} }
} }
/// The VA-API factory: a [`HybridDecoder`] around [`vaapi::VaDecoder`] for H.264 streams this
/// system's VA-API driver decodes, `None` otherwise (other codecs keep the software decoders).
#[cfg(target_os = "linux")]
pub fn vaapi_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft_codecs::Result<Box<dyn filmcraft_codecs::VideoDecoder>>> {
if !filmcraft_codecs::hw::hardware_decoding() {
return None;
}
let info = filmcraft_codecs::hw::NalStreamInfo::from_entry(entry)?.ok()?;
if info.codec != filmcraft_codecs::hw::NalCodec::H264 {
return None;
}
match vaapi::VaDecoder::new(info.clone()) {
Ok(va) => Some(Ok(Box::new(HybridDecoder::new(Box::new(va), entry.clone(), info)))),
Err(why) => {
log::info!("hardware decoding declined for {} video: {why}", entry.codec.name());
filmcraft_codecs::hw::note_hw_declined();
None
}
}
}

View File

@ -0,0 +1,159 @@
//! The VA-API FFI structures against the C compiler's view of libva's `va.h` (VA-API 1.23, libva
//! 2.23): sizes, alignments, field offsets, constants and bit-field positions. Generated by running a
//! C program (built with GCC against the libva-dev headers, x86-64 Linux) that prints them; the
//! header itself is not in this repository. To regenerate, compile a program printing `sizeof` /
//! `offsetof` of the structures of `src/vaapi/ffi.rs`, the constants it uses, and the `value` word of
//! each `seq_fields` / `pic_fields` bit-field set alone, against libva's MIT-licensed headers.
#![allow(clippy::unreadable_literal)]
use super::ffi::*;
use std::mem::{align_of, offset_of, size_of};
#[test]
fn layouts_match_the_header() {
assert_eq!((size_of::<VAConfigAttrib>(), align_of::<VAConfigAttrib>()), (8, 4), "VAConfigAttrib");
assert_eq!(offset_of!(VAConfigAttrib, type_), 0, "VAConfigAttrib.type");
assert_eq!(offset_of!(VAConfigAttrib, value), 4, "VAConfigAttrib.value");
assert_eq!((size_of::<VAPictureH264>(), align_of::<VAPictureH264>()), (36, 4), "VAPictureH264");
assert_eq!(offset_of!(VAPictureH264, picture_id), 0, "VAPictureH264.picture_id");
assert_eq!(offset_of!(VAPictureH264, frame_idx), 4, "VAPictureH264.frame_idx");
assert_eq!(offset_of!(VAPictureH264, flags), 8, "VAPictureH264.flags");
assert_eq!(offset_of!(VAPictureH264, TopFieldOrderCnt), 12, "VAPictureH264.TopFieldOrderCnt");
assert_eq!(offset_of!(VAPictureH264, BottomFieldOrderCnt), 16, "VAPictureH264.BottomFieldOrderCnt");
assert_eq!(offset_of!(VAPictureH264, va_reserved), 20, "VAPictureH264.va_reserved");
assert_eq!((size_of::<VAPictureParameterBufferH264>(), align_of::<VAPictureParameterBufferH264>()), (672, 4), "VAPictureParameterBufferH264");
assert_eq!(offset_of!(VAPictureParameterBufferH264, CurrPic), 0, "VAPictureParameterBufferH264.CurrPic");
assert_eq!(offset_of!(VAPictureParameterBufferH264, ReferenceFrames), 36, "VAPictureParameterBufferH264.ReferenceFrames");
assert_eq!(offset_of!(VAPictureParameterBufferH264, picture_width_in_mbs_minus1), 612, "VAPictureParameterBufferH264.picture_width_in_mbs_minus1");
assert_eq!(offset_of!(VAPictureParameterBufferH264, picture_height_in_mbs_minus1), 614, "VAPictureParameterBufferH264.picture_height_in_mbs_minus1");
assert_eq!(offset_of!(VAPictureParameterBufferH264, bit_depth_luma_minus8), 616, "VAPictureParameterBufferH264.bit_depth_luma_minus8");
assert_eq!(offset_of!(VAPictureParameterBufferH264, bit_depth_chroma_minus8), 617, "VAPictureParameterBufferH264.bit_depth_chroma_minus8");
assert_eq!(offset_of!(VAPictureParameterBufferH264, num_ref_frames), 618, "VAPictureParameterBufferH264.num_ref_frames");
assert_eq!(offset_of!(VAPictureParameterBufferH264, seq_fields), 620, "VAPictureParameterBufferH264.seq_fields");
assert_eq!(offset_of!(VAPictureParameterBufferH264, num_slice_groups_minus1), 624, "VAPictureParameterBufferH264.num_slice_groups_minus1");
assert_eq!(offset_of!(VAPictureParameterBufferH264, slice_group_map_type), 625, "VAPictureParameterBufferH264.slice_group_map_type");
assert_eq!(offset_of!(VAPictureParameterBufferH264, slice_group_change_rate_minus1), 626, "VAPictureParameterBufferH264.slice_group_change_rate_minus1");
assert_eq!(offset_of!(VAPictureParameterBufferH264, pic_init_qp_minus26), 628, "VAPictureParameterBufferH264.pic_init_qp_minus26");
assert_eq!(offset_of!(VAPictureParameterBufferH264, pic_init_qs_minus26), 629, "VAPictureParameterBufferH264.pic_init_qs_minus26");
assert_eq!(offset_of!(VAPictureParameterBufferH264, chroma_qp_index_offset), 630, "VAPictureParameterBufferH264.chroma_qp_index_offset");
assert_eq!(offset_of!(VAPictureParameterBufferH264, second_chroma_qp_index_offset), 631, "VAPictureParameterBufferH264.second_chroma_qp_index_offset");
assert_eq!(offset_of!(VAPictureParameterBufferH264, pic_fields), 632, "VAPictureParameterBufferH264.pic_fields");
assert_eq!(offset_of!(VAPictureParameterBufferH264, frame_num), 636, "VAPictureParameterBufferH264.frame_num");
assert_eq!(offset_of!(VAPictureParameterBufferH264, va_reserved), 640, "VAPictureParameterBufferH264.va_reserved");
assert_eq!((size_of::<VAIQMatrixBufferH264>(), align_of::<VAIQMatrixBufferH264>()), (240, 4), "VAIQMatrixBufferH264");
assert_eq!(offset_of!(VAIQMatrixBufferH264, ScalingList4x4), 0, "VAIQMatrixBufferH264.ScalingList4x4");
assert_eq!(offset_of!(VAIQMatrixBufferH264, ScalingList8x8), 96, "VAIQMatrixBufferH264.ScalingList8x8");
assert_eq!(offset_of!(VAIQMatrixBufferH264, va_reserved), 224, "VAIQMatrixBufferH264.va_reserved");
assert_eq!((size_of::<VASliceParameterBufferH264>(), align_of::<VASliceParameterBufferH264>()), (3128, 4), "VASliceParameterBufferH264");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_size), 0, "VASliceParameterBufferH264.slice_data_size");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_offset), 4, "VASliceParameterBufferH264.slice_data_offset");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_flag), 8, "VASliceParameterBufferH264.slice_data_flag");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_bit_offset), 12, "VASliceParameterBufferH264.slice_data_bit_offset");
assert_eq!(offset_of!(VASliceParameterBufferH264, first_mb_in_slice), 14, "VASliceParameterBufferH264.first_mb_in_slice");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_type), 16, "VASliceParameterBufferH264.slice_type");
assert_eq!(offset_of!(VASliceParameterBufferH264, direct_spatial_mv_pred_flag), 17, "VASliceParameterBufferH264.direct_spatial_mv_pred_flag");
assert_eq!(offset_of!(VASliceParameterBufferH264, num_ref_idx_l0_active_minus1), 18, "VASliceParameterBufferH264.num_ref_idx_l0_active_minus1");
assert_eq!(offset_of!(VASliceParameterBufferH264, num_ref_idx_l1_active_minus1), 19, "VASliceParameterBufferH264.num_ref_idx_l1_active_minus1");
assert_eq!(offset_of!(VASliceParameterBufferH264, cabac_init_idc), 20, "VASliceParameterBufferH264.cabac_init_idc");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_qp_delta), 21, "VASliceParameterBufferH264.slice_qp_delta");
assert_eq!(offset_of!(VASliceParameterBufferH264, disable_deblocking_filter_idc), 22, "VASliceParameterBufferH264.disable_deblocking_filter_idc");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_alpha_c0_offset_div2), 23, "VASliceParameterBufferH264.slice_alpha_c0_offset_div2");
assert_eq!(offset_of!(VASliceParameterBufferH264, slice_beta_offset_div2), 24, "VASliceParameterBufferH264.slice_beta_offset_div2");
assert_eq!(offset_of!(VASliceParameterBufferH264, RefPicList0), 28, "VASliceParameterBufferH264.RefPicList0");
assert_eq!(offset_of!(VASliceParameterBufferH264, RefPicList1), 1180, "VASliceParameterBufferH264.RefPicList1");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_log2_weight_denom), 2332, "VASliceParameterBufferH264.luma_log2_weight_denom");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_log2_weight_denom), 2333, "VASliceParameterBufferH264.chroma_log2_weight_denom");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l0_flag), 2334, "VASliceParameterBufferH264.luma_weight_l0_flag");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l0), 2336, "VASliceParameterBufferH264.luma_weight_l0");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_offset_l0), 2400, "VASliceParameterBufferH264.luma_offset_l0");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l0_flag), 2464, "VASliceParameterBufferH264.chroma_weight_l0_flag");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l0), 2466, "VASliceParameterBufferH264.chroma_weight_l0");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_offset_l0), 2594, "VASliceParameterBufferH264.chroma_offset_l0");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l1_flag), 2722, "VASliceParameterBufferH264.luma_weight_l1_flag");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l1), 2724, "VASliceParameterBufferH264.luma_weight_l1");
assert_eq!(offset_of!(VASliceParameterBufferH264, luma_offset_l1), 2788, "VASliceParameterBufferH264.luma_offset_l1");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l1_flag), 2852, "VASliceParameterBufferH264.chroma_weight_l1_flag");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l1), 2854, "VASliceParameterBufferH264.chroma_weight_l1");
assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_offset_l1), 2982, "VASliceParameterBufferH264.chroma_offset_l1");
assert_eq!(offset_of!(VASliceParameterBufferH264, va_reserved), 3112, "VASliceParameterBufferH264.va_reserved");
assert_eq!((size_of::<VAImageFormat>(), align_of::<VAImageFormat>()), (48, 4), "VAImageFormat");
assert_eq!(offset_of!(VAImageFormat, fourcc), 0, "VAImageFormat.fourcc");
assert_eq!(offset_of!(VAImageFormat, byte_order), 4, "VAImageFormat.byte_order");
assert_eq!(offset_of!(VAImageFormat, bits_per_pixel), 8, "VAImageFormat.bits_per_pixel");
assert_eq!(offset_of!(VAImageFormat, depth), 12, "VAImageFormat.depth");
assert_eq!(offset_of!(VAImageFormat, red_mask), 16, "VAImageFormat.red_mask");
assert_eq!(offset_of!(VAImageFormat, green_mask), 20, "VAImageFormat.green_mask");
assert_eq!(offset_of!(VAImageFormat, blue_mask), 24, "VAImageFormat.blue_mask");
assert_eq!(offset_of!(VAImageFormat, alpha_mask), 28, "VAImageFormat.alpha_mask");
assert_eq!(offset_of!(VAImageFormat, va_reserved), 32, "VAImageFormat.va_reserved");
assert_eq!((size_of::<VAImage>(), align_of::<VAImage>()), (120, 4), "VAImage");
assert_eq!(offset_of!(VAImage, image_id), 0, "VAImage.image_id");
assert_eq!(offset_of!(VAImage, format), 4, "VAImage.format");
assert_eq!(offset_of!(VAImage, buf), 52, "VAImage.buf");
assert_eq!(offset_of!(VAImage, width), 56, "VAImage.width");
assert_eq!(offset_of!(VAImage, height), 58, "VAImage.height");
assert_eq!(offset_of!(VAImage, data_size), 60, "VAImage.data_size");
assert_eq!(offset_of!(VAImage, num_planes), 64, "VAImage.num_planes");
assert_eq!(offset_of!(VAImage, pitches), 68, "VAImage.pitches");
assert_eq!(offset_of!(VAImage, offsets), 80, "VAImage.offsets");
assert_eq!(offset_of!(VAImage, num_palette_entries), 92, "VAImage.num_palette_entries");
assert_eq!(offset_of!(VAImage, entry_bytes), 96, "VAImage.entry_bytes");
assert_eq!(offset_of!(VAImage, component_order), 100, "VAImage.component_order");
assert_eq!(offset_of!(VAImage, va_reserved), 104, "VAImage.va_reserved");
}
#[test]
fn constants_match_the_header() {
assert_eq!(VA_STATUS_SUCCESS as i64, 0, "VA_STATUS_SUCCESS");
assert_eq!(VA_STATUS_ERROR_UNSUPPORTED_PROFILE as i64, 12, "VA_STATUS_ERROR_UNSUPPORTED_PROFILE");
assert_eq!(VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT as i64, 13, "VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT");
assert_eq!(VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT as i64, 14, "VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT");
assert_eq!(VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED as i64, 19, "VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED");
assert_eq!(VA_STATUS_ERROR_DECODING_ERROR as i64, 23, "VA_STATUS_ERROR_DECODING_ERROR");
assert_eq!(VA_INVALID_ID as i64, 4294967295, "VA_INVALID_ID");
assert_eq!(VA_INVALID_SURFACE as i64, 4294967295, "VA_INVALID_SURFACE");
assert_eq!(VAProfileH264Main as i64, 6, "VAProfileH264Main");
assert_eq!(VAProfileH264High as i64, 7, "VAProfileH264High");
assert_eq!(VAProfileH264ConstrainedBaseline as i64, 13, "VAProfileH264ConstrainedBaseline");
assert_eq!(VAEntrypointVLD as i64, 1, "VAEntrypointVLD");
assert_eq!(VAConfigAttribRTFormat as i64, 0, "VAConfigAttribRTFormat");
assert_eq!(VA_RT_FORMAT_YUV420 as i64, 1, "VA_RT_FORMAT_YUV420");
assert_eq!(VA_PROGRESSIVE as i64, 1, "VA_PROGRESSIVE");
assert_eq!(VAPictureParameterBufferType as i64, 0, "VAPictureParameterBufferType");
assert_eq!(VAIQMatrixBufferType as i64, 1, "VAIQMatrixBufferType");
assert_eq!(VASliceParameterBufferType as i64, 4, "VASliceParameterBufferType");
assert_eq!(VASliceDataBufferType as i64, 5, "VASliceDataBufferType");
assert_eq!(VA_SLICE_DATA_FLAG_ALL as i64, 0, "VA_SLICE_DATA_FLAG_ALL");
assert_eq!(VA_PICTURE_H264_INVALID as i64, 1, "VA_PICTURE_H264_INVALID");
assert_eq!(VA_PICTURE_H264_SHORT_TERM_REFERENCE as i64, 8, "VA_PICTURE_H264_SHORT_TERM_REFERENCE");
assert_eq!(VA_PICTURE_H264_LONG_TERM_REFERENCE as i64, 16, "VA_PICTURE_H264_LONG_TERM_REFERENCE");
assert_eq!(VA_FOURCC_NV12 as i64, 842094158, "VA_FOURCC_NV12");
assert_eq!(VA_PADDING_LOW as i64, 4, "VA_PADDING_LOW");
assert_eq!(VA_PADDING_MEDIUM as i64, 8, "VA_PADDING_MEDIUM");
}
/// Each bit-field set alone to its largest value: the word the C compiler makes of it.
#[test]
fn bit_fields_match_the_header() {
assert_eq!(3u32 << seq_bits::CHROMA_FORMAT_IDC, 0x3, "seq_fields.chroma_format_idc");
assert_eq!(1u32 << seq_bits::RESIDUAL_COLOUR_TRANSFORM, 0x4, "seq_fields.residual_colour_transform_flag");
assert_eq!(1u32 << seq_bits::GAPS_IN_FRAME_NUM_ALLOWED, 0x8, "seq_fields.gaps_in_frame_num_value_allowed_flag");
assert_eq!(1u32 << seq_bits::FRAME_MBS_ONLY, 0x10, "seq_fields.frame_mbs_only_flag");
assert_eq!(1u32 << seq_bits::MB_ADAPTIVE_FRAME_FIELD, 0x20, "seq_fields.mb_adaptive_frame_field_flag");
assert_eq!(1u32 << seq_bits::DIRECT_8X8_INFERENCE, 0x40, "seq_fields.direct_8x8_inference_flag");
assert_eq!(1u32 << seq_bits::MIN_LUMA_BI_PRED_SIZE_8X8, 0x80, "seq_fields.MinLumaBiPredSize8x8");
assert_eq!(15u32 << seq_bits::LOG2_MAX_FRAME_NUM_MINUS4, 0xf00, "seq_fields.log2_max_frame_num_minus4");
assert_eq!(3u32 << seq_bits::PIC_ORDER_CNT_TYPE, 0x3000, "seq_fields.pic_order_cnt_type");
assert_eq!(15u32 << seq_bits::LOG2_MAX_PIC_ORDER_CNT_LSB_MINUS4, 0x3c000, "seq_fields.log2_max_pic_order_cnt_lsb_minus4");
assert_eq!(1u32 << seq_bits::DELTA_PIC_ORDER_ALWAYS_ZERO, 0x40000, "seq_fields.delta_pic_order_always_zero_flag");
assert_eq!(1u32 << pic_bits::ENTROPY_CODING_MODE, 0x1, "pic_fields.entropy_coding_mode_flag");
assert_eq!(1u32 << pic_bits::WEIGHTED_PRED, 0x2, "pic_fields.weighted_pred_flag");
assert_eq!(3u32 << pic_bits::WEIGHTED_BIPRED_IDC, 0xc, "pic_fields.weighted_bipred_idc");
assert_eq!(1u32 << pic_bits::TRANSFORM_8X8_MODE, 0x10, "pic_fields.transform_8x8_mode_flag");
assert_eq!(1u32 << pic_bits::FIELD_PIC, 0x20, "pic_fields.field_pic_flag");
assert_eq!(1u32 << pic_bits::CONSTRAINED_INTRA_PRED, 0x40, "pic_fields.constrained_intra_pred_flag");
assert_eq!(1u32 << pic_bits::PIC_ORDER_PRESENT, 0x80, "pic_fields.pic_order_present_flag");
assert_eq!(1u32 << pic_bits::DEBLOCKING_FILTER_CONTROL_PRESENT, 0x100, "pic_fields.deblocking_filter_control_present_flag");
assert_eq!(1u32 << pic_bits::REDUNDANT_PIC_CNT_PRESENT, 0x200, "pic_fields.redundant_pic_cnt_present_flag");
assert_eq!(1u32 << pic_bits::REFERENCE_PIC, 0x400, "pic_fields.reference_pic_flag");
}

View File

@ -0,0 +1,337 @@
// The declarations in this file are transcribed from libva's `va/va.h` and `va/va_drm.h`
// (VA-API 1.23, libva 2.23), which carry this notice:
//
// Copyright (c) 2007-2009 Intel Corporation. All Rights Reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sub license, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice (including the
// next paragraph) shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.
// IN NO EVENT SHALL INTEL AND/OR ITS SUPPLIERS BE LIABLE FOR
// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//! VA-API (`va.h`, `va_drm.h`; MIT-licensed headers by Intel) as `libva.so.2` / `libva-drm.so.2`
//! expect it: the H.264 decode buffers this backend fills in, the image structures it reads back
//! through, and the entry points it calls. Written from the public header; sizes and field offsets
//! are checked against a C compiler's by `abi_tests.rs`.
//!
//! The structures are plain data and compile on every target, so the code that fills them in
//! (`vaapi::h264`) is tested everywhere; only `vaapi::va` loads the library (Linux).
//!
//! FFI module (docs/adr/0001-platform-ffi.md): plain `repr(C)` data, no logic.
#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals, dead_code)]
use std::ffi::{c_char, c_int, c_uint, c_void};
pub type VADisplay = *mut c_void;
pub type VAStatus = c_int;
pub type VAGenericID = c_uint;
pub type VAConfigID = VAGenericID;
pub type VAContextID = VAGenericID;
pub type VASurfaceID = VAGenericID;
pub type VABufferID = VAGenericID;
pub type VAImageID = VAGenericID;
/// `VAProfile` (a C enum: `int`).
pub type VAProfile = c_int;
/// `VAEntrypoint` (a C enum: `int`).
pub type VAEntrypoint = c_int;
/// `VABufferType` (a C enum: `int`).
pub type VABufferType = c_int;
/// `VAConfigAttribType` (a C enum: `int`).
pub type VAConfigAttribType = c_int;
pub type VAMessageCallback = Option<unsafe extern "C" fn(user_context: *mut c_void, message: *const c_char)>;
pub const VA_STATUS_SUCCESS: VAStatus = 0;
pub const VA_STATUS_ERROR_UNSUPPORTED_PROFILE: VAStatus = 0x0c;
pub const VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT: VAStatus = 0x0d;
pub const VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT: VAStatus = 0x0e;
pub const VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED: VAStatus = 0x13;
pub const VA_STATUS_ERROR_DECODING_ERROR: VAStatus = 0x17;
pub const VA_INVALID_ID: VAGenericID = 0xffff_ffff;
pub const VA_INVALID_SURFACE: VASurfaceID = VA_INVALID_ID;
pub const VAProfileH264Main: VAProfile = 6;
pub const VAProfileH264High: VAProfile = 7;
pub const VAProfileH264ConstrainedBaseline: VAProfile = 13;
pub const VAEntrypointVLD: VAEntrypoint = 1;
pub const VAConfigAttribRTFormat: VAConfigAttribType = 0;
pub const VA_RT_FORMAT_YUV420: c_uint = 0x0000_0001;
/// `vaCreateContext` flag: progressive pictures only.
pub const VA_PROGRESSIVE: c_int = 0x1;
pub const VAPictureParameterBufferType: VABufferType = 0;
pub const VAIQMatrixBufferType: VABufferType = 1;
pub const VASliceParameterBufferType: VABufferType = 4;
pub const VASliceDataBufferType: VABufferType = 5;
/// `VASliceParameterBuffer*::slice_data_flag`: the whole slice is in the buffer.
pub const VA_SLICE_DATA_FLAG_ALL: u32 = 0x00;
pub const VA_PICTURE_H264_INVALID: u32 = 0x0000_0001;
pub const VA_PICTURE_H264_SHORT_TERM_REFERENCE: u32 = 0x0000_0008;
pub const VA_PICTURE_H264_LONG_TERM_REFERENCE: u32 = 0x0000_0010;
pub const VA_FOURCC_NV12: u32 = 0x3231_564E;
pub const VA_PADDING_LOW: usize = 4;
pub const VA_PADDING_MEDIUM: usize = 8;
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct VAConfigAttrib {
pub type_: VAConfigAttribType,
pub value: u32,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VAPictureH264 {
pub picture_id: VASurfaceID,
pub frame_idx: u32,
pub flags: u32,
pub TopFieldOrderCnt: i32,
pub BottomFieldOrderCnt: i32,
pub va_reserved: [u32; VA_PADDING_LOW],
}
impl VAPictureH264 {
/// An unused entry (`VA_INVALID_SURFACE`, `VA_PICTURE_H264_INVALID`).
pub const INVALID: Self = Self {
picture_id: VA_INVALID_SURFACE,
frame_idx: 0,
flags: VA_PICTURE_H264_INVALID,
TopFieldOrderCnt: 0,
BottomFieldOrderCnt: 0,
va_reserved: [0; VA_PADDING_LOW],
};
}
/// `VAPictureParameterBufferH264`. `seq_fields` and `pic_fields` are C bit-field unions, stored
/// here as their `value` word (bits allocated from the least significant end, as GCC and Clang do
/// on the little-endian targets libva runs on).
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct VAPictureParameterBufferH264 {
pub CurrPic: VAPictureH264,
pub ReferenceFrames: [VAPictureH264; 16],
pub picture_width_in_mbs_minus1: u16,
pub picture_height_in_mbs_minus1: u16,
pub bit_depth_luma_minus8: u8,
pub bit_depth_chroma_minus8: u8,
pub num_ref_frames: u8,
pub seq_fields: u32,
pub num_slice_groups_minus1: u8,
pub slice_group_map_type: u8,
pub slice_group_change_rate_minus1: u16,
pub pic_init_qp_minus26: i8,
pub pic_init_qs_minus26: i8,
pub chroma_qp_index_offset: i8,
pub second_chroma_qp_index_offset: i8,
pub pic_fields: u32,
pub frame_num: u16,
pub va_reserved: [u32; VA_PADDING_MEDIUM],
}
/// `seq_fields` bit positions (`chroma_format_idc` 2 bits, the log2 fields 4, `pic_order_cnt_type` 2).
pub mod seq_bits {
pub const CHROMA_FORMAT_IDC: u32 = 0;
pub const RESIDUAL_COLOUR_TRANSFORM: u32 = 2;
pub const GAPS_IN_FRAME_NUM_ALLOWED: u32 = 3;
pub const FRAME_MBS_ONLY: u32 = 4;
pub const MB_ADAPTIVE_FRAME_FIELD: u32 = 5;
pub const DIRECT_8X8_INFERENCE: u32 = 6;
pub const MIN_LUMA_BI_PRED_SIZE_8X8: u32 = 7;
pub const LOG2_MAX_FRAME_NUM_MINUS4: u32 = 8;
pub const PIC_ORDER_CNT_TYPE: u32 = 12;
pub const LOG2_MAX_PIC_ORDER_CNT_LSB_MINUS4: u32 = 14;
pub const DELTA_PIC_ORDER_ALWAYS_ZERO: u32 = 18;
}
/// `pic_fields` bit positions (`weighted_bipred_idc` 2 bits).
pub mod pic_bits {
pub const ENTROPY_CODING_MODE: u32 = 0;
pub const WEIGHTED_PRED: u32 = 1;
pub const WEIGHTED_BIPRED_IDC: u32 = 2;
pub const TRANSFORM_8X8_MODE: u32 = 4;
pub const FIELD_PIC: u32 = 5;
pub const CONSTRAINED_INTRA_PRED: u32 = 6;
pub const PIC_ORDER_PRESENT: u32 = 7;
pub const DEBLOCKING_FILTER_CONTROL_PRESENT: u32 = 8;
pub const REDUNDANT_PIC_CNT_PRESENT: u32 = 9;
pub const REFERENCE_PIC: u32 = 10;
}
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct VAIQMatrixBufferH264 {
/// 4x4 scaling lists in raster scan order: Intra Y, Cb, Cr, Inter Y, Cb, Cr.
pub ScalingList4x4: [[u8; 16]; 6],
/// 8x8 scaling lists in raster scan order: Intra Y, Inter Y.
pub ScalingList8x8: [[u8; 64]; 2],
pub va_reserved: [u32; VA_PADDING_LOW],
}
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct VASliceParameterBufferH264 {
pub slice_data_size: u32,
pub slice_data_offset: u32,
pub slice_data_flag: u32,
/// Bits from the start of the NAL unit (its header byte included) to `slice_data()`, counted
/// without emulation prevention bytes; the data buffer itself keeps them.
pub slice_data_bit_offset: u16,
pub first_mb_in_slice: u16,
pub slice_type: u8,
pub direct_spatial_mv_pred_flag: u8,
pub num_ref_idx_l0_active_minus1: u8,
pub num_ref_idx_l1_active_minus1: u8,
pub cabac_init_idc: u8,
pub slice_qp_delta: i8,
pub disable_deblocking_filter_idc: u8,
pub slice_alpha_c0_offset_div2: i8,
pub slice_beta_offset_div2: i8,
pub RefPicList0: [VAPictureH264; 32],
pub RefPicList1: [VAPictureH264; 32],
pub luma_log2_weight_denom: u8,
pub chroma_log2_weight_denom: u8,
pub luma_weight_l0_flag: u8,
pub luma_weight_l0: [i16; 32],
pub luma_offset_l0: [i16; 32],
pub chroma_weight_l0_flag: u8,
pub chroma_weight_l0: [[i16; 2]; 32],
pub chroma_offset_l0: [[i16; 2]; 32],
pub luma_weight_l1_flag: u8,
pub luma_weight_l1: [i16; 32],
pub luma_offset_l1: [i16; 32],
pub chroma_weight_l1_flag: u8,
pub chroma_weight_l1: [[i16; 2]; 32],
pub chroma_offset_l1: [[i16; 2]; 32],
pub va_reserved: [u32; VA_PADDING_LOW],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct VAImageFormat {
pub fourcc: u32,
pub byte_order: u32,
pub bits_per_pixel: u32,
pub depth: u32,
pub red_mask: u32,
pub green_mask: u32,
pub blue_mask: u32,
pub alpha_mask: u32,
pub va_reserved: [u32; VA_PADDING_LOW],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct VAImage {
pub image_id: VAImageID,
pub format: VAImageFormat,
pub buf: VABufferID,
pub width: u16,
pub height: u16,
pub data_size: u32,
pub num_planes: u32,
pub pitches: [u32; 3],
pub offsets: [u32; 3],
pub num_palette_entries: i32,
pub entry_bytes: i32,
pub component_order: [i8; 4],
pub va_reserved: [u32; VA_PADDING_LOW],
}
/// The `libva.so.2` entry points used (all `VAStatus`-returning unless noted).
pub type vaInitialize = unsafe extern "C" fn(dpy: VADisplay, major: *mut c_int, minor: *mut c_int) -> VAStatus;
pub type vaTerminate = unsafe extern "C" fn(dpy: VADisplay) -> VAStatus;
pub type vaErrorStr = unsafe extern "C" fn(status: VAStatus) -> *const c_char;
pub type vaQueryVendorString = unsafe extern "C" fn(dpy: VADisplay) -> *const c_char;
pub type vaSetInfoCallback = unsafe extern "C" fn(dpy: VADisplay, callback: VAMessageCallback, user_context: *mut c_void) -> VAMessageCallback;
pub type vaSetErrorCallback = unsafe extern "C" fn(dpy: VADisplay, callback: VAMessageCallback, user_context: *mut c_void) -> VAMessageCallback;
pub type vaGetConfigAttributes =
unsafe extern "C" fn(dpy: VADisplay, profile: VAProfile, entrypoint: VAEntrypoint, attrib_list: *mut VAConfigAttrib, num_attribs: c_int) -> VAStatus;
pub type vaCreateConfig = unsafe extern "C" fn(
dpy: VADisplay,
profile: VAProfile,
entrypoint: VAEntrypoint,
attrib_list: *mut VAConfigAttrib,
num_attribs: c_int,
config_id: *mut VAConfigID,
) -> VAStatus;
pub type vaDestroyConfig = unsafe extern "C" fn(dpy: VADisplay, config_id: VAConfigID) -> VAStatus;
/// The last two arguments are `VASurfaceAttrib *attrib_list, unsigned int num_attribs`; this
/// backend passes none, so the attribute structure is not declared.
pub type vaCreateSurfaces = unsafe extern "C" fn(
dpy: VADisplay,
format: c_uint,
width: c_uint,
height: c_uint,
surfaces: *mut VASurfaceID,
num_surfaces: c_uint,
attrib_list: *mut c_void,
num_attribs: c_uint,
) -> VAStatus;
pub type vaDestroySurfaces = unsafe extern "C" fn(dpy: VADisplay, surfaces: *mut VASurfaceID, num_surfaces: c_int) -> VAStatus;
pub type vaCreateContext = unsafe extern "C" fn(
dpy: VADisplay,
config_id: VAConfigID,
picture_width: c_int,
picture_height: c_int,
flag: c_int,
render_targets: *mut VASurfaceID,
num_render_targets: c_int,
context: *mut VAContextID,
) -> VAStatus;
pub type vaDestroyContext = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID) -> VAStatus;
pub type vaCreateBuffer = unsafe extern "C" fn(
dpy: VADisplay,
context: VAContextID,
type_: VABufferType,
size: c_uint,
num_elements: c_uint,
data: *mut c_void,
buf_id: *mut VABufferID,
) -> VAStatus;
pub type vaDestroyBuffer = unsafe extern "C" fn(dpy: VADisplay, buffer_id: VABufferID) -> VAStatus;
pub type vaBeginPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID, render_target: VASurfaceID) -> VAStatus;
pub type vaRenderPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID, buffers: *mut VABufferID, num_buffers: c_int) -> VAStatus;
pub type vaEndPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID) -> VAStatus;
pub type vaSyncSurface = unsafe extern "C" fn(dpy: VADisplay, render_target: VASurfaceID) -> VAStatus;
pub type vaCreateImage = unsafe extern "C" fn(dpy: VADisplay, format: *mut VAImageFormat, width: c_int, height: c_int, image: *mut VAImage) -> VAStatus;
pub type vaDestroyImage = unsafe extern "C" fn(dpy: VADisplay, image: VAImageID) -> VAStatus;
pub type vaGetImage =
unsafe extern "C" fn(dpy: VADisplay, surface: VASurfaceID, x: c_int, y: c_int, width: c_uint, height: c_uint, image: VAImageID) -> VAStatus;
pub type vaPutImage = unsafe extern "C" fn(
dpy: VADisplay,
surface: VASurfaceID,
image: VAImageID,
src_x: c_int,
src_y: c_int,
src_width: c_uint,
src_height: c_uint,
dest_x: c_int,
dest_y: c_int,
dest_width: c_uint,
dest_height: c_uint,
) -> VAStatus;
pub type vaMapBuffer = unsafe extern "C" fn(dpy: VADisplay, buf_id: VABufferID, pbuf: *mut *mut c_void) -> VAStatus;
pub type vaUnmapBuffer = unsafe extern "C" fn(dpy: VADisplay, buf_id: VABufferID) -> VAStatus;
/// `libva-drm.so.2`: the display of a DRM device (render node) file descriptor.
pub type vaGetDisplayDRM = unsafe extern "C" fn(fd: c_int) -> VADisplay;

View File

@ -0,0 +1,533 @@
//! The H.264 side of the VA-API decoder: everything a stateless hardware decoder needs from the
//! host, done with the software decoder's own parsers (`filmcraft_h264::params`, `slice`) and its
//! decoded picture buffer (`filmcraft_h264::dpb`: reference marking, reference lists, output
//! order), so both decoders make the same decisions and output the same pictures in the same order.
//!
//! [`Front`] parses each access unit, fills the VA-API picture / matrix / slice buffers and hands
//! them to an [`Accel`] (the libva session on Linux, a recording stand-in in tests), then reads
//! back the pictures the DPB outputs. What the hardware path does not take is an error, so the
//! hybrid decoder replays the run in software: field / MBAFF coding, FMO, SP / SI and data
//! partitioning, stream changes the surfaces were not made for.
//!
//! Safe code; compiled on every target so its logic is tested everywhere.
use std::sync::Arc;
use filmcraft_bitstream::{length_prefixed_nals, unescape_rbsp};
use filmcraft_codecs::{CodecError, DecodedFrame, Result};
use filmcraft_frame::VideoFrame;
use filmcraft_h264::dpb::{Dpb, DpbEntry, Output, OutputMeta, RefMark};
use filmcraft_h264::params::{Pps, Sps};
use filmcraft_h264::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, is_new_picture, nal_type};
use super::ffi::{self, VAIQMatrixBufferH264, VAPictureH264, VAPictureParameterBufferH264, VASliceParameterBufferH264, pic_bits, seq_bits};
/// The hardware a [`Front`] drives: a fixed set of surfaces, picture decoding into one of them and
/// reading one back.
pub trait Accel: Send {
/// The VA surface ids of the decoder's surfaces; [`Front`] refers to them by index.
fn surfaces(&self) -> &[ffi::VASurfaceID];
/// Decode one picture (its slices: parameters and the NAL unit, header byte and emulation
/// prevention included) into surface `target` (an index into [`Accel::surfaces`]).
fn decode(
&mut self,
target: usize,
pic: &VAPictureParameterBufferH264,
iq: &VAIQMatrixBufferH264,
slices: &[(VASliceParameterBufferH264, Vec<u8>)],
) -> std::result::Result<(), String>;
/// The picture in surface `index`, once decoded.
fn read(&mut self, index: usize) -> std::result::Result<VideoFrame, String>;
/// Make surface `index` a copy of surface `from`, or mid-gray (every sample 128) when `None`:
/// the "non-existing" frames of a frame_num gap, as the software decoder makes them.
fn fill(&mut self, index: usize, from: Option<usize>) -> std::result::Result<(), String>;
}
/// A picture in the DPB: its surface, field order counts (after an MMCO 5, relative to itself) and
/// place in decoding order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Surface {
pub index: usize,
pub top: i32,
pub bottom: i32,
pub seq: u64,
}
/// A frame_num gap frame not given a surface yet.
const UNASSIGNED: usize = usize::MAX;
/// The picture whose slices are being collected.
struct Pending {
target: usize,
sps: Arc<Sps>,
pps: Arc<Pps>,
first: SliceHeader,
poc: Poc,
pts: i64,
slices: Vec<(VASliceParameterBufferH264, Vec<u8>)>,
}
/// H.264 front end of a stateless hardware decoder (see the module docs).
pub struct Front<A: Accel> {
accel: A,
length_size: usize,
/// Coded size (macroblocks) the surfaces were made for.
mbs: (u32, u32),
spss: Vec<Option<Arc<Sps>>>,
ppss: Vec<Option<Arc<Pps>>>,
dpb: Dpb<Surface>,
poc_state: PocState,
prev_ref_frame_num: u32,
active_sps: Option<Arc<Sps>>,
pending: Option<Pending>,
/// Pictures made so far (decoding order of [`Surface::seq`]).
seq: u64,
}
fn decode_err(e: impl std::fmt::Display) -> CodecError {
CodecError::Decode(e.to_string())
}
fn unsupported(what: &str) -> CodecError {
CodecError::Decode(format!("the hardware decoder does not take {what}"))
}
impl<A: Accel> Front<A> {
/// A front end for samples with `length_size`-byte NAL length prefixes whose sample entry
/// carried `parameter_sets` (SPS / PPS NAL units), decoding into `accel`'s surfaces of
/// `mbs` macroblocks.
pub fn new(accel: A, length_size: usize, mbs: (u32, u32), parameter_sets: &[Vec<u8>]) -> Result<Self> {
let mut f = Self {
accel,
length_size,
mbs,
spss: vec![None; 32],
ppss: vec![None; 256],
dpb: Dpb::new(),
poc_state: PocState::default(),
prev_ref_frame_num: 0,
active_sps: None,
pending: None,
seq: 0,
};
f.load_parameter_sets(parameter_sets)?;
Ok(f)
}
fn load_parameter_sets(&mut self, sets: &[Vec<u8>]) -> Result<()> {
let mut frames = Vec::new();
for nal in sets {
self.handle_nal(nal, 0, &mut frames)?;
}
Ok(())
}
pub fn accel(&self) -> &A {
&self.accel
}
/// Decode one access unit; pictures that left the DPB come back in output order.
pub fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
let nals = length_prefixed_nals(sample, self.length_size).map_err(decode_err)?;
let mut frames = Vec::new();
for nal in nals {
self.handle_nal(nal, pts, &mut frames)?;
}
// the access unit is complete
self.submit_pending(&mut frames)?;
Ok(frames)
}
/// Output every picture still held for reordering (references stay for what follows).
pub fn flush(&mut self) -> Result<Vec<DecodedFrame>> {
let mut frames = Vec::new();
self.submit_pending(&mut frames)?;
let mut outs = Vec::new();
self.dpb.flush(&mut outs);
self.read_out(outs, &mut frames)?;
Ok(frames)
}
/// Forget every picture (after a seek).
pub fn reset(&mut self) {
self.dpb = Dpb::new();
self.poc_state = PocState::default();
self.prev_ref_frame_num = 0;
self.active_sps = None;
self.pending = None;
}
/// Read back the pictures the DPB output, right away: a surface leaving the DPB is reused by
/// the next picture.
fn read_out(&mut self, outs: Vec<Output<Surface>>, frames: &mut Vec<DecodedFrame>) -> Result<()> {
for o in outs {
let frame = self.accel.read(o.frame.index).map_err(decode_err)?;
frames.push(DecodedFrame { pts: o.meta.pts, frame, draft: false });
}
Ok(())
}
fn handle_nal(&mut self, nal: &[u8], pts: i64, frames: &mut Vec<DecodedFrame>) -> Result<()> {
let Some(&first) = nal.first() else { return Ok(()) };
let rest = nal.get(1..).unwrap_or_default();
let hdr = NalHeader::parse(first).map_err(decode_err)?;
match hdr.nal_unit_type {
nal_type::SPS => {
let sps = Sps::parse(&unescape_rbsp(rest)).map_err(decode_err)?;
let slot = self.spss.get_mut(sps.id as usize).ok_or_else(|| decode_err("SPS id out of range"))?;
*slot = Some(Arc::new(sps));
}
nal_type::PPS => {
let spss: Vec<Option<Sps>> = self.spss.iter().map(|s| s.as_ref().map(|s| (**s).clone())).collect();
let pps = Pps::parse(&unescape_rbsp(rest), &spss).map_err(decode_err)?;
let slot = self.ppss.get_mut(pps.id as usize).ok_or_else(|| decode_err("PPS id out of range"))?;
*slot = Some(Arc::new(pps));
}
nal_type::SLICE | nal_type::IDR => self.handle_slice(nal, hdr, pts, frames)?,
nal_type::SLICE_DPA | nal_type::SLICE_DPB | nal_type::SLICE_DPC => return Err(unsupported("data partitioning")),
nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending(frames)?,
// SEI, AUD, filler, extensions: nothing to decode
_ => {}
}
Ok(())
}
fn handle_slice(&mut self, nal: &[u8], hdr: NalHeader, pts: i64, frames: &mut Vec<DecodedFrame>) -> Result<()> {
let rbsp = unescape_rbsp(nal.get(1..).unwrap_or_default());
let (ppss, spss) = (&self.ppss, &self.spss);
let mut found: Option<(Arc<Pps>, Arc<Sps>)> = None;
let (sh, _, _) = SliceHeader::parse(&rbsp, hdr, |id| {
let missing = |what: String| filmcraft_h264::Error::MissingParameterSet(what);
let pps = ppss.get(id as usize).and_then(Option::as_ref).ok_or_else(|| missing(format!("PPS {id}")))?;
let sps = spss.get(pps.sps_id as usize).and_then(Option::as_ref).ok_or_else(|| missing(format!("SPS {}", pps.sps_id)))?;
found = Some((pps.clone(), sps.clone()));
Ok((&**pps, &**sps))
})
.map_err(decode_err)?;
let Some((pps, sps)) = found else { return Err(decode_err("slice without a PPS")) };
sps.check_supported().map_err(decode_err)?;
if pps.num_slice_groups > 1 {
return Err(unsupported("slice groups (FMO)"));
}
if sh.field_pic {
return Err(unsupported("field pictures"));
}
if matches!(sh.slice_type, SliceType::Sp | SliceType::Si) {
return Err(unsupported("SP / SI slices"));
}
if sh.redundant_pic_cnt > 0 {
return Ok(()); // redundant slices are ignored, as in the software decoder
}
let new_pic = match &self.pending {
None => true,
Some(p) => is_new_picture(&p.first, &sh, &sps) || sh.first_mb_in_slice == 0 || !Arc::ptr_eq(&p.sps, &sps),
};
if new_pic {
self.submit_pending(frames)?;
if sh.first_mb_in_slice != 0 && self.active_sps.is_none() {
return Err(decode_err("stream does not start with the first slice of a picture"));
}
self.start_picture(&sh, &sps, &pps, pts, frames)?;
}
let Some(pending) = self.pending.as_ref() else { return Err(decode_err("slice without a started picture")) };
if !Arc::ptr_eq(&pending.pps, &pps) {
return Err(unsupported("a picture whose slices use different PPSs"));
}
let surfaces = self.accel.surfaces();
let lists = self
.dpb
.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num(), |e| (va_ref(e, surfaces), e.mark != RefMark::Unused))
.map_err(decode_err)?;
if !sh.slice_type.is_intra() && (lists[0].is_empty() || (sh.slice_type.is_b() && lists[1].is_empty())) {
return Err(decode_err("no reference pictures available for an inter slice"));
}
// a missing reference (the software decoder conceals it with another picture) is not
// something to hand the hardware
if lists.iter().flatten().any(|(_, is_ref)| !is_ref) {
return Err(decode_err("a reference picture is missing"));
}
let refs = lists.map(|l| l.into_iter().map(|(p, _)| p).collect::<Vec<_>>());
let params = slice_params(&sh, nal.len(), &refs)?;
if let Some(p) = self.pending.as_mut() {
p.slices.push((params, nal.to_vec()));
}
Ok(())
}
fn start_picture(&mut self, sh: &SliceHeader, sps: &Arc<Sps>, pps: &Arc<Pps>, pts: i64, frames: &mut Vec<DecodedFrame>) -> Result<()> {
if (sps.pic_width_in_mbs, sps.frame_height_in_mbs()) != self.mbs {
return Err(unsupported("a picture size change"));
}
if sps.max_dpb_frames() >= self.accel.surfaces().len() {
return Err(unsupported("a DPB larger than the decoder's surfaces"));
}
// a new SPS with another DPB size outputs and forgets what came before (as in software)
let changed = self.active_sps.as_ref().is_some_and(|a| a.max_dpb_frames() != sps.max_dpb_frames());
let mut outs = Vec::new();
if changed {
self.dpb.flush(&mut outs);
self.dpb.entries.clear();
}
self.active_sps = Some(sps.clone());
self.dpb.capacity = sps.max_dpb_frames();
self.dpb.max_reorder = sps.max_num_reorder_frames().min(self.dpb.capacity);
if sh.idr {
self.dpb.idr(sh.no_output_of_prior_pics, &mut outs);
self.prev_ref_frame_num = 0;
} else {
let max = sps.max_frame_num();
if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max {
self.fill_frame_num_gap(sh, sps, pts)?;
}
}
self.read_out(outs, frames)?;
let poc = self.poc_state.compute(sh, sps);
let target =
(0..self.accel.surfaces().len()).find(|&i| !self.dpb.entries.iter().any(|e| e.frame.index == i)).ok_or_else(|| decode_err("no free surface"))?;
self.pending = Some(Pending { target, sps: sps.clone(), pps: pps.clone(), first: sh.clone(), poc, pts, slices: Vec::new() });
Ok(())
}
/// frame_num gap (8.2.5.2; also every start at a non-IDR picture, an open-GOP seek): "non-existing"
/// frames as the software decoder makes them, copies of the latest decoded frame (mid-gray when
/// there is none), so pictures predicted from them come out the same.
fn fill_frame_num_gap(&mut self, sh: &SliceHeader, sps: &Arc<Sps>, pts: i64) -> Result<()> {
let max = sps.max_frame_num();
let last = self.dpb.entries.iter().filter(|e| !e.non_existing).max_by_key(|e| e.frame.seq).map(|e| e.frame.index);
let meta = Arc::new(output_meta(sps, pts, false));
let poc_state = &mut self.poc_state;
// surfaces are given afterwards, to the frames the sliding window keeps
let mut make = |_| (Surface { index: UNASSIGNED, top: 0, bottom: 0, seq: 0 }, 0, meta.clone());
let mut update = |frame_num| poc_state.update_gap_frame(frame_num, sps);
self.dpb.fill_frame_num_gap(self.prev_ref_frame_num, sh.frame_num, max, sps.max_num_ref_frames as usize, &mut make, &mut update);
self.prev_ref_frame_num = (sh.frame_num + max - 1) % max;
let used: Vec<usize> = self.dpb.entries.iter().map(|e| e.frame.index).filter(|&i| i != UNASSIGNED).collect();
let mut free = (0..self.accel.surfaces().len()).filter(|i| !used.contains(i));
let mut fills = Vec::new();
for e in self.dpb.entries.iter_mut().filter(|e| e.frame.index == UNASSIGNED) {
let index = free.next().ok_or_else(|| decode_err("no free surface for a frame_num gap"))?;
self.seq += 1;
e.frame = Surface { index, top: 0, bottom: 0, seq: self.seq };
fills.push(index);
}
for index in fills {
self.accel.fill(index, last).map_err(decode_err)?;
}
Ok(())
}
/// Decode the collected picture, then do its reference marking and DPB insertion.
fn submit_pending(&mut self, frames: &mut Vec<DecodedFrame>) -> Result<()> {
let Some(p) = self.pending.take() else { return Ok(()) };
if p.slices.is_empty() {
return Err(decode_err("a picture without slices"));
}
let surfaces = self.accel.surfaces();
let target_id = surfaces.get(p.target).copied().ok_or_else(|| decode_err("surface out of range"))?;
if self.dpb.entries.iter().filter(|e| e.mark != RefMark::Unused).count() > 16 {
return Err(decode_err("more than 16 reference frames"));
}
let mut pic = picture_params(&p.sps, &p.pps, &p.first);
pic.CurrPic = VAPictureH264 {
picture_id: target_id,
frame_idx: p.first.frame_num,
flags: 0,
TopFieldOrderCnt: p.poc.top,
BottomFieldOrderCnt: p.poc.bottom,
va_reserved: [0; ffi::VA_PADDING_LOW],
};
for (slot, e) in pic.ReferenceFrames.iter_mut().zip(self.dpb.entries.iter().filter(|e| e.mark != RefMark::Unused)) {
*slot = va_ref(e, surfaces);
}
let iq = iq_matrix(&p.pps);
self.accel.decode(p.target, &pic, &iq, &p.slices).map_err(decode_err)?;
self.poc_state.update(&p.first, &p.poc);
let mmco5 = p.first.has_mmco5();
if p.first.nal_ref_idc != 0 {
self.prev_ref_frame_num = if mmco5 { 0 } else { p.first.frame_num };
}
// MMCO 5: the picture's order counts become relative to itself (tempPicOrderCnt, 8.2.1)
let temp = if mmco5 { p.poc.frame() } else { 0 };
self.seq += 1;
let surface = Surface { index: p.target, top: p.poc.top.wrapping_sub(temp), bottom: p.poc.bottom.wrapping_sub(temp), seq: self.seq };
let fpoc = if mmco5 { 0 } else { p.poc.frame() };
let meta = Arc::new(output_meta(&p.sps, p.pts, p.first.idr));
let mut outs = Vec::new();
self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs);
self.read_out(outs, frames)
}
}
/// A DPB entry as a VA-API reference picture.
fn va_ref(e: &DpbEntry<Surface>, surfaces: &[ffi::VASurfaceID]) -> VAPictureH264 {
let long = e.mark == RefMark::Long;
VAPictureH264 {
picture_id: surfaces.get(e.frame.index).copied().unwrap_or(ffi::VA_INVALID_SURFACE),
frame_idx: if long { e.long_term_frame_idx } else { e.frame_num },
flags: if long { ffi::VA_PICTURE_H264_LONG_TERM_REFERENCE } else { ffi::VA_PICTURE_H264_SHORT_TERM_REFERENCE },
TopFieldOrderCnt: e.frame.top,
BottomFieldOrderCnt: e.frame.bottom,
va_reserved: [0; ffi::VA_PADDING_LOW],
}
}
fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta {
let vui = sps.vui.clone().unwrap_or_default();
OutputMeta {
pts,
key,
crop: sps.crop_rect(),
full_range: vui.full_range,
colour_primaries: vui.colour_primaries,
transfer_characteristics: vui.transfer_characteristics,
matrix_coefficients: vui.matrix_coefficients,
sar: vui.sar,
draft: false,
}
}
fn flag(on: bool, bit: u32) -> u32 {
u32::from(on) << bit
}
/// The picture parameters of a picture coded with `sps` / `pps` whose first slice is `sh` (the
/// current and reference pictures are filled in by the caller).
pub fn picture_params(sps: &Sps, pps: &Pps, sh: &SliceHeader) -> VAPictureParameterBufferH264 {
let seq_fields = (sps.chroma_format_idc & 3) << seq_bits::CHROMA_FORMAT_IDC
| flag(sps.separate_colour_plane, seq_bits::RESIDUAL_COLOUR_TRANSFORM)
| flag(sps.gaps_in_frame_num_allowed, seq_bits::GAPS_IN_FRAME_NUM_ALLOWED)
| flag(sps.frame_mbs_only, seq_bits::FRAME_MBS_ONLY)
| flag(sps.mb_adaptive_frame_field, seq_bits::MB_ADAPTIVE_FRAME_FIELD)
| flag(sps.direct_8x8_inference, seq_bits::DIRECT_8X8_INFERENCE)
// A.3.3.2: bi-prediction of blocks under 8x8 is not allowed from level 3.1
| flag(sps.level_idc >= 31, seq_bits::MIN_LUMA_BI_PRED_SIZE_8X8)
| (sps.log2_max_frame_num.saturating_sub(4) & 15) << seq_bits::LOG2_MAX_FRAME_NUM_MINUS4
| (sps.pic_order_cnt_type & 3) << seq_bits::PIC_ORDER_CNT_TYPE
| (sps.log2_max_poc_lsb.saturating_sub(4) & 15) << seq_bits::LOG2_MAX_PIC_ORDER_CNT_LSB_MINUS4
| flag(sps.delta_pic_order_always_zero, seq_bits::DELTA_PIC_ORDER_ALWAYS_ZERO);
let pic_fields = flag(pps.entropy_coding_mode, pic_bits::ENTROPY_CODING_MODE)
| flag(pps.weighted_pred, pic_bits::WEIGHTED_PRED)
| (pps.weighted_bipred_idc & 3) << pic_bits::WEIGHTED_BIPRED_IDC
| flag(pps.transform_8x8_mode, pic_bits::TRANSFORM_8X8_MODE)
| flag(sh.field_pic, pic_bits::FIELD_PIC)
| flag(pps.constrained_intra_pred, pic_bits::CONSTRAINED_INTRA_PRED)
| flag(pps.bottom_field_pic_order_in_frame_present, pic_bits::PIC_ORDER_PRESENT)
| flag(pps.deblocking_filter_control_present, pic_bits::DEBLOCKING_FILTER_CONTROL_PRESENT)
| flag(pps.redundant_pic_cnt_present, pic_bits::REDUNDANT_PIC_CNT_PRESENT)
| flag(sh.nal_ref_idc != 0, pic_bits::REFERENCE_PIC);
// the parsers bound these (frame size by the level limits, qp offsets to ±12, qp to 0..51)
let narrow = |v: u32| u16::try_from(v.saturating_sub(1)).unwrap_or(u16::MAX);
let small = |v: i32| i8::try_from(v).unwrap_or(0);
VAPictureParameterBufferH264 {
CurrPic: VAPictureH264::INVALID,
ReferenceFrames: [VAPictureH264::INVALID; 16],
picture_width_in_mbs_minus1: narrow(sps.pic_width_in_mbs),
picture_height_in_mbs_minus1: narrow(sps.frame_height_in_mbs()),
bit_depth_luma_minus8: u8::try_from(sps.bit_depth_luma.saturating_sub(8)).unwrap_or(0),
bit_depth_chroma_minus8: u8::try_from(sps.bit_depth_chroma.saturating_sub(8)).unwrap_or(0),
num_ref_frames: u8::try_from(sps.max_num_ref_frames).unwrap_or(16),
seq_fields,
num_slice_groups_minus1: 0,
slice_group_map_type: 0,
slice_group_change_rate_minus1: 0,
pic_init_qp_minus26: small(pps.pic_init_qp - 26),
pic_init_qs_minus26: small(pps.pic_init_qs - 26),
chroma_qp_index_offset: small(pps.chroma_qp_index_offset),
second_chroma_qp_index_offset: small(pps.second_chroma_qp_index_offset),
pic_fields,
frame_num: u16::try_from(sh.frame_num).unwrap_or(0),
va_reserved: [0; ffi::VA_PADDING_MEDIUM],
}
}
/// The scaling matrices of `pps` (after the SPS / PPS fall-back rules), raster order.
pub fn iq_matrix(pps: &Pps) -> VAIQMatrixBufferH264 {
VAIQMatrixBufferH264 { ScalingList4x4: pps.scaling.m4, ScalingList8x8: [pps.scaling.m8[0], pps.scaling.m8[1]], va_reserved: [0; ffi::VA_PADDING_LOW] }
}
/// The slice parameters of the slice `sh` (a `nal_len`-byte NAL unit) with reference lists `refs`.
pub fn slice_params(sh: &SliceHeader, nal_len: usize, refs: &[Vec<VAPictureH264>; 2]) -> Result<VASliceParameterBufferH264> {
let bit_offset = sh.header_bits.checked_add(8).and_then(|b| u16::try_from(b).ok()).ok_or_else(|| decode_err("slice header too long"))?;
let mut s = VASliceParameterBufferH264 {
slice_data_size: u32::try_from(nal_len).map_err(|_| decode_err("slice too large"))?,
slice_data_offset: 0,
slice_data_flag: ffi::VA_SLICE_DATA_FLAG_ALL,
slice_data_bit_offset: bit_offset,
first_mb_in_slice: u16::try_from(sh.first_mb_in_slice).map_err(|_| decode_err("first_mb_in_slice out of range"))?,
slice_type: (sh.slice_type_raw % 5) as u8,
direct_spatial_mv_pred_flag: u8::from(sh.direct_spatial_mv_pred),
num_ref_idx_l0_active_minus1: u8::try_from(sh.num_ref_idx_active[0].saturating_sub(1)).unwrap_or(31).min(31),
num_ref_idx_l1_active_minus1: u8::try_from(sh.num_ref_idx_active[1].saturating_sub(1)).unwrap_or(31).min(31),
cabac_init_idc: u8::try_from(sh.cabac_init_idc).unwrap_or(0),
slice_qp_delta: i8::try_from(sh.slice_qp_delta).map_err(|_| decode_err("slice_qp_delta out of range"))?,
disable_deblocking_filter_idc: u8::try_from(sh.disable_deblocking_filter_idc).unwrap_or(0),
slice_alpha_c0_offset_div2: i8::try_from(sh.slice_alpha_c0_offset_div2).unwrap_or(0),
slice_beta_offset_div2: i8::try_from(sh.slice_beta_offset_div2).unwrap_or(0),
RefPicList0: [VAPictureH264::INVALID; 32],
RefPicList1: [VAPictureH264::INVALID; 32],
luma_log2_weight_denom: 0,
chroma_log2_weight_denom: 0,
luma_weight_l0_flag: 0,
luma_weight_l0: [0; 32],
luma_offset_l0: [0; 32],
chroma_weight_l0_flag: 0,
chroma_weight_l0: [[0; 2]; 32],
chroma_offset_l0: [[0; 2]; 32],
luma_weight_l1_flag: 0,
luma_weight_l1: [0; 32],
luma_offset_l1: [0; 32],
chroma_weight_l1_flag: 0,
chroma_weight_l1: [[0; 2]; 32],
chroma_offset_l1: [[0; 2]; 32],
va_reserved: [0; ffi::VA_PADDING_LOW],
};
for (dst, src) in s.RefPicList0.iter_mut().zip(&refs[0]) {
*dst = *src;
}
for (dst, src) in s.RefPicList1.iter_mut().zip(&refs[1]) {
*dst = *src;
}
// explicit weighted prediction: every entry, the default weight where none was coded
if let Some(w) = &sh.pred_weight_table {
s.luma_log2_weight_denom = u8::try_from(w.luma_log2_denom).unwrap_or(0);
s.chroma_log2_weight_denom = u8::try_from(w.chroma_log2_denom).unwrap_or(0);
let w16 = |v: i32| i16::try_from(v).unwrap_or(0);
let lists = [
(
&w.l0,
&mut s.luma_weight_l0,
&mut s.luma_offset_l0,
&mut s.chroma_weight_l0,
&mut s.chroma_offset_l0,
&mut s.luma_weight_l0_flag,
&mut s.chroma_weight_l0_flag,
),
(
&w.l1,
&mut s.luma_weight_l1,
&mut s.luma_offset_l1,
&mut s.chroma_weight_l1,
&mut s.chroma_offset_l1,
&mut s.luma_weight_l1_flag,
&mut s.chroma_weight_l1_flag,
),
];
for (entries, lw, lo, cw, co, lflag, cflag) in lists {
if entries.is_empty() {
continue;
}
*lflag = 1;
*cflag = 1;
for (i, e) in entries.iter().take(32).enumerate() {
lw[i] = w16(e.luma_weight);
lo[i] = w16(e.luma_offset);
for c in 0..2 {
cw[i][c] = w16(e.chroma_weight[c]);
co[i][c] = w16(e.chroma_offset[c]);
}
}
}
}
Ok(s)
}

View File

@ -0,0 +1,131 @@
//! Hardware decoding through VA-API (Linux: Intel, AMD and other Mesa / vendor drivers).
//!
//! VA-API decoding is stateless: the application parses the stream and keeps the decoded picture
//! buffer, and the GPU decodes one picture's slice data at a time into a surface. [`h264`] does the
//! host side with the software decoder's own parsers and DPB (so the two decide alike), [`va`]
//! drives libva (loaded at run time; no libva means no hardware decoder, never a failure to start),
//! and [`VaDecoder`] puts them together as a [`VideoDecoder`]. H.264 only so far: 8-bit 4:2:0
//! progressive, Constrained Baseline / Main / High; everything else stays in software.
//!
//! The FFI is in [`ffi`] (declarations) and `va` (calls); the rest of this module is safe code.
pub mod ffi;
pub mod h264;
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub mod va;
#[cfg(test)]
mod abi_tests;
#[cfg(test)]
mod tests;
#[cfg(target_os = "linux")]
pub use linux::VaDecoder;
#[cfg(target_os = "linux")]
mod linux {
use filmcraft_bitstream::unescape_rbsp;
use filmcraft_codecs::hw::{NalCodec, NalStreamInfo};
use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder};
use filmcraft_h264::params::Sps;
use super::ffi;
use super::h264::Front;
use super::va::Session;
use crate::biplanar::Geometry;
/// The VA-API profiles that decode an H.264 stream of `profile_idc`, best fit first (a Main or
/// High decoder also decodes what the smaller profiles allow).
pub(crate) fn profiles(profile_idc: u8) -> Option<&'static [ffi::VAProfile]> {
Some(match profile_idc {
66 => &[ffi::VAProfileH264ConstrainedBaseline, ffi::VAProfileH264Main, ffi::VAProfileH264High],
77 => &[ffi::VAProfileH264Main, ffi::VAProfileH264High],
100 => &[ffi::VAProfileH264High],
_ => return None,
})
}
/// A VA-API H.264 decoder for one stream.
pub struct VaDecoder {
front: Front<Session>,
info: NalStreamInfo,
name: String,
fed: u64,
fail_after: Option<u64>,
}
impl VaDecoder {
/// A decoder for the stream `info` describes, or why VA-API does not take it on this system.
pub fn new(info: NalStreamInfo) -> std::result::Result<Self, String> {
if info.codec != NalCodec::H264 {
return Err("only H.264 is decoded through VA-API so far".into());
}
if info.chroma_format_idc != 1 || info.bit_depth_luma != 8 || info.bit_depth_chroma != 8 || info.interlaced {
return Err(format!(
"{} 4:2:0 {}-bit {} is not taken",
if info.interlaced { "interlaced" } else { "progressive" },
info.bit_depth_luma,
if info.chroma_format_idc == 1 { "only" } else { "(other chroma formats)" }
));
}
let profiles = profiles(info.profile_idc).ok_or_else(|| format!("H.264 profile_idc {} is not taken", info.profile_idc))?;
let sps = info
.parameter_sets
.iter()
.find(|n| n.first().is_some_and(|h| h & 0x1f == 7))
.and_then(|n| Sps::parse(&unescape_rbsp(n.get(1..)?)).ok())
.ok_or("no readable SPS")?;
sps.check_supported().map_err(|e| e.to_string())?;
let mbs = (sps.pic_width_in_mbs, sps.frame_height_in_mbs());
// the DPB, the picture being decoded, and one spare
let count = sps.max_dpb_frames().saturating_add(2).min(18);
let geometry = Geometry { crop: info.crop, bits: 8, color: info.color, par: info.par };
let session = Session::new(profiles, (sps.width(), sps.height()), count, geometry)?;
let name = format!("VA-API H.264 ({})", session.vendor());
let front = Front::new(session, info.length_size, mbs, &info.parameter_sets).map_err(|e| e.to_string())?;
Ok(Self { front, info, name, fed: 0, fail_after: None })
}
/// Test hook: fail every decode call after the first `n` (a GPU lost mid-stream).
pub fn fail_after(&mut self, n: u64) {
self.fail_after = Some(n);
}
}
impl VideoDecoder for VaDecoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
self.fed += 1;
if self.fail_after.is_some_and(|n| self.fed > n) {
return Err(CodecError::Decode("VA-API decoder failed (test hook)".into()));
}
self.front.decode(sample, pts)
}
fn flush(&mut self) -> Vec<DecodedFrame> {
match self.front.flush() {
Ok(out) => out,
Err(e) => {
log::warn!("{}: {e} while flushing", self.name);
Vec::new()
}
}
}
fn reset(&mut self) {
self.front.reset();
}
fn name(&self) -> &str {
&self.name
}
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
self.info.is_random_access(sample)
}
fn is_disposable(&self, sample: &[u8]) -> bool {
self.info.is_disposable(sample)
}
}
}

View File

@ -0,0 +1,225 @@
//! The H.264 front end against a recording stand-in for the hardware, on every system: for x264
//! streams with B-pyramids, several references, weighted prediction, several slices and open GOPs,
//! the pictures come out in the software decoder's order with its pts (whole stream, after seeks,
//! after a mid-stream flush), and every buffer handed to the hardware is consistent: the current
//! picture's surface is free, every reference is a decoded surface listed in the DPB, reference
//! lists have the active number of entries, and slice data offsets lie inside the slice.
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use filmcraft_codecs::DecodedFrame;
use filmcraft_codecs::hw::NalStreamInfo;
use filmcraft_frame::VideoFrame;
use super::ffi::{self, VAIQMatrixBufferH264, VAPictureH264, VAPictureParameterBufferH264, VASliceParameterBufferH264};
use super::h264::{Accel, Front};
use crate::biplanar::{self, Biplanar, Geometry};
/// Records what it is asked to do and checks it; "decodes" nothing.
struct Recorder {
ids: Vec<ffi::VASurfaceID>,
decoded: Vec<bool>,
pictures: usize,
frame: VideoFrame,
}
impl Recorder {
fn new(count: usize) -> Self {
let g = Geometry { crop: (0, 0, 2, 2), bits: 8, color: Default::default(), par: (1, 1) };
let frame = biplanar::to_frame(&Biplanar { luma: &[128; 4], chroma: &[128; 2], stride: 2, rows: 2 }, &g).unwrap();
Self { ids: (0..count as u32).map(|i| 0x100 + i).collect(), decoded: vec![false; count], pictures: 0, frame }
}
}
fn valid(p: &VAPictureH264) -> bool {
p.flags & ffi::VA_PICTURE_H264_INVALID == 0
}
impl Accel for Recorder {
fn surfaces(&self) -> &[ffi::VASurfaceID] {
&self.ids
}
fn decode(
&mut self,
target: usize,
pic: &VAPictureParameterBufferH264,
_iq: &VAIQMatrixBufferH264,
slices: &[(VASliceParameterBufferH264, Vec<u8>)],
) -> Result<(), String> {
assert_eq!(pic.CurrPic.picture_id, self.ids[target], "current picture surface");
let refs: Vec<_> = pic.ReferenceFrames.iter().filter(|r| valid(r)).collect();
let ref_ids: BTreeSet<_> = refs.iter().map(|r| r.picture_id).collect();
assert_eq!(ref_ids.len(), refs.len(), "each reference frame once");
assert!(!ref_ids.contains(&pic.CurrPic.picture_id), "the target is not a reference");
for r in &refs {
let i = self.ids.iter().position(|&id| id == r.picture_id).expect("reference is a decoder surface");
assert!(self.decoded[i], "reference surface {i} holds a picture");
assert!(r.flags & (ffi::VA_PICTURE_H264_SHORT_TERM_REFERENCE | ffi::VA_PICTURE_H264_LONG_TERM_REFERENCE) != 0, "reference flags");
}
assert!(!slices.is_empty());
for (s, data) in slices {
assert_eq!(s.slice_data_size as usize, data.len());
assert!((s.slice_data_bit_offset as usize) < data.len() * 8, "slice data starts inside the slice");
let ty = s.slice_type;
let lists: &[(&[VAPictureH264; 32], u8)] = match ty {
2 => &[],
0 => &[(&s.RefPicList0, s.num_ref_idx_l0_active_minus1)],
_ => &[(&s.RefPicList0, s.num_ref_idx_l0_active_minus1), (&s.RefPicList1, s.num_ref_idx_l1_active_minus1)],
};
for (list, minus1) in lists {
let n = *minus1 as usize + 1;
assert!(list[..n].iter().all(valid), "{n} active entries");
assert!(list[n..].iter().all(|p| !valid(p)), "nothing past the active entries");
assert!(list[..n].iter().all(|p| ref_ids.contains(&p.picture_id)), "list entries are reference frames");
}
}
self.decoded[target] = true;
self.pictures += 1;
Ok(())
}
fn read(&mut self, index: usize) -> Result<VideoFrame, String> {
assert!(self.decoded[index], "read back a decoded surface");
Ok(self.frame.clone())
}
fn fill(&mut self, index: usize, from: Option<usize>) -> Result<(), String> {
assert!(from.is_none_or(|f| self.decoded[f]));
self.decoded[index] = true;
Ok(())
}
}
/// (file, x264 profile, x264 parameters).
const STREAMS: &[(&str, &str, &str)] = &[
("va_front_high.mp4", "high", "bframes=3:b-pyramid=normal:keyint=12:min-keyint=12:scenecut=0:ref=4:weightp=2"),
("va_front_slices.mp4", "baseline", "keyint=12:min-keyint=12:scenecut=0:slices=4:ref=2"),
("va_front_open_gop.mp4", "high", "bframes=3:b-pyramid=strict:keyint=12:min-keyint=12:scenecut=0:open-gop=1:direct=temporal"),
];
fn fixture(ff: &std::path::Path, name: &str, profile: &str, params: &str) -> Option<PathBuf> {
let out = filmcraft_testkit::fixtures_dir("platform").join(name);
let src = "testsrc2=s=320x184:r=24:d=2";
filmcraft_testkit::fixtures::generate(&out, |tmp| {
Command::new(ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", src, "-c:v", "libx264", "-profile:v", profile, "-x264-params", params, "-pix_fmt", "yuv420p"])
.arg(tmp)
.stdin(Stdio::null())
.status()
.is_ok_and(|s| s.success())
})
}
struct Stream {
entry: filmcraft_isobmff::SampleEntry,
samples: Vec<(Vec<u8>, i64)>,
sync: Vec<bool>,
}
fn read_stream(path: &std::path::Path) -> Stream {
let bytes = std::fs::read(path).unwrap();
let file = filmcraft_isobmff::open(bytes.clone()).unwrap();
let t = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
let track = &file.tracks[t];
let samples = (0..track.samples.len()).map(|i| (file.read_sample(&bytes, t, i).unwrap(), track.samples[i].pts)).collect();
Stream { entry: track.entries[0].clone(), samples, sync: track.samples.iter().map(|s| s.is_sync).collect() }
}
fn front(s: &Stream) -> Front<Recorder> {
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
let mbs = (info.coded.0 / 16, info.coded.1 / 16);
Front::new(Recorder::new(18), info.length_size, mbs, &info.parameter_sets).unwrap()
}
fn pts(frames: &[DecodedFrame]) -> Vec<i64> {
frames.iter().map(|f| f.pts).collect()
}
fn run_front(f: &mut Front<Recorder>, samples: &[(Vec<u8>, i64)]) -> Vec<i64> {
let mut out = Vec::new();
for (s, p) in samples {
out.extend(pts(&f.decode(s, *p).unwrap()));
}
out.extend(pts(&f.flush().unwrap()));
out
}
fn run_software(d: &mut dyn filmcraft_codecs::VideoDecoder, samples: &[(Vec<u8>, i64)]) -> Vec<i64> {
let mut out = Vec::new();
for (s, p) in samples {
out.extend(pts(&d.decode(s, *p).unwrap()));
}
out.extend(pts(&d.flush()));
out
}
#[test]
fn output_order_and_buffers_match_the_software_decoder() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, profile, params) in STREAMS {
let Some(path) = fixture(&ff, name, profile, params) else { continue };
let s = read_stream(&path);
let mut f = front(&s);
let mut sw = filmcraft_codecs::software_video_decoder(&s.entry).unwrap();
let a = run_front(&mut f, &s.samples);
assert_eq!(a, run_software(sw.as_mut(), &s.samples), "{name}");
assert_eq!(f.accel().pictures, s.samples.len(), "{name}: every picture decoded");
// seeks to every sync sample (open-GOP I pictures included: frame_num gap stand-ins)
for k in (1..s.samples.len()).filter(|&i| s.sync[i]) {
f.reset();
sw.reset();
let end = (k + 13).min(s.samples.len());
assert_eq!(run_front(&mut f, &s.samples[k..end]), run_software(sw.as_mut(), &s.samples[k..end]), "{name} from sample {k}");
}
// a flush mid-GOP, then on
f.reset();
sw.reset();
let mid = s.samples.len() / 2 + 1;
let mut a = run_front(&mut f, &s.samples[..mid]);
a.extend(run_front(&mut f, &s.samples[mid..]));
let mut b = run_software(sw.as_mut(), &s.samples[..mid]);
b.extend(run_software(sw.as_mut(), &s.samples[mid..]));
assert_eq!(a, b, "{name} with a flush at {mid}");
}
}
/// Damaged samples give errors (the hybrid decoder then continues in software), never a panic.
#[test]
fn damaged_samples_are_errors() {
let ff = filmcraft_testkit::require_ffmpeg!();
let (name, profile, params) = STREAMS[0];
let Some(path) = fixture(&ff, name, profile, params) else { return };
let s = read_stream(&path);
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for round in 0..40 {
let mut f = front(&s);
for (i, (smp, p)) in s.samples.iter().enumerate() {
let mut m = smp.clone();
if i % 3 == round % 3 {
match next() % 3 {
0 => m.truncate(next() as usize % m.len().max(1)),
1 => {
let at = next() as usize % m.len().max(1);
if let Some(b) = m.get_mut(at) {
*b ^= 1 << (next() % 8);
}
}
_ => m.iter_mut().skip(4).take(6).for_each(|b| *b = next() as u8),
}
}
if f.decode(&m, *p).is_err() {
f.reset();
}
}
let _ = f.flush();
}
}

View File

@ -0,0 +1,484 @@
//! libva itself (Linux): `libva.so.2` and `libva-drm.so.2` are loaded at run time (a system without
//! them simply has no hardware decoder), a display is opened on a DRM render node, and a [`Session`]
//! (decode configuration, context and surfaces for one stream) decodes pictures and reads them back
//! as NV12 images.
//!
//! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the
//! public API is safe, and the one callback into Rust (libva's error messages) runs under
//! `catch_unwind`.
use std::ffi::{CStr, c_char, c_int, c_uint, c_void};
use std::fs::{File, OpenOptions};
use std::os::fd::AsRawFd;
use std::sync::OnceLock;
use filmcraft_frame::VideoFrame;
use libloading::Library;
use super::ffi::{self, VAConfigAttrib, VAImage, VAImageFormat, VASurfaceID};
use super::h264::Accel;
use crate::biplanar::{self, Biplanar, Geometry};
/// The libva entry points (the libraries stay loaded for the life of the process).
struct Api {
_va: Library,
_drm: Library,
initialize: ffi::vaInitialize,
terminate: ffi::vaTerminate,
error_str: ffi::vaErrorStr,
vendor_string: ffi::vaQueryVendorString,
set_info_callback: ffi::vaSetInfoCallback,
set_error_callback: ffi::vaSetErrorCallback,
get_config_attributes: ffi::vaGetConfigAttributes,
create_config: ffi::vaCreateConfig,
destroy_config: ffi::vaDestroyConfig,
create_surfaces: ffi::vaCreateSurfaces,
destroy_surfaces: ffi::vaDestroySurfaces,
create_context: ffi::vaCreateContext,
destroy_context: ffi::vaDestroyContext,
create_buffer: ffi::vaCreateBuffer,
destroy_buffer: ffi::vaDestroyBuffer,
begin_picture: ffi::vaBeginPicture,
render_picture: ffi::vaRenderPicture,
end_picture: ffi::vaEndPicture,
sync_surface: ffi::vaSyncSurface,
create_image: ffi::vaCreateImage,
destroy_image: ffi::vaDestroyImage,
get_image: ffi::vaGetImage,
put_image: ffi::vaPutImage,
map_buffer: ffi::vaMapBuffer,
unmap_buffer: ffi::vaUnmapBuffer,
get_display_drm: ffi::vaGetDisplayDRM,
}
static API: OnceLock<Result<Api, String>> = OnceLock::new();
fn api() -> Result<&'static Api, String> {
API.get_or_init(load).as_ref().map_err(Clone::clone)
}
fn load() -> Result<Api, String> {
// SAFETY: loading libva runs its library initialisers, which only set up libva's own state;
// it is the system's VA-API runtime, loaded the way every VA-API client loads it.
let va = unsafe { Library::new("libva.so.2") }.map_err(|e| format!("libva.so.2: {e}"))?;
// SAFETY: as above, for libva's DRM display library.
let drm = unsafe { Library::new("libva-drm.so.2") }.map_err(|e| format!("libva-drm.so.2: {e}"))?;
macro_rules! sym {
($lib:expr, $name:ident) => {{
// SAFETY: `ffi::$name` is the C signature of the libva function of that name,
// transcribed from va.h and checked against it (abi_tests.rs). The pointer is copied
// out; the library it points into is kept loaded in `Api` for the life of the process.
let s = unsafe { $lib.get::<ffi::$name>(concat!(stringify!($name), "\0").as_bytes()) };
*s.map_err(|e| format!("{}: {e}", stringify!($name)))?
}};
}
Ok(Api {
initialize: sym!(va, vaInitialize),
terminate: sym!(va, vaTerminate),
error_str: sym!(va, vaErrorStr),
vendor_string: sym!(va, vaQueryVendorString),
set_info_callback: sym!(va, vaSetInfoCallback),
set_error_callback: sym!(va, vaSetErrorCallback),
get_config_attributes: sym!(va, vaGetConfigAttributes),
create_config: sym!(va, vaCreateConfig),
destroy_config: sym!(va, vaDestroyConfig),
create_surfaces: sym!(va, vaCreateSurfaces),
destroy_surfaces: sym!(va, vaDestroySurfaces),
create_context: sym!(va, vaCreateContext),
destroy_context: sym!(va, vaDestroyContext),
create_buffer: sym!(va, vaCreateBuffer),
destroy_buffer: sym!(va, vaDestroyBuffer),
begin_picture: sym!(va, vaBeginPicture),
render_picture: sym!(va, vaRenderPicture),
end_picture: sym!(va, vaEndPicture),
sync_surface: sym!(va, vaSyncSurface),
create_image: sym!(va, vaCreateImage),
destroy_image: sym!(va, vaDestroyImage),
get_image: sym!(va, vaGetImage),
put_image: sym!(va, vaPutImage),
map_buffer: sym!(va, vaMapBuffer),
unmap_buffer: sym!(va, vaUnmapBuffer),
get_display_drm: sym!(drm, vaGetDisplayDRM),
_va: va,
_drm: drm,
})
}
/// libva's error messages, into our log (instead of stderr).
extern "C" fn on_error(_user: *mut c_void, message: *const c_char) {
let _ = std::panic::catch_unwind(|| {
if message.is_null() {
return;
}
// SAFETY: libva passes a NUL-terminated message that is valid for the duration of the call.
let text = unsafe { CStr::from_ptr(message) }.to_string_lossy();
log::warn!("libva: {}", text.trim_end());
});
}
/// An initialised VA display on a DRM render node.
pub struct Display {
api: &'static Api,
dpy: ffi::VADisplay,
vendor: String,
/// The render node; open for as long as the display uses it.
_device: File,
}
// SAFETY: a VA display is not tied to the thread that opened it, and a `Display` is only used
// through its single owner (`&mut` or `&` of one `Session` at a time), never concurrently.
unsafe impl Send for Display {}
impl Display {
/// The first DRM render node with a working VA-API driver.
pub fn open() -> Result<Self, String> {
let api = api()?;
let mut why = "no DRM render node (/dev/dri/renderD*)".to_string();
for n in 128..144 {
let path = format!("/dev/dri/renderD{n}");
let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else { continue };
match Self::init(api, file) {
Ok(d) => return Ok(d),
Err(e) => why = format!("{path}: {e}"),
}
}
Err(why)
}
fn init(api: &'static Api, device: File) -> Result<Self, String> {
// SAFETY: the descriptor is an open render node, kept open by `device` (moved into the
// returned display) for as long as the display exists.
let dpy = unsafe { (api.get_display_drm)(device.as_raw_fd()) };
if dpy.is_null() {
return Err("vaGetDisplayDRM failed".into());
}
// From here on `Drop` terminates the display.
let mut d = Self { api, dpy, vendor: String::new(), _device: device };
// SAFETY: `dpy` is a display from vaGetDisplayDRM; a null info callback turns libva's info
// messages off, and `on_error` matches `VAMessageCallback` and never unwinds.
unsafe {
(api.set_info_callback)(dpy, None, std::ptr::null_mut());
(api.set_error_callback)(dpy, Some(on_error), std::ptr::null_mut());
}
let (mut major, mut minor): (c_int, c_int) = (0, 0);
// SAFETY: `dpy` is valid; the version outputs point to live locals.
let st = unsafe { (api.initialize)(dpy, &mut major, &mut minor) };
d.check(st, "vaInitialize")?;
// SAFETY: `dpy` is an initialised display; the vendor string is NUL-terminated and owned by
// the driver for the life of the display (copied out here).
let vendor = unsafe { (api.vendor_string)(dpy) };
if !vendor.is_null() {
// SAFETY: non-null, NUL-terminated (see above).
d.vendor = unsafe { CStr::from_ptr(vendor) }.to_string_lossy().into_owned();
}
Ok(d)
}
/// The driver's description ("Intel iHD driver …").
pub fn vendor(&self) -> &str {
&self.vendor
}
fn check(&self, st: ffi::VAStatus, what: &str) -> Result<(), String> {
if st == ffi::VA_STATUS_SUCCESS {
return Ok(());
}
// SAFETY: vaErrorStr takes any status and returns a static NUL-terminated string.
let text = unsafe { (self.api.error_str)(st) };
let text = if text.is_null() {
String::new()
} else {
// SAFETY: non-null static NUL-terminated string (see above).
unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned()
};
Err(format!("{what}: {text} ({st:#x})"))
}
}
impl Drop for Display {
fn drop(&mut self) {
// SAFETY: `dpy` came from vaGetDisplayDRM and is terminated exactly once, here, after
// every session object made on it was destroyed (`Session` drops its objects first).
unsafe { (self.api.terminate)(self.dpy) };
}
}
/// One stream's decoder: a VLD configuration of a profile, its surfaces and context, and the image
/// pictures are read back through.
pub struct Session {
display: Display,
config: ffi::VAConfigID,
context: ffi::VAContextID,
surfaces: Vec<VASurfaceID>,
image: Option<VAImage>,
/// Coded size of the surfaces (luma samples).
size: (u32, u32),
geometry: Geometry,
}
impl Session {
/// A decoder for the first of `profiles` the driver decodes (VLD, 8-bit 4:2:0), with `count`
/// surfaces of `size` (coded luma samples); pictures read back are cut to `geometry`.
pub fn new(profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result<Self, String> {
let display = Display::open()?;
let api = display.api;
let dpy = display.dpy;
let mut config = None;
let mut why = "no VA-API profile for the stream".to_string();
for &profile in profiles {
let mut attr = VAConfigAttrib { type_: ffi::VAConfigAttribRTFormat, value: 0 };
// SAFETY: `dpy` is initialised; `attr` is one live attribute (count 1).
let st = unsafe { (api.get_config_attributes)(dpy, profile, ffi::VAEntrypointVLD, &mut attr, 1) };
if let Err(e) = display.check(st, "vaGetConfigAttributes") {
why = e;
continue;
}
if attr.value & ffi::VA_RT_FORMAT_YUV420 == 0 {
why = format!("profile {profile} does not decode to 8-bit 4:2:0");
continue;
}
attr.value = ffi::VA_RT_FORMAT_YUV420;
let mut id = ffi::VA_INVALID_ID;
// SAFETY: as above; `id` is a live output.
let st = unsafe { (api.create_config)(dpy, profile, ffi::VAEntrypointVLD, &mut attr, 1, &mut id) };
match display.check(st, "vaCreateConfig") {
Ok(()) => {
config = Some(id);
break;
}
Err(e) => why = e,
}
}
let config = config.ok_or(why)?;
// From here on `Drop` releases what was made.
let mut s = Self { display, config, context: ffi::VA_INVALID_ID, surfaces: Vec::new(), image: None, size, geometry };
let count_c = c_uint::try_from(count).map_err(|_| "too many surfaces")?;
let mut surfaces = vec![ffi::VA_INVALID_SURFACE; count];
// SAFETY: `surfaces` has room for `count` ids; no attributes are passed (null, 0).
let st = unsafe { (api.create_surfaces)(dpy, ffi::VA_RT_FORMAT_YUV420, size.0, size.1, surfaces.as_mut_ptr(), count_c, std::ptr::null_mut(), 0) };
s.display.check(st, "vaCreateSurfaces")?;
s.surfaces = surfaces;
let (w, h) = (c_int::try_from(size.0).map_err(|_| "picture too wide")?, c_int::try_from(size.1).map_err(|_| "picture too tall")?);
let n = c_int::try_from(count).map_err(|_| "too many surfaces")?;
let mut context = ffi::VA_INVALID_ID;
// SAFETY: `config` and the `count` surfaces were made on this display; `context` is a live output.
let st = unsafe { (api.create_context)(dpy, s.config, w, h, ffi::VA_PROGRESSIVE, s.surfaces.as_mut_ptr(), n, &mut context) };
s.display.check(st, "vaCreateContext")?;
s.context = context;
Ok(s)
}
pub fn vendor(&self) -> &str {
self.display.vendor()
}
/// A buffer of `ty` holding a copy of `len` bytes at `data`.
fn buffer(&self, ty: ffi::VABufferType, data: *const c_void, len: usize) -> Result<ffi::VABufferID, String> {
let size = c_uint::try_from(len).map_err(|_| "buffer too large")?;
let mut id = ffi::VA_INVALID_ID;
// SAFETY: `data` points to `len` readable bytes (the callers pass a live value or slice);
// libva copies them into the new buffer and never writes through the pointer.
let st = unsafe { (self.display.api.create_buffer)(self.display.dpy, self.context, ty, size, 1, data.cast_mut(), &mut id) };
self.display.check(st, "vaCreateBuffer")?;
Ok(id)
}
fn value_buffer<T>(&self, ty: ffi::VABufferType, value: &T) -> Result<ffi::VABufferID, String> {
self.buffer(ty, (value as *const T).cast(), std::mem::size_of::<T>())
}
fn render(&self, buffers: &mut [ffi::VABufferID]) -> Result<(), String> {
let n = c_int::try_from(buffers.len()).map_err(|_| "too many buffers")?;
// SAFETY: `buffers` are live buffer ids made on this context, `n` of them.
let st = unsafe { (self.display.api.render_picture)(self.display.dpy, self.context, buffers.as_mut_ptr(), n) };
self.display.check(st, "vaRenderPicture")
}
fn decode_into(
&self,
surface: VASurfaceID,
buffers: &mut Vec<ffi::VABufferID>,
pic: &ffi::VAPictureParameterBufferH264,
iq: &ffi::VAIQMatrixBufferH264,
slices: &[(ffi::VASliceParameterBufferH264, Vec<u8>)],
) -> Result<(), String> {
buffers.push(self.value_buffer(ffi::VAPictureParameterBufferType, pic)?);
buffers.push(self.value_buffer(ffi::VAIQMatrixBufferType, iq)?);
for (params, data) in slices {
buffers.push(self.value_buffer(ffi::VASliceParameterBufferType, params)?);
buffers.push(self.buffer(ffi::VASliceDataBufferType, data.as_ptr().cast(), data.len())?);
}
let api = self.display.api;
// SAFETY: `surface` is one of this context's render targets.
let st = unsafe { (api.begin_picture)(self.display.dpy, self.context, surface) };
self.display.check(st, "vaBeginPicture")?;
let mut rendered = self.render(&mut buffers[..2]);
for pair in buffers[2..].chunks_mut(2) {
if rendered.is_err() {
break;
}
rendered = self.render(pair);
}
// a begun picture is always ended, so the context stays usable
// SAFETY: the picture was begun on this context above.
let st = unsafe { (api.end_picture)(self.display.dpy, self.context) };
rendered?;
self.display.check(st, "vaEndPicture")
}
fn image(&mut self) -> Result<VAImage, String> {
if let Some(image) = self.image {
return Ok(image);
}
let mut format = VAImageFormat { fourcc: ffi::VA_FOURCC_NV12, byte_order: 1, bits_per_pixel: 12, ..Default::default() };
let mut image = VAImage { image_id: ffi::VA_INVALID_ID, buf: ffi::VA_INVALID_ID, ..Default::default() };
let (w, h) = (c_int::try_from(self.size.0).map_err(|_| "picture too wide")?, c_int::try_from(self.size.1).map_err(|_| "picture too tall")?);
// SAFETY: `format` and `image` are live; the driver fills `image` in.
let st = unsafe { (self.display.api.create_image)(self.display.dpy, &mut format, w, h, &mut image) };
self.display.check(st, "vaCreateImage")?;
self.image = Some(image);
Ok(image)
}
/// The picture in surface `index`, copied into the session's image once decoded.
fn get_image(&mut self, index: usize) -> Result<VAImage, String> {
let surface = *self.surfaces.get(index).ok_or("surface out of range")?;
let image = self.image()?;
let api = self.display.api;
// SAFETY: `surface` is one of this session's surfaces.
let st = unsafe { (api.sync_surface)(self.display.dpy, surface) };
self.display.check(st, "vaSyncSurface")?;
// SAFETY: the image and the surface were made on this display, with the same size.
let st = unsafe { (api.get_image)(self.display.dpy, surface, 0, 0, self.size.0, self.size.1, image.image_id) };
self.display.check(st, "vaGetImage")?;
Ok(image)
}
/// Every sample of `image` set to 128 (mid-gray in NV12).
fn write_gray(&self, image: &VAImage) -> Result<(), String> {
let api = self.display.api;
let mut ptr: *mut c_void = std::ptr::null_mut();
// SAFETY: `image.buf` is the data buffer of an image made on this display; `ptr` is a live output.
let st = unsafe { (api.map_buffer)(self.display.dpy, image.buf, &mut ptr) };
self.display.check(st, "vaMapBuffer")?;
if !ptr.is_null() {
// SAFETY: a mapped image buffer holds `data_size` writable bytes at `ptr` until it is
// unmapped below.
unsafe { std::ptr::write_bytes(ptr.cast::<u8>(), 128, image.data_size as usize) };
}
// SAFETY: the buffer was mapped above.
let st = unsafe { (api.unmap_buffer)(self.display.dpy, image.buf) };
self.display.check(st, "vaUnmapBuffer")?;
if ptr.is_null() { Err("vaMapBuffer returned no data".into()) } else { Ok(()) }
}
fn read_image(&self, image: &VAImage) -> Result<VideoFrame, String> {
let api = self.display.api;
let mut ptr: *mut c_void = std::ptr::null_mut();
// SAFETY: `image.buf` is the data buffer of an image made on this display; `ptr` is a live output.
let st = unsafe { (api.map_buffer)(self.display.dpy, image.buf, &mut ptr) };
self.display.check(st, "vaMapBuffer")?;
let result = if ptr.is_null() {
Err("vaMapBuffer returned no data".to_string())
} else {
// SAFETY: a mapped image buffer holds `data_size` bytes at `ptr` until it is unmapped
// below; the slice does not outlive this block.
let data = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>().cast_const(), image.data_size as usize) };
biplanar_view(image, data).and_then(|b| biplanar::to_frame(&b, &self.geometry))
};
// SAFETY: the buffer was mapped above; `data` is no longer used.
let st = unsafe { (api.unmap_buffer)(self.display.dpy, image.buf) };
let frame = result?;
self.display.check(st, "vaUnmapBuffer")?;
Ok(frame)
}
}
/// The two planes of a mapped NV12 image.
fn biplanar_view<'a>(image: &VAImage, data: &'a [u8]) -> Result<Biplanar<'a>, String> {
if image.format.fourcc != ffi::VA_FOURCC_NV12 || image.num_planes < 2 {
return Err(format!("the read-back image is not NV12 (fourcc {:#x}, {} planes)", image.format.fourcc, image.num_planes));
}
let [p0, p1, _] = image.pitches;
if p0 != p1 {
return Err(format!("NV12 planes with different pitches ({p0}, {p1})"));
}
let luma = data.get(image.offsets[0] as usize..).ok_or("NV12 luma offset out of range")?;
let chroma = data.get(image.offsets[1] as usize..).ok_or("NV12 chroma offset out of range")?;
Ok(Biplanar { luma, chroma, stride: p0 as usize, rows: usize::from(image.height) })
}
impl Accel for Session {
fn surfaces(&self) -> &[VASurfaceID] {
&self.surfaces
}
fn decode(
&mut self,
target: usize,
pic: &ffi::VAPictureParameterBufferH264,
iq: &ffi::VAIQMatrixBufferH264,
slices: &[(ffi::VASliceParameterBufferH264, Vec<u8>)],
) -> Result<(), String> {
let surface = *self.surfaces.get(target).ok_or("surface out of range")?;
let mut buffers = Vec::with_capacity(2 + 2 * slices.len());
let decoded = self.decode_into(surface, &mut buffers, pic, iq, slices);
for id in buffers {
// SAFETY: each id is a buffer made above on this display, destroyed once; vaEndPicture
// has returned, so the driver no longer reads it.
unsafe { (self.display.api.destroy_buffer)(self.display.dpy, id) };
}
decoded
}
fn read(&mut self, index: usize) -> Result<VideoFrame, String> {
let image = self.get_image(index)?;
self.read_image(&image)
}
fn fill(&mut self, index: usize, from: Option<usize>) -> Result<(), String> {
let surface = *self.surfaces.get(index).ok_or("surface out of range")?;
let image = match from {
Some(from) => self.get_image(from)?,
None => {
let image = self.image()?;
self.write_gray(&image)?;
image
}
};
let (w, h) = self.size;
// SAFETY: the image and the surface were made on this display, with the same size.
let st = unsafe { (self.display.api.put_image)(self.display.dpy, surface, image.image_id, 0, 0, w, h, 0, 0, w, h) };
self.display.check(st, "vaPutImage")
}
}
impl Drop for Session {
fn drop(&mut self) {
let (api, dpy) = (self.display.api, self.display.dpy);
// SAFETY: each object below was made on this display and is destroyed once; the display
// itself is terminated afterwards (when the `display` field is dropped).
unsafe {
if let Some(image) = self.image.take() {
(api.destroy_image)(dpy, image.image_id);
}
if self.context != ffi::VA_INVALID_ID {
(api.destroy_context)(dpy, self.context);
}
if !self.surfaces.is_empty() {
let n = c_int::try_from(self.surfaces.len()).unwrap_or(0);
(api.destroy_surfaces)(dpy, self.surfaces.as_mut_ptr(), n);
}
if self.config != ffi::VA_INVALID_ID {
(api.destroy_config)(dpy, self.config);
}
}
}
}
/// Whether this system has a working VA-API driver on a DRM render node (checked once), with the
/// driver's description, or why not.
pub fn probe() -> &'static Result<String, String> {
static PROBE: OnceLock<Result<String, String>> = OnceLock::new();
PROBE.get_or_init(|| Display::open().map(|d| d.vendor().to_string()))
}

View File

@ -0,0 +1,388 @@
//! VA-API decoding against our software decoder (Linux): every picture of H.264 fixtures covering
//! the stream features the hardware path takes (CAVLC and CABAC, B-pyramids, several references,
//! weighted prediction, temporal direct, custom scaling matrices, several slices per picture,
//! 1080p / 2160p) must be identical (bit-exact planes, colour, pixel aspect, pts and presentation
//! order), also after `reset` + reseek and a mid-stream `flush`; a forced mid-stream failure must
//! continue in software with the software decoder's exact output; what the hardware path does not
//! take (interlaced, 10-bit, HEVC so far) is declined; damaged samples and parameter sets give
//! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without a
//! VA-API driver.
#![cfg(target_os = "linux")]
mod common;
use std::time::Duration;
use common::*;
use filmcraft_codecs::VideoDecoder;
use filmcraft_codecs::hw::NalStreamInfo;
use filmcraft_platform::HybridDecoder;
use filmcraft_platform::vaapi::VaDecoder;
/// The hardware decoder for a stream, or `None` (skip) when this machine has none for it.
fn hardware(s: &Stream) -> Option<Box<dyn VideoDecoder>> {
match filmcraft_platform::vaapi_factory(&s.entry) {
Some(Ok(d)) => {
assert!(d.name().starts_with("VA-API"), "{}", d.name());
Some(d)
}
Some(Err(e)) => panic!("factory error: {e}"),
None => {
eprintln!("SKIPPED: no VA-API hardware decoder for {}", s.entry.codec.name());
None
}
}
}
fn software(s: &Stream) -> Box<dyn VideoDecoder> {
filmcraft_codecs::software_video_decoder(&s.entry).unwrap()
}
/// H.264 fixtures for the stream features the hardware path takes: (file, x264 profile, x264
/// parameters). All 640x360 (coded 368: cropping), three seconds at 24 fps, keyframes every second.
const H264: &[(&str, &str, &str)] = &[
("va_h264_cbp_slices.mp4", "baseline", "keyint=24:min-keyint=24:scenecut=0:slices=3:ref=3"),
("va_h264_main_weighted.mp4", "main", "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0:ref=4:weightp=2:weightb=1"),
("va_h264_temporal_direct.mp4", "high", "bframes=2:b-pyramid=strict:direct=temporal:keyint=24:min-keyint=24:scenecut=0:ref=3:weightb=0"),
("va_h264_cqm_jvt.mp4", "high", "bframes=3:keyint=24:min-keyint=24:scenecut=0:cqm=jvt:8x8dct=1:slices=2"),
("va_h264_open_gop.mp4", "high", "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0:open-gop=1"),
];
fn h264_fixture(ff: &std::path::Path, name: &str, profile: &str, params: &str) -> Option<std::path::PathBuf> {
let src = "testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t";
fixture(ff, name, &["-f", "lavfi", "-i", src, "-c:v", "libx264", "-profile:v", profile, "-preset", "fast", "-x264-params", params, "-pix_fmt", "yuv420p"])
}
/// The small fixtures: the shared H.264 High one and the feature fixtures above.
fn small_streams(ff: &std::path::Path) -> Vec<(String, Stream)> {
let mut out = Vec::new();
if let Some(path) = named(ff, "h264_high.mp4") {
out.push(("h264_high.mp4".to_string(), read_stream(&path)));
}
for (name, profile, params) in H264 {
if let Some(path) = h264_fixture(ff, name, profile, params) {
out.push((name.to_string(), read_stream(&path)));
}
}
out
}
/// Every picture of `s` from the hardware decoder equals the software decoder's: whole stream,
/// after `reset` + reseek to each later sync sample, after a mid-stream `flush`, and a full pass
/// after all the resets.
fn parity(name: &str, s: &Stream) {
let Some(mut hw) = hardware(s) else { return };
let mut sw = software(s);
let a = decode_all(hw.as_mut(), &s.samples);
let b = decode_all(sw.as_mut(), &s.samples);
assert!(a.len() >= 40, "{name}: {} pictures", a.len());
assert_same(name, &a, &b);
assert!(hw.name().starts_with("VA-API"), "{name}: stayed in hardware ({})", hw.name());
// reset + reseek to each later sync sample, decode a stretch. After a seek to a non-IDR I picture
// (open GOP), the pictures shown before it are predicted from frames that were never decoded:
// both decoders conceal them (the software decoder from mid-gray stand-ins, the hardware also
// from motion data those stand-ins do not have), so only the pictures from the seek point on
// must match, as they must.
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
let syncs: Vec<usize> = (1..s.samples.len()).filter(|&i| s.sync[i]).collect();
assert!(!syncs.is_empty(), "{name}: more than one GOP");
for &k in syncs.iter().rev() {
let end = (k + 17).min(s.samples.len());
for d in [&mut hw, &mut sw] {
d.reset();
}
let mut a = decode_all(hw.as_mut(), &s.samples[k..end]);
let mut b = decode_all(sw.as_mut(), &s.samples[k..end]);
assert_eq!(a.len(), b.len(), "{name} from sample {k}: pictures");
if !info.nal_types(&s.samples[k].0).contains(&5) {
let start = s.samples[k].1;
a.retain(|f| f.pts >= start);
b.retain(|f| f.pts >= start);
}
assert!(!a.is_empty(), "{name}: pictures after seeking to {k}");
assert_same(&format!("{name} from sample {k}"), &a, &b);
}
// a flush in the middle of a GOP, then carrying on (references survive a flush)
for d in [&mut hw, &mut sw] {
d.reset();
}
let mid = s.samples.len() / 2;
let mut a = Vec::new();
let mut b = Vec::new();
for (d, out) in [(&mut hw, &mut a), (&mut sw, &mut b)] {
for (smp, pts) in &s.samples[..mid] {
out.extend(d.decode(smp, *pts).unwrap());
}
out.extend(d.flush());
out.extend(decode_all(d.as_mut(), &s.samples[mid..]));
}
assert_same(&format!("{name} with a flush at {mid}"), &a, &b);
// back to the start after seeking around: the whole stream again
hw.reset();
sw.reset();
assert_same(&format!("{name} after resets"), &decode_all(hw.as_mut(), &s.samples), &decode_all(sw.as_mut(), &s.samples));
assert!(hw.name().starts_with("VA-API"), "{name}: stayed in hardware ({})", hw.name());
// random-access / disposable answers match the software decoder's
for (smp, _) in &s.samples {
assert_eq!(hw.is_random_access(smp), sw.is_random_access(smp), "{name}");
assert_eq!(hw.is_disposable(smp), sw.is_disposable(smp), "{name}");
}
}
#[test]
fn bit_exact_with_the_software_decoder() {
let ff = filmcraft_testkit::require_ffmpeg!();
let streams = small_streams(&ff);
assert!(streams.len() > H264.len(), "fixtures");
for (name, s) in streams {
parity(&name, &s);
}
}
fn large(size: &str) {
let ff = filmcraft_testkit::require_ffmpeg!();
let (_, h) = size.split_once('x').unwrap();
let src = format!("testsrc2=s={size}:r=24:d=2,noise=alls=12:allf=t");
let args = [
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libx264",
"-profile:v",
"high",
"-preset",
"veryfast",
"-x264-params",
"bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0",
"-pix_fmt",
"yuv420p",
];
let Some(path) = fixture(&ff, &format!("h264_{h}.mp4"), &args) else { return };
parity(&format!("h264 {size}"), &read_stream(&path));
}
#[test]
fn h264_1080p_and_2160p_are_bit_exact() {
large("1920x1080");
large("3840x2160");
}
#[test]
fn mid_stream_failure_continues_in_software() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in small_streams(&ff) {
let name = name.as_str();
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
let Ok(_) = VaDecoder::new(info.clone()) else {
eprintln!("SKIPPED: no VA-API hardware decoder for {name}");
continue;
};
let reference = decode_all(software(&s).as_mut(), &s.samples);
assert!(!reference.is_empty(), "{name}");
// fail at the first sample, inside the first GOP, at a sync sample and just after one
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
for fail_at in [0, 5, k, k + 1, k + 9] {
let mut va = VaDecoder::new(info.clone()).unwrap();
va.fail_after(fail_at as u64);
let mut d = HybridDecoder::new(Box::new(va), s.entry.clone(), info.clone());
let before = filmcraft_codecs::hw::hw_stats().fallbacks;
let out = decode_all(&mut d, &s.samples);
assert!(!d.is_hardware(), "{name}: switched to software");
assert!(filmcraft_codecs::hw::hw_stats().fallbacks > before, "{name}: fallback counted");
assert_same(&format!("{name} failing at sample {fail_at}"), &out, &reference);
d.reset();
assert_same(&format!("{name} after the fallback and a reset"), &decode_all(&mut d, &s.samples), &reference);
}
}
}
/// What the hardware path does not take is declined (the software decoders keep it): interlaced
/// and 10-bit H.264, and HEVC (not decoded through VA-API yet).
#[test]
fn declines_what_the_hardware_path_does_not_take() {
let ff = filmcraft_testkit::require_ffmpeg!();
let src = "testsrc2=s=640x360:r=24:d=1";
let cases: [(&str, Vec<&str>); 2] = [
("va_h264_interlaced.mp4", vec!["-f", "lavfi", "-i", src, "-c:v", "libx264", "-x264-params", "interlaced=1:keyint=12", "-pix_fmt", "yuv420p"]),
("va_h264_10bit.mp4", vec!["-f", "lavfi", "-i", src, "-c:v", "libx264", "-profile:v", "high10", "-pix_fmt", "yuv420p10le"]),
];
for (name, args) in cases {
// an ffmpeg whose x264 lacks 10-bit leaves the fixture out
let Some(path) = fixture(&ff, name, &args) else { continue };
let s = read_stream(&path);
assert!(filmcraft_platform::vaapi_factory(&s.entry).is_none(), "{name}: declined");
}
if let Some(path) = named(&ff, "hevc_main.mp4") {
let s = read_stream(&path);
assert!(filmcraft_platform::vaapi_factory(&s.entry).is_none(), "HEVC: declined");
}
// Settings ▸ Playback ▸ Hardware decoding Off
let Some(path) = named(&ff, "h264_high.mp4") else { return };
let s = read_stream(&path);
filmcraft_codecs::hw::set_hardware_decoding(false);
let off = filmcraft_platform::vaapi_factory(&s.entry).is_none();
filmcraft_codecs::hw::set_hardware_decoding(true);
assert!(off, "declined while hardware decoding is Off");
}
/// Run `f` on a thread; fail if it panics or takes longer than `limit`.
fn bounded(what: &str, limit: Duration, f: impl FnOnce() + Send + 'static) {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
let _ = tx.send(r.is_ok());
});
match rx.recv_timeout(limit) {
Ok(true) => {}
Ok(false) => panic!("{what}: panicked"),
Err(_) => panic!("{what}: hung"),
}
}
#[test]
fn damaged_samples_and_parameter_sets_never_crash_or_hang() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in small_streams(&ff) {
let name = name.as_str();
if hardware(&s).is_none() {
continue;
}
// damaged samples (bit flips, truncation, corrupt length prefixes): through the hybrid, and
// straight into the hardware decoder (no software to fall back to)
for seed in 1..=8u64 {
let samples = s.samples.clone();
let entry = s.entry.clone();
bounded(&format!("{name} seed {seed}"), Duration::from_secs(60), move || {
let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut d = filmcraft_platform::vaapi_factory(&entry).unwrap().unwrap();
let info = NalStreamInfo::from_entry(&entry).unwrap().unwrap();
let mut raw = VaDecoder::new(info).unwrap();
for (i, (smp, pts)) in samples.iter().enumerate().take(40) {
let mut m = smp.clone();
match xorshift(&mut seed) % 4 {
0 => {
for _ in 0..1 + xorshift(&mut seed) % 8 {
let at = (xorshift(&mut seed) as usize) % m.len().max(1);
if let Some(b) = m.get_mut(at) {
*b ^= 1 << (xorshift(&mut seed) % 8);
}
}
}
1 => m.truncate((xorshift(&mut seed) as usize) % m.len().max(1)),
2 if m.len() > 4 => m[..4].copy_from_slice(&(xorshift(&mut seed) as u32).to_be_bytes()),
_ => {}
}
let _ = d.decode(&m, *pts);
let _ = raw.decode(&m, *pts);
if i % 13 == 12 {
d.reset();
raw.reset();
}
}
let _ = d.flush();
let _ = raw.flush();
});
}
// damaged parameter sets: the decoder declines, errors or decodes; never crashes
let filmcraft_isobmff::CodecConfig::Avc(a) = &s.entry.codec else { continue };
let rec = a.to_bytes();
let mut seed = 0x1234_5678u64;
for round in 0..24 {
let mut r = rec.clone();
if round % 3 == 2 {
r.truncate(r.len() * (round + 1) / 30);
} else {
for _ in 0..1 + round % 4 {
let at = 6 + (xorshift(&mut seed) as usize) % r.len().saturating_sub(6).max(1);
if let Some(b) = r.get_mut(at) {
*b ^= 1 << (xorshift(&mut seed) % 8);
}
}
}
let samples: Vec<_> = s.samples.iter().take(12).cloned().collect();
bounded(&format!("{name} record round {round}"), Duration::from_secs(60), move || {
let Ok(info) = NalStreamInfo::from_avcc(&r) else { return };
let Ok(mut va) = VaDecoder::new(info) else { return };
for (smp, pts) in &samples {
if va.decode(smp, *pts).is_err() {
break;
}
}
let _ = va.flush();
});
}
}
}
/// Creating and dropping decoders again and again (every clip on a timeline makes one) neither
/// fails nor leaks VA-API objects into the next one.
#[test]
fn repeated_initialisation() {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(path) = named(&ff, "h264_high.mp4") else { return };
let s = read_stream(&path);
let reference = decode_all(software(&s).as_mut(), &s.samples[..13]);
for round in 0..40 {
let Some(mut hw) = hardware(&s) else { return };
if round % 8 == 0 {
assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..13]), &reference);
}
}
}
/// Several decoders at once on different threads (the frame workers) give the software decoder's
/// pictures.
#[test]
fn concurrent_decoders() {
let ff = filmcraft_testkit::require_ffmpeg!();
let mut paths: Vec<_> = named(&ff, "h264_high.mp4").into_iter().collect();
for (name, profile, params) in H264.iter().take(3) {
paths.extend(h264_fixture(&ff, name, profile, params));
}
let jobs: Vec<_> = paths
.into_iter()
.chain(named(&ff, "h264_high.mp4"))
.map(|path| {
std::thread::spawn(move || {
let s = read_stream(&path);
let Some(mut hw) = hardware(&s) else { return };
let n = s.samples.len().min(40);
let a = decode_all(hw.as_mut(), &s.samples[..n]);
hw.reset();
let b = decode_all(hw.as_mut(), &s.samples[..n]);
let c = decode_all(software(&s).as_mut(), &s.samples[..n]);
assert_same("concurrent", &a, &c);
assert_same("concurrent, after a reset", &b, &c);
})
})
.collect();
for j in jobs {
j.join().unwrap();
}
}
/// A decoder used and moved between threads (decoders migrate between frame workers).
#[test]
fn decoder_moves_between_threads() {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(path) = named(&ff, "h264_high.mp4") else { return };
let s = read_stream(&path);
let Some(mut hw) = hardware(&s) else { return };
let reference = decode_all(software(&s).as_mut(), &s.samples[..30]);
let samples = s.samples.clone();
let (hw, out) = std::thread::spawn(move || {
let out = decode_all(hw.as_mut(), &samples[..30]);
(hw, out)
})
.join()
.unwrap();
assert_same("first thread", &out, &reference);
let mut hw = hw;
hw.reset();
let samples = s.samples.clone();
let out = std::thread::spawn(move || decode_all(hw.as_mut(), &samples[..30])).join().unwrap();
assert_same("second thread", &out, &reference);
}

View File

@ -25,8 +25,8 @@ Containment rules:
1. The crate does not use `lints.workspace = true`. Its own `[lints]` table copies the workspace 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 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`, and `clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`,
`nvenc::ffi` / `nvenc::session` on Windows) carry `#[allow(unsafe_code)]`; the rest of the crate `nvenc::ffi` / `nvenc::session` on Windows, 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 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`) has no `unsafe`.
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.
@ -101,3 +101,27 @@ there will not be one (pure Rust, clean-room: x265 is GPL), so `Format::Hevc` ex
OS has a hardware encoder. Choosing the format is the opt-in, `filmcraft_export::available` asks a OS has a hardware encoder. Choosing the format is the opt-in, `filmcraft_export::available` asks a
probe the platform crate registers, and what the hardware path does not take (two-pass, odd sizes, 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. 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 decoding through VA-API (`vaapi/`). The rules above
hold; VA-API differs from the other two backends in three ways.
- **libva is loaded at run time** (`libva.so.2`, `libva-drm.so.2`, through `libloading`: ISC, already
in the lockfile through wgpu). Building needs no libva headers, and a system without libva, a DRM
render node or a working driver starts as before and decodes in software (`register()` reports
`Unavailable`). The declarations (`vaapi/ffi.rs`) are transcribed from libva's MIT-licensed
`va.h`, and `vaapi/abi_tests.rs` checks sizes, offsets, constants and bit-field positions against a
C compiler's view of it, as for NVENC. Only `vaapi::va` carries `#[allow(unsafe_code)]`; the
declarations, the H.264 front end (`vaapi::h264`) and the decoder (`VaDecoder`) are safe code.
- **Decoding is stateless:** the host parses the stream and keeps the decoded picture buffer; the GPU
only decodes slice data. FilmCraft does that host side with the software decoder's own parsers and
DPB (`filmcraft_h264::dpb` is generic over what a picture is), so the two decoders decide alike by
construction, and the front end is tested on every OS against a stand-in for the hardware. The
one place they cannot agree is concealment: pictures predicted from frames that were never decoded
(the leading pictures of an open GOP after a seek) are concealed by both but can differ.
- **One callback into Rust:** libva's error messages go to our log through a callback that runs under
`catch_unwind`; its info messages are turned off.
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`.

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 / VP9 / AV1 on Windows; VA-API H.264 on Linux, with our own H.264 parsers and DPB driving the stateless hardware; 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)) |
| `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)) |

View File

@ -349,6 +349,29 @@ Software AV1 decodes 4K at 5.5 fps here, so it never plays in real time; with th
plays without a drop. As on the other codecs, the hardware ignores draft mode and what is left on the 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. CPU is the readback and plane conversion.
## Results (Linux VA-API H.264 hardware decoding, Off → Auto)
Same commit, Settings ▸ Playback ▸ Hardware decoding switched with the bench flag
(`cargo xtask bench --sections decode --only dec_h --repeat 3 --hw off|auto`), 2026-10-08, Intel
Core i5-13500H with Iris Xe graphics (Raptor Lake-P), 7.4 GB RAM, Intel iHD driver 26.1.2 through
libva 2.22, load average 5–15. Only H.264 goes through VA-API so far; the HEVC rows decode in
software either way and show the run-to-run spread. The decoded pictures are identical
(bit-exact parity tests, `crates/platform/tests/vaapi.rs`).
| codec | size | CPU ms/frame Off → **Auto** | fps Off → **Auto** (best of 3) | hw frames |
|---|---|---|---|---|
| H.264 | 1080p | 103.7 → **2.3** | 133 → **263** | 360 |
| H.264 | 2160p | 416.5 → **11.4** | 32 → **56** | 216 |
| HEVC | 1080p | 63.2 → 46.5 | 99 → 131 | 0 |
| HEVC | 2160p | 349.5 → 353.7 | 30 → 35 | 0 |
| HEVC Main 10 | 2160p | 348.9 → 352.6 | 29 → 29 | 0 |
What is left on the CPU per H.264 frame is the host side of stateless decoding (parsing, DPB,
filling the VA buffers) and the read-back: `vaGetImage` into an NV12 image and the copy into
planar Y'CbCr. One picture is decoded at a time and read back as soon as the DPB outputs it, so
4K throughput (56 fps) is bound by that round trip, not by the video engine; overlapping decode
and read-back, or zero-copy into wgpu, would raise it.
## Results (GPU1: blend modes on the GPU compositor, #30, before → after) ## 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 Before = this change with the old whole-frame CPU fallback for non-Normal blend modes put back