diff --git a/src/bin/sw_encode_bench.rs b/src/bin/sw_encode_bench.rs index 220b05b..e41d0c8 100644 --- a/src/bin/sw_encode_bench.rs +++ b/src/bin/sw_encode_bench.rs @@ -1,9 +1,40 @@ +//! 软件编码流水线性能基准(独立二进制 `sw_encode_bench`)。 +//! +//! ## 用途 +//! +//! 测量"纯 CPU"屏幕采集编码流水线的端到端耗时,作为对照参考与 VAAPI 硬件编码 +//! 基准 `vaapi_import_bench`(`src/bin/vaapi_import_bench.rs`)形成对比: +//! - 本文件:Portal 采集 → `mmap` 把 DMA-BUF 映射到用户态 → `sws_scale` 在 CPU +//! 上做 BGR0→YUV420P 颜色空间/缩放转换 → libx264/openh264 软件编码。 +//! - 对照 `vaapi_import_bench.rs`:Portal 采集 → `av_hwframe_map` 在 GPU 上做 +//! 零拷贝格式转换 → VAAPI 硬件编码(GPU)。 +//! +//! ## 输出 +//! +//! 打印 mmap / sws_scale / encode 三段每帧平均耗时与总体 FPS,便于判断"软件路径" +//! 在当前硬件上能否达到 30 FPS 目标。AMD GPU 在某些驱动下不允许 CPU 读取 DMA-BUF, +//! `mmap` 会失败——这正是 `vaapi_import_bench` 存在的意义。 +//! +//! ## Rust ↔ Go 对照 +//! +//! - `clap::Parser` derive 宏:类似 Go 的 `flag` 包,但在编译期生成解析代码。 +//! - `std::time::Instant`:高精度单调时钟,等价于 Go 的 `time.Now()` + `time.Since()`。 +//! - `crossbeam_channel::recv_timeout`:等价于 Go 的 `select { case <-time.After(): }`。 +//! - 本文件大量使用裸 `unsafe` FFI 调用 FFmpeg C API;现有 21 处 unsafe 块均 +//! 未标注 `// SAFETY:`,本任务也不补充,仅在每个 unsafe 块上方加普通 `//` +//! 中文概述,说明"为什么必须 unsafe"。 +//! +//! 用法:`cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4` + // sw_encode_bench.rs — Software encoding pipeline benchmark for screen capture // // Benchmarks: Portal capture -> mmap DMA-BUF -> sws_scale BGR0->YUV420P -> libx264 encode // // Usage: cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4 +// 以下 `use` 语句分组:FFI 字符串/裸 fd 转换/路径/指针/计时 → anyhow/clap → +// ffmpeg_next 别名与 ffi → crate 内 Portal 采集器。Rust 没有 Go 的 "package" +// 概念,每个外部 crate 都要显式 `use`。 use std::ffi::CString; use std::os::fd::AsRawFd; use std::path::Path; @@ -11,15 +42,24 @@ use std::ptr; use std::time::Instant; use anyhow::{bail, Result}; +// `clap::Parser` derive 宏:编译期生成 CLI 解析代码,等价于 Go 的 `flag` 包 +// 但支持子命令/类型转换/帮助文本自动生成。 use clap::Parser; +// FFmpeg 绑定,使用 `ffmpeg_next` crate(社区维护的 next 分支)。`as ff` 别名 +// 缩短调用路径;`ffi` 子模块直接暴露 C ABI(裸指针、`AVFormatContext` 等)。 use ffmpeg_next as ff; use ffmpeg_next::ffi; use ffmpeg_next::packet::Mut; +// 复用主程序的 `Args` 与 Portal 采集器:基准与主二进制共享同一采集代码路径, +// 仅"消费方"不同(基准直接落盘,主程序走 WebRTC 推流)。 use wl_webrtc::args::Args; use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent}; +// 基准 CLI 参数定义。`#[derive(Parser, Debug)]` 让 clap 在编译期为 struct +// 生成 `parse()` 方法;`#[command(...)]` 设置程序元信息。等价于 Go 程序的 +// `flag.StringVar(...)` 序列,但在 Rust 里完全声明式。 #[derive(Parser, Debug)] #[command( name = "sw_encode_bench", @@ -39,6 +79,9 @@ struct BenchArgs { enc_height: u32, } +// 帧级耗时统计容器。每帧把 mmap/sws_scale/encode/total 的微秒数 push 进 Vec, +// 结束后用 `avg_ms` 算平均值。这是"简单算术 + Vec"模式,比 streaming stats +// 复杂但能保留分布信息(虽然本基准只打印均值)。Go 类似 `[]int64`。 #[derive(Default)] struct FrameStats { mmap_us: Vec, @@ -48,7 +91,12 @@ struct FrameStats { mmap_failures: u32, } +// 关联函数(不是 method——没有 `&self`/`&mut self` receiver),类似 Go 的 +// package-level helper function。Rust 把它放在 `impl FrameStats` 内是组织习惯, +// 也可以写成自由函数 `fn avg_ms(...)`。 impl FrameStats { + // 把 Vec 求和后除以元素数得到微秒均值,再除以 1000 转毫秒。空 Vec + // 返回 0.0 避免除零。注意 Rust 这里 `as f64` 是显式转换(不像 Go 的隐式)。 fn avg_ms(data: &[u64]) -> f64 { if data.is_empty() { return 0.0; @@ -57,12 +105,18 @@ impl FrameStats { } } +// 把 `ffmpeg_next` 的高级 Pixel 枚举转换为 FFmpeg C API 期望的原始 +// `AVPixelFormat`(i32 别名)。`Into::into` 在此处零成本——编译期已知映射。 fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat { Into::::into(p) } +// 从 Portal channel 拉取首帧:阻塞等待 PipeWire 推送 DMA-BUF。 +// 同时监控控制 channel(流结束/格式变更/错误)。Go 类比: +// `for { select { case f := <-frameCh: return f; case <-time.After(10*time.Second): ... } }` fn receive_first_frame(cap: &CapPortal) -> Result { loop { + // `try_recv` 非阻塞地检查控制 channel 是否有事件(流结束/错误/格式变更)。 if let Ok(ctrl) = cap.event_receiver().try_recv() { match ctrl { PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"), @@ -70,6 +124,7 @@ fn receive_first_frame(cap: &CapPortal) -> Result bail!("PipeWire error: {e}"), } } + // `recv_timeout` 阻塞最多 10s 等首帧。三路分支处理 Ok/Timeout/Disconnected。 match cap .frame_receiver() .recv_timeout(std::time::Duration::from_secs(10)) @@ -85,7 +140,13 @@ fn receive_first_frame(cap: &CapPortal) -> Result` 把所有错误用 `?` 传播到 main 顶层——Rust 的 main 可以返回 +// Result,运行时打印错误并退出码非零,类似 Go 1.0 时代 `log.Fatal` 的现代等价物。 fn main() -> Result<()> { + // clap 生成的 `BenchArgs::parse()` 解析 argv;类型不符直接 panic 退出。 let bench_args = BenchArgs::parse(); println!("=== Software Encode Benchmark ==="); @@ -97,11 +158,14 @@ fn main() -> Result<()> { ); println!(); + // 初始化 FFmpeg 全局状态(注册编解码器、协议等)。`?` 在 Result 上传播错误。 ff::init()?; println!("[1/4] Requesting screen capture via XDG Portal..."); println!(" (Select a screen to share in the portal dialog)"); + // 复用主二进制的 `Args` struct 来构造 Portal 请求;hw_accel="vaapi" 只是为了 + // 走到 VAAPI 兼容的 DRM 设备路径(本基准并不会真正调用 VAAPI)。 let portal_args = Args { output: Some(bench_args.output.clone()), output_name: None, @@ -119,12 +183,15 @@ fn main() -> Result<()> { stats: false, }; + // `CapPortal::new` 会触发 XDG Portal 授权对话框(用户需要在屏幕共享对话框里选屏)。 let cap = CapPortal::new(&portal_args)?; println!("[1/4] Portal connected, PipeWire stream active\n"); println!("[2/4] Waiting for first frame from PipeWire..."); let first_frame = receive_first_frame(&cap)?; + // PipeWire 推来的首帧携带了 DMA-BUF 的元数据:fd(文件描述符)+ offset + // + stride(每行字节数)+ width/height/format。后续 mmap 就靠这些。 let src_width = first_frame.width; let src_height = first_frame.height; let src_stride = first_frame.stride; @@ -142,6 +209,9 @@ fn main() -> Result<()> { println!("[3/4] Testing mmap on DMA-BUF..."); let mmap_size = (src_stride as usize) * (src_height as usize); + // unsafe #1:调用 libc::mmap 把 DMA-BUF fd 映射到用户态地址空间。FFI 之所以 + // 必须 unsafe:mmap 接受 void* 返回 raw 指针,编译器无法验证其有效性; + // 调用方必须保证 fd 真的是有效的 DMA-BUF 且 PROT_READ 权限匹配。 let mmap_ptr = unsafe { libc::mmap( ptr::null_mut(), @@ -153,6 +223,8 @@ fn main() -> Result<()> { ) }; + // `MAP_FAILED` 是 mmap 失败的哨兵值(不是 NULL)。AMD 某些驱动禁止 CPU 读 + // DMA-BUF,必须改用 VAAPI 硬件路径——这就是 `vaapi_import_bench.rs` 的意义。 if mmap_ptr == libc::MAP_FAILED { let errno = std::io::Error::last_os_error(); bail!( @@ -173,6 +245,8 @@ fn main() -> Result<()> { "[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n", mmap_size as f64 / 1024.0 / 1024.0 ); + // unsafe #2:解除映射。FFI 调用必须 unsafe——libc::munmap 接受 raw pointer, + // 编译期无法保证 ptr 真的来自之前 mmap 的同一区域(不匹配会 UB)。 unsafe { libc::munmap(mmap_ptr, mmap_size); } @@ -180,10 +254,15 @@ fn main() -> Result<()> { // Set up libx264 encoder via FFI (same pattern as avhw.rs) println!("[4/4] Setting up libx264 encoder..."); + // 输出路径转 C 字符串(FFmpeg C API 期望 `const char*`,不接受 Rust &str)。 + // CString 保证结尾有 NUL 字节,调用方必须保证字符串内部不含 NUL。 let output_path = Path::new(&bench_args.output); let output_cstr = CString::new(output_path.to_str().unwrap())?; // Try libx264 first (best quality/speed), fall back to openh264 + // 查找软件 H.264 编码器:优先 libx264(最快/质量最好),缺失则 fallback openh264。 + // Rust 的 `or_else` + `ok_or_else` 是 Result/Option 链式习惯,类似 Go 的 + // 多次 if err != nil 但不嵌套。 let codec = ff::encoder::find_by_name("libx264") .or_else(|| ff::encoder::find_by_name("libopenh264")) .ok_or_else(|| { @@ -191,11 +270,13 @@ fn main() -> Result<()> { })?; println!("[4/4] Using encoder: {}\n", codec.name()); + // 创建 FFmpeg 编码器 Context 并提取 video encoder 句柄。`enc.open()` 会在后面调用。 let mut enc = { let ctx = ff::codec::Context::new_with_codec(codec); ctx.encoder().video()? }; + // 编码器基础参数:分辨率/像素格式/时基/GOP。`time_base = 1/60` 表示一帧 = 1/60 秒。 enc.set_width(enc_width); enc.set_height(enc_height); enc.set_format(ff::format::Pixel::YUV420P); @@ -205,6 +286,9 @@ fn main() -> Result<()> { let codec_name = codec.name(); if codec_name == "libx264" { + // unsafe #3:调用 FFmpeg 的 `av_opt_set` 设置 libx264 的私有 preset/tune 选项。 + // FFI 必须 unsafe:接受 `*const c_char` 裸指针,编译期无法验证指针指向有效内存, + // 也无法保证 priv_data 字段确实属于 libx264(其它编码器会 UB)。 unsafe { let key = CString::new("preset").unwrap(); let val = CString::new("veryfast").unwrap(); @@ -219,7 +303,11 @@ fn main() -> Result<()> { let mut enc_video = opened.0; // Create output format context via FFI + // FFmpeg 输出格式上下文:根据文件扩展名(如 .mp4)自动推断容器。 let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut(); + // unsafe #4:`avformat_alloc_output_context2` 接受 out-pointer 模式(C 风格返回 + // 指针的指针)。FFI 必须 unsafe:编译期无法验证 fmt_ctx_ptr 可写、不能保证 + // 调用方传入了正确的容器格式猜测。 let ret = unsafe { ffi::avformat_alloc_output_context2( &mut fmt_ctx_ptr, @@ -232,21 +320,31 @@ fn main() -> Result<()> { bail!("Failed to allocate output format context: error {ret}"); } + // unsafe #5:在 fmt_ctx 内创建一条新流(mp4 容器内的一条视频 track)。 + // 返回的 `stream_ptr` 是裸指针,调用方负责不 double-free(FFmpeg 内部托管)。 let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) }; if stream_ptr.is_null() { bail!("Failed to create new stream"); } + // unsafe #6:把编码器参数(分辨率/时基/像素格式)拷贝到流的 codecpar 字段。 + // FFmpeg C API 允许裸指针字段写入(`(*stream_ptr).codecpar`),编译期无法验证 + // 两个上下文确实兼容(同 codec、同 pixel format),调用方需自己保证。 let ret = unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) }; if ret < 0 { bail!("Failed to copy encoder parameters: error {ret}"); } + // unsafe #7:直接通过裸指针写字段:把编码器的 time_base 复制到流,避免后续 + // mux 时再 rescale。FFI 必须 unsafe——`(*stream_ptr).time_base = ...` 是 C 风格 + // 的指针解引用赋值,编译期无法验证 stream_ptr 仍存活。 unsafe { (*stream_ptr).time_base = (*enc_video.as_ptr()).time_base; } + // unsafe #8:`avio_open` 打开输出文件的 IO 上下文。FFI 必须 unsafe:编译期 + // 无法验证 fmt_ctx_ptr->pb 字段可写、不能保证文件路径可写(运行时才报错)。 let ret = unsafe { ffi::avio_open( &mut (*fmt_ctx_ptr).pb, @@ -261,17 +359,27 @@ fn main() -> Result<()> { ); } + // unsafe #9:写容器头(mp4 的 ftyp box 等)。FFI 必须 unsafe:调用顺序约束 + // (必须在 avio_open 之后、第一帧之前)由调用方维护,编译期不验证。 let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) }; if ret < 0 { bail!("Failed to write header: error {ret}"); } + // unsafe #10:`Output::wrap` 把 C 指针包装成 Rust 类型——FFI 边界。 + // unsafe 必须:调用方保证 fmt_ctx_ptr 在此后由 Rust 独占管理(FFmpeg C 代码 + // 不能再 free 它,否则 double-free)。这是 `unsafe impl Send` 在 avhw.rs 中 + // 同款的"独占所有权"约定。 let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) }; // Create sws_scale context: BGRZ (BGR0) -> YUV420P + // sws_scale 是 FFmpeg 的颜色空间转换器(CPU 软件)。本基准的"软件路径"核心: + // 把 DMA-BUF 的 BGR0 像素数据转成 libx264 期望的 YUV420P planar 格式。 let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ); let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P); + // unsafe #11:`sws_getContext` 创建转换器。FFI 必须 unsafe:返回 raw 指针, + // 调用方负责后续 `sws_freeContext` 释放(cleanup 阶段会做)。 let sws_ctx = unsafe { ffi::sws_getContext( src_width as i32, @@ -291,14 +399,20 @@ fn main() -> Result<()> { } // Allocate reusable YUV frame + // 预分配一个 YUV420P 帧,循环里反复写入(避免每帧 malloc)。FFmpeg C API 要求 + // 显式 alloc/get_buffer/free 三步——Rust 端无法用 RAII 自动管理,必须 unsafe。 let mut yuv_frame = unsafe { + // unsafe #12:`av_frame_alloc` 只分配 struct 本体,不分配 data 缓冲区。 let mut f = ffi::av_frame_alloc(); if f.is_null() { bail!("av_frame_alloc failed"); } + // unsafe #13:通过裸指针写入 width/height/format 字段。 (*f).width = enc_width as i32; (*f).height = enc_height as i32; (*f).format = yuv420p_fmt as i32; + // unsafe #14:`av_frame_get_buffer` 根据 width/height/format 分配实际像素缓冲区。 + // 失败时必须 free 已分配的 struct(避免泄漏)。 let ret = ffi::av_frame_get_buffer(f, 0); if ret < 0 { ffi::av_frame_free(&mut f); @@ -314,12 +428,16 @@ fn main() -> Result<()> { println!("=== Encoding {} frames ===\n", bench_args.frames); + // 统计容器初始化。`Instant::now()` 是单调时钟(不受系统时间调整影响), + // 类比 Go 的 `time.Now()`,但 Rust 的 Instant 设计上不允许"墙上时钟"用途。 let mut stats = FrameStats::default(); let total_start = Instant::now(); let mut frames_encoded: u32 = 0; let mut pts: i64 = 0; + // 主采集编码循环:每帧从 PipeWire 拉帧 → mmap → sws_scale → send_frame → drain。 while frames_encoded < bench_args.frames { + // 控制通道优先检查(流结束/错误)。`try_recv` 非阻塞返回 Result>。 if let Ok(ctrl) = cap.event_receiver().try_recv() { match ctrl { PwCtrlEvent::StreamEnded => { @@ -334,6 +452,7 @@ fn main() -> Result<()> { } } + // 5s 超时拉帧。任何错误(超时/断开)都视为流终止,跳出循环。 let frame = match cap .frame_receiver() .recv_timeout(std::time::Duration::from_secs(5)) @@ -345,10 +464,14 @@ fn main() -> Result<()> { } }; + // 帧级别计时:本轮 mmap/scale/encode 的总耗时统计锚点。 let frame_start = Instant::now(); + // ---- 第 1 段:mmap DMA-BUF 到用户态 ---- let mmap_start = Instant::now(); let frame_size = (frame.stride as usize) * (frame.height as usize); + // unsafe #15:与首帧的 mmap 同语义——把 PipeWire 推来的 DMA-BUF fd 映射到 + // 用户态。每帧都重新 mmap 是因为 fd 可能切换(Portal 可能用 buffer pool)。 let mmap_ptr = unsafe { libc::mmap( ptr::null_mut(), @@ -368,9 +491,16 @@ fn main() -> Result<()> { } stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64); + // ---- 第 2 段:sws_scale BGR0 → YUV420P ---- let scale_start = Instant::now(); + // unsafe #16:`slice::from_raw_parts` 把裸指针+长度包成 Rust slice。 + // 这是 Rust 最危险的 unsafe 之一:编译期无法验证 (ptr, len) 真的指向 + // 有效内存、对齐正确、与 aliasing 规则兼容(不允许其它 &mut 同时存活)。 let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) }; + // unsafe #17:调用 FFmpeg 的 sws_scale 做颜色空间转换。三个 FFI 风险: + // (1) 裸指针 src_ptr / src_linesize;(2) yuv_frame->data/linesize 数组 + // 必须有效;(3) sws_ctx 必须与 src/dst 像素格式匹配(不匹配会 UB)。 unsafe { ffi::av_frame_make_writable(yuv_frame); @@ -391,13 +521,18 @@ fn main() -> Result<()> { .scale_us .push(scale_start.elapsed().as_micros() as u64); + // unsafe #18:解除本帧的 mmap。FFI 必须 unsafe——ptr 必须仍是之前 mmap 的返回值。 unsafe { libc::munmap(mmap_ptr, frame_size); } drop(frame); + // ---- 第 3 段:libx264 编码 ---- let encode_start = Instant::now(); + // unsafe #19:`avcodec_send_frame` 把一帧 YUV 喂给编码器(异步:内部入队)。 + // FFI 必须 unsafe:裸指针 enc_video.as_mut_ptr()/yuv_frame;编译期无法 + // 验证 enc 已 open、yuv_frame 的 width/height/format 与编码器配置一致。 unsafe { (*yuv_frame).pts = pts; pts += 1; @@ -431,6 +566,8 @@ fn main() -> Result<()> { let total_elapsed = total_start.elapsed(); println!("\nFlushing encoder..."); + // unsafe #20:发 NULL frame 表示"flush"——编码器吐出剩余的延迟帧(B-frame 等)。 + // 本基准 max_b_frames=0 所以没有延迟帧,但调用约定必须保留。 unsafe { ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null()); } @@ -440,6 +577,8 @@ fn main() -> Result<()> { .map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?; // Cleanup + // unsafe #21:手动释放 yuv_frame 与 sws_ctx。FFmpeg C API 不支持 RAII, + // 必须显式 free,否则内存泄漏。`as *mut _` 是为了取 *mut *mut AVFrame 引用。 unsafe { ffi::av_frame_free(&mut yuv_frame as *mut _); ffi::sws_freeContext(sws_ctx); @@ -448,6 +587,8 @@ fn main() -> Result<()> { drop(cap); // Print results + // 结果汇总:把 mmap/scale/encode 三段均值 + 总 FPS 打印成表格。Go 类比 + // `fmt.Printf`——Rust println! 是宏不是函数,编译期检查参数。 let mmap_count = stats.mmap_us.len() as u32; let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 { mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0 @@ -456,6 +597,7 @@ fn main() -> Result<()> { }; let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64(); let avg_total_ms = FrameStats::avg_ms(&stats.total_us); + // 最大理论 FPS = 1000ms / 每帧均耗时。avg_total_ms 为 0 时跳过避免除零。 let max_fps = if avg_total_ms > 0.0 { 1000.0 / avg_total_ms } else { @@ -520,14 +662,21 @@ fn main() -> Result<()> { Ok(()) } +// 从编码器 drain(抽取)已经编码好的压缩包并写入输出容器。FFmpeg 编码 API 是 +// 异步的:`avcodec_send_frame` 入队原始帧,`avcodec_receive_packet` 出队 H.264 +// NAL;可能 send 一帧后 receive 多包(关键帧场景),也可能 receive 返回 EAGAIN +// (编码器内部还在缓冲)。Go 类比:双 channel + select 循环,先收再吐。 fn drain_encoder( enc_video: &mut ff::encoder::video::Video, octx: &mut ff::format::context::Output, ) -> Result<()> { loop { let mut pkt = ff::Packet::empty(); + // unsafe #22:`avcodec_receive_packet` 出队一个 H.264 压缩包到 pkt。FFI 必须 + // unsafe:编译期无法验证 enc_video 已 open、pkt.as_mut_ptr() 真指向空 packet。 let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) }; if ret < 0 { + // EAGAIN = 暂时没有更多包可吐(需要再 send);EOF = flush 完成。两者都退出。 if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF { break; } @@ -536,13 +685,19 @@ fn drain_encoder( } let enc_tb = enc_video.time_base(); + // unsafe #23:从 `(*octx.as_ptr()).streams` 取第一条流的 time_base,用于 + // rescale 时间戳。FFI 必须 unsafe——裸指针 + `*streams.add(0)` 假定 streams + // 数组至少有一项(fmt_ctx 已注册至少一条流,否则前面 avformat_new_stream + // 就 bail 了)。 let stream_tb = unsafe { let streams = (*octx.as_ptr()).streams; let st = *streams.add(0); ff::Rational::from((*st).time_base) }; + // 把 PTS 从编码器时基 rescale 到流时基(mp4 容器要求)。Go 类比:单位换算。 pkt.rescale_ts(enc_tb, stream_tb); pkt.set_stream(0); + // `write_interleaved` 让 FFmpeg 自动处理 interleaving(音视频交错,避免 demuxer 卡)。 pkt.write_interleaved(octx) .map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?; }