0%

Xinle_Player:一个 Qt6 + FFmpeg + OpenGL 播放器与特效系统的演进

项目背景

Xinle_Player 是一个独立的本地视频播放器演示工程,参考 Olive 0.1 音视频处理与 GLSL 特效管线的思路,针对 Qt 6.9.1 + MSVC 2022 + OpenGL 3.3 Core Profile 环境重新实现。

与参考项目的主要差异:

  • 参考项目使用 Qt 5.12.1 + MSVC 2015 + 传统 OpenGL(glBegin/glEnd、固定管线、内置 shader 变量)。
  • Xinle_Player 使用 Qt 6.9.1 + MSVC 2022 + 现代 OpenGL Core Profile(VAO/VBO、QOpenGLShaderProgram、显式 vertex attribute)。
  • 音频输出从 QAudioOutput(Qt 5)改为 QAudioSink(Qt 6)。
  • GLSL shader 语法从 #version 120 风格迁移到 #version 330 core

这个 demo 的定位是学习和沉淀:把 Olive 0.1 工程里关于音视频解码、OpenGL 渲染、特效管线的思想,用现代 Qt 和 OpenGL 重新实现一遍,方便理解和扩展。

技术栈与项目结构

技术栈:

  • Qt 6.9.1 + MSVC 2022
  • FFmpeg 7.x
  • OpenGL 3.3 Core Profile
  • GLSL 330 core

项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Xinle_Player/
├── Xinle_Player.vcxproj # Visual Studio 工程文件
├── bin/ # 编译输出(.exe、.dll、.pdb)
├── include/ # 第三方头文件
├── lib/ # 第三方导入库
├── src/ # 源码
│ ├── main.cpp
│ ├── player.h/.cpp/.ui # 主窗口:UI 布局、播放控制、特效选择
│ ├── glwidget.h/.cpp # OpenGL 视频显示控件
│ ├── videodecoder.h/.cpp # FFmpeg 音视频解码器
│ ├── effect.h/.cpp # 通用 GLSL 特效封装
│ └── effectregistry.h/.cpp
└── res/ # Qt 资源
├── player.qrc
├── effects/effects.json
└── shaders/*.frag

核心模块职责:

  • VideoDecoder:继承 QThread,在独立线程中循环读取 FFmpeg 数据包,解码视频帧压入队列,音频直接写入 QAudioSink
  • GLWidget:继承 QOpenGLWidget,负责上传 RGBA 视频帧到纹理、渲染到全屏四边形、加载并执行 GLSL 特效。
  • Player:主窗口,组合 GLWidget 和控制按钮、进度条、特效下拉框。

添加播放进度条

播放器最初只有”打开文件”和”播放/暂停”两个按钮。为了让体验更像一个真正的播放器,加上了进度条和时间标签。

实现要点:

  1. UI 层:在控制栏加入 QSliderQLabel

    • QSlider 的范围按视频总时长设置为毫秒精度。
    • 拖动时通过 sliderPressed / sliderReleased / sliderMoved 控制交互:按下时暂停自动刷新,释放时调用 seek() 跳转。
  2. 位置跟踪VideoDecoder 在解码每一帧视频时,根据 AVFrame->pts 和视频流 time_base 计算当前播放位置:

    1
    m_currentPosition = av_q2d(m_videoTimeBase) * m_videoFrame->pts;

    主线程按帧率定时读取 currentPosition(),更新进度条和时间标签。

  3. 跳转:用户释放滑块时,调用 VideoDecoder::seek(seconds)。解码线程在 run() 里执行:

    1
    2
    3
    avformat_seek_file(m_fmtCtx, -1, INT64_MIN, ts, INT64_MAX, 0);
    // 清空视频帧队列
    // flush 音视频解码器

修复拖动进度条后音频静音

进度条加上后,画面能跳转,但拖动后音频没了。这是这次改动里最有价值的一个 bug。

排查过程

最初怀疑是 QAudioSink 的 reset 时机不对。因为 seek 时需要清空音频缓冲里的旧数据,之前尝试过几种方案:

  • 在解码子线程里直接调用 m_audioSink->reset(),结果触发 IAudioClient3::Start failed "AUDCLNT_E_NOT_STOPPED"QWinEventNotifier 跨线程警告,导致 seek 后静音。
  • 把 reset 移到主线程,并给音频设备加互斥锁,解决了崩溃,但音频仍然不响。

加日志后发现,seek 之后音频包还在被读取,但 avcodec_send_packet 一直返回 -541478725,也就是 AVERROR_EOF

根因

flushAudioDecoder() 里只做了 drain 解码器(发送 nullptr 包把剩余帧取出来),但没有调用 avcodec_flush_buffers()。解码器被 drain 后处于 EOF 状态,不重置就没法再接收新包。

视频解码器当时已经写了 avcodec_flush_buffers(),音频这边漏掉了。

修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void VideoDecoder::flushAudioDecoder() {
if (!m_audioCodecCtx) {
return;
}

avcodec_send_packet(m_audioCodecCtx, nullptr);
while (avcodec_receive_frame(m_audioCodecCtx, m_audioFrame) == 0) {
av_frame_unref(m_audioFrame);
}

// 关键:清空解码器内部状态,避免 seek 后 send_packet 返回 AVERROR_EOF。
avcodec_flush_buffers(m_audioCodecCtx);

if (m_swrCtx) {
swr_init(m_swrCtx);
}
}

这个教训很直接:FFmpeg 的音视频解码器 API 是对称的,处理视频时想到的步骤,音频也要同步做。

音频设备的互斥访问

在尝试 reset 音频的过程中,还遇到过一次崩溃:m_audioIODevice->write() 时访问冲突。原因是主线程调用 QAudioSink::reset() 会重建/释放 QIODevice,而解码子线程可能正拿着旧指针写数据。

解决办法是给所有 QAudioSink / QIODevice 操作加一把锁 m_audioMutex

  • initAudioOutput()
  • stopAudioOutput()
  • play() / pause() 里的 resume() / suspend()
  • decodeAudioPacket() 里的 write()

保证 reset/resume/write 不会并发执行,子线程写音频时设备不会被突然释放。

启动封面与窗口标题

顺手把启动体验也调整了一下:

  • 窗口标题从 OpenGL + FFmpeg Player 改成 Xinle Player
  • GLWidget 在没有视频帧时不再清屏成蓝色,而是加载 cover.jpg 作为启动背景。图片通过 Qt 资源文件 player.qrc 打包进可执行文件,运行时不需要依赖外部文件。

封面使用 QPainter 绘制在 paintGL() 里,保持等比铺满窗口:

1
2
3
4
5
6
7
8
if (!m_coverPixmap.isNull()) {
QPainter painter(this);
painter.drawPixmap(
rect(),
m_coverPixmap.scaled(rect().size(),
Qt::KeepAspectRatioByExpanding,
Qt::SmoothTransformation));
}

清理测试特效

开发过程中临时加了一个”我的特效”用于测试 shader。完成主要功能后,把它从 effects.json 和对应的 res/shaders/my_effect.frag 中清理掉,避免 demo 里留下半成品。

现在特效下拉框里只剩四个选项:无、反色、高斯模糊、遮罩。

架构流程

整个播放器的音视频流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
┌─────────────────┐     文件路径      ┌──────────────────┐
│ Player 窗口 │ ────────────────▶ │ VideoDecoder │
│ │ │ (QThread) │
└─────────────────┘ └────────┬─────────┘
│ │
│ 按帧率取帧 │ 解码视频包
│ ▼
│ ┌──────────────────┐
│ │ RGBA 视频帧队列 │
│ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────┐ RGBA 帧 ┌──────────────────┐
│ GLWidget │ ◀──────────────── │ 解码音频包 │
│ (QOpenGLWidget)│ │ S16 立体声 PCM │
└────────┬────────┘ └────────┬─────────┘
│ │
│ 纹理上传 │ 写入
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ OpenGL 纹理 │ │ QAudioSink │
└────────┬────────┘ │ (声卡输出) │
│ └──────────────────┘

┌─────────────────┐
│ FBO ping-pong │
│ + GLSL 特效 │
└────────┬────────┘


┌─────────────────┐
│ 屏幕显示 │
└─────────────────┘

渲染管线

无特效时:

1
视频纹理 ──▶ pass-through shader ──▶ 屏幕

有特效时:

1
2
3
4
5
视频纹理 ──▶ pass-through ──▶ FBO[0]
FBO[0] ──▶ effect shader ──▶ FBO[1]
FBO[1] ──▶ effect shader ──▶ FBO[0] (多 pass 时继续交换)
...
FBO[n] ──▶ pass-through ──▶ 屏幕

QOpenGLFramebufferObject 纹理作为来源渲染时存在固有的上下翻转,统一通过顶点 shader 中的 flipY uniform 处理。

一点工程反思

这次小功能用 AI Agent(Claude Code)配合完成,有几个体会:

  • 定位 bug 比写代码更重要。音频静音的问题,如果没有日志确认 send_packet 返回 AVERROR_EOF,很容易在 QAudioSink 的状态切换上绕圈子。
  • 对称性检查。音视频解码器在很多处理上是镜像的,一处写了 flush_buffers,另一处也要检查。
  • 资源生命周期要加锁。跨线程的 QIODevice 指针,reset 时很容易变成悬空指针。

后续可做的事

这个 demo 目前只是简单播放器,还有很多可以扩展的方向:

  • 精确音视频同步(目前视频按固定帧率显示,与音频不完全同步)。
  • 更多内置特效,以及特效参数的 UI 绑定。
  • 播放列表、倍速播放、截图等常见播放器功能。
  • 把播放逻辑进一步抽象,方便迁移到其他 Qt/FFmpeg 项目中。

代码在 hanxinle/Xinle_Player,欢迎参考。