说实话,第一次看到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、Timebase |
| ffmpreg-format | 容器格式解封装与封装(MP4、MKV、TS) | Demuxer、Muxer |
| ffmpreg-codec | 编解码抽象层 | Decoder、Encoder |
| ffmpreg-filter | 滤镜链框架 | FilterGraph、FilterNode |
| ffmpreg-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: Arc<Vec<u8>>, pub pts: i64, pub dts: i64, pub duration: i64, pub stream_index: usize, } pub struct Frame { pub data: Vec<Arc<Vec<u8>>>, // 多平面的像素数据,比如 Y、U、V 分别是一个平面 pub width: u32, pub height: u32, pub format: PixelFormat, pub pts: i64, }数据体本身用Arc<Vec<u8>>而不是Vec<u8>,是有讲究的。音视频转码过程中,同一个数据块可能会被多个组件引用:滤镜系统要读、编码器要读、调试模块可能也要读。如果每次都深拷贝,内存带宽会迅速成为瓶颈。用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: Option<String>, /// 视频滤镜链,例如 scale=1280:720 #[arg(short = 'f', value_name = "FILTERS", long = "filter")] pub filters: Option<String>, }这里有个小细节: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 集合做“数据并行”,也就是同时处理多帧。这个场景我用的是 Rayon:
use rayon::prelude::*; fn parallel_filter(frames: Vec<Frame>, filter: &dyn FilterNode) -> Vec<Frame> { 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 命令行里是format=yuv420p,在ffmpreg里就要在滤镜框架里插入一个PixelFormatConverter节点:
pub struct PixelFormatConverter { target: PixelFormat, // 这里可以接入快速色彩空间转换库,比如 zune-image 或者自研 swscale } impl FilterNode for PixelFormatConverter { fn process(&mut self, frame: Frame) -> Result<Frame> { 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 整帧数据。前面用Arc<Vec<u8>>做共享已经规避了一部分拷贝,但要特别注意:Arc的clone只增加引用计数,代价很小,可一旦代码里调用了Vec::clone()就变成了深拷贝。写代码时要明确筛选类型:只读场景用&Frame或Arc<Frame>,要独立修改时才用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 调用老库,也是巨大的进步。真实项目里“部分替换并且跑得稳”的价值,远高于“设计完美但一直没发布”。项目宁可慢一点,也要确保每个阶段都有一个可运行、可验证、可对比的版本。这个节奏,比什么技术选型都重要。