视频播放器比音乐播放器多三件事:全屏、画面比例、拖动跳转。 其中最容易被低估的是拖动——它的成本由编码时的 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 秒实验
- 把 GOP 从 60 改成 250,重跑一遍,看「要解几帧」变成多少。
- 算一下:30 fps、GOP 60,最坏情况下要解多少帧?(提示:刚过关键帧的时候最省, 快到一个关键帧之前的时候最费。)
- 判断一下:直播流的 GOP 该长还是该短?为什么跟点播相反?
常见误区
- 以为
setPosition是同步的。 它立刻返回,画面过一会儿才变。 设完马上读position()拿到的是旧值。 - 用
IgnoreAspectRatio去「铺满」窗口。 那是拉伸,4:3 的片子会变形。 要铺满应该裁剪,不是拉伸。 - 全屏不留 Esc 出口。 无边框全屏没有标题栏,用户出不来,只能 Alt+F4 或者杀进程。
- 把「拖动卡」怪到播放器头上。 GOP 是编码参数,换播放器一样卡。要改善只能重压或者接受。
自测三问(点开对答案)
1. seek 到第 5 秒,播放器实际做了什么? —— 找到第 5 秒之前最近的关键帧,从那里开始解码,一帧帧往后解到时间戳不小于 5 秒。 所以成本取决于「离关键帧多远」。 2. 为什么 P 帧不能单独解出来? —— 因为它存的是「跟前一帧相比变了什么」,是一组差值。没有参照帧,这些数字没有意义。 只有 I 帧是完整的。 3. 点播和直播的 GOP 为什么取相反的方向? —— 点播要频繁拖动,GOP 短才好拖,代价是文件变大; 直播不在乎拖动,GOP 长能省码率、也避免关键帧的突发带宽。第四章到此为止。四节走完一条线: 框架是什么、最小播放器怎么写、音乐播放器加什么、视频播放器多什么。第五章换一个完全不同的方向——不是「播」,是「采」。
- ☐ (本章完,下一节 05.01 使用VS 2015搭建OpenCV 4开发环境)