用 Rust 重写 FFmpeg:ffmpreg 架构拆解与转码流水线实战 说实话第一次看到ffmpreg这个项目名的时候我愣了一下。preg 这个后缀怎么看都像 pregnant 的缩写一个“怀孕版 FFmpeg”听起来确实有点无厘头。但点进去才发现项目的目标异常硬核——用 Rust 完全重写 FFmpeg。在音视频处理这个圈子里FFmpeg 几乎是“瑞士军刀”级别的东西从视频转码、剪辑、推流到滤镜特效几乎所有工具链底层都有它的影子。与此同时它的代码体量之大、历史包袱之重也是出了名的。所以当听说有人试图用 Rust 对这块“硬骨头”下手时我的第一反应不是怀疑而是好奇他们到底想解决什么问题又要从哪儿入手是重写一个命令行工具还是重新设计一套媒体处理框架这篇就聊聊我从项目结构和实现思路上看到的门道以及如果你也想做类似的重写应该怎么规划才不会翻车。先说结论ffmpreg并不是要把 FFmpeg 的每个 C 文件翻译成 Rust那既没有意义也不现实。它的做法更像是在搭一个 Rust 原生的媒体处理底座先用 CLI 兼容层保住使用习惯再用模块化方式逐个替换编解码、封装、滤镜这些核心组件。这篇我会从动机、整体设计、最小可运行流水线、常见翻车点四个维度展开顺便给出一些可以直接抄作业的代码骨架。1. 为什么要重写FFmpeg 的痛点和 Rust 的切入点1.1 FFmpeg 的“历史债”到底有多重聊重写之前得先把 FFmpeg 本身的问题说透。FFmpeg 是 C 语言写的发展了几十年代码规模已经到百万行级别。这个体量本身不是问题问题在于几十年来积累出来的结构性问题。最明显的一点是手工内存管理。音视频数据天生就是高频分配、高频释放的场景一个 1080p 的视频流一秒钟就有 25 到 60 帧每帧解码出来可能是一两 MB 的 YUV 数据加上音频、字幕、封装的 buffer整个生命周期里经过的数据块数量非常惊人。C 语言里这些都要靠av_malloc、av_free一类的接口手动管理稍有不慎就是泄漏、悬垂指针、越界读写。FFmpeg 开发团队填了无数漏洞但很多漏洞的根源就是“在解析不可信输入时某个长度字段被恶意改成超大值导致缓冲区计算溢出”。这类问题在 C 语言里只能靠开发者小心再小心没法从语言层面根治。第二个痛点是全局状态和线程安全。FFmpeg 早年为了性能用了很多全局配置和可重入性较差的 API虽然后续版本一直在改进线程安全但很多历史接口仍然依赖上下文、依赖调用顺序甚至在不同版本之间存在 ABI 变化。下游依赖 FFmpeg 的播放器、转码工具、GPU 厂商驱动稍有版本升级就要跟着适配。不少发行版维护者最怕的就是 FFmpeg 大版本更新一次 API 变动能让一大批软件包连锁出问题。第三个问题是可维护性。百万行 C 代码里掺杂着大量条件编译宏针对不同平台、不同 CPU 架构、不同编译器的分支逻辑盘根错节。想修一个 bug往往要同时理解调用链、内存模型、平台差异三件事。并不是说 FFmpeg 的代码写得不好事实上它是 C 语言工程里天花板级别的项目但它的复杂度已经接近人类维护能力的上限了。1.2 Rust 能给音视频工具链带来什么Rust 在重写 FFmpeg 这件事上最核心的卖点不是“性能比 C 快”而是内存安全、数据竞争消除、现代工程化这三个维度的提升。内存安全不用多说Rust 的借用检查器让悬垂指针、缓冲区溢出这类问题在编译期就被拦下来。音视频场景有个特殊性大量代码要处理“不可信的外部输入”比如从一个恶意构造的 MP4 文件里解析stbl子盒子、读取样本大小表、计算偏移量。用 Rust 写解析器的时候类型系统会逼着你把边界情况处理清楚Option、Result、checked_add这些都是常规操作而不是靠开发者自觉。数据竞争消除对音视频这种并发大户也极其重要。转码场景天然适合并行多个视频帧可以同时解码、多个音频包可以同时处理但并行之后必须保证帧与帧之间的顺序不乱、PTS 不乱。Rust 的Send、Sync机制在编译期就能告诉你某个类型能不能安全地跨线程传递这比 C 里“加个锁就当线程安全”要可靠得多。工程化方面则是老生常谈但确实有效cargo 的依赖管理、rustfmt 的格式化、clippy 的静态检查、内置的测试框架这些让一个大型项目的协作门槛低了不少。对长期维护来说还有一点很关键Rust 的类型系统可以让“大规模重构”这件事从挑战变成日常操作。改一个函数签名编译器会把所有调用点全部标出来这比 C 里 grep 然后人肉确认要踏实太多。注意Rust 不是银弹。FFI 边界、异步生态不统一、编译时间增长这些都是实际会踩到的坑。重写 FFmpeg 真正的难点从来不在语言本身而在音视频领域知识色彩空间怎么转、时间基怎么换算、封装格式的字节布局长什么样。语言只是工具领域理解才是地基。2. 整体架构拆解ffmpreg 的 crate 划分与兼容策略2.1 先做 CLI 兼容层而不是先做解码器任何一个大型重写项目最容易犯的错误就是一上来就扎进某个技术难点比如“我要先写一个纯 Rust 的 H.264 解码器”。结果写了大半年命令行工具还是跑不起来连一个最简单的高清视频都没法流畅播放。ffmpreg的思路反过来了先做 CLI 兼容层让现有 FFmpeg 用户和脚本能无缝迁移再逐步填充内部实现。这样做有几个实打实的好处。第一CLI 是唯一一个用户每天都会接触的接口。只要命令行参数行为对齐用户和自动化脚本几乎感觉不到底层换了实现。第二CLI 层提供了一个绝佳的“测试驱动框架”每实现一个子功能就可以用和 FFmpeg 完全相同的命令去跑输出结果做像素级 diff 对比。第三CLI 层的实现难度相对低能让项目快速跑通端到端流程建立起团队信心。先有一个能转码的最小闭环再去优化内部模块这是重写工程的正确姿势。具体做法是用 clap 这个 Rust 生态里最主流的命令行解析库把 ffmpeg 风格参数翻译成一份结构化的转码任务描述PipelineSpec然后交给后端的执行引擎处理。注意这里不是逐字解析 ffmpeg 的全部参数而是先覆盖高频场景输入文件、输出文件、视频编码器、音频编码器、滤镜链、码率控制、分辨率缩放这些。低频参数可以在后续版本里慢慢补。2.2 workspace 式的模块边界ffmpreg采用 cargo workspace 组织代码这一点很值得参考。直接把所有代码塞进一个 crate 会急剧拉长编译时间也没法单独发布和测试某个模块。拆成多个 crate 之后模块边界清晰编译缓存友好CI 也可以按 crate 并行跑测试。实际划分大概是这样的Crate职责关键类型ffmpreg-core媒体基础数据结构和时间基Packet、Frame、Timebaseffmpreg-format容器格式解封装与封装MP4、MKV、TSDemuxer、Muxerffmpreg-codec编解码抽象层Decoder、Encoderffmpreg-filter滤镜链框架FilterGraph、FilterNodeffmpreg-cli命令行入口CliOptions、PipelineBuilder这个分层有一个明显的优势每个 crate 可以独立演进。比如ffmpreg-format里的 MP4 解析器可以先用 C 库通过 FFI 调用 FFmpeg等纯 Rust 实现成熟后再替换ffmpreg-codec里的 H.264 解码器同理。对外暴露的接口保持不变内部实现悄悄替换。同时这个结构也方便其他人参与贡献。新人不需要理解整个项目才能帮上忙只需关注某一个 crate比如去完善某个封装格式的 demuxer或者给滤镜框架加一个新的图像处理节点。大型开源项目最怕的就是“无处下手”清晰的模块边界能极大降低贡献门槛。2.3 数据模型从 AVPacket 到 Rust 的 Packet/Frame在 FFmpeg 里AVPacket代表一个“压缩后的数据块”可能是编码后的视频帧或者音频包AVFrame代表“解码后的数据”比如一张 YUV 图像或一段 PCM 音频。ffmpreg保留了这两个核心概念但实现上完全遵循 Rust 的所有权和借用规则。核心数据结构大致长这样#[derive(Clone)] pub struct Timebase { pub num: u32, pub den: u32, } impl Timebase { pub fn new(num: u32, den: u32) - Self { Timebase { num, den } } pub fn rescale(self, value: i64, to: Timebase) - i64 { // 这是 ffmpeg 里 av_rescale_q 的 Rust 简化版 // 注意这里要考虑溢出保护生产代码需要 checked_mul/checked_add value * self.num as i64 * to.den as i64 / (self.den as i64 * to.num as i64) } } pub struct Packet { pub data: ArcVecu8, pub pts: i64, pub dts: i64, pub duration: i64, pub stream_index: usize, } pub struct Frame { pub data: VecArcVecu8, // 多平面的像素数据比如 Y、U、V 分别是一个平面 pub width: u32, pub height: u32, pub format: PixelFormat, pub pts: i64, }数据体本身用ArcVecu8而不是Vecu8是有讲究的。音视频转码过程中同一个数据块可能会被多个组件引用滤镜系统要读、编码器要读、调试模块可能也要读。如果每次都深拷贝内存带宽会迅速成为瓶颈。用Arc做引用计数共享可以做到“读的时候零拷贝写的时候才复制”这是音视频框架里非常关键的优化点。3. 动手实现5 步搭一个 Rust 版最小转码流水线3.1 项目骨架与依赖选型先别急着写解码器我们从一个能跑的骨架开始。用 cargo workspace 建项目cargo new ffmpreg --workspace cd ffmpreg cargo new crates/ffmpreg-core cargo new crates/ffmpreg-cli在根目录的Cargo.toml里声明 workspace 成员[workspace] members [crates/ffmpreg-core, crates/ffmpreg-cli] resolver 2依赖选型上我建议按这个组合起步# crates/ffmpreg-cli/Cargo.toml [dependencies] clap { version 4, features [derive] } anyhow 1 crossbeam-channel 0.5 rayon 1 ffmpreg-core { path ../ffmpreg-core } # 如果需要通过 FFI 调用现有解码器可以加上 ffmpeg 的 binding crate # ffmpeg-sys-next { version 6, features [build] }crossbeam-channel用来做流水线阶段之间的数据传递rayon用来做数据并行clap负责命令行解析。这三个库是我实测下来最稳的组合再复杂的场景基本够用了。3.2 用 clap 实现兼容 ffmpeg 的参数入口ffmpreg的 CLI 要和 FFmpeg 长得像但不能做到 100% 一致百分百一致其实也没必要。关键是覆盖高频用法-i指定输入、-c:v指定视频编码器、-c:a指定音频编码器、-vf指定滤镜链、-b:v指定码率、-s指定分辨率。use clap::Parser; #[derive(Parser, Debug)] #[command(name ffmpreg, version 0.1.0, about Rust 实现的音视频转码工具)] pub struct CliOptions { /// 输入文件路径支持 -i input.mp4 形式 #[arg(short i, value_name INPUT)] pub input: String, /// 输出文件路径作为位置参数 #[arg(value_name OUTPUT)] pub output: String, /// 视频编码器例如 libx264 #[arg(short c, value_name CODEC, long codec)] pub codec: OptionString, /// 视频滤镜链例如 scale1280:720 #[arg(short f, value_name FILTERS, long filter)] pub filters: OptionString, }这里有个小细节ffmpeg 的-codec是分视频和音频的-c:v和-c:a要分开。上面只写了通用--codec生产环境建议用value_enum拆成-c:v和-c:a两个参数。为了演示方便我先用一个粗粒度的参数顶替实际项目里要按 FFmpeg 的参数规范拆细。3.3 流水线调度demux、decode、filter、encode、mux 如何串联转码的完整数据流是Demux - Decode - Filter - Encode - Mux。读进一个封装好的 MP4 文件先拆成一个个压缩数据包然后送到解码器变成图像帧再经过滤镜调整尺寸/色域/添加水印最后压缩成新的视频流写进输出容器。ffmpreg把这条链路抽象成“阶段线程 通道”的模型use std::thread; use crossbeam_channel::{bounded, Sender}; fn build_pipeline(spec: PipelineSpec) - Result(), anyhow::Error { // 每个阶段之间用有界通道连接背压由通道容量自动控制 let (demux_tx, demux_rx) bounded::Packet(32); let (decode_tx, decode_rx) bounded::Frame(32); let (filter_tx, filter_rx) bounded::Frame(32); let (encode_tx, encode_rx) bounded::Packet(32); // 阶段 1: demuxer 线程 let demux_handle thread::spawn(move || { let mut demuxer create_demuxer(spec.input).unwrap(); while let Some(pkt) demuxer.next_packet().unwrap() { demux_tx.send(pkt).unwrap(); } drop(demux_tx); }); // 阶段 2: decoder 线程 let decode_handle thread::spawn(move || { let mut decoder create_decoder(spec).unwrap(); while let Ok(pkt) demux_rx.recv() { if let Some(frame) decoder.decode(pkt).unwrap() { decode_tx.send(frame).unwrap(); } } drop(decode_tx); }); // 阶段 3 4: 滤镜和编码可以在同一个线程里处理 let encode_handle thread::spawn(move || { let mut filter create_filter_chain(spec).unwrap(); let mut encoder create_encoder(spec).unwrap(); while let Ok(frame) filter_rx.recv() { let filtered filter.process(frame).unwrap(); let packet encoder.encode(filtered).unwrap(); encode_tx.send(packet).unwrap(); } drop(encode_tx); }); // 阶段 5: muxer 线程 let mux_handle thread::spawn(move || { let mut muxer create_muxer(spec.output).unwrap(); while let Ok(pkt) encode_rx.recv() { muxer.write_packet(pkt).unwrap(); } }); demux_handle.join().unwrap(); decode_handle.join().unwrap(); encode_handle.join().unwrap(); mux_handle.join().unwrap(); Ok(()) }有两点值得展开讲。第一通道容量选 32 不是拍脑袋定的。如果容量太小比如 4那么解码器一旦稍微慢一点demuxer 就会被卡住整个流水线吞吐被拉低。如果容量太大比如 1024那么帧数据大量堆积在内存里一个 4K 视频的 Frame 能到几十 MB堆几百个就是几个 GB 内存开销。32 是我实测下来在“吞吐”和“内存占用”之间比较均衡的值具体项目应根据帧大小动态调整。第二滤镜和编码放在同一个线程是有意为之。滤镜节点之间有严格的顺序依赖比如先缩放再裁剪再加水印必须保证滤镜链内部的时间顺序不被破坏。而编码器又依赖滤镜输出的顺序。把这两个阶段串在同一个线程里可以避免加锁还能天然保证帧序不乱。3.4 并发与内存安全Arcs、channel 和 Rayon 的取舍上面的流水线是“阶段并行”每个线程只处理一个阶段不同阶段之间并行。这已经能发挥多核 CPU 的能力了因为解码器内部一般会自带多线程滤镜框架也能在单帧内做像素级并行。如果还想进一步榨干 CPU可以对解码后的 Frame 集合做“数据并行”也就是同时处理多帧。这个场景我用的是 Rayonuse rayon::prelude::*; fn parallel_filter(frames: VecFrame, filter: dyn FilterNode) - VecFrame { frames .into_par_iter() .map(|frame| filter.process(frame)) .collect() }但数据并行有一个坑处理完的帧顺序可能和输入顺序不一致而编码器要求 PTS 必须单调递增。所以并行处理后必须加一个“重排缓冲区”按 PTS 排序再逐个喂给编码器。这个重排逻辑稍有点繁琐实际中很容量翻车所以我强烈建议第一阶段先用单线程把流水线跑通验证输出文件能正常播放再考虑并行加速。正确性永远优先于性能。4. 转码实战中的 4 个高频坑点与排查记录4.1 颜色空间与像素格式不匹配这是转码新手最常遇到的问题症状非常典型输出的视频画面偏绿、偏紫或者干脆黑屏但是播放器没有报错。这类问题几乎都出在“像素格式”在滤镜链里被改变了而编码器不认。H.264 编码器通常只认yuv420p这种平面 YUV 格式但你去滤镜链里加了一个水印或者做了一下模糊很多滤镜实现内部会把数据转成 RGB 来处理。处理完输出的 Frame 就变成了rgb24或rgba再直接送进编码器编码器要么报错要么输出一个完全错乱的颜色空间信号。解决办法是在滤镜链的末尾强制加一个格式转换节点。在 FFmpeg 命令行里是formatyuv420p在ffmpreg里就要在滤镜框架里插入一个PixelFormatConverter节点pub struct PixelFormatConverter { target: PixelFormat, // 这里可以接入快速色彩空间转换库比如 zune-image 或者自研 swscale } impl FilterNode for PixelFormatConverter { fn process(mut self, frame: Frame) - ResultFrame { if frame.format self.target { return Ok(frame); } // 执行 YUV - RGB 转换注意缩放比例和色度采样位置 let converted convert_pixel_format(frame, self.target)?; Ok(converted) } }排查这类问题时最快的工具是在输出前用ffprobe查看流信息ffprobe -show_streams output.mp4重点看pix_fmt和color_space两个字段。如果显示的是rgb24并且你在命令行里没显式指定-pix_fmt yuv420p那基本可以断定是滤镜链改变了像素格式。建议写一个自动测试每次转码完成后用 ffprobe 解析输出流断言pix_fmt必须是目标编码器支持的格式。这种断言脚本的成本极低但能拦掉 90% 的格式类回归问题。4.2 PTS/DTS 错乱与时间基换算第二个高频坑是时间戳错乱。播放器出现“一直快进根本停不下来”、“视频播一会儿就跳到结尾”、“音画不同步差出好几秒”这类问题基本都是 PTS/DTS 或者时间基在某个环节被算错了。音视频里最基础的概念是“时间基”它表示“一个时间戳单位代表多少秒”。MP4 文件里视频流的时间基可能是1/90000音频流可能是1/48000而处理过程中滤镜阶段可能改成了微秒1/1000000。如果代码里直接把不同时间基的数值相加相减数值上就是错的换算成真实时间自然也不对。ffmpreg的处理策略是在核心层统一用高精度时间基我习惯用Timebase::new(1, 1_000_000)也就是微秒。所有组件拿到 Packet 或 Frame 后第一件事就是把时间戳统一换算到微秒所有组件向外输出前再把时间戳换算成目标时间基。换算可以用前文的rescale方法本质是分数乘法一定要用i64避免浮点数精度损失。实际踩坑的细节还在于起始时间戳。很多输入文件的第一个包 PTS 不是 0可能是 1000 或 90000因为前面有 B 帧导致 DTS 先于 PTS或者封装里有 start_time 偏移。如果直接把这个值写进输出文件播放器会认为视频从中间开始。所以编码输出前要做一次“原点校正”将每条流的第一个包的 PTS 记为偏移量后续所有包都减去这个偏移保证输出文件从 0 开始。4.3 性能瓶颈和内存分配器ffmpreg跑到大文件、高分辨率场景时性能问题逐渐暴露出来。最典型的瓶颈有两个内存分配器的锁竞争和数据的无谓拷贝。Rust 默认使用系统的内存分配器在 Linux 上就是 glibc 的 malloc。多线程转码时各个线程频繁分配释放帧数据malloc 的全局锁会成为明显的瓶颈。实测下来同样的转码任务切到 mimalloc 之后整体吞吐能提升 10% 到 20%不要小看这个数字在高分辨率转码场景里非常可观。接入 mimalloc 很简单在入口 crate 里加一行[dependencies] mimalloc 0.1然后在main.rs里声明全局分配器use mimalloc::MiMalloc; #[global_allocator] static GLOBAL_ALLOCATOR: MiMalloc MiMalloc;数据拷贝方面常见的问题是在滤镜链里频繁 clone 整帧数据。前面用ArcVecu8做共享已经规避了一部分拷贝但要特别注意Arc的clone只增加引用计数代价很小可一旦代码里调用了Vec::clone()就变成了深拷贝。写代码时要明确筛选类型只读场景用Frame或ArcFrame要独立修改时才用Frame::clone。4.4 构建体积与链接时间Rust 项目越写越大编译和链接时间会越来越长这是绕不开的成本。ffmpreg这种大型项目对应的优化是一个必做动作。在根 Cargo.toml 的[profile.release]段开启三个关键选项[profile.release] opt-level 3 lto fat codegen-units 1 strip truelto fat和codegen-units 1让编译器做全程序范围内的跨 crate 优化代价是链接时间明显变长但性能收益是实打实的。strip true可以去掉二进制里的符号表把体积压下来不少对分发到容器镜像里的场景特别友好。如果项目里通过 FFI 依赖了 FFmpeg 的 C 库另一个常见问题是构建环境需要编译一堆第三方依赖。用ffmpeg-sys-next这类 binding crate 时建议按需启用 feature只编译当前需要的解码器/编码器/封装格式不要默认全量编译。全量编译可能让 CI 跑一次构建就花掉 20 分钟按需裁剪后能控制在 5 分钟以内。5. 关于这个项目的后续方向和个人体会看完整套设计我对ffmpreg最大的感触是它没有试图一步到位吃掉整个 FFmpeg而是先搭了一个扎实的 Rust 原生底座再按模块逐个替换。这种重写策略在大型系统里是目前最靠谱的玩法。我自己的下一步会关注三个方向。首先是滤镜系统的纯 Rust 化目前滤镜框架里最容易成为短板的反而不是核心数据流而是那些图像处理算法缩放、卷积、色彩转换这些是像素级操作Rust 写起来性能不输 C但需要一个成熟的算法库支撑。其次是硬件编解码接入用 Rust 封装 NVIDIA 的 NVENC 或 QSV 这类硬件编码器的 API其实比在 C 里做更安全因为 FFI 边界被 Rust 的类型系统卡住了不容易传错指针。最后是 WASM 方向如果能编译到 WebAssembly浏览器里就能直接跑音视频转码前面搭好的模块化架构对这种新目标平台的适配也会相对友好。最后分享一个我在代码评审里反复强调的原则重写一个成熟工具时不要在生产环境追求“完美主义”。哪怕你只替换了解封装模块其他模块仍然用 FFI 调用老库也是巨大的进步。真实项目里“部分替换并且跑得稳”的价值远高于“设计完美但一直没发布”。项目宁可慢一点也要确保每个阶段都有一个可运行、可验证、可对比的版本。这个节奏比什么技术选型都重要。