feat(oakcodec): hardware video decoding by default on all platforms
FFmpeg 8 removed the standalone hardware decoders (h264_videotoolbox/ vaapi/nvdec/d3d11va no longer exist in its configure) — hardware decode now only exists as a hwaccel attached to the software decoder. The new oakcodec::hwdecode module therefore opens the regular decoder with the platform's hardware device context attached (VideoToolbox on macOS, VA-API then NVDEC on Linux, D3D11VA then NVDEC on Windows): FFmpeg engages the matching hwaccel, decodes into hardware surfaces, and we transfer them to system memory (NV12/P010) ahead of swscale. - HardwareDecoding config switch, default ON by mandate; a checkbox in Preferences > Rendering (EN/ZH); device creation failure skips to the next candidate and finally to software; a decode-time failure on a hardware session reopens it as software and retries once. - hw_decoder_name() observability hook plus a HW_TRANSFERS counter so tests can prove the hwaccel really engaged (not silently software). - Verification: demo.mp4 H.264 decodes through VideoToolbox with a transferred hardware surface, and the pixels match the software decode within 0.05; switch off forces software. - build-ffmpeg.sh also enables nvdec when ffnvcodec headers exist.
This commit is contained in:
+105
-12
@@ -219,6 +219,20 @@ impl FFmpegDecoder {
|
||||
state: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardware device driving this session (`None` = software
|
||||
/// decoding), as a display name (`videotoolbox` / `vaapi` /
|
||||
/// `cuda/nvdec` / `d3d11va`). An observability hook for the
|
||||
/// hardware-decode config and the first-frame fallback — tests
|
||||
/// assert on it to prove the hardware path is really taken (and
|
||||
/// really abandoned on fallback).
|
||||
pub fn hw_decoder_name(&self) -> Option<String> {
|
||||
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
state
|
||||
.as_ref()
|
||||
.and_then(|s| s.hw_device.map(crate::hwdecode::device_type_name))
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FFmpegDecoder {
|
||||
@@ -305,8 +319,17 @@ impl Decoder for FFmpegDecoder {
|
||||
// the requested timestamp.
|
||||
// Note: the Rust `RetrieveVideoParams` carries no cancellation atom
|
||||
// (dropped from the C++ struct), so decoding is not cancellable here.
|
||||
let f = state
|
||||
.retrieve_frame(&p.time, p.time == crate::decoder::k_any_timecode(), None)?
|
||||
let mut decoded = state.retrieve_frame(&p.time, p.time == crate::decoder::k_any_timecode(), None);
|
||||
if decoded.is_err() && state.hw_device.is_some() {
|
||||
// First-frame hardware fallback: the hardware decoder opened
|
||||
// but cannot actually decode this stream (unsupported profile
|
||||
// / driver issue at decode time). Reopen as software and retry
|
||||
// once — this is the automatic fallback path; the config
|
||||
// switch is the manual one.
|
||||
state.reopen_software()?;
|
||||
decoded = state.retrieve_frame(&p.time, p.time == crate::decoder::k_any_timecode(), None);
|
||||
}
|
||||
let f = decoded?
|
||||
.ok_or_else(|| fail("no video frame available at the requested time"))?;
|
||||
|
||||
let (w, h, bytes) = state.scale_video_to_f32(f, p.force_range)?;
|
||||
@@ -410,8 +433,14 @@ enum PacketFeed {
|
||||
/// One opened (filename, stream) decode session.
|
||||
struct DecoderState {
|
||||
input: ffmpeg::format::context::Input,
|
||||
/// The media file (kept for the hardware-decode fallback reopen).
|
||||
filename: String,
|
||||
stream_index: usize,
|
||||
inner: DecoderInner,
|
||||
/// The hardware device in use (`None` = software decoding). Kept so
|
||||
/// a hardware surface can be detected and the first-frame fallback
|
||||
/// can reopen the session as software.
|
||||
hw_device: Option<sys::AVHWDeviceType>,
|
||||
stream_time_base: FfRational,
|
||||
stream_start_time: i64,
|
||||
/// Format start time in microseconds (`AV_TIME_BASE`).
|
||||
@@ -475,6 +504,12 @@ struct AudioResampler {
|
||||
impl DecoderState {
|
||||
/// Open `(filename, stream_index)` for decoding.
|
||||
fn open(stream: &CodecStream) -> crate::error::Result<DecoderState> {
|
||||
Self::open_impl(stream, true)
|
||||
}
|
||||
|
||||
/// Open with the hardware-decode preference explicitly on/off (the
|
||||
/// off path is the hardware fallback's software reopen).
|
||||
fn open_impl(stream: &CodecStream, allow_hw: bool) -> crate::error::Result<DecoderState> {
|
||||
let mut dict = Dictionary::new();
|
||||
dict.set("analyzeduration", "5000000");
|
||||
dict.set("probesize", "20000000");
|
||||
@@ -488,8 +523,32 @@ impl DecoderState {
|
||||
let params = fstream.parameters();
|
||||
let medium = params.medium();
|
||||
let codec_id = params.id();
|
||||
let codec = ffmpeg::decoder::find(codec_id)
|
||||
.ok_or_else(|| fail(format!("no decoder for codec {codec_id:?}")))?;
|
||||
|
||||
// Hardware decode is the mandated default: on video streams open
|
||||
// the regular decoder with the platform's hardware device
|
||||
// attached (the FFmpeg 8 hwaccel model: VideoToolbox / VA-API /
|
||||
// NVDEC / D3D11VA, config-gated); pure software is the fallback
|
||||
// when no hardware device is available.
|
||||
let mut hw_device: Option<sys::AVHWDeviceType> = None;
|
||||
let hw_opened = if allow_hw
|
||||
&& matches!(medium, MediaType::Video)
|
||||
&& crate::hwdecode::hardware_decoding_enabled()
|
||||
{
|
||||
ffmpeg::decoder::find(codec_id).and_then(|codec| {
|
||||
crate::hwdecode::device_type_candidates()
|
||||
.iter()
|
||||
.find_map(|device_type| {
|
||||
crate::hwdecode::open_hw_accel(¶ms, codec, *device_type).map(
|
||||
|(opened, device_type)| {
|
||||
hw_device = Some(device_type);
|
||||
opened
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Per-medium stream parameters (mirroring the bridge stream info);
|
||||
// read before `params` is moved into the codec context below.
|
||||
@@ -503,13 +562,20 @@ impl DecoderState {
|
||||
input_channel_layout_mask = unsafe { ChannelLayout::from((*raw).ch_layout) }.bits();
|
||||
}
|
||||
|
||||
let mut open_opts = Dictionary::new();
|
||||
open_opts.set("threads", "auto");
|
||||
let opened = ffmpeg::codec::Context::from_parameters(params)
|
||||
.map_err(ffmpeg_err)?
|
||||
.decoder()
|
||||
.open_as_with(codec, open_opts)
|
||||
.map_err(ffmpeg_err)?;
|
||||
let opened = match hw_opened {
|
||||
Some(opened) => opened,
|
||||
None => {
|
||||
let codec = ffmpeg::decoder::find(codec_id)
|
||||
.ok_or_else(|| fail(format!("no decoder for codec {codec_id:?}")))?;
|
||||
let mut open_opts = Dictionary::new();
|
||||
open_opts.set("threads", "auto");
|
||||
ffmpeg::codec::Context::from_parameters(params)
|
||||
.map_err(ffmpeg_err)?
|
||||
.decoder()
|
||||
.open_as_with(codec, open_opts)
|
||||
.map_err(ffmpeg_err)?
|
||||
}
|
||||
};
|
||||
|
||||
let inner = match medium {
|
||||
MediaType::Video => DecoderInner::Video(ffmpeg::codec::decoder::Video(opened)),
|
||||
@@ -548,8 +614,10 @@ impl DecoderState {
|
||||
|
||||
Ok(DecoderState {
|
||||
input,
|
||||
filename: stream.filename().to_string(),
|
||||
stream_index,
|
||||
inner,
|
||||
hw_device,
|
||||
stream_time_base,
|
||||
stream_start_time,
|
||||
format_start_time,
|
||||
@@ -562,6 +630,16 @@ impl DecoderState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reopen the session as a pure software decode (the hardware decoder
|
||||
/// opened but cannot decode this stream). Swaps the state in place;
|
||||
/// the retry re-seeks to the requested frame on the fresh session.
|
||||
fn reopen_software(&mut self) -> crate::error::Result<()> {
|
||||
let stream = CodecStream::with_block(self.filename.clone(), self.stream_index as i32, None);
|
||||
let mut fresh = Self::open_impl(&stream, false)?;
|
||||
std::mem::swap(self, &mut fresh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_packet(&mut self, packet: &ffmpeg::packet::Packet) -> crate::error::Result<()> {
|
||||
match &mut self.inner {
|
||||
DecoderInner::Video(d) => d.send_packet(packet).map_err(ffmpeg_err),
|
||||
@@ -738,7 +816,22 @@ impl DecoderState {
|
||||
}
|
||||
|
||||
let frame = match self.pull()? {
|
||||
Pull::Frame(DecodedFrame::Video(f)) => f,
|
||||
Pull::Frame(DecodedFrame::Video(f)) => {
|
||||
// A hardware decoder yields hardware surfaces
|
||||
// (AV_PIX_FMT_VIDEOTOOLBOX/VAAPI/CUDA/D3D11*):
|
||||
// transfer to system memory so the cache and swscale
|
||||
// only ever see CPU frames.
|
||||
// SAFETY: plain read of the frame's format field.
|
||||
let raw_format = unsafe { (*f.as_ptr()).format };
|
||||
if self.hw_device.is_some()
|
||||
&& crate::hwdecode::is_hw_format(unsafe {
|
||||
std::mem::transmute::<i32, sys::AVPixelFormat>(raw_format)
|
||||
}) {
|
||||
crate::hwdecode::transfer_to_cpu(&f)?
|
||||
} else {
|
||||
f
|
||||
}
|
||||
}
|
||||
Pull::Frame(_) => unreachable!("video session yields only video frames"),
|
||||
Pull::Eof => {
|
||||
// Handle an "expected" EOF by using the last cached frame
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Hardware video decoding (user-mandated default): the platform's
|
||||
//! hardware acceleration is preferred over pure software decoding, with
|
||||
//! a config switch and an automatic software fallback.
|
||||
//!
|
||||
//! **FFmpeg 8 removed the standalone hardware decoders** (`h264_videotoolbox`,
|
||||
//! `h264_vaapi`, `h264_nvdec`, `h264_d3d11va` are all gone from its
|
||||
//! configure): hardware decode now only exists as a *hwaccel* attached
|
||||
//! to the software decoder. The model here is therefore uniform across
|
||||
//! platforms: create the platform's hardware device context
|
||||
//! (`av_hwdevice_ctx_create`), set it as `hw_device_ctx` on the codec
|
||||
//! context of the regular decoder, and FFmpeg automatically engages the
|
||||
//! matching hwaccel (`h264_videotoolbox_hwaccel` & co) on open. Codecs
|
||||
//! without a matching hwaccel silently stay software — the pipeline
|
||||
//! below only transfers frames whose pixel format is actually a
|
||||
//! hardware surface.
|
||||
//!
|
||||
//! - **macOS**: `AV_HWDEVICE_TYPE_VIDEOTOOLBOX`
|
||||
//! - **Linux**: `VAAPI`, then `CUDA` (NVDEC)
|
||||
//! - **Windows**: `D3D11VA`, then `CUDA` (NVDEC)
|
||||
//!
|
||||
//! Device creation can fail on machines without the device/driver (a
|
||||
//! headless Linux box, no NVIDIA GPU) — the candidate is skipped and
|
||||
//! the next one (or the software decoder) is used. Hardware frames
|
||||
//! (`AV_PIX_FMT_VIDEOTOOLBOX` / `VAAPI` / `CUDA` / `D3D11VA_VLD` /
|
||||
//! `D3D11`) are transferred to system memory with
|
||||
//! `av_hwframe_transfer_data` before the swscale conversion.
|
||||
|
||||
use ffmpeg::ffi as sys;
|
||||
use ffmpeg::Dictionary;
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Number of hardware frames transferred to system memory so far
|
||||
/// (process-wide). An observability counter: a hardware decode that
|
||||
/// never produces a hardware surface stays at zero, so tests can prove
|
||||
/// the hwaccel really engaged rather than silently staying software.
|
||||
pub static HW_TRANSFERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The config key of the hardware-decode switch (1 = prefer hardware,
|
||||
/// 0 = force software). Default ON by user mandate.
|
||||
pub const CONFIG_KEY_HARDWARE_DECODING: &str = "HardwareDecoding";
|
||||
|
||||
/// Whether hardware decoding is preferred (the config switch). Default
|
||||
/// ON by user mandate; only an explicit `"false"` turns it off (the
|
||||
/// string accessor, same convention as the app's config helpers — the
|
||||
/// store's typed `get_bool` only parses pre-typed Bool entries).
|
||||
pub fn hardware_decoding_enabled() -> bool {
|
||||
match oakcommon::configstore::ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_HARDWARE_DECODING)
|
||||
{
|
||||
Ok(value) => value != "false",
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardware device types to try, most preferred first. On a machine
|
||||
/// without the device/driver the candidate fails creation and the next
|
||||
/// one is tried; the software decoder is the final fallback.
|
||||
pub fn device_type_candidates() -> &'static [sys::AVHWDeviceType] {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
&[sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX]
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
&[
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
]
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
&[
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// A display name for a device type (status reporting and tests).
|
||||
pub fn device_type_name(device_type: sys::AVHWDeviceType) -> &'static str {
|
||||
match device_type {
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX => "videotoolbox",
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI => "vaapi",
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA => "cuda/nvdec",
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA => "d3d11va",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to open the software codec with a hardware device context of
|
||||
/// `device_type` attached — FFmpeg then engages the matching hwaccel
|
||||
/// (e.g. `h264_videotoolbox_hwaccel`) on open. Returns the opened codec
|
||||
/// context plus the device type in use, or `None` when the device is
|
||||
/// unavailable (the caller tries the next candidate, then pure
|
||||
/// software).
|
||||
pub fn open_hw_accel(
|
||||
params: &ffmpeg::codec::Parameters,
|
||||
codec: ffmpeg::Codec,
|
||||
device_type: sys::AVHWDeviceType,
|
||||
) -> Option<(ffmpeg::codec::decoder::Opened, sys::AVHWDeviceType)> {
|
||||
let mut context = ffmpeg::codec::Context::from_parameters(params.clone()).ok()?;
|
||||
let mut device: *mut sys::AVBufferRef = std::ptr::null_mut();
|
||||
// SAFETY: `device` is a valid out-pointer; on success it owns the
|
||||
// device reference, which is handed to the codec context below.
|
||||
let rc = unsafe {
|
||||
sys::av_hwdevice_ctx_create(
|
||||
&mut device,
|
||||
device_type,
|
||||
std::ptr::null(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if rc < 0 || device.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `hw_device_ctx` takes ownership of the reference; the codec
|
||||
// context frees it with the context.
|
||||
unsafe { (*context.as_mut_ptr()).hw_device_ctx = device };
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("threads", "auto");
|
||||
context
|
||||
.decoder()
|
||||
.open_as_with(codec, opts)
|
||||
.ok()
|
||||
.map(|opened| (opened, device_type))
|
||||
}
|
||||
|
||||
/// Whether a decoded frame's pixel format is a hardware surface that
|
||||
/// must be transferred to system memory before swscale can consume it.
|
||||
pub fn is_hw_format(format: sys::AVPixelFormat) -> bool {
|
||||
matches!(
|
||||
format,
|
||||
sys::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_VAAPI
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_CUDA
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_D3D11VA_VLD
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_D3D11
|
||||
)
|
||||
}
|
||||
|
||||
/// Transfer a hardware frame to system memory (`av_hwframe_transfer_data`
|
||||
/// picks the software format: NV12 for 8-bit, P010LE for 10-bit sources;
|
||||
/// swscale consumes both). Presentation metadata the frame cache relies
|
||||
/// on is carried over.
|
||||
pub fn transfer_to_cpu(frame: &ffmpeg::frame::Video) -> Result<ffmpeg::frame::Video> {
|
||||
let mut cpu = ffmpeg::frame::Video::empty();
|
||||
// SAFETY: `cpu` and `frame` are valid AVFrames; flags 0 = default
|
||||
// transfer direction (hardware -> system memory).
|
||||
let rc = unsafe { sys::av_hwframe_transfer_data(cpu.as_mut_ptr(), frame.as_ptr(), 0) };
|
||||
if rc < 0 {
|
||||
return Err(Error::Failed(format!(
|
||||
"hwframe transfer to CPU failed (av error {rc})"
|
||||
)));
|
||||
}
|
||||
HW_TRANSFERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
cpu.set_pts(frame.pts());
|
||||
// SAFETY: plain field copies between valid AVFrames.
|
||||
unsafe {
|
||||
(*cpu.as_mut_ptr()).pkt_dts = (*frame.as_ptr()).pkt_dts;
|
||||
(*cpu.as_mut_ptr()).duration = (*frame.as_ptr()).duration;
|
||||
(*cpu.as_mut_ptr()).time_base = (*frame.as_ptr()).time_base;
|
||||
}
|
||||
Ok(cpu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The current platform has at least one hardware device candidate
|
||||
/// (hardware decode is the mandated default on every supported
|
||||
/// platform).
|
||||
#[test]
|
||||
fn platform_has_a_hardware_candidate() {
|
||||
assert!(
|
||||
!device_type_candidates().is_empty(),
|
||||
"no hardware decode candidate on this platform"
|
||||
);
|
||||
}
|
||||
|
||||
/// Device types round-trip through their display names.
|
||||
#[test]
|
||||
fn device_type_names_are_stable() {
|
||||
assert_eq!(
|
||||
device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX),
|
||||
"videotoolbox"
|
||||
);
|
||||
assert_eq!(device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI), "vaapi");
|
||||
assert_eq!(device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA), "cuda/nvdec");
|
||||
assert_eq!(
|
||||
device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA),
|
||||
"d3d11va"
|
||||
);
|
||||
}
|
||||
|
||||
/// Software and hardware pixel formats are classified correctly.
|
||||
#[test]
|
||||
fn hw_format_classification() {
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX));
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_VAAPI));
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_CUDA));
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_D3D11VA_VLD));
|
||||
assert!(!is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_YUV420P));
|
||||
assert!(!is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_NV12));
|
||||
}
|
||||
|
||||
/// On macOS the VideoToolbox device context must be creatable (the
|
||||
/// mandated default decode path) and the H.264 demo must open with
|
||||
/// the hwaccel attached.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn videotoolbox_device_opens_for_h264() {
|
||||
let mut input = ffmpeg::format::input(&"../../tests/demo.mp4".to_string())
|
||||
.expect("open demo.mp4");
|
||||
let fstream = input.stream(0).expect("stream 0");
|
||||
let params = fstream.parameters();
|
||||
let codec = ffmpeg::decoder::find(params.id()).expect("software h264 codec");
|
||||
let opened = open_hw_accel(
|
||||
¶ms,
|
||||
codec,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
|
||||
);
|
||||
assert!(opened.is_some(), "VideoToolbox hwaccel must open for H.264");
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ pub mod ffmpeg;
|
||||
pub mod footagedescription;
|
||||
pub mod frame;
|
||||
pub mod framemanager;
|
||||
pub mod hwdecode;
|
||||
pub mod oiio;
|
||||
pub mod oiioframebridge;
|
||||
pub mod planarfiledevice;
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::encoder::create_from_params;
|
||||
use crate::ffmpeg::FFmpegDecoder;
|
||||
use crate::frame::Frame;
|
||||
use oakcore_rs::{PixelFormat, Rational, TimeRange};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `tests/demo.mp4` at the repository root.
|
||||
fn demo_path() -> std::path::PathBuf {
|
||||
@@ -286,3 +287,78 @@ fn testmedia_audio_track_encodes() {
|
||||
assert!(desc.audio_stream_count() >= 1, "audio stream present");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
|
||||
/// Serialize the config-toggling hardware-decode test (the config store
|
||||
/// is process-global; decode tests must not observe each other's flag).
|
||||
static HW_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Hardware decoding (the mandated default): with the switch ON the
|
||||
/// session must open the platform's hardware decoder (on macOS,
|
||||
/// `h264_videotoolbox` for the H.264 demo); with the switch OFF it must
|
||||
/// be pure software. Both paths must decode the same frame to matching
|
||||
/// pixels (small tolerance for decoder rounding).
|
||||
#[test]
|
||||
fn hardware_decode_matches_software_decode() {
|
||||
let _guard = HW_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let config = oakcommon::configstore::ConfigStore::instance();
|
||||
let key = crate::hwdecode::CONFIG_KEY_HARDWARE_DECODING;
|
||||
|
||||
let decode_at = |time: i64| -> (Option<String>, Arc<Frame>) {
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(demo_path().to_string_lossy().into_owned(), 0, None);
|
||||
d.open(&s).expect("open video stream");
|
||||
let hw = d.hw_decoder_name();
|
||||
let f = d
|
||||
.retrieve_video_frame(&video_params(s, Rational::new(time, 1)))
|
||||
.expect("decode frame");
|
||||
(hw, f)
|
||||
};
|
||||
|
||||
// Hardware path.
|
||||
config.set(None, key, "true");
|
||||
let (hw_name, hw_frame) = decode_at(5);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert_eq!(
|
||||
hw_name.as_deref(),
|
||||
Some("videotoolbox"),
|
||||
"macOS must decode H.264 through VideoToolbox by default"
|
||||
);
|
||||
assert!(
|
||||
crate::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed) > 0,
|
||||
"the VideoToolbox hwaccel must really engage (a hardware surface was transferred)"
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
assert!(
|
||||
hw_name.is_none()
|
||||
|| hw_name.as_deref().unwrap().contains("vaapi")
|
||||
|| hw_name.as_deref().unwrap().contains("nvdec")
|
||||
|| hw_name.as_deref().unwrap().contains("d3d11va"),
|
||||
"unexpected decoder {hw_name:?}"
|
||||
);
|
||||
|
||||
// Software path (the switch off).
|
||||
config.set(None, key, "false");
|
||||
let (sw_name, sw_frame) = decode_at(5);
|
||||
assert!(sw_name.is_none(), "switch off must force software decoding");
|
||||
config.set(None, key, "true");
|
||||
|
||||
// Same geometry, same pixels (within decoder rounding).
|
||||
assert_eq!(
|
||||
(hw_frame.width(), hw_frame.height()),
|
||||
(sw_frame.width(), sw_frame.height())
|
||||
);
|
||||
let (hw_data, sw_data) = (hw_frame.data().unwrap(), sw_frame.data().unwrap());
|
||||
assert_eq!(hw_data.len(), sw_data.len());
|
||||
let mut max_diff = 0.0f32;
|
||||
for (a, b) in hw_data.chunks_exact(4).zip(sw_data.chunks_exact(4)) {
|
||||
let fa = f32::from_le_bytes(a.try_into().unwrap());
|
||||
let fb = f32::from_le_bytes(b.try_into().unwrap());
|
||||
max_diff = max_diff.max((fa - fb).abs());
|
||||
}
|
||||
assert!(
|
||||
max_diff < 0.05,
|
||||
"hardware and software decodes diverge (max channel diff {max_diff})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ pub struct PreferencesContent {
|
||||
theme: Entity<ComboBox>,
|
||||
cache_dir: Entity<PathField>,
|
||||
use_proxy: Entity<CheckBox>,
|
||||
hw_decode: Entity<CheckBox>,
|
||||
proxy_divider: Entity<ComboBox>,
|
||||
snapshot_interval: Entity<SpinBox>,
|
||||
transition_length: Entity<SpinBox>,
|
||||
@@ -251,6 +252,30 @@ impl PreferencesContent {
|
||||
combo.set_selected(Some(divider_selected), cx)
|
||||
});
|
||||
|
||||
// --- 渲染 Rendering: hardware decoding switch -------------------
|
||||
// Default ON (user mandate); off forces software decoding.
|
||||
let hw_decode = cx.new(|cx| {
|
||||
CheckBox::new(
|
||||
9,
|
||||
if config_get_bool("HardwareDecoding", true) {
|
||||
CheckState::Checked
|
||||
} else {
|
||||
CheckState::Unchecked
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_label(i18n::tr("preferences.hwdecode.enable"))
|
||||
});
|
||||
cx.subscribe(&hw_decode, |_this, check, event: &CheckBoxEvent, cx| {
|
||||
if let CheckBoxEvent::Toggled { state, .. } = event {
|
||||
let enabled = *state == CheckState::Checked;
|
||||
config_set_bool("HardwareDecoding", enabled);
|
||||
check.update(cx, |check, cx| check.set_state(*state, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// --- 项目 Project: snapshot interval + default transition ----------
|
||||
let snapshot_interval = cx.new(|cx| {
|
||||
let current =
|
||||
@@ -342,6 +367,7 @@ impl PreferencesContent {
|
||||
theme,
|
||||
cache_dir,
|
||||
use_proxy,
|
||||
hw_decode,
|
||||
proxy_divider,
|
||||
snapshot_interval,
|
||||
transition_length,
|
||||
@@ -499,6 +525,7 @@ impl Render for PreferencesContent {
|
||||
i18n::tr("preferences.backend").into(),
|
||||
self.backend.clone(),
|
||||
))
|
||||
.child(self.hw_decode.clone())
|
||||
// 缓存 Cache
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.cache").into()))
|
||||
.child(form_row(
|
||||
|
||||
@@ -434,6 +434,7 @@ const EN: &[(&str, &str)] = &[
|
||||
("preferences.section.project", "Project"),
|
||||
("preferences.section.audio", "Audio"),
|
||||
("preferences.backend", "Renderer backend"),
|
||||
("preferences.hwdecode.enable", "Hardware-accelerated video decoding (VideoToolbox / VA-API / NVDEC / D3D11VA)"),
|
||||
("preferences.backend.placeholder", "Select a backend…"),
|
||||
("preferences.language", "Language"),
|
||||
("preferences.language.placeholder", "Select a language…"),
|
||||
@@ -899,6 +900,7 @@ const ZH: &[(&str, &str)] = &[
|
||||
("preferences.section.project", "项目"),
|
||||
("preferences.section.audio", "音频"),
|
||||
("preferences.backend", "渲染后端"),
|
||||
("preferences.hwdecode.enable", "硬件加速视频解码(VideoToolbox / VA-API / NVDEC / D3D11VA)"),
|
||||
("preferences.backend.placeholder", "选择一个后端…"),
|
||||
("preferences.language", "语言"),
|
||||
("preferences.language.placeholder", "选择语言…"),
|
||||
|
||||
@@ -182,10 +182,10 @@ case "$OS" in
|
||||
esac
|
||||
# NVIDIA (ffnvcodec headers are distribution-free; enable when present).
|
||||
if [ -d /usr/local/cuda ] || pkg-config --exists ffnvcodec 2>/dev/null; then
|
||||
FLAGS+=(--enable-nvenc --enable-cuda-llvm)
|
||||
echo " + nvenc/cuda"
|
||||
FLAGS+=(--enable-nvdec --enable-nvenc --enable-cuda-llvm)
|
||||
echo " + nvdec/nvenc/cuda"
|
||||
else
|
||||
echo " - nvenc/cuda (no ffnvcodec headers)"
|
||||
echo " - nvdec/nvenc/cuda (no ffnvcodec headers)"
|
||||
fi
|
||||
|
||||
# --- Build ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user