🎬 第四章 Qt播放音视频及Multimedia多媒体模块
📄 一句话概括

视频播放器比音乐播放器多三件事:全屏、画面比例、拖动跳转。 其中最容易被低估的是拖动——它的成本由编码时的 GOP 决定,改不了。

📝 本节和前面几节的关系

上一节的进度条讲的是「怎么同步」,这一节讲「松手之后到底发生了什么」。 一次 seek 不是「跳过去」,是「回到关键帧再解过来」。 这件事决定了拖动手感,也决定了为什么有些片子怎么拖都卡。

一、全屏、无边框、画面比例

视频窗口有三件跟音乐播放器不一样的事:

功能 关键调用 注意什么
全屏切换 showFullScreen() / showNormal() 要留 Esc 退出,否则用户出不来
画面比例 setAspectRatioMode() 默认留黑边,别轻易拉伸
双击全屏 重写 mouseDoubleClickEvent 双击同时要处理单击播放/暂停的冲突

「Esc 退出全屏」这条不是小事:无边框全屏的窗口没有标题栏, 用户找不到关闭按钮,只能按 Alt+F4 或者杀进程。 所以 keyPressEvent 里必须拦 Qt::Key_Escape。

「单击播放暂停」与「双击全屏」会打架:双击的第一次单击会先触发播放/暂停, 于是双击全屏时画面先暂停了一下。 解法是用定时器延迟单击响应, 等 QApplication::doubleClickInterval() 过了没有第二次点击才执行。

画面比例那格值得多说一句:QVideoWidget 默认按比例居中留黑边, 很多人为了「铺满」把它设成 IgnoreAspectRatio,结果 4:3 的老片被拉成 16:9,人脸全变形。 正确的「铺满」是裁剪(KeepAspectRatioByExpanding 加视口),不是拉伸。

二、拖动进度:一次 seek 到底做了什么

用户拖到第 5 秒,播放器并不「跳到第 5 秒」。它做的是: 找到第 5 秒之前最近的那个关键帧。 从那个关键帧开始解码,一帧帧往后解。 解到时间戳 ≥ 5 秒的那一帧,把它显示出来。

为什么不能直接跳到第 5 秒:因为压缩视频里大部分帧是「差值」, P 帧记的是「跟前一帧比变了什么」,没有前面的帧,它就是一串无意义的数字。 只有关键帧(I 帧)能独立解出来。

这解释了三件事: 为什么拖动会「先花一下」:从关键帧解到目标要时间。 为什么 GOP 越短越好拖:关键帧密了,每次要解的帧就少。 为什么直播不能随意拖:直播没有「后面的关键帧」可跳。

Demo 里那张表最能说明问题:GOP 设成 60(两秒一个关键帧), 往 1.0、3.0、5.0、7.0 秒各 seek 一次,四次都要解 31 帧。 因为四个位置恰好都落在关键帧之后一秒左右。

「31 帧」这个数是这么来的:30 fps 下一帧 33.3 毫秒, 一秒就是 30 帧,再加上目标那一帧本身,31 帧。

三、GOP 决定手感,播放器改不了

这一节最该记住的一句是:GOP 长度是编码时就写进文件里的,播放器只能读,不能改。

所以「这个片子拖动很卡」这个问题,根子在压制参数,不在播放器。 你换三个播放器都一样卡。

GOP 长度 拖动一次要解 文件大小
30 帧(一秒) 十几帧 偏大
60 帧(两秒) 三十来帧 中等
250 帧(十秒) 上百帧 偏小

这张表的取舍很清楚:关键帧多 → 好拖、文件大;关键帧少 → 省码率、拖动顿。 所以「点播」与「直播」的 GOP 选择是相反的: 点播要频繁拖 → GOP 短一点,两秒左右。 直播不在乎拖 → GOP 长一点,省码率、也省关键帧的突发带宽。

还有一个细节:QMediaPlayer::setPosition 是异步的, 它立刻返回,画面过一会儿才变。所以「设完位置马上取 position()」拿到的还是旧值。 要等 positionChanged 回来才算跳完。

这也带来一个体验优化:seek 期间先把界面切到「加载中」,别让用户以为卡死了。 一个 QTimer 加半秒延迟就能做到,但它对「感觉快不快」的影响,比优化解码大得多。

四、动手 Demo:拖动进度条,为什么要从关键帧解起

这段编一段 300 帧的测试片,GOP 设成 60(两秒一个关键帧),并关掉场景切换自动插帧。 然后往四个位置各 seek 一次,数「解了多少帧才落到目标」。

s.options = {"preset": "veryfast", "g": "60",
             "sc_threshold": "0"}   # 关键:别让场景切换插帧

for target in (1.0, 3.0, 5.0, 7.0):
    c.seek(int(target / s.time_base), s)
    n = 0
    t0 = time.perf_counter()
    for frame in c.decode(s):
        n += 1
        if frame.time >= target:
            break
    print(target, n, "%.1f 毫秒" % ((time.perf_counter()-t0)*1e3))

实跑输出:

--------------------------------------------------------------------
拖动进度条,为什么要从关键帧解起
--------------------------------------------------------------------
**压缩视频里只有关键帧能独立解出来,其余帧都要参照前面的帧。**
**所以「跳到第 90 帧」这件事,实际是「找到它前面那个关键帧,从那里解过来」。**

  测试片:320×240、300 帧、30 fps、**GOP 设成 60**(两秒一个关键帧)。

  **实际关键帧位置(秒):0.0、2.0、4.0、6.0、8.0**

**往四个位置各 seek 一次,量「解了多少帧才落到目标」**:

  目标位置     最近的前关键帧     要解几帧     耗时 毫秒
  ----------------------------------------------------------
  1.0 秒       0.0 秒             31           6.3
  3.0 秒       2.0 秒             31           5.4
  5.0 秒       4.0 秒             31           6.4
  7.0 秒       6.0 秒             31           4.8

**四个位置都要解三十来帧,时间也差不多。**
**这不是巧合**——因为 GOP 是 60(两秒),而四个目标都落在关键帧之后一秒左右。

**这解释了三件事**:
  **为什么拖动进度条会「先花一下」**:从关键帧解到目标要时间。
  **为什么 GOP 越短越好拖**:关键帧密了,每次要解的帧就少——但文件会变大。
  **为什么直播不能随意拖**:直播没有「后面的关键帧」可跳。

**GOP 长度是编码时就定死的,播放器改不了。**
**所以「这个片子拖动很卡」这个问题,根子在压制参数,不在播放器。**

sc_threshold=0 这行是整段代码的关键。不改它的话,场景切换会自动插一个关键帧, 关键帧位置就变成「60 帧左右,但不固定」,统计出来的「解几帧」全是乱的。 这是第一次跑这段时的真实教训。

改完之后,关键帧严格落在 0、2、4、6、8 秒,四个目标位置都在关键帧后一秒, 所以四次都是 31 帧、四到六毫秒。

「都一样」这件事本身就是结论: seek 的成本不取决于目标位置,只取决于「离前一个关键帧多远」。 这才是「GOP 决定手感」的准确说法。

如果把 GOP 改成 250(十秒一个关键帧),最坏情况要解二百五十帧, 一帧一毫秒的话就是四分之一秒——这就是「拖一下要等半秒」的来源。

注意这里的耗时数字比真实解码快:测试片只有 320×240, 真实 1080p 解一帧要几毫秒,31 帧就是一百多毫秒。 所以「拖动时那一下顿」在高分辨率下是躲不掉的。

30 秒实验

  1. 把 GOP 从 60 改成 250,重跑一遍,看「要解几帧」变成多少。
  2. 算一下:30 fps、GOP 60,最坏情况下要解多少帧?(提示:刚过关键帧的时候最省, 快到一个关键帧之前的时候最费。)
  3. 判断一下:直播流的 GOP 该长还是该短?为什么跟点播相反?

常见误区

  1. 以为 setPosition 是同步的。 它立刻返回,画面过一会儿才变。 设完马上读 position() 拿到的是旧值。
  2. 用 IgnoreAspectRatio 去「铺满」窗口。 那是拉伸,4:3 的片子会变形。 要铺满应该裁剪,不是拉伸。
  3. 全屏不留 Esc 出口。 无边框全屏没有标题栏,用户出不来,只能 Alt+F4 或者杀进程。
  4. 把「拖动卡」怪到播放器头上。 GOP 是编码参数,换播放器一样卡。要改善只能重压或者接受。
自测三问(点开对答案) 1. seek 到第 5 秒,播放器实际做了什么? —— 找到第 5 秒之前最近的关键帧,从那里开始解码,一帧帧往后解到时间戳不小于 5 秒。 所以成本取决于「离关键帧多远」。 2. 为什么 P 帧不能单独解出来? —— 因为它存的是「跟前一帧相比变了什么」,是一组差值。没有参照帧,这些数字没有意义。 只有 I 帧是完整的。 3. 点播和直播的 GOP 为什么取相反的方向? —— 点播要频繁拖动,GOP 短才好拖,代价是文件变大; 直播不在乎拖动,GOP 长能省码率、也避免关键帧的突发带宽。

第四章到此为止。四节走完一条线: 框架是什么、最小播放器怎么写、音乐播放器加什么、视频播放器多什么。第五章换一个完全不同的方向——不是「播」,是「采」。

相关笔记