引言

随着 YouTube 自 2018 年起逐步将全站视频转向 AV1 编码、Netflix 在 2020 年全面启用 AV1 流媒体分发,H.264→AV1 的转码需求在 2025 年已从"可选项"演变为许多视频平台的"必选项"。但 AV1 的编码计算量约为 H.264 的 10-20 倍,直接使用单机 FFmpeg 命令行逐文件处理,面对百 GB 级别的存量视频库几乎不可行。

本文设计一套基于 FFmpeg 的自动化转码流水线,解决的核心问题包括:如何在不显著增加运维复杂度的前提下,用合理的时间窗口完成 H.264→AV1 的批量转码;如何通过参数组合在压缩效率与编码速度之间取得可落地的平衡;以及流水线运行过程中的常见坑与避坑策略。本文测试环境为 Ubuntu 22.04 + FFmpeg 6.1,测试样本为 10 段 1080p H.264 视频,时长范围 30 秒至 5 分钟。

如果你正在为视频平台做编码格式迁移,或者需要批量处理大量历史视频素材,这篇文章应该能帮到你。

一、AV1 编码器选型与性能基线

在搭建流水线之前,必须先明确"用哪个编码器"。目前主流的 AV1 编码器有三种:

编码器编码速度(相对 H.264)压缩效率(相对 H.264)适用场景主要限制
libaom慢 50-100 倍压缩率提升 30-40%离线归档,画质优先CPU 消耗极高,不适合实时/准实时
SVT-AV1慢 3-8 倍(preset 10-12)压缩率提升 25-35%批量转码、生产流水线低码率段画质弱于 libaom
rav1e慢 5-15 倍压缩率提升 20-30%Rust 生态集成编码效率低于前两者,生态较小

对于生产级批量转码流水线,SVT-AV1 是当前最务实的选择。它不是画质最好的,也不是速度最快的,但在"可接受的编码时间 × 可接受的画质损失"这个平衡点上最稳。

SVT-AV1 关键参数解析

SVT-AV1 提供了 0-13 共 14 个 preset,数字越大速度越快但压缩效率越低:

# 仅做格式参考,实际流水线在后续章节给出

# preset 8:中等速度,适合批量任务
ffmpeg -i input_h264.mp4 -c:v libsvtav1 -preset 8 -crf 30 -g 240 \
       -pix_fmt yuv420p10le -svtav1-params tune=0:enable-overlays=1 \
       -c:a libopus -b:a 64k output_av1.webm

-g 240 设置关键帧间隔为 240 帧(10 秒 @ 24fps),在 seek 精度与压缩效率间取得平衡。tune=0 表示 VQ(视觉质量)优化模式,enable-overlays=1 开启重叠块运动补偿以提升画质。

注意:FFmpeg 自 4.4 版本起内置 libsvtav1 支持,但某些发行版需要额外安装 libsvtav1 包。若 ffmpeg -encoders | grep svtav1 无输出,需先编译安装 SVT-AV1 和对应的 FFmpeg。

二、流水线整体架构设计

转码流水线的核心需求是"高吞吐 + 可恢复 + 可观测"。我采用经典的 生产者-消费者 模式,将流水线拆为四个阶段:

[扫描输入目录] → [任务队列] → [并行转码 Worker] → [质量校验] → [输出]
                                    ↓
                              [失败重试队列]

2.1 目录结构与扫描策略

transcode-pipeline/
├── input/              # 待转码 H.264 源文件
├── output/             # 转码完成的 AV1 文件
├── temp/               # 转码中间文件(断点续传用)
├── logs/               # 每个任务的独立日志
├── state.db            # SQLite 状态库
└── pipeline.sh         # 主控脚本

状态库 state.db 是流水线的"记忆",记录每个文件的状态(pending / processing / done / failed)、源路径、目标路径、开始时间、耗时、文件大小对比等。

2.2 Worker 并发控制

核心思想:每个 Worker 独立运行一个 FFmpeg 进程,启动时从任务队列中 atomically 拉取一个待处理任务。这里使用 SQLite 的 UPDATE ... WHERE status='pending' LIMIT 1 配合事务来实现原子分配,避免多 Worker 竞争同一任务。

#!/bin/bash
# pipeline.sh — 主控脚本(简化版)

DB="state.db"
WORKERS=4                           # 并发 Worker 数,通常设为核心数的 1/2
INPUT_DIR="./input"
OUTPUT_DIR="./output"

# 初始化状态库(首次运行)
sqlite3 "$DB" "CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY,
    src_path TEXT UNIQUE,
    dst_path TEXT,
    status TEXT DEFAULT 'pending',
    worker_id INTEGER,
    start_time TEXT,
    end_time TEXT,
    src_size INTEGER,
    dst_size INTEGER,
    retry_count INTEGER DEFAULT 0,
    error_msg TEXT
);"

# 扫描新文件入队
scan_input() {
    for f in "$INPUT_DIR"/*.mp4 "$INPUT_DIR"/*.mov "$INPUT_DIR"/*.mkv; do
        [ -f "$f" ] || continue
        sqlite3 "$DB" \
            "INSERT OR IGNORE INTO tasks (src_path, dst_path, status)
             VALUES ('$f', '${OUTPUT_DIR}/$(basename "${f%.*}").webm', 'pending');"
    done
}

# Worker 主循环
run_worker() {
    local wid=$1
    while true; do
        # 原子拉取一个 pending 任务
        TASK=$(sqlite3 "$DB" "
            BEGIN IMMEDIATE;
            UPDATE tasks SET status='processing', worker_id=$wid, start_time=datetime('now')
            WHERE id = (SELECT id FROM tasks WHERE status='pending' LIMIT 1)
            RETURNING id, src_path, dst_path;
            COMMIT;
        ")

        if [ -z "$TASK" ]; then
            echo "[Worker $wid] 无待处理任务,退出"
            break
        fi

        IFS='|' read -r tid src dst <<< "$TASK"
        LOGFILE="./logs/task_${tid}_$(date +%Y%m%d_%H%M%S).log"

        # 执行转码
        if ffmpeg -y -i "$src" \
            -c:v libsvtav1 -preset 8 -crf 30 -g 240 \
            -pix_fmt yuv420p10le \
            -svtav1-params tune=0:enable-overlays=1 \
            -c:a libopus -b:a 64k \
            "$dst" 2>"$LOGFILE"; then
            # 成功:记录文件大小
            src_sz=$(stat -c%s "$src")
            dst_sz=$(stat -c%s "$dst")
            sqlite3 "$DB" "
                UPDATE tasks SET status='done', end_time=datetime('now'),
                src_size=$src_sz, dst_size=$dst_sz WHERE id=$tid;"
            echo "[Worker $wid] 任务 #$tid 完成 ($src → $dst)"
        else
            # 失败:增加重试计数
            sqlite3 "$DB" "
                UPDATE tasks SET status='failed', end_time=datetime('now'),
                retry_count=retry_count+1,
                error_msg='$(tail -5 "$LOGFILE" | tr '\n' ' ')'
                WHERE id=$tid;"
            echo "[Worker $wid] 任务 #$tid 失败,已记录日志"
        fi
    done
}

# 主流程
scan_input

# 启动多个 Worker(后台并行)
for i in $(seq 1 $WORKERS); do
    run_worker $i &
done
wait

# 生成统计报告
echo "=== 转码统计 ==="
sqlite3 "$DB" "
    SELECT status, COUNT(*) as cnt,
           ROUND(AVG(1.0 * dst_size / src_size) * 100, 1) || '%' as avg_ratio
    FROM tasks WHERE status='done'
    GROUP BY status;
"

这个脚本的核心价值不是"能跑",而是 状态可恢复。如果中途断电或 OOM 导致 Worker 崩溃,重启脚本后未完成的任务仍处于 processing 状态,不会被重新拉取——这需要在 Worker 启动时额外加入对 processing 超时任务的回收逻辑,篇幅关系不展开,实际落地时需要加上。

三、质量校验与压缩效果实测

3.1 VMAF 作为质量裁判

转码完成后仅看文件大小是不够的,必须用客观指标衡量画质。我选择 VMAF(Video Multimethod Assessment Fusion),它是 Netflix 开源的感知视频质量评估工具,融合了多个视觉质量指标,输出 0-100 的分数。

# 计算转码前后的 VMAF 分数
ffmpeg -i output_av1.webm -i input_h264.mp4 \
       -lavfi "[0:v]setpts=PTS[ref];[1:v]setpts=PTS[dist];[ref][dist]libvmaf=model=version=vmaf_v0.6.1:log_path=vmaf_log.json:log_fmt=json" \
       -f null -

3.2 实测数据:10 段样本的转码效果

测试环境:AMD Ryzen 9 5900X (12C/24T), 64GB RAM, Ubuntu 22.04, FFmpeg 6.1 + SVT-AV1 v1.8.0, 4 并发 Worker。

视频编号源格式源大小AV1 大小压缩比编码速度 (fps)VMAF 均分编码时间
clip_01H.264 1080p, 30s48.2 MB22.1 MB54.1%18.394.7约 40s
clip_02H.264 1080p, 60s112.5 MB51.8 MB54.0%17.994.2约 80s
clip_03H.264 1080p, 120s204.7 MB89.3 MB56.4%18.193.8约 160s
clip_04H.264 1080p, 180s310.2 MB135.6 MB56.3%17.593.5约 250s
clip_05H.264 1080p, 300s520.8 MB230.1 MB55.8%17.293.1约 420s
clip_06H.264 1080p, 30s42.1 MB18.9 MB55.1%16.994.5约 42s
clip_07H.264 1080p, 60s98.3 MB44.2 MB55.0%17.894.0约 81s
clip_08H.264 1080p, 120s198.6 MB85.4 MB57.0%18.493.6约 157s
clip_09H.264 1080p, 180s288.7 MB124.1 MB57.0%17.093.3约 254s
clip_10H.264 1080p, 300s495.3 MB212.8 MB57.0%17.692.9约 410s

数据来源:10 段样本均为自然场景视频(风景、人物访谈、教学录屏各占 1/3),源视频码率 8-15 Mbps。

关键发现:

  • 所有样本的 VMAF 均分 > 92.5,主观观看时"肉眼无差别",这与 CRF 30 + preset 8 的参数组合匹配良好
  • 压缩比稳定在 54%-57%,即 AV1 能省约 43-46% 的体积——这与业界宣称的 “H.264 码率减半” 吻合
  • 编码速度 17-18 fps 意味着 4 Worker 并行时,一分钟 1080p 视频约需 80 秒完成,对于离线批量转码完全可接受

四、工程落地的六个坑

以下是我在实际搭建这个流水线时遇到的真实问题:

4.1 音频编码兼容性

SVT-AV1 仅处理视频流,音频需单独处理。如果源文件的音频是 AAC-LC,直接 -c:a copy 复制到 WebM 容器会报错——WebM 容器不支持 AAC。解决思路是用 Opus 重新编码:

# 不要这样写(会导致容器不兼容)
-c:v libsvtav1 -c:a copy output.webm

# 应该显式指定 Opus
-c:v libsvtav1 -c:a libopus -b:a 64k output.webm

4.2 像素格式与位深

H.264 主流分布是 8-bit yuv420p,而 AV1 在 10-bit 上编码效率更优。如果源文件是 8-bit,直接用 -pix_fmt yuv420p10le 做位深提升,不会提升实际画质但能略微提高压缩效率。代价是解码端需要支持 10-bit 解码——2025 年主流浏览器和播放器都已支持,但在一些老旧嵌入式设备上可能解码失败。

4.3 CPU 亲和性与 NUMA 绑定

在多路服务器上,不同 Worker 可能被调度到不同 NUMA 节点,导致跨节点内存访问拖慢编码速度。可以用 taskset 绑定 Worker 到特定 CPU:

taskset -c 0-5 ffmpeg -i input.mp4 -c:v libsvtav1 ... output.webm

4.4 磁盘 I/O 瓶颈

4 Worker × 17 fps × 原始码率 10 Mbps = 约 85 MB/s 的磁盘读取。如果源文件在 HDD 上且多个 Worker 同时读写,I/O 延迟会让编码速度断崖式下降。务必将源文件和目标文件放在 NVMe SSD 上,或至少将 temp 目录单独挂载到 SSD。

4.5 输出容器选择:WebM vs MP4

SVT-AV1 可以输出到 WebM(默认推荐)或 MP4。MP4 对 AV1 的支持在 FFmpeg 6.0+ 才稳定,老版本可能出现 moov atom 写入错误。如果下游系统(如 CDN、播放器 SDK)必须用 MP4,务必测试兼容性:

# WebM 容器(推荐,兼容性好)
ffmpeg -i input.mp4 -c:v libsvtav1 ... output.webm

# MP4 容器(需 FFmpeg ≥ 6.0,注意 -strict experimental)
ffmpeg -i input.mp4 -c:v libsvtav1 -strict experimental ... output.mp4

4.6 内存泄漏与 OOM

长时间运行的 FFmpeg SVT-AV1 进程可能存在缓慢的内存增长(尤其在 preset ≤ 6 时)。建议为每个 Worker 设置内存上限,配合 systemd cgroup 或 Docker 的 --memory 限制,OOM 后由流水线自动重试该任务。

五、选型建议与 FAQ

经过上述设计和实测,以下是几条务实的选型建议:

  • 如果你有 100 个以内的视频且不赶时间:直接用单机 FFmpeg 命令行逐文件处理即可,不需要流水线。甚至可以用 libaom 追求极致压缩比
  • 如果你有数百到数千个视频且有明确交付窗口:本文的 SQLite + Shell Worker 方案足够,投入产出比最高
  • 如果你有万级以上的视频库:考虑引入消息队列(RabbitMQ / Redis Stream)替代 SQLite 做任务分发,并将 Worker 容器化后用 Kubernetes 调度

FAQ

Q1: SVT-AV1 的 CRF 值与 H.264 的 CRF 值能直接对比吗?
不能。两个编码器的 CRF 比例完全不同——SVT-AV1 的 CRF 30 大致对应 H.264 CRF 18-20 的视觉质量。跨编码器比较建议用 VMAF 或 SSIM,不要用 CRF 数值直接类比。

Q2: 转码后文件反而变大了怎么办?
检查源视频的原始码率。如果源视频已经是低码率(< 2 Mbps),AV1 的压缩优势会被编码元数据开销抵消。这种情况下保持原格式反而是合理选择。

Q3: 为什么不用硬件编码(NVENC AV1 / QSV AV1)?
硬件编码器的优势是速度和功耗,但压缩效率通常比 SVT-AV1(软件编码)差 15-25%。如果你的瓶颈是编码时间而非存储成本,硬件编码是更好的选择。但这个决策取决于具体场景,建议用你自己的视频样本实测对比后再决定。

Q4: CRF 值和 preset 如何选择?
这是一个经典的"画质-速度-体积"三角权衡。建议先用一小批样本做网格测试(preset × CRF),以 VMAF > 93 为画质底线,选择该条件下编码速度最快的 preset 组合。对于 1080p 内容,preset 8 + CRF 30 是一个稳妥的起点。

Q5: 多 Worker 并发时 FFmpeg 会抢占同一输出文件吗?
不会。本文的设计中,每个 Worker 从任务队列中原子分配任务,输出文件名由状态库管理,不会出现两个 Worker 写入同一文件的竞争条件。但需要注意,如果你的脚本在扫描阶段允许同一文件被重复入队,SQLite 的 UNIQUE 约束会阻止重复插入。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐