From 1518da4f30c538814dc6ad5282ebba6d3ffa38cd Mon Sep 17 00:00:00 2001 From: dailz Date: Mon, 22 Jun 2026 17:34:53 +0800 Subject: [PATCH] =?UTF-8?q?docs(avhw):=20[1/4]=20=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=20FFmpeg/VAAPI=20=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/avhw.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/avhw.rs b/src/avhw.rs index 60f2be5..3bc0950 100644 --- a/src/avhw.rs +++ b/src/avhw.rs @@ -1,3 +1,28 @@ +//! # avhw — FFmpeg / VAAPI 硬件加速 FFI 绑定(FFI 最密集的文件) +//! +//! 本文件是整个 crate 中 `unsafe` 块密度最高的模块:直接调用 FFmpeg C API。 +//! `ffmpeg-next` 是 ffmpeg-sys-next(C 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 导入:CString(C 字符串)、mem::zeroed(FFI 零初始化)、RawFd/AsRawFd(fd 桥), +// c_void(C void 跨语言)、Path(DRM 设备路径)、ptr(null_mut 等裸指针工具), +// slice(从裸指针构造切片)、AtomicBool/Ordering/Arc(跨线程暂停标志,T10b 用), +// Instant(编码计时,T10b 用)。本 sub-todo(lines 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(错误传播)。 +// ffmpeg-next:稍安全的 API(ff::frame::Video、ff::format::Pixel、ff::codec 等)。 +// ffmpeg_next::ffi:FFmpeg C 头绑定的裸 API(AVBufferRef、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 内部模块:PwDmaBufFrame(PipeWire DMA-BUF 帧元数据:fd/width/height/stride/modifier), +// Transform(Wayland wl_output 变换,决定 ROI 是否需要转置处理)。 use crate::cap_portal::PwDmaBufFrame; use crate::transform::{transpose_if_transform_transposed, Transform}; // --------------------------------------------------------------------------- // Bitrate feedback command (WebRTC BWE → SW encoder) // --------------------------------------------------------------------------- +// 跨线程控制信令:WebRTC 线程根据 BWE(bandwidth 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_us:libswscale 把 NV12(VAAPI 输出)转 YUV420P(x264 输入)的耗时(微秒) +// - 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 持有一个 ref(refcount=1)。 pub fn new_vaapi(drm_device: &Path) -> Result { + // CString::new 在路径含内部 NUL 时返回 Err(FFmpeg C API 要求 NUL 结尾)。 + // `to_str().unwrap()`:路径非 UTF-8 时 panic(Linux DRM 设备路径通常 ASCII)。 let device_cstr = CString::new(drm_device.to_str().unwrap())?; + // 初始化为 null_mut:FFmpeg 的 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。如果是最后一个 ref(refcount→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 pipeline:1 帧 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 { + // 中文概述:分配 `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 引用计数 -1,refcount=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 拷贝。 +// 类比 Go:Go 没有等价物——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 { + // `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 自定义 AVBufferRef:data 指向 descriptor,free 回调为 cleanup_drm_descriptor。 + // 失败时(极少)需要手动恢复 Box 并 close fd,否则泄漏。 let buf_ref = ffi::av_buffer_create( desc_ptr as *mut u8, std::mem::size_of::(), @@ -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 {