项目背景
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 | Xinle_Player/ |
核心模块职责:
VideoDecoder:继承QThread,在独立线程中循环读取 FFmpeg 数据包,解码视频帧压入队列,音频直接写入QAudioSink。GLWidget:继承QOpenGLWidget,负责上传 RGBA 视频帧到纹理、渲染到全屏四边形、加载并执行 GLSL 特效。Player:主窗口,组合GLWidget和控制按钮、进度条、特效下拉框。
添加播放进度条
播放器最初只有”打开文件”和”播放/暂停”两个按钮。为了让体验更像一个真正的播放器,加上了进度条和时间标签。
实现要点:
UI 层:在控制栏加入
QSlider和QLabel。QSlider的范围按视频总时长设置为毫秒精度。- 拖动时通过
sliderPressed/sliderReleased/sliderMoved控制交互:按下时暂停自动刷新,释放时调用seek()跳转。
位置跟踪:
VideoDecoder在解码每一帧视频时,根据AVFrame->pts和视频流time_base计算当前播放位置:1
m_currentPosition = av_q2d(m_videoTimeBase) * m_videoFrame->pts;
主线程按帧率定时读取
currentPosition(),更新进度条和时间标签。跳转:用户释放滑块时,调用
VideoDecoder::seek(seconds)。解码线程在run()里执行:1
2
3avformat_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 | void VideoDecoder::flushAudioDecoder() { |
这个教训很直接: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 | if (!m_coverPixmap.isNull()) { |
清理测试特效
开发过程中临时加了一个”我的特效”用于测试 shader。完成主要功能后,把它从 effects.json 和对应的 res/shaders/my_effect.frag 中清理掉,避免 demo 里留下半成品。
现在特效下拉框里只剩四个选项:无、反色、高斯模糊、遮罩。
架构流程
整个播放器的音视频流程如下:
1 | ┌─────────────────┐ 文件路径 ┌──────────────────┐ |
渲染管线
无特效时:
1 | 视频纹理 ──▶ pass-through shader ──▶ 屏幕 |
有特效时:
1 | 视频纹理 ──▶ pass-through ──▶ FBO[0] |
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,欢迎参考。