docs(avhw): [1/4] 中文注释 FFmpeg/VAAPI 初始化

This commit is contained in:
dailz
2026-06-22 17:34:53 +08:00
parent 279ec97647
commit 1518da4f30
+148
View File
@@ -1,3 +1,28 @@
//! # avhw — FFmpeg / VAAPI 硬件加速 FFI 绑定(FFI 最密集的文件)
//!
//! 本文件是整个 crate 中 `unsafe` 块密度最高的模块:直接调用 FFmpeg C API。
//! `ffmpeg-next` 是 ffmpeg-sys-nextC FFI 绑定)的薄包装;硬件加速相关 API
//! `AVBufferRef`/`av_hwframe_*`)仍是裸 C 接口。
//!
//! ## FFmpeg 引用计数对象的生命周期
//! - `AVBufferRef`FFmpeg 引用计数句柄,`av_buffer_ref` 加 1、`av_buffer_unref` 减 1。
//! `AvHwDevCtx`/`AvHwFrameCtx` 用 RAII 包装:`new` alloc`Drop` unref。
//! - `AVFrame`:硬件帧(`AV_PIX_FMT_VAAPI`)的 `data[0]` 是 GPU 表面指针。
//!
//! ## `unsafe impl Send` 的存在原因(AGENTS.md 明确警告)
//! `*mut AVBufferRef` 默认 `!Send`wrapper 显式声明 Send 的前提是**外部调用方
//! 保证 `&mut self` 独占访问**。AGENTS.md:不要跨线程移动这些 wrapper 而不重新
//! 检查 exclusivity 假设。
//!
//! ## 与 Go cgo 的类比
//! - `extern "C" fn` ≈ Go `//export` C 回调;
//! - `unsafe { ffi::av_*() }` ≈ Go `C.av_xxx()`
//! - RAII `Drop` ≈ Go `runtime.SetFinalizer`(但 Rust Drop 是确定性的)。
// std 导入:CStringC 字符串)、mem::zeroedFFI 零初始化)、RawFd/AsRawFdfd 桥),
// c_voidC void 跨语言)、PathDRM 设备路径)、ptrnull_mut 等裸指针工具),
// slice(从裸指针构造切片)、AtomicBool/Ordering/Arc(跨线程暂停标志,T10b 用),
// Instant(编码计时,T10b 用)。本 sub-todolines 1-330)仅部分使用。
use std::ffi::CString;
use std::mem;
use std::os::fd::{AsRawFd, RawFd};
@@ -9,17 +34,28 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
// anyhow 错误处理:bail!(提前返回 Err)、Result<T>(错误传播)。
// ffmpeg-next:稍安全的 APIff::frame::Video、ff::format::Pixel、ff::codec 等)。
// ffmpeg_next::ffiFFmpeg C 头绑定的裸 APIAVBufferRef、av_hwdevice_ctx_create 等),
// 所有 `unsafe` FFI 调用的入口。`packet::Mut as _`:导入 packet 的 Mut trait 但匿名,
// 仅用于 trait method 解析,不污染命名空间。
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut as _;
// crate 内部模块:PwDmaBufFramePipeWire DMA-BUF 帧元数据:fd/width/height/stride/modifier),
// TransformWayland wl_output 变换,决定 ROI 是否需要转置处理)。
use crate::cap_portal::PwDmaBufFrame;
use crate::transform::{transpose_if_transform_transposed, Transform};
// ---------------------------------------------------------------------------
// Bitrate feedback command (WebRTC BWE → SW encoder)
// ---------------------------------------------------------------------------
// 跨线程控制信令:WebRTC 线程根据 BWEbandwidth estimation)估算的可用带宽,
// 通过 MPSC channel 把 `BitrateCommand` 发给软件编码线程,由后者调整 x264 参数。
// 注意:硬件(VAAPI)路径目前不接受动态码率调整,只有软件(x264)路径消费这些命令。
// 类比 Go`chan BitrateCommand` 单向 channel。
/// Commands sent from the WebRTC thread to the SW encoder when the
/// bandwidth estimate changes significantly.
@@ -31,12 +67,26 @@ pub enum BitrateCommand {
ForceKeyframe,
}
// 分辨率变更事件:WebRTC 线程根据对端能力(SVC/layered encoding 协商)建议切换分辨率,
// 编码线程收到后重建 encoder(x264 不支持运行时改变 width/height,必须销毁重建)。
// `#[derive(Clone, Copy, Debug)]`Copy 表示按位拷贝(小值类型,无需 clone 调用),
// 类比 Go 的 `type ResolutionChange struct{...}`Go 默认值语义即 Copy)。
#[derive(Clone, Copy, Debug)]
pub struct ResolutionChange {
pub width: u32,
pub height: u32,
}
// 每帧编码耗时快照,由编码线程填好后通过 channel 发给统计线程(stats thread)。
// `#[derive(Default, Clone, Copy, Debug)]`Default 允许 `SwEncodeTiming::default()` 全零初始化;
// Copy/Clone 表示小值类型按位拷贝(无堆字段)。
//
// 字段语义:
// - sws_uslibswscale 把 NV12VAAPI 输出)转 YUV420Px264 输入)的耗时(微秒)
// - encode_us`avcodec_send_frame` + drain 接收所有 packet 的总耗时(微秒)
// - output_bytes:本帧产出的 H.264 字节数(即使下游 WebRTC 因暂停丢帧也计入)
//
// 类比 Go`type SwEncodeTiming struct{...}` + atomic store/load 跨 goroutine 传递。
/// Per-frame timing snapshot for the software encoder, consumed by the stats
/// thread. `sws_us` measures NV12→YUV420P conversion, `encode_us` measures
/// `avcodec_send_frame` + drain, and `output_bytes` counts encoded bytes
@@ -48,6 +98,10 @@ pub struct SwEncodeTiming {
pub output_bytes: usize,
}
// `encode_cpu_frame` 的返回值,编码线程据此判断是否计 encoded_fps(只有 `Encoded`
// 才算一次真实编码)。`#[derive(Debug, Clone, Copy, PartialEq, Eq)]`PartialEq/Eq
// 允许 `==` 比较(测试和 stats 聚合用),Copy 表示小值类型按位拷贝。
// 类比 Go`type EncodeOutcome int` + const 枚举值。
/// Outcome of a single `encode_cpu_frame` call. Used by the encode thread
/// to decide whether to report timing stats (only real encodes tick encoded_fps).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -66,19 +120,39 @@ pub enum EncodeOutcome {
// AvHwDevCtx
// ---------------------------------------------------------------------------
// VAAPI 设备上下文的 RAII 包装。`ptr` 是 FFmpeg 引用计数的 `AVBufferRef`
// 包装 `AVHWDeviceContext`VAAPI 内部的 `VADisplay`)。`new_vaapi` 时 alloc
// `Drop` 时 `av_buffer_unref``ref_clone` 用于把内部 ref 传递给编码器的
// `AVCodecContext::hw_device_ctx`FFmpeg 会再 `av_buffer_ref` 一次)。
// **独占访问假设**`unsafe impl Send` 的前提是外部调用方保证 `&mut self`
// 不允许多线程共享 `&AvHwDevCtx` 同时调用 mut method(详见 AGENTS.md 警告)。
pub struct AvHwDevCtx {
// FFmpeg 内部引用计数句柄,类型擦除为 `*mut AVBufferRef`。
// 永不为 null(除非在 Drop 中被置 null)。
ptr: *mut ffi::AVBufferRef,
}
// 中文概述:声明 `AvHwDevCtx` 可以跨线程 `Send`。前提是调用方用 `&mut self`
// 独占访问(无并发可变借用)。VAAPI 的 `VADisplay` 在硬件驱动层线程安全,
// FFmpeg 的 `AVHWDeviceContext` 内部状态在 send/receive 编码模式下也线程安全。
// 不修复:保留 AGENTS.md 文档的 exclusivity 假设(这是本 crate 的并发模型基础)。
// SAFETY: AvHwDevCtx wraps an FFmpeg AVBufferRef which is not Send by default,
// but we guarantee exclusive access through &mut self. The underlying VAAPI
// device context is thread-safe for the operations we perform.
unsafe impl Send for AvHwDevCtx {}
impl AvHwDevCtx {
// 创建 VAAPI 设备上下文。`drm_device` 通常是 `/dev/dri/renderD128`。
// 失败原因:设备不存在、无 VAAPI 驱动、权限不足(用户不在 `video` 组)。
// 成功时返回 `AvHwDevCtx`ptr 持有一个 refrefcount=1)。
pub fn new_vaapi(drm_device: &Path) -> Result<Self> {
// CString::new 在路径含内部 NUL 时返回 ErrFFmpeg C API 要求 NUL 结尾)。
// `to_str().unwrap()`:路径非 UTF-8 时 panicLinux DRM 设备路径通常 ASCII)。
let device_cstr = CString::new(drm_device.to_str().unwrap())?;
// 初始化为 null_mutFFmpeg 的 out-pointer 约定(调用者置 null,被调者赋值)。
let mut p: *mut ffi::AVBufferRef = ptr::null_mut();
// 中文概述:调用 FFmpeg C API 创建 VAAPI `AVHWDeviceContext`,写入 `*p`。
// 失败返回负的 AVERROR;成功返回 0 且 `p` 指向新分配的 `AVBufferRef`refcount=1)。
// SAFETY: device_cstr is a valid C string for the duration of the call;
// p is a valid out-pointer that FFmpeg initializes on success.
let ret = unsafe {
@@ -100,19 +174,30 @@ impl AvHwDevCtx {
Ok(Self { ptr: p })
}
// 返回裸 `*mut AVBufferRef`,调用方负责管理引用计数。
// 通常用于传给 FFmpeg API 的 `hw_device_ctx` 字段(FFmpeg 内部会 av_buffer_ref)。
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.ptr
}
// 增加 refcount,返回新的 `*mut AVBufferRef`(不复制底层缓冲)。
// 用于把设备 ref 传给编码器的 `AVCodecContext::hw_device_ctx`,编码器 Drop 时
// 调用 `av_buffer_unref` 释放自己持有的 ref,不影响本 wrapper。
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// 中文概述:FFmpeg 引用计数 +1,返回新 ref;线程安全(内部原子操作)。
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
// RAII 析构:减少 refcount。如果是最后一个 refrefcount→0),FFmpeg 释放
// 底层的 `AVHWDeviceContext` 和 `VADisplay`。`is_null()` 守卫是为了应对
// `Drop` 被多次调用或 `new_vaapi` 失败后 ptr 仍为 null 的边缘情况(实际上
// Rust 的 Drop 不会被调用两次,但 FFmpeg 的 av_buffer_unref 接受 null 入参)。
impl Drop for AvHwDevCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// 中文概述:FFmpeg 引用计数 -1,refcount=0 时释放底层设备上下文。
// SAFETY: av_buffer_unref decrements refcount; frees the buffer when it hits zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
@@ -123,23 +208,38 @@ impl Drop for AvHwDevCtx {
// AvHwFrameCtx
// ---------------------------------------------------------------------------
// VAAPI 硬件帧池的 RAII 包装。`AVHWFramesContext` 是 FFmpeg 的硬件帧分配器,
// 编码器通过它分配 `AV_PIX_FMT_VAAPI` 帧(GPU 表面)。`initial_pool_size=4`
// 表示池大小为 4(足够 send/receive pipeline1 帧 encode 中 + 1 帧 queue + 2 缓冲)。
// 字段配置(width/height/sw_format)必须在 `av_hwframe_ctx_init` 前设置;
// `sw_fmt` 是 CPU 可读的软件格式(VAAPI 内部从该格式上传/下载)。
pub struct AvHwFrameCtx {
ptr: *mut ffi::AVBufferRef,
}
// 中文概述:声明 `AvHwFrameCtx` 可以跨线程 `Send`。前提是 `&mut self` 独占访问,
// 且底层硬件帧池在 send/receive 模式下线程安全。与 `AvHwDevCtx` 同样的 exclusivity 假设。
// 不修复:保留 AGENTS.md 文档的并发约束。
// SAFETY: AvHwFrameCtx wraps an FFmpeg AVBufferRef to an AVHWFramesContext.
// It is only accessed through &mut self, ensuring no concurrent mutation.
// The underlying hardware frames pool is thread-safe for the send/receive pattern.
unsafe impl Send for AvHwFrameCtx {}
impl AvHwFrameCtx {
// 内部构造函数:分配 `AVHWFramesContext`、配置字段、调用 `av_hwframe_ctx_init`。
// 失败时(alloc 或 init 失败)返回 Err,确保 ptr 不泄漏。
fn new_inner(hw_dev: &AvHwDevCtx, w: u32, h: u32, sw_fmt: ff::format::Pixel) -> Result<Self> {
// 中文概述:分配 `AVHWFramesContext`refcount=1,关联到 hw_dev 的设备上下文。
// 返回的 ref 的 `data` 字段指向未初始化的 `AVHWFramesContext`,需手动配置。
// SAFETY: hw_dev is a live AVHWDeviceContext; FFmpeg returns either a valid
// frames context ref or null (checked below).
let mut p = unsafe { ffi::av_hwframe_ctx_alloc(hw_dev.as_ptr()) };
if p.is_null() {
bail!("av_hwframe_ctx_alloc returned null");
}
// 中文概述:通过 `(*p).data as *mut AVHWFramesContext` 访问 FFmpeg 内部类型擦除的
// 字段(`AVBufferRef::data` 是 `*mut u8`,实际指向具体 context 类型)。
// 直接赋值 `format`/`sw_format`/`width`/`height`/`initial_pool_size` 5 个字段。
// SAFETY: p is a valid AVBufferRef from av_hwframe_ctx_alloc.
// Its .data field points to an AVHWFramesContext that we must configure.
unsafe {
@@ -150,10 +250,12 @@ impl AvHwFrameCtx {
(*fc).height = h as i32;
(*fc).initial_pool_size = 4;
}
// 中文概述:初始化帧池(实际分配 GPU 表面)。失败时必须手动 unref p 否则泄漏。
// SAFETY: p is a valid AVHWFramesContext ref configured above and not yet
// transferred or freed.
let ret = unsafe { ffi::av_hwframe_ctx_init(p) };
if ret < 0 {
// 中文概述:init 失败的清理路径——p 仍有效但不可用,unref 释放。
// SAFETY: p is valid but init failed; clean up.
unsafe { ffi::av_buffer_unref(&mut p) };
bail!("av_hwframe_ctx_init failed: {}", ff_err(ret));
@@ -161,6 +263,7 @@ impl AvHwFrameCtx {
Ok(Self { ptr: p })
}
// 为采集路径创建硬件帧池。`sw_fmt` 通常是 `BGRA`PipeWire DMA-BUF 像素格式)。
pub fn for_capture(
hw_dev: &AvHwDevCtx,
w: u32,
@@ -175,26 +278,35 @@ impl AvHwFrameCtx {
}
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// 中文概述:FFmpeg 引用计数 +1,返回新 ref;用于把帧池 ref 传给编码器。
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
// RAII 析构:与 AvHwDevCtx::Drop 同样的模式(unref 释放 ref)。
impl Drop for AvHwFrameCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// 中文概述:FFmpeg 引用计数 -1refcount=0 时释放帧池和 GPU 表面。
// SAFETY: av_buffer_unref decrements refcount; frees when zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
}
}
// 启动时探针:测试 `drm_device` 能否通过 VAAPI 导入 PipeWire 给的 DMA-BUF 帧。
// 用于 backend_detect 决定走硬件(VAAPI)还是软件(x264)编码路径。
// 失败原因:硬件不支持给定格式/修饰符组合、DRM 设备无 VAAPI、内核 driver 限制。
// 成功只是"探针通过",不代表实际编码时一直可用(运行时仍可能因 OOM 等失败)。
/// Test whether `drm_device` can import the PipeWire DMA-BUF frame via VAAPI.
pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<()> {
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
let frames =
AvHwFrameCtx::for_capture(&hw_dev, frame.width, frame.height, ff::format::Pixel::BGRA)?;
// 中文概述:调用下面的 `import_dma_buf_to_vaapi`,用 `av_hwframe_map` 把 DMA-BUF
// 映射到 VAAPI 表面(零拷贝)。`as_raw_fd()` 把 `OwnedFd` 转为裸 fd 传给 FFI。
// SAFETY: frames is a live VAAPI frames context; frame carries valid DMA-BUF metadata.
unsafe {
import_dma_buf_to_vaapi(
@@ -212,6 +324,10 @@ pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<(
Ok(())
}
// 中文概述:把 PipeWire 的 DMA-BUF 帧零拷贝映射为 VAAPI `AVFrame`。
// DMA-BUF 是 Linux 内核的 GPU 缓冲共享机制(fd 指向 GPU 内存);`av_hwframe_map`
// 在 VAAPI 内部通过 `vaCreateSurfaces` + `vaMapBuffer` 直接 GPU 映射,无需 CPU 拷贝。
// 类比 GoGo 没有等价物——cgo + libavutil 才能实现同样的零拷贝路径。
/// Import a DMA-BUF into a VAAPI hardware frame via zero-copy `av_hwframe_map`.
///
/// # Safety
@@ -227,11 +343,16 @@ pub unsafe fn import_dma_buf_to_vaapi(
stride: u32,
offset: u64,
) -> Result<ff::frame::Video> {
// `libc::dup` 复制 fd——`AVDRMFrameDescriptor` 持有 dup 后的 fd
// 当 descriptor 释放时 `cleanup_drm_descriptor` 会 `libc::close` 它;
// 原始 `raw_fd` 不被本函数消费(仍由调用方拥有,通常是 PipeWire)。
let duped_fd = libc::dup(raw_fd);
if duped_fd < 0 {
bail!("dup(fd) failed: {}", std::io::Error::last_os_error());
}
// `mem::zeroed()`FFI 零初始化(所有字段置 0),类似 Go 的 `&drmDesc{}`。
// SAFETY 后续不再赘述——这是 `unsafe fn` 内部,调用契约由函数签名保证。
let mut desc: ffi::AVDRMFrameDescriptor = mem::zeroed();
desc.nb_objects = 1;
desc.objects[0].fd = duped_fd;
@@ -244,9 +365,14 @@ pub unsafe fn import_dma_buf_to_vaapi(
desc.layers[0].planes[0].offset = offset as isize;
desc.layers[0].planes[0].pitch = stride as isize;
// `Box::new(desc)` 把栈上的 descriptor 移到堆上;`Box::into_raw` 交出所有权给 FFmpeg。
// `av_buffer_create` 会接管这块内存:当 AVBufferRef refcount 归零时调用
// `cleanup_drm_descriptor`(自定义 free 回调)→ close fd + Box::from_raw 释放堆内存。
let desc_box = Box::new(desc);
let desc_ptr = Box::into_raw(desc_box);
// 创建 FFmpeg 自定义 AVBufferRefdata 指向 descriptorfree 回调为 cleanup_drm_descriptor。
// 失败时(极少)需要手动恢复 Box 并 close fd,否则泄漏。
let buf_ref = ffi::av_buffer_create(
desc_ptr as *mut u8,
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
@@ -260,6 +386,8 @@ pub unsafe fn import_dma_buf_to_vaapi(
bail!("av_buffer_create returned null for DRM descriptor");
}
// 构造 src AVFrame:格式为 `AV_PIX_FMT_DRM_PRIME`FFmpeg 的 DMA-BUF 包装格式),
// `data[0]` 指向 AVBufferRef 的 data(即 descriptor),`buf[0]` 持有 ref 防止提前释放。
let mut src = ff::frame::Video::empty();
{
let sp = src.as_mut_ptr();
@@ -270,7 +398,10 @@ pub unsafe fn import_dma_buf_to_vaapi(
(*sp).buf[0] = buf_ref;
}
// 构造 dst AVFrame:格式为 `AV_PIX_FMT_VAAPI`,挂上 `hw_frames_ctx` 让 FFmpeg 知道用哪个池。
let mut dst = ff::frame::Video::empty();
// 中文概述:把 dst 的 format 设为 VAAPI、`hw_frames_ctx` 设为新的 ref。
// `av_buffer_ref(frames_ctx)` 必须成功,否则 av_hwframe_map 无法定位帧池。
// SAFETY: frames_ctx is guaranteed by this unsafe function's contract to be a
// valid initialized VAAPI frames context; we set format/hw_frames_ctx on a
// freshly allocated dst frame.
@@ -282,6 +413,8 @@ pub unsafe fn import_dma_buf_to_vaapi(
bail!("av_buffer_ref(frames_ctx) returned null");
}
}
// 中文概述:核心 FFI 调用——FFmpeg 在内部调用 VAAPI `vaCreateSurfaces`+`vaMapBuffer`
// 把 DMA-BUF 映射到 GPU 表面,写入 dst。零 CPU 拷贝。
// SAFETY: src and dst are initialized AVFrames; dst has a valid hw_frames_ctx
// ref and av_hwframe_map fills dst from src.
let ret = unsafe {
@@ -298,6 +431,11 @@ pub unsafe fn import_dma_buf_to_vaapi(
Ok(dst)
}
// `av_buffer_create` 的 free 回调:当自定义 AVBufferRef 的 refcount 归零时被 FFmpeg 调用。
// `unsafe extern "C" fn` 是因为 FFmpeg 通过 C 函数指针调用它(C ABI 兼容)。
// 职责:(1) close DMA-BUF 的 dup'd fd(2) `Box::from_raw` 恢复堆内存所有权并立即 drop。
// `_opaque`FFmpeg 的 AVBufferRef 允许附带 opaque 数据(本例未用,故 `_` 前缀)。
// 类比 Go:相当于 `runtime.SetFinalizer` 的回调,但是 FFmpeg 主动调用的(确定性)。
unsafe extern "C" fn cleanup_drm_descriptor(_opaque: *mut c_void, data: *mut u8) {
let desc = data as *mut ffi::AVDRMFrameDescriptor;
if !desc.is_null() && (*desc).nb_objects > 0 && (*desc).objects[0].fd >= 0 {
@@ -306,19 +444,29 @@ unsafe extern "C" fn cleanup_drm_descriptor(_opaque: *mut c_void, data: *mut u8)
let _ = Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor);
}
// 把 FFmpeg 的负数错误码(AVERROR)转成人类可读字符串。
// `av_strerror` 是 FFmpeg 的线程安全 strerror 等价物,写入调用方提供的缓冲区。
// `pub(crate)` 可见性:仅供本 crate 内部错误格式化使用(不暴露给外部)。
/// Convert an FFmpeg error code to a human-readable string.
pub(crate) fn av_err_to_string(err: i32) -> String {
// 128 字节足够 AVERROR 描述(FFmpeg 内部字符串都很短)。
let mut buf = vec![0u8; 128];
// 中文概述:调用 FFmpeg `av_strerror`,把错误码描述写入 buf。
// 失败(无效错误码)时 buf 仍为全 0,trim 后为空字符串。
// SAFETY: buf points to 128 writable bytes and lives for the duration of
// av_strerror.
unsafe {
ffi::av_strerror(err, buf.as_mut_ptr() as *mut i8, buf.len());
}
// `from_utf8_lossy` 容忍非 UTF-8 字节;`trim_end_matches('\0')` 去掉 C 字符串结尾的 NUL。
String::from_utf8_lossy(&buf)
.trim_end_matches('\0')
.to_string()
}
// 带数字码的错误格式化:`"error -22 (Invalid argument)"`。
// 数字码很重要——AVERROR 是负数,但有些 FFmpeg 错误是宏(如 AVERROR(EAGAIN)),
// 看数字能区分底层 errno 还是 FFmpeg 自定义错误。
/// Format an FFmpeg error code with both numeric value and description.
/// Example output: "error -22 (Invalid argument)"
pub(crate) fn ff_err(ret: i32) -> String {