{
+ let (rt_format, _, _) = formats(geometry.bits)?;
let display = Display::open()?;
let api = display.api;
let dpy = display.dpy;
@@ -233,11 +245,11 @@ impl Session {
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");
+ if attr.value & rt_format == 0 {
+ why = format!("profile {profile} does not decode to {}-bit 4:2:0", geometry.bits);
continue;
}
- attr.value = ffi::VA_RT_FORMAT_YUV420;
+ attr.value = rt_format;
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) };
@@ -255,7 +267,7 @@ impl Session {
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) };
+ let st = unsafe { (api.create_surfaces)(dpy, rt_format, 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")?);
@@ -294,16 +306,21 @@ impl Session {
self.display.check(st, "vaRenderPicture")
}
- fn decode_into(
+ /// Decode one picture into `surface`: the picture parameters `pic`, the scaling matrices `iq`
+ /// when given, and a parameter + data buffer per slice; the buffers made go into `buffers`.
+ fn decode_into(
&self,
surface: VASurfaceID,
buffers: &mut Vec,
- pic: &ffi::VAPictureParameterBufferH264,
- iq: &ffi::VAIQMatrixBufferH264,
- slices: &[(ffi::VASliceParameterBufferH264, Vec)],
+ pic: &P,
+ iq: Option<&Q>,
+ slices: &[(S, Vec)],
) -> Result<(), String> {
buffers.push(self.value_buffer(ffi::VAPictureParameterBufferType, pic)?);
- buffers.push(self.value_buffer(ffi::VAIQMatrixBufferType, iq)?);
+ if let Some(iq) = iq {
+ buffers.push(self.value_buffer(ffi::VAIQMatrixBufferType, iq)?);
+ }
+ let head = buffers.len();
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())?);
@@ -312,8 +329,9 @@ impl Session {
// 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) {
+ let (picture, slice_buffers) = buffers.split_at_mut(head);
+ let mut rendered = self.render(picture);
+ for pair in slice_buffers.chunks_mut(2) {
if rendered.is_err() {
break;
}
@@ -330,7 +348,8 @@ impl Session {
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 (_, fourcc, bits_per_pixel) = formats(self.geometry.bits)?;
+ let mut format = VAImageFormat { fourcc, byte_order: 1, bits_per_pixel, ..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.
@@ -354,7 +373,8 @@ impl Session {
Ok(image)
}
- /// Every sample of `image` set to 128 (mid-gray in NV12).
+ /// Every sample of `image` set to mid-gray: 128 in NV12, 512 (0x8000 in the high bits of
+ /// little-endian 16-bit words) in P010.
fn write_gray(&self, image: &VAImage) -> Result<(), String> {
let api = self.display.api;
let mut ptr: *mut c_void = std::ptr::null_mut();
@@ -363,8 +383,15 @@ impl Session {
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::(), 128, image.data_size as usize) };
+ // unmapped below; the slice does not outlive this block.
+ let data = unsafe { std::slice::from_raw_parts_mut(ptr.cast::(), image.data_size as usize) };
+ if self.geometry.bits > 8 {
+ for w in data.as_chunks_mut::<2>().0 {
+ w.copy_from_slice(&0x8000u16.to_le_bytes());
+ }
+ } else {
+ data.fill(128);
+ }
}
// SAFETY: the buffer was mapped above.
let st = unsafe { (api.unmap_buffer)(self.display.dpy, image.buf) };
@@ -372,6 +399,18 @@ impl Session {
if ptr.is_null() { Err("vaMapBuffer returned no data".into()) } else { Ok(()) }
}
+ fn decode_picture(&mut self, target: usize, pic: &P, iq: Option<&Q>, slices: &[(S, Vec)]) -> 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_image(&self, image: &VAImage) -> Result {
let api = self.display.api;
let mut ptr: *mut c_void = std::ptr::null_mut();
@@ -394,10 +433,10 @@ impl Session {
}
}
-/// The two planes of a mapped NV12 image.
+/// The two planes of a mapped NV12 / P010 image.
fn biplanar_view<'a>(image: &VAImage, data: &'a [u8]) -> Result, 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));
+ if !matches!(image.format.fourcc, ffi::VA_FOURCC_NV12 | ffi::VA_FOURCC_P010) || image.num_planes < 2 {
+ return Err(format!("the read-back image is not NV12 / P010 (fourcc {:#x}, {} planes)", image.format.fourcc, image.num_planes));
}
let [p0, p1, _] = image.pitches;
if p0 != p1 {
@@ -413,22 +452,24 @@ impl Accel for Session {
&self.surfaces
}
- fn decode(
+ fn decode_h264(
&mut self,
target: usize,
pic: &ffi::VAPictureParameterBufferH264,
iq: &ffi::VAIQMatrixBufferH264,
slices: &[(ffi::VASliceParameterBufferH264, Vec)],
) -> 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
+ self.decode_picture(target, pic, Some(iq), slices)
+ }
+
+ fn decode_hevc(
+ &mut self,
+ target: usize,
+ pic: &ffi::VAPictureParameterBufferHEVC,
+ iq: Option<&ffi::VAIQMatrixBufferHEVC>,
+ slices: &[(ffi::VASliceParameterBufferHEVC, Vec)],
+ ) -> Result<(), String> {
+ self.decode_picture(target, pic, iq, slices)
}
fn read(&mut self, index: usize) -> Result {
diff --git a/crates/platform/tests/vaapi.rs b/crates/platform/tests/vaapi.rs
index d79caabd..507e7768 100644
--- a/crates/platform/tests/vaapi.rs
+++ b/crates/platform/tests/vaapi.rs
@@ -1,10 +1,12 @@
-//! 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
+//! VA-API decoding against our software decoders (Linux): every picture of H.264 and HEVC fixtures
+//! covering the stream features the hardware path takes (H.264: CAVLC and CABAC, B-pyramids,
+//! several references, weighted prediction, temporal direct, custom scaling matrices, several
+//! slices per picture; HEVC Main and Main 10: open GOP with RASL pictures, scaling lists, several
+//! slices with wavefront parallel processing, weighted prediction, transform skip and AMP; 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
+//! take (interlaced and 10-bit H.264, 4:2:2 HEVC) 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")]
@@ -53,17 +55,44 @@ fn h264_fixture(ff: &std::path::Path, name: &str, profile: &str, params: &str) -
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.
+/// HEVC fixtures for the stream features the hardware path takes: (file, pixel format, x265
+/// parameters). 640x360, three seconds at 24 fps, keyframes every second.
+const HEVC: &[(&str, &str, &str)] = &[
+ ("va_hevc_scaling_lists.mp4", "yuv420p", "log-level=error:bframes=3:keyint=24:min-keyint=24:scenecut=0:scaling-list=default"),
+ ("va_hevc_slices_wpp.mp4", "yuv420p10le", "log-level=error:bframes=3:keyint=24:min-keyint=24:scenecut=0:slices=3:wpp=1"),
+ ("va_hevc_weighted.mp4", "yuv420p", "log-level=error:bframes=3:keyint=24:min-keyint=24:scenecut=0:weightp=1:weightb=1:ref=4"),
+ ("va_hevc_tskip_amp.mp4", "yuv420p10le", "log-level=error:bframes=4:b-pyramid=1:keyint=24:min-keyint=24:scenecut=0:tskip=1:amp=1:rect=1:open-gop=1"),
+];
+
+fn hevc_fixture(ff: &std::path::Path, name: &str, pix_fmt: &str, params: &str) -> Option {
+ let src = "testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t";
+ let profile = if pix_fmt.contains("10") { "main10" } else { "main" };
+ fixture(
+ ff,
+ name,
+ &["-f", "lavfi", "-i", src, "-c:v", "libx265", "-preset", "fast", "-profile:v", profile, "-tag:v", "hvc1", "-x265-params", params, "-pix_fmt", pix_fmt],
+ )
+}
+
+/// The small fixtures: the shared H.264 High, HEVC Main (open GOP) and Main 10 ones 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 in ["h264_high.mp4", "hevc_main.mp4", "hevc_main10.mp4"] {
+ if let Some(path) = named(ff, name) {
+ out.push((name.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)));
}
}
+ for (name, pix_fmt, params) in HEVC {
+ if let Some(path) = hevc_fixture(ff, name, pix_fmt, params) {
+ out.push((name.to_string(), read_stream(&path)));
+ }
+ }
out
}
@@ -95,7 +124,7 @@ fn parity(name: &str, s: &Stream) {
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) {
+ if info.codec == filmcraft_codecs::hw::NalCodec::H264 && !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);
@@ -103,21 +132,24 @@ fn parity(name: &str, s: &Stream) {
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());
+ // a flush in the middle of a GOP, then carrying on: H.264 only, where references survive a
+ // flush (an HEVC flush ends the stream; the GOP cache always seeks after one)
+ if info.codec == filmcraft_codecs::hw::NalCodec::H264 {
+ for d in [&mut hw, &mut sw] {
+ d.reset();
}
- out.extend(d.flush());
- out.extend(decode_all(d.as_mut(), &s.samples[mid..]));
+ 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);
}
- 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();
@@ -130,6 +162,40 @@ fn parity(name: &str, s: &Stream) {
}
}
+/// [`parity`] for large streams, with little memory: the two decoders run side by side and each
+/// picture is compared and dropped as soon as both have output it (a 4K 10-bit stream held whole
+/// takes gigabytes). The whole stream, then from the last sync sample after a reset.
+fn parity_streaming(name: &str, s: &Stream) {
+ let Some(mut hw) = hardware(s) else { return };
+ let mut sw = software(s);
+ let last_sync = (1..s.samples.len()).rev().find(|&i| s.sync[i]).unwrap_or(0);
+ for start in [0, last_sync] {
+ hw.reset();
+ sw.reset();
+ let (mut a, mut b) = (std::collections::VecDeque::new(), std::collections::VecDeque::new());
+ let mut compared = 0usize;
+ let mut compare = |a: &mut std::collections::VecDeque,
+ b: &mut std::collections::VecDeque| {
+ while !a.is_empty() && !b.is_empty() {
+ let (Some(x), Some(y)) = (a.pop_front(), b.pop_front()) else { break };
+ assert_same(&format!("{name} from sample {start}"), &[x], &[y]);
+ compared += 1;
+ }
+ };
+ for (smp, pts) in &s.samples[start..] {
+ a.extend(hw.decode(smp, *pts).unwrap());
+ b.extend(sw.decode(smp, *pts).unwrap());
+ compare(&mut a, &mut b);
+ }
+ a.extend(hw.flush());
+ b.extend(sw.flush());
+ compare(&mut a, &mut b);
+ assert!(a.is_empty() && b.is_empty(), "{name} from sample {start}: picture counts differ");
+ assert!(compared >= s.samples.len().saturating_sub(start) / 2, "{name} from sample {start}: {compared} pictures");
+ assert!(hw.name().starts_with("VA-API"), "{name}: stayed in hardware ({})", hw.name());
+ }
+}
+
#[test]
fn bit_exact_with_the_software_decoder() {
let ff = filmcraft_testkit::require_ffmpeg!();
@@ -161,7 +227,7 @@ fn large(size: &str) {
"yuv420p",
];
let Some(path) = fixture(&ff, &format!("h264_{h}.mp4"), &args) else { return };
- parity(&format!("h264 {size}"), &read_stream(&path));
+ parity_streaming(&format!("h264 {size}"), &read_stream(&path));
}
#[test]
@@ -170,6 +236,42 @@ fn h264_1080p_and_2160p_are_bit_exact() {
large("3840x2160");
}
+fn large_hevc(size: &str, pix_fmt: &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 ten = pix_fmt.contains("10");
+ let args = [
+ "-f",
+ "lavfi",
+ "-i",
+ &src,
+ "-c:v",
+ "libx265",
+ "-preset",
+ "ultrafast",
+ "-profile:v",
+ if ten { "main10" } else { "main" },
+ "-tag:v",
+ "hvc1",
+ "-x265-params",
+ "log-level=error:bframes=4:keyint=24:min-keyint=24:scenecut=0:open-gop=1",
+ "-pix_fmt",
+ pix_fmt,
+ ];
+ let name = format!("va_hevc_{}_{h}.mp4", if ten { "main10" } else { "main" });
+ let Some(path) = fixture(&ff, &name, &args) else { return };
+ parity_streaming(&name, &read_stream(&path));
+}
+
+#[test]
+fn hevc_main_and_main10_1080p_and_2160p_are_bit_exact() {
+ for size in ["1920x1080", "3840x2160"] {
+ large_hevc(size, "yuv420p");
+ large_hevc(size, "yuv420p10le");
+ }
+}
+
#[test]
fn mid_stream_failure_continues_in_software() {
let ff = filmcraft_testkit::require_ffmpeg!();
@@ -215,9 +317,10 @@ fn declines_what_the_hardware_path_does_not_take() {
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 args = ["-f", "lavfi", "-i", src, "-c:v", "libx265", "-tag:v", "hvc1", "-x265-params", "log-level=error:keyint=12", "-pix_fmt", "yuv422p10le"];
+ if let Some(path) = fixture(&ff, "va_hevc_422.mp4", &args) {
let s = read_stream(&path);
- assert!(filmcraft_platform::vaapi_factory(&s.entry).is_none(), "HEVC: declined");
+ assert!(filmcraft_platform::vaapi_factory(&s.entry).is_none(), "HEVC 4:2:2: declined");
}
// Settings ▸ Playback ▸ Hardware decoding Off
let Some(path) = named(&ff, "h264_high.mp4") else { return };
diff --git a/docs/adr/0001-platform-ffi.md b/docs/adr/0001-platform-ffi.md
index a6da16e0..4c6eb7aa 100644
--- a/docs/adr/0001-platform-ffi.md
+++ b/docs/adr/0001-platform-ffi.md
@@ -104,7 +104,7 @@ a machine without the encoder) is an error naming the reason instead of a differ
## 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
+The Linux backend this decision named: H.264 and HEVC decoding through VA-API (`vaapi/`). The rules above
hold; VA-API differs from the other two backends in three ways.
- **libva is loaded at run time** (`libva.so.2`, `libva-drm.so.2`, through `libloading`: ISC, already
@@ -115,11 +115,12 @@ hold; VA-API differs from the other two backends in three ways.
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
+ only decodes slice data. FilmCraft does that host side with the software decoders' own parsers and
+ DPBs (`filmcraft_h264::dpb` and `filmcraft_hevc::dpb` are 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.
+ (the leading pictures of an open H.264 GOP after a seek) are concealed by both but can differ;
+ HEVC pictures with references that were never decoded go to the software decoder instead.
- **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.
diff --git a/docs/architecture.md b/docs/architecture.md
index dac14710..3541e9cd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -72,7 +72,7 @@ and `filmcraft-cli`.
| `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 |
| `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 / 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)) |
+| `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 / HEVC on Linux, with our own parsers and DPBs 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-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)) |
diff --git a/docs/performance.md b/docs/performance.md
index ba41ef56..e6ac4eaf 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -349,7 +349,7 @@ 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
CPU is the readback and plane conversion.
-## Results (Linux VA-API H.264 hardware decoding, Off → Auto)
+## Results (Linux VA-API H.264 and HEVC 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
@@ -372,6 +372,25 @@ planar Y'CbCr. One picture is decoded at a time and read back as soon as the DPB
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.
+### HEVC through VA-API (HW5 follow-up)
+
+Same machine and commands on 2026-10-09 with HEVC added, at a lower load (the software decoders ran
+faster than in the table above; compare within a row). H.264 is unchanged; every HEVC stream now
+goes through VA-API too (216–360 hardware frames, no fallbacks), bit-exact with the software decoder.
+
+| codec | size | CPU ms/frame Off → **Auto** | fps Off → **Auto** (best of 3) |
+|---|---|---|---|
+| H.264 | 1080p | 57.3 → **2.6** | 227 → **241** |
+| H.264 | 2160p | 231.8 → **12.6** | 58 → **52** |
+| HEVC | 1080p | 45.6 → **2.3** | 141 → **341** |
+| HEVC | 2160p | 202.5 → **10.1** | 47 → **76** |
+| HEVC Main 10 | 2160p | 211.6 → **18.4** | 42 → **46** |
+
+At this lower load the multi-threaded software decoders keep up with the one-picture-at-a-time
+hardware path on throughput for H.264 and 10-bit HEVC, at 5–20 % of their CPU time; under load
+(the run above) the hardware path is ahead on both. 10-bit pictures cost more to read back (P010 is
+twice the bytes of NV12 and is shifted down into 16-bit planes).
+
## 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