🎬 第五章 FFmpeg命令行实现音视频转码
📄 一句话概括

编码就是把「重复的部分」换成「一句引用」,把「人看不出的细节」直接扔掉。 CRF 是质量目标,不是码率目标。

📝 本节和前面几节的关系

第四章从头到尾都在换盒子,一个像素都没动;从这一章开始动内容。 这一节先把「编码」这件事拆成三步,后面的所有参数都挂在这三步上。

一、编码器的三步流水线

任何视频编码器,不管 H.264 还是 AV1,内部都是这三步:

步骤 做什么 丢不丢信息


预测 猜这一块和哪块像,只传差值 无损 变换与量化 把差值换成频域系数,再除一个大数 有损 熵编码 把系数压成变长比特流 无损

三步里只有第二步是有损的,而第二步唯一的旋钮就是那个「大数」——量化步长。 后面见到的 CRF、QP、码率、preset,调的都是这一个旋钮的松紧。

第一步为什么能省:一段 2 秒的片子,前后帧常常九成以上是同一片墙、同一张脸。 把「和上一帧一样」写成一句引用,比老老实实存一遍要省得多。

二、CRF:质量目标,不是码率目标

同一批 50 帧,四个 CRF 各压一遍,字节如下:

CRF 字节 相对 CRF23


18 6956 1.23× 23 5678 1.00× 28 4888 0.86× 35 4236 0.75×

CRF 的含义是「我愿意掉多少画质」,不是「我每秒花多少字节」。 所以它给的字节数是不固定的:同一档 CRF 在静止画面上可能只有几百 kbps, 在爆炸场面能冲到几十 Mbps。想要「一定不超过多少」,那要用码率模式。

常用档位记这三条就够了:18 左右肉眼无差、23 是默认、28 以上能看出块。 CRF 每降 6,字节大约翻一倍——这个经验值在多数素材上都成立。

三、I 帧、P 帧、B 帧:体积差从哪来

同一个文件里,包的大小能差一个数量级:

统计项 值


总包数 50 关键帧个数 1 关键帧字节 933 字节 普通帧平均 75 字节 关键帧是普通帧的 12.5 倍

关键帧(I 帧)不参考任何人,自己完整说一遍,所以体积大; 普通帧(P 帧、B 帧)只说「和谁差多少」,所以体积小。

帧类型 参考谁 能单独解吗


I 帧 谁也不参考 能 P 帧 前面某一帧 不能 B 帧 前后各若干帧 不能,还要等后面

两个直接后果:拖进度条要落在 I 帧上才快;I 帧隔得越远, 随机跳转越迟钝,但压缩率越高。这个间隔就是后面要讲的 GOP。

四、动手 Demo:四个 CRF 各压一遍,再数关键帧与普通帧

把 2 秒素材按四个 CRF 重压一遍,比较字节;再打开其中一份,把关键帧与普通帧分开统计。 重点看两件事:CRF 每降一档字节涨多少,关键帧比普通帧大多少倍。

import av, os

def enc(dst, crf):
    c1 = av.open(dst, u"w")
    s1 = c1.add_stream(u"libx264", rate=25)
    s1.width, s1.height, s1.pix_fmt = 160, 90, u"yuv420p"
    s1.options = {u"crf": u"%d" % crf}
    c0 = av.open(u"s5.mp4")
    cc = c0.streams.video[0].codec_context
    for pk in c0.demux(video=0):
        if not pk.size:
            continue
        for f in cc.decode(pk):
            for o in s1.encode(f):
                c1.mux(o)
    for f in cc.decode(None):
        for o in s1.encode(f):
            c1.mux(o)
    for o in s1.encode(None):
        c1.mux(o)
    c1.close()
    c0.close()
    return os.path.getsize(dst)

for crf in (18, 23, 28, 35):
    print(u"CRF %d  %6d 字节" % (crf, enc(u"c%d.mp4" % crf, crf)))

nk, np_, sk, sp = 0, 0, 0, 0
for pk in av.open(u"c23.mp4").demux(video=0):
    if not pk.size:
        continue
    if pk.is_keyframe:
        nk, sk = nk + 1, pk.size
    else:
        np_, sp = np_ + 1, sp + pk.size
print(u"关键帧 %d 个,%d 字节" % (nk, sk))
print(u"普通帧 %d 个,平均 %.0f 字节" % (np_, sp / float(np_)))
print(u"倍数 %.1f" % (sk / (sp / float(np_))))

实跑输出:

--------------------------------------------------------------------
5.1.1 视频编解码——CRF 一动,字节就跟着跳
--------------------------------------------------------------------

**① 同一批 50 帧,四个质量档压一遍**

  CRF     字节     相对 CRF23
  ----------------------------
  18      6956     1.23×
  23      5678     1.00×
  28      4888     0.86×
  35      4236     0.75×

**CRF 是「质量目标」而不是「码率目标」:它让编码器自己决定花多少字节。**
**所以同一档 CRF 在简单画面上很省、在复杂画面上很费,字节数不固定。**

**② 关键帧与普通帧的字节差多少**

  统计项               值
  ------------------------------
  总包数               50
  关键帧个数           1
  关键帧字节           933 字节
  普通帧个数           49
  普通帧平均           75 字节
  关键帧是普通帧的     12.5 倍

**同一个画面里,包的体积能差出一个数量级:关键帧能独立解,体积大;**
**普通帧只记「与前一帧的差」,体积小。编码的本质就是把重复的部分换成一句引用。**

s1.options 要在开始编码前设好,开始喂帧之后再改就无效了。 这一行就是命令行的 -crf 23。

cc.decode(None) 这一步不能省。 解码器为了处理 B 帧的重排,内部会压住最后几帧,不 flush 就少两帧—— 第一版正是这么少了两帧,50 帧的数对不上,查了半天。

pk.is_keyframe 读的是包上的标记位,不用解码就能判断。 这也是为什么「快速定位到某个时间点」能做得很快:只扫包头,不碰画面数据。

30 秒实验

  1. 看字节走向:-crf 18 与 -crf 28 各转一遍,看大小差几倍。
  2. 看帧类型:ffprobe -show_frames -select_streams v -show_entries frame=pict_type,会打出 I、P、B。
  3. 换个 preset:-preset veryslow 与 -preset ultrafast 对比,字节会降但时间涨很多。

常见误区

  1. 以为 CRF 是固定码率。它是质量目标,字节数随画面复杂度浮动。
  2. 以为 CRF 0 最好。CRF 0 是无损模式,文件能大出十倍,日常没人这么用。
  3. 以为帧数一定等于包数。解码要 flush,编码也有尾包,两头都要算清楚。
自测三问(点开对答案) 1. 编码三步里,哪一步是有损的? —— 变换与量化。预测和熵编码都是可逆的,量化那一步把细节除掉就再也回不来了。 2. 为什么同一档 CRF 的文件大小不固定? —— 因为 CRF 定的是画质目标,编码器自己决定花多少字节去达到它,画面越复杂花得越多。 3. 关键帧为什么比普通帧大得多? —— 关键帧不参考任何帧,要自己完整描述一幅画面;普通帧只描述与参考帧的差异。

相关笔记