渲染成图再 CLI 拼接,还是进程内直推?Rust 帧到视频的两条路

2015 年,最早的 rust-ffmpeg 封装库收到了第一个 issue(meh/rust-ffmpeg#1)。提问者 jaredly 想做的事再普通不过:在程序里逐帧画好画面,再把它们编码成一个视频文件。他翻遍 API 后写道,"couldn't find a way to: open a file as writer; encode video frames to it"——找不到把文件当写入端打开、把视频帧编码进去的办法。他的变通方案:"rendering to a bunch of images and then using ffmpeg on the cli"——先把每帧渲染成一堆图片,再用命令行 ffmpeg 拼起来。十年过去,不少用于可视化、仿真和程序化动画的 Rust 代码,仍在沿用这条 2015 年的变通方案。这篇讲怎么把流程放回进程内:用 Rust crate ez-ffmpeg(别和 JS 那个 "Ez FFmpeg" 混淆,两者无关)0.14 提供的 VideoWriter,渲染循环里一行 write,帧直接进 FFmpeg 的滤镜→编码→封装流水线(pipeline)。读完你会得到一段完整可运行的代码,以及四条真正需要理解的契约:所有权、背压、时间戳、收尾语义。

老办法:先落盘 80 MB 图片,再交给 CLI 拼

这条老办法今天写出来大概是这样(为了不引入图片编码 crate,存 PPM——3 行文本头加裸 RGB 就是合法图片):

// Step 1: render every frame to its own image file on disk.
let mut rgb = vec![0u8; (WIDTH * HEIGHT * 3) as usize];
for i in 0..120 {
    render_gradient_rgb(&mut rgb, WIDTH, HEIGHT, i as f32 / 30.0);
    let mut f = File::create(format!("frame_{i:04}.ppm"))?;
    write!(f, "P6\n{WIDTH} {HEIGHT}\n255\n")?; // PPM needs no image crate
    f.write_all(&rgb)?;
}

// Step 2: hope the ffmpeg on PATH exists and agrees with your flags.
let status = Command::new("ffmpeg")
    .args(["-y", "-framerate", "30", "-i", "frame_%04d.ppm",
           "-c:v", "mpeg4", "-q:v", "5", "images.mp4"])
    .status()?;

能跑,笔者亲自跑过,120 帧进 120 帧出。但代价也很实在,一共三条。一是磁盘:640×360、30 fps、4 秒的动画,中间落盘 120 个 PPM 共 80 MB,而最终 mp4 只有 213 KiB;分辨率或时长翻倍,这个数字也会跟着增长。二是命名约定:frame_{i:04}frame_%04d 是两种语言里的同一条隐式约定,补零宽度改错一边,ffmpeg 就默默停在第一个对不上的文件名。三是报错:第一步的错误是 Rust 的 Result,第二步只剩退出码加 stderr 文本——"编码器不存在"和"第 37 张图损坏"都得你从日志里打捞。

公平起见,把第三条老路也摆上桌:不落盘也可以——ffmpeg -f rawvideo -pix_fmt rgba -s 640x360 -framerate 30 -i - out.mp4,裸帧直接从 stdin 管进去,零临时文件、零命名约定,管道写满时天然阻塞,背压白送(ffmpeg-sidecar 封装的正是这条)。它消掉了磁盘那笔税,但另外两笔还在:错误仍是退出码加 stderr 文本,运行环境仍要有版本合适的 ffmpeg 可执行文件;而且滤镜图结构不对要跑起来才炸,不是提交时被拒。下面这个 API 的差异点在后一半——类型化错误、open() 期校验、无子进程生命周期——不在「省临时文件」上。

VideoWriter:帧不出进程

你的渲染循环                 VideoWriter 流水线(同一进程)
┌───────────┐ write()/     ┌────────┐   ┌──────┐   ┌──────┐
│ render(帧) │─write_owned─▶│ 有界队列│──▶│ 滤镜图│──▶│ 编码器│─▶ gradient.mp4
└───────────┘  满则阻塞     └────────┘   └──────┘   └──────┘
     帧源线程坐在"解码器的位置";流结束 = 显式带内 EOF 标记

同一个动画,进程内直推。依赖只需一行:

[dependencies]
ez-ffmpeg = "0.15"   # needs FFmpeg 7.1-8.x installed (links libav)

完整代码,可整段复制:

use ez_ffmpeg::{Output, VideoWriter};

const WIDTH: u32 = 640;
const HEIGHT: u32 = 360;
const FPS: i32 = 30;
const SECONDS: i32 = 4;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Name the encoder explicitly. With a bare "gradient.mp4" the linked
    // FFmpeg build picks the container default (H.264 when libx264 is
    // compiled in, otherwise mpeg4 at a low default bitrate).
    let output = Output::from("gradient.mp4")
        .set_video_codec("mpeg4")
        .set_video_qscale(5);

    let mut writer = VideoWriter::builder(WIDTH, HEIGHT)
        .pixel_format("rgba") // the default, spelled out
        .fps(FPS, 1)
        .open(output)?;

    // The writer tells you the exact byte count of one tightly packed frame.
    let mut frame = vec![0u8; writer.frame_size()];

    let total = FPS * SECONDS;
    for i in 0..total {
        render_gradient(&mut frame, WIDTH, HEIGHT, i as f32 / FPS as f32);
        writer.write(&frame)?; // copies the slice; `frame` is reusable
    }

    writer.finish()?; // drains the encoder, writes the trailer, reports errors
    println!("wrote {total} frames to gradient.mp4");
    Ok(())
}

/// Fills `buf` (tightly packed RGBA) with a gradient that drifts right over time.
fn render_gradient(buf: &mut [u8], width: u32, height: u32, t: f32) {
    for y in 0..height {
        let fy = y as f32 / height as f32;
        for x in 0..width {
            let fx = x as f32 / width as f32;
            let idx = ((y * width + x) * 4) as usize;
            buf[idx] = (((fx + t * 0.25) % 1.0) * 255.0) as u8; // red wave, drifting
            buf[idx + 1] = (fy * 255.0) as u8; // green: vertical ramp
            buf[idx + 2] = ((1.0 - fx) * 255.0) as u8; // blue: horizontal ramp
            buf[idx + 3] = 255; // opaque
        }
    }
}

先交代两句实话。第一,装 FFmpeg、链 libav 的麻烦它不解决——该装还得装(7.1–8.x),它省掉的是子进程、80 MB 中间图片和 stderr 打捞,不是 FFmpeg 依赖本身。第二,这个 API 在文档里标着 experimental:0.14 新增,后续小版本可能还会微调。

这段代码里有三处值得注意。builder(宽, 高) 是唯一的必填项;像素格式默认 rgba,也接受 rgb24/gray8/yuv420p/nv12 等任意非硬件格式,平面按描述符顺序紧密打包。frame_size() 给出一帧所需的精确字节数,字节数不对时得到的是带期望值和实际值的 InvalidSize 类型化错误,而不是花屏。输出目标是普通的 Output:编码器、容器、码率、movflags 都在这一层配置——演示里选 mpeg4,图的是不依赖 libx264 也能跑;换 set_video_codec("libx264") 就是 H.264。

四条契约,比方法名重要

所有权:write 拷贝,write_owned 转移。 复用同一块缓冲就用 write(借用切片,内部拷走);每帧本来就新造一个 Vec 就用 write_owned(整个移交,省掉那次借用拷贝)。下面是验证用的对照代码:

    // write(): you keep the buffer. The slice is copied into the pipeline,
    // so one allocation serves the whole run.
    let mut frame = vec![0u8; writer.frame_size()];
    for i in 0..2u8 {
        frame.fill(i * 100);
        writer.write(&frame)?;
    }

    // write_owned(): the Vec moves into the pipeline — no borrow-copy on the
    // caller side. Use it when each frame is born as its own Vec anyway.
    for i in 2..4u8 {
        let owned = vec![i * 60; writer.frame_size()];
        writer.write_owned(owned)?;
    }

两条路殊途同归:工作线程都会把字节逐平面填进池化的对齐 AVFrame,这一次拷贝省不掉。还有个细节:write_ownedVec 原样入队,超出长度的多余 capacity 会在排队期间一直占着内存。所有权上还有一处有意安排:write&mut self,帧的先后顺序在编译期就定死,不存在两个线程抢着写导致乱序的可能;writer 本身是 Send,整体移到专门的生产线程没问题,但刻意不做 Sync

这套推入机制背后没有解封装器、也没有解码器。builder 构建的流水线里,一个帧源工作线程坐在"解码器本该在的位置",直接喂滤镜图的 buffersrc;流的结束是一个显式的带内 EOF 标记,而不是"发送端断开"这种隐式信号——所以像 reversetpad 这种要攒齐全部输入才吐帧的滤镜,收尾时照样能把每一帧都输出(测试 finish_flushes_buffering_filterdrop_flushes_buffering_filter 都锁着这条)。

背压:队列有界,write 会等。 入口队列按帧数设上限,默认 max(1, min(4, 64 MiB / 帧大小))——1080p RGBA 合 4 帧,4K RGBA 只剩 2 帧。渲染速度快于编码时 write 阻塞,内存不无限涨,这是有意设计的行为。流水线中途停止也不会把你挂死:阻塞中的 write 每 100 ms 探一次流水线状态,停了就立刻返回 PipelineClosed。这个错误是路标,不是结论:

    let mut pushed = 0;
    loop {
        let frame = vec![0u8; writer.frame_size()];
        match writer.write_owned(frame) {
            Ok(()) => pushed += 1,
            // Not an error by itself: the pipeline stopped taking frames.
            // finish() holds the verdict — the real failure, or Ok when the
            // job simply completed (as the frame limit does here).
            Err(PushError::PipelineClosed) => break,
            Err(e) => return Err(e.into()),
        }
    }

比如 Output 上设了 set_max_video_frames(3):编码满 3 帧后流水线正常结束,之后的 writePipelineClosed,而 finish() 返回 Ok,文件里恰好 3 帧——实测如此。如果确实是编码器出错,同一个 finish() 会把真正的错误交给你。顺带记住,错误分两处报:编码器名字写错这类配置问题,open() 当场就报;而有些失败要到流水线里第一帧真正过编码器时才暴露(这条流水线的编码器是懒初始化的),这时 open() 会先成功——写入端因 PipelineClosed 解除阻塞,真正的错误从 finish() 出来。测试套件里 worker_failure_unblocks_write_and_finish_reports 验证的正是这条:写入端永不挂死,结论永远在 finish。

时间戳:恒定帧率,没有逐帧 PTS。 每帧恰好前进 den/num 秒,fps(30000, 1001) 这类 NTSC 分数也支持。这是 v1 的明确取舍:不做可变帧率,也没有音频。收尾后的总时长是完整的:120 帧 30 fps 的文件时长是 4.000000 整,不是去掉最后一帧后的 119/30——最后一帧的完整时长由显式 EOF 标记保住(测试 eof_preserves_final_frame_duration)。

收尾:finish 是结论,drop 是兜底,abort 是丢弃。 finish() 关入口、排空编码器、写容器尾,返回流水线的第一个错误——唯一能拿到 Result 的途径。忘了调也不丢帧:Drop 会执行同样的排空(测试 drop_without_finish_keeps_all_frames;笔者推 10 帧直接 drop,文件里就是 10 帧),但错误只会写入日志。不想要这个文件了就 abort():放弃这次导出,半成品不保证能播(测试 abort_returns_cleanly)。

滤镜部分简单说一句:builder 有 filter_desc,单进单出的视频链都可以挂上——"hue=s=0" 实测把纯红帧推成灰。结构不对的图会在 open() 时直接拒绝,不是跑到一半才挂;filter_desc("split") 得到的程序输出逐字如下:

rejected at open(): Video writer error: filter_desc must have exactly one video input pad and one video output pad; found 1 input pad(s) (1 video) and 2 output pad(s) (2 video)

断开的图、到达不了输出的图,也各有对应的类型化错误。带生成器的合成图仍然允许——"color=c=red:s=64x48[bg];[in][bg]overlay=shortest=1" 这种图,推入的帧能到达输出就放行,输入 EOF 时随 shortest=1 一起收尾,和 CLI 的规则一字不差。

跑给你看

上面那段代码跑完,ffprobe 逐字输出:

$ ffprobe -v error -select_streams v:0 -show_entries \
    stream=codec_name,width,height,r_frame_rate,nb_frames,duration \
    -of default=noprint_wrappers=1 gradient.mp4
codec_name=mpeg4
width=640
height=360
r_frame_rate=30/1
duration=4.000000
nb_frames=120

120 帧进,120 帧出,时长 4.000000 整。没有中间文件,没有子进程,错误全程带类型。

什么场景别用它

先把生态说清楚。"Rust 没法把自己画的帧编成 MP4"是假的——video-rs 的 README 示例就是用 ndarray 逐帧编成 rainbow.mp4(而且 video-rs 自己就是 FFmpeg 系,走 ffmpeg-next),OpenCV 的绑定库有 VideoWriter,openh264 + mp4 crate 也能全 Rust 出片。所以站得住脚的说法收窄到具体差异:这个 API 多给的是那套契约工——滤镜链在 open() 时校验而不是跑到一半才炸、显式的背压与收尾语义、全程类型化错误——外加文件之外的输出面;不是"别家编不了帧"。滤镜图校验上文演示过了;"文件之外的输出面"里的直播目标,本文只验证到 API 层面——文档承诺、代码编译通过——没起服务器实跑,别把本文当推流教程。

按场景区分,各有所长。图片已经在磁盘上,而且只转这一次:CLI 一行 ffmpeg -framerate 30 -i frame_%04d.png out.mp4 最省,为它写 Rust 程序是绕路。要 GStreamer 级的直播流水线编排——动态改拓扑、多路分发、精细时钟——gstreamer crate 那套成熟的流水线模型才更合适,这个 crate 不是。要可变帧率、逐帧 PTS 或者音视频混封:v1 不覆盖(仅视频、恒定帧率;stream map 直接拒收;帧是裸数据,所以也不存在视频流拷贝,推进来必然重编码),老老实实用底层绑定库,或者等这个 API 把能力补齐。帧本来就来自另一个视频文件,而不是你的代码:那是转码,crate 里普通的 Input/Output 任务就够,轮不到 VideoWriter。剩下的——渲染循环、仿真输出、程序化视频,帧在你手里、目标是个文件:这就是 VideoWriter 的场子。

写在最后

渲染循环直接出 mp4/mkv,滤镜链挂在帧后面,背压替你管内存,错误带着类型回来——jaredly 在 2015 年那个 issue 里描述的变通方案,到这里可以退休了。

完整示例在仓库的 examples/frames_to_video(等离子动画)和 examples/bouncing_balls(弹球物理,真实的逐帧状态循环),项目地址:github.com/YeautyYE/ez-ffmpeg

posted @ 2026-07-27 13:18  Yeauty  阅读(0)  评论(0)    收藏  举报