编程 FFmpeg 9.0「Lei」深度拆解:当音视频框架决定把滤镜链整个搬进 GPU——Vulkan 统一计算层、SMPTE 2094-50 元数据透传与 ONNX Runtime DNN 后端的三条暗线

2026-08-09 04:19:53 +0800 CST views 9

FFmpeg 9.0「Lei」深度拆解:当音视频框架决定把滤镜链整个搬进 GPU——Vulkan 统一计算层、SMPTE 2094-50 元数据透传与 ONNX Runtime DNN 后端的三条暗线

2026 年 8 月 4 日,FFmpeg 9.0 发布,代号 Lei

接下来 48 小时,中文技术圈几乎被同一个信息刷屏:这个代号纪念的是一位十年前离世的中国音视频开发者,社区里大家叫他「雷神」。在 2013 到 2016 那几年,AVFormatContextAVCodecContextAVPacketAVFrameav_read_frame 的调用顺序、pts/dts 到底谁先谁后、sws_scale 的 stride 为什么老是对不齐——这些今天一搜一大把的东西,当年在中文互联网上几乎只有一个人在成体系地写。很多人第一次跑通 ffmpeg -i 之外的东西,是照着他的博客一行行敲出来的。

这件事值得被记住。但作为程序员,我更在意另一件事:几乎所有报道都停在了「新增动画 WebP 解码器」这一行,然后就没有然后了。

而 9.0 这个版本,据社区统计有 2200 多次提交、1781 个文件变更、净增约 5.2 万行代码、160 多位贡献者参与,七个库(libavutillibavcodeclibavformatlibavdevicelibavfilterlibswscalelibswresample)的 ABI 主版本号全部上跳。距离上一个版本 8.1「Hoare」只隔了四个半月。

这个体量,不是「加了几个解码器」能解释的。

我把 9.0 的完整 Changelog 拉下来逐条读完,又往回追了 8.1、8.0、7.1 的条目,得到的结论是:FFmpeg 正在同时推进三条互相独立、但方向高度一致的架构主线。这三条线加起来,指向同一个定位迁移——

FFmpeg 正在从「一个转码器」,变成「像素与元数据的操作系统」。

这篇文章不复述 Changelog。我们逐条拆这三条主线,讲清楚每条线的历史包袱、技术本质、以及对你手上那条转码流水线到底意味着什么。文中所有可执行的部分我都会给命令,但有一个原则我先说在前面:凡是我不能确认的具体参数名、滤镜名、bsf 名,我不会编,我会给你查询命令。 这也是使用一个刚发布四天的大版本时唯一靠谱的姿势。


一、先看清楚:主版本号跳的是什么

在讨论新特性之前,得先说清楚 9.0 为什么是 9.0 而不是 8.2。

FFmpeg 的版本号规则很朴素:只要有任何一个库发生 ABI 不兼容变更,主版本号就要跳。 9.0 是七个库全跳,这是一次彻底的 ABI 断代。

对使用者来说,这意味着三件完全不同层级的事:

第一层:只用 ffmpeg / ffprobe / ffplay 命令行的人。
基本无感。CLI 层的兼容性 FFmpeg 一向保得很紧,除非你踩到了被移除的选项(下面会讲 NVENC 的坑)。

第二层:链接 libav 系列动态库的人。*
必须重新编译。libavcodec.so.61 变成新的 soname,旧的 .so 不会被自动满足。如果你的发行版同时装了两代 FFmpeg,ldd 一下你的二进制,确认它到底链到了谁:

# 看你的程序实际链接的是哪个版本
ldd ./your_transcoder | grep -E 'libav|libsw'

# 看系统里到底有几代 FFmpeg 库共存
ldconfig -p | grep -E 'libavcodec|libavformat' | sort

# 运行时确认真实加载的版本(比编译期版本更可信)
./your_transcoder 2>&1 | head -5

第三层:写 C 代码直接调 API 的人。
这一层要看具体删了什么。9.0 明确移除的有:

  • Remove CELT decoding support不影响 Opus 内部的 CELT 层,删的是独立的 CELT 编解码器)
  • Remove ogg/celt parsing
  • Remove deprecated NVENC options and support for pre-11.1 SDK versions

第三条是最容易在生产环境炸的。如果你的构建机上 NVIDIA Video Codec SDK 还停在 11.0 或更早,9.0 直接不给你编 NVENC。而很多公司的 GPU 转码机是「装完就不动」的,SDK 版本可能已经躺了三四年。

升级前先跑这个检查:

# 1. 确认 nvenc 是否还在编出来的二进制里
ffmpeg -hide_banner -encoders 2>/dev/null | grep -i nvenc

# 2. 确认驱动/SDK 版本
nvidia-smi --query-gpu=driver_version --format=csv,noheader

# 3. 关键:把你线上正在用的 nvenc 参数逐个验一遍
#    被移除的 deprecated 选项不会「警告后忽略」,而是直接报错退出
ffmpeg -hide_banner -h encoder=h264_nvenc | sed -n '/AVOptions/,$p'

第 3 步是重点。FFmpeg 对 deprecated 选项的移除策略是硬移除,不是软降级。 一条跑了三年的转码命令,升级后可能因为一个 -rc-cq 的老写法直接退出码非零。如果你的调度系统只看退出码不看 stderr,这会表现为「升级后转码任务大面积失败但日志里啥也没有」。

我的建议是:任何 FFmpeg 主版本升级,都必须先把线上全量转码命令模板抽出来,在新二进制上空跑一遍参数解析。 具体做法是构造 1 秒的测试源,用真实参数跑通:

# 生成一个 1 秒测试源,用来做参数冒烟测试
ffmpeg -y -f lavfi -i testsrc2=size=1280x720:rate=30:duration=1 \
       -f lavfi -i sine=frequency=440:duration=1 \
       -c:v libx264 -c:a aac /tmp/smoke.mp4

# 把线上模板里的输入换成 /tmp/smoke.mp4,逐条跑
# 只关心「参数能不能解析」,不关心画质
while read -r cmd; do
  if ! eval "${cmd/INPUT//tmp/smoke.mp4}" >/dev/null 2>/tmp/err.log; then
    echo "FAIL: $cmd"
    tail -3 /tmp/err.log
  fi
done < /path/to/your/cmd_templates.txt

这个脚本很土,但它能在升级前一小时之内把 90% 的参数级坑捞出来。


二、主线一:Vulkan 正在吃掉 FFmpeg 的硬件加速层

这是 9.0 最重要、也最被低估的一条线。

2.1 历史包袱:六套硬件后端的碎片化地狱

先看清楚 FFmpeg 现在背着多少套硬件加速方案:

后端厂商/平台覆盖范围典型滤镜
NVENC / NVDEC / CUDANVIDIA编码、解码、部分滤镜scale_cudabwdif_cudapad_cudatranspose_cuda(9.0)
VAAPILinux + Intel/AMD编解码 + 滤镜scale_vaapipad_vaapidrawbox_vaapi
QSVIntel编解码vpp_qsv
VideoToolboxApple编解码ProRes RAW hwaccel(9.0)
AMFAMD编码 + 滤镜vpp_amffrc_amf(9.0)、vqe_amf(9.0)
D3D12VAWindows编解码 + 滤镜scale_d3d12mestimate_d3d12deinterlace_d3d12(8.1)
Vulkan跨厂商解码、编码、计算滤镜*_vulkan 系列

七套。每套有自己的 AVHWDeviceContext、自己的 pixel format、自己的一套滤镜命名、自己的帧池管理。

这个碎片化的代价是什么?举个最具体的例子:你想写一条「解码 → 缩放 → 去隔行 → 旋转 → 编码」的全硬件流水线,在 NVIDIA 上你需要 scale_cuda + bwdif_cuda + transpose_cuda,在 AMD 上你需要 vpp_amf 的对应能力,在 Intel Linux 上你需要 scale_vaapi + deinterlace_vaapi,在 Windows 上还有一套 *_d3d12。四套代码路径,四套测试矩阵,四套踩坑记录。

而且只要其中任何一环在某个平台上缺失,整条链就断了。

2.2 为什么 transpose_cuda 这种「平平无奇」的滤镜值得单独讲

9.0 加了一个 transpose_cuda。旋转和转置,听起来是 2005 年就该有的功能。

但它解决的是一个非常昂贵的问题:滤镜链上任何一个 CPU-only 的节点,都会把整条 GPU 流水线劈成两半。

FFmpeg 的硬件帧(AV_PIX_FMT_CUDAAV_PIX_FMT_VULKAN 等)本质上是一个「显存句柄」,AVFrame->data[0] 不是像素指针而是设备内存引用。CPU 滤镜没法直接吃它。所以当你写:

# 反面教材:链条中间混了一个 CPU-only 滤镜
ffmpeg -hwaccel cuda -hwaccel_output_format cuda -i in.mp4 \
  -vf "scale_cuda=1280:720,transpose=1" \
  -c:v h264_nvenc out.mp4

FFmpeg 要么直接报错「Impossible to convert between the formats」,要么(在能自动插入转换的场景下)悄悄给你插入 hwdownload → format → hwupload。后者更可怕:它能跑通,所以你不会发现,但每一帧都要走两次 PCIe 往返。

720p NV12 一帧约 1.3 MB。30fps 下这是每秒 40 MB 的下行 + 40 MB 的上行,还要加上 CPU 侧的 memcpy 和同步等待。真实吞吐可能从「一张卡跑 20 路」掉到「一张卡跑 4 路」,而 nvidia-smi 里 GPU 利用率看起来还挺低——因为瓶颈在总线和同步,不在计算单元。

所以 transpose_cuda 补的不是「一个滤镜」,是一条流水线的完整性

2.3 实战:如何证明你的滤镜链没有隐藏的 PCIe 往返

这是我认为本文最有实用价值的一段。三层验证:

第一层:让 FFmpeg 把它构建的滤镜图打印出来。

ffmpeg -v verbose -hwaccel cuda -hwaccel_output_format cuda -i in.mp4 \
  -vf "scale_cuda=1280:720,transpose_cuda=dir=clock" \
  -c:v h264_nvenc -f null - 2>&1 | grep -iE 'auto_scale|hwdownload|hwupload|Impossible|format converter'

只要输出里出现 auto_scalehwdownloadhwupload,你的链就断了。 干净的全 GPU 链,这个 grep 应该是空的。

第二层:看 filtergraph 的实际拓扑。

# -filter_complex 配合 verbose 会打印完整的 graph 结构
ffmpeg -v debug -hwaccel cuda -hwaccel_output_format cuda -i in.mp4 \
  -filter_complex "[0:v]scale_cuda=1280:720[a];[a]transpose_cuda=dir=clock[out]" \
  -map "[out]" -c:v h264_nvenc -f null - 2>&1 | grep -A30 'Filtergraph'

关注每个 link 的 pixel format。全程应该是 cuda,一旦中间出现 nv12/yuv420p,说明落回主存了。

第三层:直接测总线流量。

# 跑转码的同时,另开一个终端采样 PCIe 吞吐
nvidia-smi dmon -s t -d 1

rxpci / txpci 两列(单位 MB/s)。纯解码+编码的链路,PCIe 流量应该只有码流本身的量级(几 MB/s)。如果你看到几十上百 MB/s,那就是原始帧在来回搬。

这三层验证,我建议直接写进你的 CI。 硬件流水线的性能退化是静默的,靠人工 review 命令行迟早会漏。

2.4 Vulkan 路线的技术本质:不依赖固定功能单元

现在说 Vulkan 这条线为什么不一样。

前面六套后端,本质上都是厂商固定功能单元(fixed-function)的封装。NVENC 是一块专用 ASIC,VAAPI 是 Intel 媒体引擎的抽象层,AMF 是 AMD 的 SDK。它们的共同特点是:能力边界由硬件决定,厂商不实现就没有。

Vulkan 有两条完全不同的路径:

  1. Vulkan VideoVK_KHR_video_decode_* / encode_*):仍然是对固定功能单元的抽象,但是跨厂商的标准接口。FFmpeg 6.1 加了 Vulkan decode hwaccel(H.264/HEVC/AV1),7.1 加了 Vulkan H.264/H.265 编码器,8.0 加了 AV1 Vulkan 编码器和 VP9 Vulkan hwaccel。
  2. Vulkan Compute:用通用计算着色器(compute shader)实现编解码和滤镜。这条路完全不依赖厂商的媒体引擎——只要 GPU 支持 Vulkan 计算,就能跑。

第二条才是真正的破局点。看 8.1 的两行:

- Vulkan compute codec optimizations
- ProRes Vulkan encoder
- ProRes Vulkan hwaccel
- DPX Vulkan hwaccel
- swscale Vulkan support

swscale Vulkan support 这一条尤其关键。swscale 是 FFmpeg 的色彩空间转换和缩放库,是几乎每条转码链路都会经过的地方。把 swscale 搬到 Vulkan 上,意味着跨厂商 GPU 上的通用缩放/色转换成为可能,不再需要 scale_cuda / scale_vaapi / scale_d3d12 三份实现。

而 9.0 在这条线上加了:

- APV Vulkan hwaccel
- Add v360_vulkan filter

再看已经进入下个版本开发分支的:

- APV Vulkan encoder

编解码器正在被 compute shader 重新实现。 ProRes 编码器、DPX 解码、APV 编解码——这些都是没有任何 GPU 有专用 ASIC 的格式,但用 Vulkan compute 全都能跑在 GPU 上。

这是一个方向性判断:未来五年,FFmpeg 的硬件加速会分成两层——热门标准编解码(H.264/HEVC/AV1/VVC)继续走各家 ASIC,长尾格式和所有滤镜逐渐统一到 Vulkan compute。

2.5 v360_vulkan:为什么全景投影是 GPU 的天然工作

v360 是 FFmpeg 里做 360 度全景视频投影转换的滤镜——等距柱状投影(equirectangular)转立方体贴图(cubemap)、转鱼眼、转平面视角,诸如此类。

它的计算模式是逐像素独立的反向映射:对输出图上每个像素 (x, y),算出它在输入球面上对应的方向向量,再投影回输入图的坐标 (u, v),做一次插值采样。

输出像素 (x,y)
   → 归一化到输出投影的参数空间
   → 转成三维方向向量 (X,Y,Z)
   → 按输入投影模型反算 (u,v)
   → 双线性/双三次插值采样
   → 写回输出

每个输出像素完全独立,没有任何跨像素依赖。这是教科书级别的 embarrassingly parallel。 CPU 上一个 8K 等距柱状帧(7680×3840,约 2950 万像素)做一次三次插值重投影,单核要几百毫秒;GPU 上几千个线程齐上,可以压到毫秒级。

而且这个场景没有专用 ASIC——没有哪个厂商会为 360 视频投影做硬件单元。所以它必须走 compute shader。v360_vulkan 是 Vulkan compute 路线价值的最好例证。

用法(注意 hwupload / hwdownload 的位置):

ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk \
  -i panorama_8k.mp4 \
  -vf "format=yuv420p,hwupload,\
v360_vulkan=input=e:output=flat:h_fov=100:v_fov=60:yaw=30:pitch=-10:w=1920:h=1080,\
hwdownload,format=yuv420p" \
  -c:v libx264 -preset medium -crf 20 out.mp4

这里必须 hwdownload 是因为 libx264 是 CPU 编码器。如果你的编码器也在 GPU 上(比如 h264_vulkan),就可以省掉这次回传,那才是真正的全链路 GPU:

# 全 GPU 链路(编码器也在 Vulkan 上)
ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk \
  -hwaccel vulkan -hwaccel_output_format vulkan \
  -i panorama_8k.mp4 \
  -vf "v360_vulkan=input=e:output=flat:h_fov=100:v_fov=60:w=1920:h=1080" \
  -c:v h264_vulkan out.mp4

先用这条命令确认你的环境到底有哪些 Vulkan 能力:

# 有哪些 vulkan 滤镜
ffmpeg -hide_banner -filters 2>/dev/null | grep -i vulkan

# 有哪些 vulkan 编解码器
ffmpeg -hide_banner -encoders 2>/dev/null | grep -i vulkan
ffmpeg -hide_banner -decoders 2>/dev/null | grep -i vulkan

# 具体滤镜的参数(不要凭记忆写参数,以这个输出为准)
ffmpeg -hide_banner -h filter=v360_vulkan

# Vulkan 设备能不能初始化
ffmpeg -hide_banner -init_hw_device vulkan=vk:0 -f lavfi -i nullsrc -frames:v 1 -f null - 

最后一条特别重要。Vulkan 后端对驱动版本、VK_ICD_FILENAMES、以及是否有可用的 compute queue 都有要求,容器环境里很容易初始化失败。 先用这条空跑命令验通,再去调滤镜。

2.6 APV 与 ProRes RAW:专业制作链路的补完

顺带说清楚 9.0 里两个陌生的名字。

APV(Advanced Professional Video)是一个面向专业制作的编解码格式,特点是帧内编码(intra-only)、高比特深度、低复杂度、高码率。它的定位不是分发,是中间片(mezzanine)——拍摄、剪辑、调色环节里那些需要逐帧随机访问、不能有帧间依赖的场景。

FFmpeg 对 APV 的支持是分批进来的:

  • 8.0:APV 解码器、parser、raw bitstream 封装/解封装、通过 libopenapv 的编码支持、APV in MP4/ISOBMFF
  • 9.0:APV Vulkan hwaccel
  • 开发分支:APV Vulkan encoder

ProRes RAW 是苹果的 RAW 格式,同样是制作链路的东西。

  • 8.0:ProRes RAW 解码器 + ProRes RAW Vulkan hwaccel
  • 9.0:ProRes RAW VideoToolbox hwaccel

看出规律了吗?同一个格式,FFmpeg 会同时提供 Vulkan(跨平台通用计算)和厂商原生(VideoToolbox)两条加速路径。 这不是重复造轮子,这是有意为之的双轨:厂商路径在自家硬件上最优,Vulkan 路径保证在所有地方都能用。

这对做跨平台剪辑工具/媒体资产管理系统的人是实打实的好消息——你终于可以在 Linux 服务器上用 GPU 解 ProRes RAW 了,不用非得买 Mac。


三、主线二:元数据从「附属品」升级为「一等公民」

第二条主线更隐蔽,但对做分发的人影响更大。

3.1 传统转码流水线的元数据黑洞

传统的转码思维是:解码成像素 → 处理像素 → 编码像素。 元数据(HDR 参数、色彩体积信息、增强层数据)在这个模型里是「附加信息」,能带就带,带不了就算了。

结果就是我们都很熟悉的那种事故:

  • 源片是 HDR10+,转完变成普通 HDR10,动态元数据没了,暗部细节全糊
  • 源片是 Dolby Vision Profile 7(双层),转完只剩基础层,播放器不认 DV 了
  • 源片带 LCEVC 增强层,转封装之后增强层丢了,画质回落到基础层

这些问题的共性是:像素被正确处理了,但描述「这些像素应该怎么被显示」的信息丢了。

在 SDR 时代这不是大问题,因为 Rec.709 是事实上的唯一解释。但在 HDR 时代,同一组像素值配不同的元数据,出来的画面可以差出一个数量级的观感。

3.2 ST 2094 家族与 9.0 的 SMPTE 2094-50 metadata support and passthrough

SMPTE ST 2094 是动态色彩体积变换元数据(Dynamic Metadata for Color Volume Transform,DMCVT)的标准族。它按「应用」分册,每一册对应一套厂商方案:

  • ST 2094-10:对应 Dolby Vision 路线
  • ST 2094-40:对应 HDR10+ 路线
  • 其余分册对应其他厂商提交的方案

它们要解决的是同一个问题:HDR10 的静态元数据(MaxCLL / MaxFALL / Mastering Display)是整片一套,但一部电影里白天沙漠和夜晚室内的动态范围需求完全不同。 动态元数据允许逐场景、甚至逐帧地告诉显示设备「这一段该怎么做色调映射」。

**ST 2094-50 是这个家族里较新的一册。**关于它的具体归属和技术细节,我建议直接查 SMPTE 官方文档,我不在这里做二手转述——这类标准细节转述错了会误导人。

但架构层面的判断很清楚:Changelog 里写的是 metadata support and passthroughpassthrough(透传)这个词比 support 重要得多。

support 意味着「能解析」。passthrough 意味着「解析之后能原样带到输出」。对转码流水线来说,后者才是有价值的那个——你要的不是 ffprobe 能打印出来,而是转完之后播放器还能读到。

3.3 实战:元数据链路完整性校验

这是我强烈建议加进 CI 的检查。核心思路:转码前后各 probe 一次 side data,逐项对比。

#!/usr/bin/env bash
# meta_check.sh — 校验转码前后 HDR/动态元数据是否完整透传
set -euo pipefail

SRC="$1"
DST="$2"

probe_sidedata() {
  ffprobe -v error -select_streams v:0 \
    -show_frames -read_intervals "%+#3" \
    -show_entries frame=side_data_list \
    -of json "$1" 2>/dev/null
}

probe_streamside() {
  # 流级别的 side data(Mastering Display / Content Light Level 等)
  ffprobe -v error -select_streams v:0 \
    -show_entries stream_side_data_list \
    -of json "$1" 2>/dev/null
}

probe_color() {
  ffprobe -v error -select_streams v:0 \
    -show_entries stream=color_range,color_space,color_transfer,color_primaries,pix_fmt \
    -of json "$1"
}

echo "=== 色彩描述 ==="
diff <(probe_color "$SRC" | python3 -m json.tool) \
     <(probe_color "$DST" | python3 -m json.tool) \
  && echo "OK: 色彩描述一致" || echo "WARN: 色彩描述发生变化(见上方 diff)"

echo "=== 流级 side data ==="
diff <(probe_streamside "$SRC" | python3 -m json.tool) \
     <(probe_streamside "$DST" | python3 -m json.tool) \
  && echo "OK: 流级元数据一致" || echo "WARN: 流级元数据发生变化"

echo "=== 帧级 side data(前 3 帧)==="
diff <(probe_sidedata "$SRC" | python3 -m json.tool) \
     <(probe_sidedata "$DST" | python3 -m json.tool) \
  && echo "OK: 帧级动态元数据一致" || echo "WARN: 帧级动态元数据发生变化"

用法:

chmod +x meta_check.sh
./meta_check.sh source_hdr10plus.mp4 output.mp4

几个使用要点:

  1. -read_intervals "%+#3" 只读前 3 帧,避免对长片全量 probe(那会跑很久)。做严格校验时可以改成采样多个时间点:-read_intervals "10%+#2,50%+#2,90%+#2"
  2. 色彩描述(color_transfersmpte2084 还是 bt709)是最容易静默丢失的一项,也是最容易发现的一项。如果源片是 PQ(smpte2084)而输出变成 bt709,说明你的链路里发生了未预期的色调映射或者干脆只是标记丢了。
  3. 帧级 side data 里会出现 HDR Dynamic Metadata SMPTE2094-40(HDR10+)、Dolby Vision RPU Data 之类的条目。diff 出现差异不一定是错——比如你有意做了 HDR→SDR 转换。关键是「差异是不是你预期的」。

3.4 Dolby Vision 多层 HEVC 拆分 bsf

9.0 加了一条:Bitstream filter to split Dolby Vision multi-layer HEVC

背景:Dolby Vision Profile 7 是双层结构——基础层(BL,通常是 HDR10 兼容的 HEVC)+ 增强层(EL)+ RPU(Reference Processing Unit,携带动态元数据的 NAL 单元)。UHD 蓝光用的就是这个 profile。

问题在于:大多数流媒体分发场景只接受单层的 Profile 8(BL + RPU,无 EL)。 从 Profile 7 转 Profile 8,第一步就是把多层码流拆开,丢掉 EL,保留 BL 和 RPU。

以前这件事要靠外部工具(dovi_tool 之类)。现在 FFmpeg 内部有 bsf 了。

具体 bsf 的名字我不确定,不编。查询命令:

# 列出所有 bitstream filter,找 dolby vision 相关的
ffmpeg -hide_banner -bsfs | grep -iE 'dov|dolby|dv'

# 找到名字之后看它的参数
ffmpeg -hide_banner -h bsf=<你查到的名字>

这个「先查再用」的习惯,在用刚发布的大版本时是刚需。Changelog 只承诺功能存在,不承诺参数名和你想的一样。

3.5 LCEVC 入 MP4:分层编码的最后一公里

LCEVC track muxing support in MP4 muxer 这条,是一条走了三个版本的长跑。

LCEVC(MPEG-5 Part 2,Low Complexity Enhancement Video Coding)的思路很聪明:用任意现有编解码器(H.264/HEVC/AV1 都行)编一个低分辨率的基础层,再叠一个轻量的增强层来重建全分辨率。

好处是:

  • 基础层用老编码器,全世界的硬件解码器都认
  • 增强层计算量很低,纯软解也扛得住
  • 总码率比直接编全分辨率低

FFmpeg 对 LCEVC 的支持路径:

版本能力
7.1LCEVC 滤镜;在 H.26x 和 MP4/ISOBMFF 中导出 enhancement data
8.0——
8.1LCEVC parser;LCEVC metadata bitstream filter;MPEG-TS 中导出增强层
9.0LCEVC track 在 MP4 muxer 中的封装支持
开发分支LCEVC payload merging bitstream filter

从「能解析」到「能作为独立 track 封装进 MP4」,这是从「实验性支持」到「可用于生产分发」的分界线。 之前你只能把增强数据塞在 SEI 里带走,现在它可以是一条正经的 track,有自己的 codec tag,播放器可以按标准流程发现和处理。

再看开发分支那条 LCEVC payload merging bitstream filter——这是在补反向操作:把分开的载荷合并回去。一个格式的支持是否成熟,看的就是「拆」和「合」是不是都齐了。


四、主线三:AI 正式进入滤镜图

这条线在中文报道里我一条都没看到,但它可能是三条线里长期影响最大的。

4.1 ONNX Runtime DNN backend with GPU execution provider support

这一行藏在 9.0 Changelog 的后半段。

先说 FFmpeg 的 DNN 子系统历史。libavfilter/dnn 是一个抽象层,让滤镜可以调用神经网络模型。基于它的滤镜有:

  • dnn_processing:通用的「一帧进、一帧出」模型推理(超分、去噪、风格化)
  • dnn_classify:分类,结果写进帧的 side data
  • dnn_detect:目标检测,输出检测框
  • derain:去雨
  • sr:超分(已被 dnn_processing 取代)

后端的演进:

后端引入问题
native早期只支持极少数算子,模型要手工转换,基本没人用
TensorFlow早期需要链接完整 libtensorflow,体积巨大,C API 极不友好
OpenVINO中期好用,但强绑 Intel 生态
libtorch7.0灵活,但依赖沉重
ONNX Runtime + GPU EP9.0——

ONNX Runtime 是这几个里唯一一个「模型格式中立 + 执行后端中立 + 部署轻量」三者兼备的选项。

  • 模型中立:PyTorch / TensorFlow / JAX 都能导出 ONNX
  • 执行后端中立:ONNX Runtime 的 Execution Provider 机制支持 CUDA、TensorRT、DirectML、CoreML、ROCm、OpenVINO……
  • 部署轻量:一个 libonnxruntime.so,几十 MB 量级

而 Changelog 特意点出 with GPU execution provider support——这意味着模型推理可以直接跑在 GPU 上,而不是把帧下载到 CPU 推理再上传。

把这一点和主线一连起来看,你会发现一个很有意思的图景:

GPU 解码 (NVDEC/Vulkan Video)
    ↓ 帧一直在显存
GPU 滤镜 (scale_cuda / *_vulkan)
    ↓ 帧一直在显存
GPU 神经网络推理 (ONNX Runtime CUDA EP)     ← 9.0 补上的这一环
    ↓ 帧一直在显存
GPU 编码 (NVENC/Vulkan Video)

这是一条从解码到编码、中间夹着 AI 推理、全程不碰主存的流水线。 在 9.0 之前,中间那一环是断的。

4.2 实战:用 dnn_processing 跑超分

再次强调:具体的 backend 名称和参数以查询为准。

# 1. 确认 dnn_processing 在不在
ffmpeg -hide_banner -filters | grep dnn

# 2. 关键:看 dnn_backend 支持哪些值(不要凭记忆写)
ffmpeg -hide_banner -h filter=dnn_processing

-h filter=dnn_processing 的输出里会列出 dnn_backend 这个 AVOption 的所有可选值,以及 modelinputoutputoptions 等参数。照着这个输出写,不要照着任何博客(包括这篇)写。

典型形态大致是这样(backend 名以上面查到的为准):

ffmpeg -i input_540p.mp4 \
  -vf "format=rgb24,dnn_processing=dnn_backend=<查到的名字>:model=./espcn_x2.onnx:input=x:output=y,format=yuv420p" \
  -c:v libx264 -crf 18 output_1080p.mp4

几个必须知道的坑:

  1. inputoutput 是模型里张量的名字,不是随便填的。 用 Python 查:
import onnx
m = onnx.load("espcn_x2.onnx")
print("inputs :", [i.name for i in m.graph.input])
print("outputs:", [o.name for o in m.graph.output])
# 顺便看看输入形状,NCHW 还是 NHWC,动态维度有没有
for i in m.graph.input:
    dims = [d.dim_value or d.dim_param for d in i.type.tensor_type.shape.dim]
    print(f"  {i.name}: {dims}")
  1. 像素格式必须和模型输入匹配。 绝大多数视觉模型吃 RGB float32 归一化到 [0,1] 或 [-1,1],而视频帧是 YUV uint8。format=rgb24 是必须的,但归一化通常要模型自己在图里做(导出 ONNX 时把预处理算子一起导出去),否则你得靠 options 传参或者干脆改模型。
  2. 动态分辨率是个大坑。 很多超分模型导出时把输入形状固定成了 [1,3,540,960]。喂一个不同分辨率的视频进去会直接报错。导出 ONNX 时把 H/W 设成动态维度:
torch.onnx.export(
    model, dummy_input, "espcn_x2.onnx",
    input_names=["x"], output_names=["y"],
    dynamic_axes={"x": {0: "N", 2: "H", 3: "W"},
                  "y": {0: "N", 2: "H2", 3: "W2"}},
    opset_version=17,
)
  1. 性能上,DNN 滤镜几乎必然是整条链的瓶颈。 一个 540p→1080p 的轻量超分模型在消费级 GPU 上大概能到几十 fps;重一点的模型个位数 fps 很正常。不要指望它能实时。 这类链路的正确定位是离线批处理,不是直播。

4.3 别忘了 8.0 的 Whisper 滤镜

FFmpeg 8.0 加了一条 Whisper filter

这意味着语音转写变成了滤镜图里的一个节点。你不需要「先用 ffmpeg 抽音频 → 存 wav → 调 whisper.cpp → 解析输出 → 生成字幕文件 → 再用 ffmpeg 烧进去」这一串胶水,理论上可以在一条命令里完成。

# 先确认它在不在,以及参数长什么样
ffmpeg -hide_banner -filters | grep -i whisper
ffmpeg -hide_banner -h filter=whisper

把 Whisper 滤镜和 ONNX DNN 后端放在一起看,FFmpeg 的定位变化就非常清楚了:

传统 FFmpeg 处理的是信号——它知道这是一帧 1920×1080 的 YUV420P,但不知道画面里有什么。

带 AI 滤镜的 FFmpeg 处理的是内容——它可以知道这一帧里有几个人、说了什么话、是什么场景。

一旦滤镜图里能跑推理,很多以前需要「拆成三个服务 + 一个消息队列」的流水线,可以塌缩成一条 -filter_complex

  • 检测到人脸 → 自动打码(dnn_detect + delogo/boxblur + sendcmd
  • 检测到静音段 → 自动裁掉(silencedetect + select
  • 转写语音 → 自动生成硬字幕(whisper + subtitles
  • 分类场景 → 按内容动态调整码率(dnn_classify + 编码器参数)

这是 FFmpeg 从「转码器」变成「内容处理管线」的关键一步。

我的判断:这条线目前还很粗糙(模型管理、批处理、显存占用、错误处理都不成熟),但方向是对的,而且没有竞品——没有第二个工具能同时把「工业级编解码」和「模型推理」放进同一个进程的同一张显存里。


五、被忽略的两条:HE-AAC 960 与动画 WebP

5.1 HE-AAC 960 decoding (DAB+):一个数字背后的时序约束

这条看起来最不起眼,但它的技术根因很有意思。

标准 AAC-LC 用 1024 个采样作为一个帧(长块),对应 2048 点的 MDCT 窗口。这个数字是 AAC 标准定下来的,绝大多数场景都用它。

DAB+(欧洲数字广播)用的是 960 个采样的帧。

为什么?因为 DAB+ 的音频超帧(audio super frame)固定是 120 毫秒。

算一下:

采样率960 样本时长120ms 能放几帧1024 样本时长能整除吗
48 kHz20.0 ms621.33 ms
32 kHz30.0 ms432.0 ms❌(3.75 帧)
24 kHz40.0 ms342.67 ms
16 kHz60.0 ms264.0 ms

960 在所有 DAB+ 采样率下都能整除 120ms,1024 一个都不行。

广播系统必须有严格对齐的帧结构——因为要做时间交织(time interleaving)、纠错编码、以及和数据服务复用。帧长和超帧长度不成整数倍,整个复用层的设计就没法做。

所以 DAB+ 选了 960。代价是:这些码流用标准 AAC 解码器解不了。 你需要一个知道帧长是 960 的解码器(MDCT 窗口变成 1920 点,窗函数系数表全部要换一套)。

9.0 之前,FFmpeg 处理 DAB+ 音频要靠外部库。现在原生支持了。

受众很窄,但对做广播监测、数字广播归档、SDR(软件定义无线电)接收的人来说,这是从「不可能」到「一条命令」的变化。

5.2 动画 WebP:为什么等了这么久

Animated WebP decoder + Animated WebP demuxer 是 9.0 被报道最多的一条,但没人讲清楚它为什么难。

WebP 不是一个编解码格式,它是一个 RIFF 容器 + 两种编码格式的组合。

RIFF 容器
 └─ 'WEBP'
     ├─ 'VP8 '  ← 有损:VP8 关键帧的一帧
     ├─ 'VP8L'  ← 无损:一套完全独立的无损编码
     ├─ 'VP8X'  ← 扩展头(声明有没有动画/alpha/ICC/EXIF)
     ├─ 'ALPH'  ← 独立的 alpha 通道数据
     ├─ 'ANIM'  ← 动画全局参数(背景色、循环次数)
     └─ 'ANMF'  ← 动画帧(每帧一个,含偏移、尺寸、时长、混合/处置模式)
          └─ 内嵌 VP8 / VP8L (+ ALPH)

难点在于这是一个「容器套编码」的双层结构,而 FFmpeg 的架构里容器(libavformat)和编码(libavcodec)是严格分离的两层。

静态 WebP 好办:一个 RIFF,里面一帧 VP8,libavcodec 里加个 webp decoder 就完事。

动画 WebP 麻烦在:

  1. 需要一个真正的 demuxer(解复用器)来遍历 ANMF 链,把每帧拆出来,还要正确解析每帧的时长(构造 pts
  2. 每帧有独立的偏移量和尺寸——一帧可能只覆盖画布的一小块矩形区域
  3. 每帧有混合模式(blend:和上一帧混合还是覆盖)和处置模式(dispose:这一帧显示完之后画布怎么处理)
  4. Alpha 可能在 ALPH chunk 里(有损路径),也可能内嵌在 VP8L 里(无损路径)
  5. 有损帧和无损帧可以在同一个动画里混用

第 2、3 条是关键:这和 GIF 的帧处置逻辑几乎是同构的,需要在 demuxer/decoder 里维护一个画布状态机。 FFmpeg 里已经有 GIF 的这套逻辑,但 WebP 的语义细节不一样,得重写一套。

所以 9.0 的 Changelog 分了两行写(decoderdemuxer 各一行)——这是两个模块的工作。

实用价值:

# 以前需要 libwebp/libwebpdemux 或者外部工具,现在原生
ffmpeg -i animated.webp -c:v libx264 -pix_fmt yuv420p out.mp4

# 抽帧
ffmpeg -i animated.webp frames_%04d.png

# 看看里面到底有几帧、帧率多少
ffprobe -v error -select_streams v:0 \
  -show_entries stream=nb_frames,avg_frame_rate,width,height,pix_fmt \
  -of default=noprint_wrappers=1 animated.webp

顺带一个实用的横向对比(具体数字取决于内容,请自测,我给的是量级和权衡,不是 benchmark):

格式体积解码成本Alpha浏览器支持适用场景
GIF最大(256 色调色板)极低1-bit全部兼容性兜底
APNG8-bit广泛需要真 alpha 的简单动画
动画 WebP中(通常显著小于 GIF)8-bit广泛通用替代 GIF
动画 AVIF8-bit+较新浏览器追求极致体积
H.264/HEVC MP4最小低(有硬解)全部不需要 alpha 的长动画

决策建议: 短循环 + 需要 alpha + 要在 <img> 标签里直接用 → 动画 WebP。长动画(>5 秒)+ 不需要 alpha → 老老实实用 MP4,用 <video autoplay muted loop playsinline>,体积和解码功耗都是碾压级的优势。


六、升级实战与性能验证

6.1 编译配置建议

如果你自己编 FFmpeg 9.0,几个和上面三条主线相关的开关:

./configure \
  --enable-gpl --enable-version3 --enable-nonfree \
  --enable-vulkan \                 # 主线一:Vulkan compute + Vulkan Video
  --enable-libshaderc \             # Vulkan 滤镜的着色器编译(或 --enable-libglslang)
  --enable-libplacebo \             # 高质量色彩管理 / 色调映射(配合 HDR 链路)
  --enable-cuda-nvcc --enable-libnpp \  # CUDA 滤镜
  --enable-ffnvcodec \              # NVENC/NVDEC header
  --enable-vaapi \
  --enable-libx264 --enable-libx265 --enable-libsvtav1 \
  --enable-libopus --enable-libfdk-aac \
  --enable-libwebp \
  --enable-lto \
  --enable-optimizations

几个要点:

  1. --enable-vulkan 需要 --enable-libshaderc--enable-libglslang,否则 Vulkan 滤镜编不出来(着色器需要在运行时或编译期从 GLSL 编成 SPIR-V)。这是 Vulkan 滤镜「configure 通过了但 -filters 里没有」的最常见原因。
  2. --enable-libplacebo 强烈建议开。libplacebo 提供了工业级的色调映射和色彩管理,是做 HDR→SDR 转换时唯一靠谱的选项。手写 zscale + tonemap 参数调出来的效果通常不如它。
  3. ONNX Runtime 后端的 configure 开关名我不确定,请以 ./configure --help | grep -i onnx 的输出为准。
  4. --enable-nonfree 会让产物不可分发libfdk-aac 等)。公司内部用没问题,要对外发布就得去掉。

编完先自检:

# 一次性确认三条主线的能力都在
echo "=== Vulkan 滤镜 ==="; ffmpeg -hide_banner -filters 2>/dev/null | grep -c vulkan
echo "=== CUDA 滤镜 ==="; ffmpeg -hide_banner -filters 2>/dev/null | grep -c cuda
echo "=== DNN 滤镜 ==="; ffmpeg -hide_banner -filters 2>/dev/null | grep dnn
echo "=== 硬件加速 ==="; ffmpeg -hide_banner -hwaccels
echo "=== 编译配置 ==="; ffmpeg -hide_banner -buildconf | grep -E 'vulkan|onnx|placebo|shaderc|cuda'

-buildconf 这条特别有用——它打印的是这个二进制实际编译时的 configure 参数,比你回忆自己敲了什么可信得多。线上排障时第一条就该跑它。

6.2 不要用 -benchmark 骗自己

FFmpeg 有个 -benchmark 选项,会打印 utime/stime/rtime 和内存峰值。很多人拿它做性能对比,然后得出错误结论。

问题在于:-benchmark 测的是整个进程,包含了输入解析、输出写盘、以及最重要的——磁盘 I/O。 你测出来的差异可能只是页缓存冷热不同。

正确的做法:

# 1. 输出丢弃,排除写盘影响
ffmpeg -benchmark -i in.mp4 -vf "..." -c:v h264_nvenc -f null -

# 2. 输入预热到页缓存,排除读盘影响(跑两遍取第二遍)
cat in.mp4 > /dev/null   # 预热
ffmpeg -benchmark ... -f null -

# 3. 只测滤镜本身:用 lavfi 生成源,彻底排除解码
ffmpeg -benchmark -f lavfi -i "testsrc2=size=3840x2160:rate=60:duration=10" \
  -vf "你要测的滤镜" -f null -

# 4. 多跑几次看方差,单次数据没有意义
for i in 1 2 3 4 5; do
  /usr/bin/time -f "%e %U %S" ffmpeg -v quiet -f lavfi \
    -i "testsrc2=size=3840x2160:rate=60:duration=10" \
    -vf "你要测的滤镜" -f null - 2>&1
done

第 3 条是关键。testsrc2 做源可以把解码开销完全排除,测出来的才是滤镜本身的成本。做 CPU 滤镜 vs GPU 滤镜对比时,这是唯一公平的方法。

另外,GPU 场景下 -benchmark 的 CPU 时间几乎没有参考价值——GPU 在算的时候 CPU 在等,utime 很低不代表快。GPU 场景要看的是:

# 端到端墙钟时间 + GPU 占用率 + PCIe 流量,三个一起看
ffmpeg -v quiet -stats -hwaccel cuda ... -f null - &
FFPID=$!
nvidia-smi dmon -s um -d 1 -c 30    # u=利用率, m=显存
wait $FFPID

6.3 七条调优规律

跑了几年硬件转码流水线,这几条是反复被验证的:

1. 滤镜链的完整性 > 单个滤镜的速度。
一个「慢但在 GPU 上」的滤镜,通常好过「快但要下载到 CPU」的滤镜。先保证不断链,再优化单点。

2. hwupload / hwdownload 的位置决定一切。
能推多晚就推多晚(hwupload 尽早,hwdownload 尽晚)。整条链只应该有一次上传和一次下载。

3. 像素格式转换是隐形杀手。
format= 滤镜看起来无害,但 yuv420p → rgb24 → yuv420p 的一来一回既耗 CPU 又损精度。用 -v verbose 看 FFmpeg 自动插了几个 format

4. 硬件解码的输出格式必须显式指定。
只写 -hwaccel cuda 而不写 -hwaccel_output_format cuda,FFmpeg 会在解码后立刻把帧下载回主存,你的「硬件加速」只加速了解码那一步。这是最常见的性能错觉。

5. 单进程多路 > 多进程单路(在 GPU 上)。
每个 FFmpeg 进程都要初始化自己的 CUDA/Vulkan context,显存和初始化开销都是重复的。多路转码优先用 -filter_complex 在一个进程里做。

6. 编码器的 preset 影响远大于滤镜优化。
在你花两天优化滤镜链之前,先试试把 -preset p7 改成 -preset p4。通常这一个改动的收益超过所有滤镜优化的总和。

7. HDR 链路上,libplacebo 优于手搓 zscale+tonemap
色调映射是个有大量工程细节的问题(高光滚降曲线、色域压缩、亮度自适应)。除非你是这个领域的专家,否则用现成的。


七、十条踩坑清单

按「踩到的概率 × 排查难度」排的序:

1. 升级后 NVENC 直接编不出来。
9.0 移除了 pre-11.1 SDK 支持。检查:ffmpeg -encoders | grep nvenc。空的话去更新 nv-codec-headers 和驱动。

2. 老的 NVENC 参数导致命令直接退出。
硬移除不是软降级。升级前必须用第一节的冒烟脚本全量验一遍参数。

3. --enable-vulkan 编过了,但 -filters 里没有 vulkan 滤镜。
libshaderclibglslang。用 ffmpeg -buildconf 确认,不要靠回忆。

4. Vulkan 在容器里初始化失败。
容器里需要挂载 /dev/dri、正确的 VK_ICD_FILENAMES、以及和宿主机匹配的驱动。先用 ffmpeg -init_hw_device vulkan=vk:0 -f lavfi -i nullsrc -frames:v 1 -f null - 单独验证设备初始化。

5. 滤镜链里静默插入了 hwdownload/hwupload,性能腰斩但不报错。
用 2.3 节的三层验证。这个坑最阴险,因为它「能跑通」。

6. HDR 元数据在转码后丢失,但画面「看起来还行」。
color_transfersmpte2084 变回 bt709 是最常见的表现。用 3.3 节的脚本做 CI 检查。在标准 SDR 显示器上你看不出来,在 HDR 电视上一眼就废。

7. DNN 滤镜报「input/output tensor not found」。
input= / output= 填的是模型里张量的真实名字。用 onnx.load 打出来对照。

8. DNN 模型固定了输入分辨率,换个视频就崩。
导出 ONNX 时设 dynamic_axes

9. 动画 WebP 转出来第一帧正常、后面全花。
大概率是帧的 dispose/blend 模式处理问题,或者你的源文件混用了有损/无损帧。先 ffprobe 确认帧数和帧率,再单独抽帧检查是哪一帧开始坏。这是新代码,遇到问题值得去 FFmpeg trac 提 issue。

10. 同一台机器上多代 FFmpeg 共存,实际跑的不是你以为的那个。
which ffmpeg 只告诉你 PATH 里第一个。真正要看的是 lddffmpeg -buildconf生产环境强烈建议用静态编译或者容器固定版本,不要依赖系统包。


八、下一站:开发分支已经在跑的东西

Changelog 顶部的 version <next> 段落已经有了内容,可以看出下一个版本的方向:

- extensively improved AAC encoder
- APV Vulkan encoder
- iTerm2 inline image protocol muxer
- LCEVC payload merging bitstream filter
- MVR demuxer
- latticepal filter
- DVD-Audio LPCM decoder and demuxing support
- AVFoundation input device selection by unique ID and USB serial number

几条值得注意:

extensively improved AAC encoder —— FFmpeg 原生 AAC 编码器的质量长期落后于 libfdk-aac,而 libfdk-aac 因为许可证问题不能进主流发行版的默认构建。如果原生编码器真的追上来了,这会消除一个存在了十年的「必须自己编 FFmpeg」的理由。 这条的实际影响可能比 9.0 的任何一条都大。

APV Vulkan encoder —— 印证了主线一的判断:编码器正在被 compute shader 重新实现。

LCEVC payload merging bitstream filter —— 印证了主线二:LCEVC 的「拆」和「合」正在补齐,走向生产可用。

iTerm2 inline image protocol muxer —— 这个纯属有趣:FFmpeg 可以直接把视频输出到支持 iTerm2 内联图片协议的终端里。ffplay 的终端版本。


九、总结:一个代号,和它背后的三条线

回到开头。

FFmpeg 9.0 的代号 Lei 会被记住,因为它证明了一件事:开源世界的贡献,不只是提交了多少行代码。 一个人可以完全不在核心贡献者名单上,仅仅因为把一个高门槛的领域用中文讲清楚了,就在十年后被一个全球性项目用版本代号纪念。

这件事本身,比任何技术特性都值得写。

但如果只记住这个代号,就浪费了 5.2 万行代码的信息量。这个版本真正在说的是:

第一,硬件加速的碎片化时代正在结束。 Vulkan compute 提供了一条不依赖厂商固定功能单元的路径,swscale Vulkan supportProRes Vulkan encoderAPV Vulkan hwaccelv360_vulkan 是同一个方向上的连续动作。长期看,*_cuda / *_vaapi / *_d3d12 / *_amf 这四套并行的滤镜实现会逐渐收敛到 *_vulkan 一套。

第二,元数据正在从附属品变成一等公民。 SMPTE 2094-50 passthrough、Dolby Vision 多层拆分 bsf、LCEVC 入 MP4——这些都在说同一件事:转码不再只是「像素进、像素出」,而是「像素 + 描述像素该如何被显示的完整信息」一起进出。 HDR 时代,丢元数据等于毁画质,而且是静默的。

第三,AI 推理正在进入滤镜图。 ONNX Runtime + GPU EP 补上了「解码→滤镜→推理→编码」全链路不落主存的最后一环。加上 8.0 的 Whisper 滤镜,FFmpeg 开始具备处理「内容」而不只是「信号」的能力。这条线现在还粗糙,但它没有竞争对手。

三条线指向同一个结论:FFmpeg 不再满足于做一个转码工具。它在把自己变成像素和元数据的操作系统——统一的硬件抽象层(Vulkan)、完整的元数据总线(ST 2094 / DV / LCEVC)、以及可编程的内容理解层(DNN)。

对我们这些天天在用它的人来说,实际的行动清单其实很短:

  1. 升级前跑参数冒烟测试,尤其是 NVENC 相关的命令模板
  2. 把「滤镜链完整性检查」写进 CI,别让静默的 PCIe 往返吃掉你的吞吐
  3. 把「元数据 diff」写进 CI,别在 HDR 链路上静默丢信息
  4. 如果你在做 AI + 视频,现在可以认真评估把推理搬进 -filter_complex
  5. 凡是新版本的参数名,一律 -h filter=xxx 现查,别信任何博客的硬编码(包括这一篇)

最后一条不是客套。这个版本发布才几天,我文中所有的 <你查到的名字> 占位符都是真的不确定——在快速演进的项目上,「我不知道,这是查询命令」比「我猜是这个参数」有用一百倍。

十年前那位开发者留下的东西里,最有价值的其实也是这个:不是某段能直接抄的代码,而是「把复杂的东西一层层拆开讲清楚」这件事本身。

代号会随着下一个版本翻篇。这个方法不会。

推荐文章

Vue 中如何处理跨组件通信?
2024-11-17 15:59:54 +0800 CST
JavaScript设计模式:发布订阅模式
2024-11-18 01:52:39 +0800 CST
在 Rust 生产项目中存储数据
2024-11-19 02:35:11 +0800 CST
你可能不知道的 18 个前端技巧
2025-06-12 13:15:26 +0800 CST
程序员茄子在线接单