播放器开发--音视频解码(3)
PlayerController —— 流水线总控 前面两篇分别讲了 Demuxer(解封装)和 Decoder(解码),但它们还各自独立,需要一个角色把整条流水线串起来。PlayerController 就是这个”总控”——它负责:
按正确顺序组装流水线各组件
启动和停止所有工作线程
对外提供 play / stop / pause / resume / seek 控制接口
类的结构一览 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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 class PlayerController : public QObject { Q_OBJECT Q_PROPERTY (double duration READ getDuration NOTIFY durationChanged) Q_PROPERTY (bool isPlaying READ isPlaying NOTIFY isPlayingChanged) Q_PROPERTY (bool paused READ isPaused NOTIFY pausedChanged) public : Q_INVOKABLE bool play(const QString &filePath); Q_INVOKABLE void stop () ; Q_INVOKABLE void pause () ; Q_INVOKABLE void resume () ; Q_INVOKABLE void seek (double seconds) ; Q_INVOKABLE FrameQueue* getVideoFrameQueue () ; Q_INVOKABLE FrameQueue* getAudioFrameQueue () ; Q_INVOKABLE double getCurrentTime () ; Q_INVOKABLE void setVolume (int volume) ; Q_INVOKABLE int getVolume () const ; Q_INVOKABLE Clock* getClockPtr () const ; signals: void videoFrameReady () ; void durationChanged () ; void isPlayingChanged () ; void pausedChanged () ;private : void demuxLoop () ; Demuxer _demuxer; VideoDecoder _videoDecoder; AudioDecoder _audioDecoder; AudioOutput _audioOutput; PacketQueue _videoPacketQueue; PacketQueue _audioPacketQueue; FrameQueue _videoFrameQueue; FrameQueue _audioFrameQueue; std::thread _demuxThread; std::atomic<bool > _isPlaying{false }; std::atomic<bool > _paused{false }; std::atomic<bool > _seeking{false }; std::atomic<bool > _seekInProgress{false }; std::atomic<bool > _hasAudio{false }; int _volume = 50 ; std::shared_ptr<Clock> _clock; };
这个类的职责很纯粹——它自己不做任何音视频处理,只负责”组装和调度”。所有复杂的逻辑都在 Demuxer、Decoder、AudioOutput 内部,PlayerController 只是把它们的接口配合起来调用。
阅读提示 :Seek 和 Clock 相关逻辑将在后续文章中详细讲解,本文聚焦流水线的组装、启动和停止。
play() —— 组装并启动流水线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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 bool PlayerController::play (const QString &filePath) { stop (); _videoPacketQueue.reset (); _audioPacketQueue.reset (); _videoFrameQueue.reset (); _audioFrameQueue.reset (); _clock->setTime (0.0 ); std::string stdFilePath = filePath.toStdString (); if (!_demuxer.open (stdFilePath.data ())) { std::cerr << "PlayerController: Failed to open file." << std::endl; return false ; } int videoIdx = _demuxer.findVideoStream (); if (videoIdx < 0 ) { std::cerr << "PlayerController: No video stream found." << std::endl; return false ; } int audioIdx = _demuxer.findAudioStream (); AVCodecParameters *videoParams = _demuxer.getVideoStreamCodecParameters (); if (!videoParams || !_videoDecoder.init (videoParams, _demuxer.getVideoStreamTimeBase ())) { std::cerr << "PlayerController: Video decoder init failed." << std::endl; return false ; } _hasAudio = false ; if (audioIdx >= 0 ) { AVCodecParameters *audioParams = _demuxer.getAudioStreamCodecParameters (); if (audioParams && _audioDecoder.init (audioParams, _demuxer.getAudioStreamTimeBase ())) { _hasAudio = true ; } else { std::cerr << "PlayerController: Audio decoder init failed, " "playing video only." << std::endl; } } _videoDecoder.setSeekFlag (&_seekInProgress); _videoDecoder.start (_videoPacketQueue, _videoFrameQueue, [this ]() { QMetaObject::invokeMethod (this , [this ]() { emit videoFrameReady (); }, Qt::QueuedConnection); }); if (_hasAudio) { _audioDecoder.setSeekFlag (&_seekInProgress); _audioDecoder.start (_audioPacketQueue, _audioFrameQueue); } if (_hasAudio) { AVCodecParameters *ap = _demuxer.getAudioStreamCodecParameters (); _audioOutput.setClock (_clock); if (_audioOutput.init (ap->ch_layout, ap->sample_rate)) { _audioOutput.start (_audioFrameQueue); } } _isPlaying = true ; _demuxThread = std::thread (&PlayerController::demuxLoop, this ); emit durationChanged () ; emit isPlayingChanged () ; return true ; }
启动顺序为什么重要? play() 中各组件的启动顺序是从下游到上游 :
1 2 3 4 1. 初始化解码器 → 准备好解码环境 2. 启动解码线程(消费者) → 消费者就位,等待 PacketQueue 中有数据 3. 启动音频输出(消费者) → 消费者就位,等待 FrameQueue 中有数据 4. 启动解复用线程(生产者)→ 最后启动,开始往管道里推数据
如果反过来先启动解复用线程,demuxLoop 会立刻往 PacketQueue 塞数据,但此时解码线程还没启动,包会堆积在队列中。虽然不会导致错误(队列能容纳一定数据),但让消费者先就位、生产者后启动,逻辑上更清晰。
另外还有一个硬性约束:必须先 setClock 再 start AudioOutput 。如果 AudioOutput 的消费线程跑起来时 _clock 还是 nullptr,会直接崩溃。
onFrameReady 回调与跨线程信号 视频解码线程中,每当一帧解码完成并推入 FrameQueue,需要通知 QML 端的 VideoSurface 刷新显示。但解码线程不能直接操作 UI 组件(Qt 的 GUI 操作必须在主线程),所以用 QMetaObject::invokeMethod + Qt::QueuedConnection 把信号发射调度到主线程:
1 2 3 4 _videoDecoder.start (_videoPacketQueue, _videoFrameQueue, [this ]() { QMetaObject::invokeMethod (this , [this ]() { emit videoFrameReady (); }, Qt::QueuedConnection); });
音频解码没有加这个回调,因为音频由 AudioOutput 的消费线程自行驱动,不需要通知 UI。
demuxLoop() —— 流水线的源头这是整个播放循环的起点,运行在独立的解复用线程中。逻辑很直白:不断读包,按 stream_index 分发 。
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 void PlayerController::demuxLoop () { constexpr double kTailProtectionSeconds = 0.7 ; int videoStreamIndex = _demuxer.getVideoStreamIndex (); int audioStreamIndex = _demuxer.getAudioStreamIndex (); if (videoStreamIndex < 0 ) { std::cerr << "PlayerController: No video stream found during demuxing." << std::endl; return ; } while (_isPlaying.load ()) { AVPacketPtr pkt = _demuxer.readPacket (); if (!pkt) { const double videoEndTime = _demuxer.getVideoStreamEndTime (); const double tailProtectStart = videoEndTime - kTailProtectionSeconds; _videoFrameQueue.protectTailFrom (tailProtectStart); _videoPacketQueue.push (AVPacketPtr ()); if (audioStreamIndex >= 0 ) { _audioPacketQueue.push (AVPacketPtr ()); } _isPlaying.store (false ); QMetaObject::invokeMethod (this , [this ]() { emit isPlayingChanged (); }, Qt::QueuedConnection); break ; } if (pkt->stream_index == videoStreamIndex) { if (!_videoPacketQueue.push (std::move (pkt))) { if (_isPlaying.load ()) { std::cerr << "PlayerController: Failed to push packet to queue." << std::endl; } break ; } } else if (pkt->stream_index == audioStreamIndex) { if (!_audioPacketQueue.push (std::move (pkt))) { if (_isPlaying.load ()) { std::cerr << "PlayerController: Failed to push audio " "packet to queue." << std::endl; } break ; } } else { continue ; } } }
核心逻辑拆解 主循环 :while (_isPlaying) 不断调用 _demuxer.readPacket()。这个调用会阻塞直到读到一帧数据,或者被 I/O 中断唤醒(stop/seek 时)。
分发路由 :拿到 packet 后比较 pkt->stream_index:
匹配
操作
== videoStreamIndex
推入 _videoPacketQueue,视频解码线程会从这里取
== audioStreamIndex
推入 _audioPacketQueue,音频解码线程会从这里取
其他
continue,直接丢弃(不关心的流类型,如字幕)
push 是阻塞的——如果 PacketQueue 满了(说明解码端处理不过来),demuxLoop 会挂起等待,形成自然的反压。队列空出来之后自动恢复读取。
文件读完的处理 :readPacket() 返回空指针时,说明文件已到结尾,需要进行三步排空操作:
**protectTailFrom(videoEndTime - 0.7s)**:设置视频帧队列的尾部保护区,告诉渲染端最后 0.7 秒的帧不能丢弃(保证播放到结尾时画面完整,不会因为同步机制跳帧)
推入空 packet :向视频和音频 PacketQueue 各塞入一个空指针。解码线程收到空指针后会进入 draining 模式,将内部缓冲的帧全部吐出来
_isPlaying = false 并通知 UI :播放结束,更新状态
提示 :尾部保护和 draining 机制的细节将在 A/V 同步 文章中展开。
stop() —— 安全关闭流水线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 void PlayerController::stop () { _isPlaying.store (false ); _videoPacketQueue.abort (); _audioPacketQueue.abort (); _videoFrameQueue.abort (); _audioFrameQueue.abort (); _audioOutput.stop (); _audioDecoder.stop (); _videoDecoder.stop (); _demuxer.interrupt (); if (_demuxThread.joinable ()) { _demuxThread.join (); } _demuxer.clearInterrupt (); _videoPacketQueue.clear (); _audioPacketQueue.clear (); _videoFrameQueue.clear (); _audioFrameQueue.clear (); if (_paused.load ()) { _paused.store (false ); emit pausedChanged () ; } emit isPlayingChanged () ; }
为什么要按这个顺序? stop() 要做的事可以概括为:先断输入,再杀消费者,最后清场 。
abort 队列 :如果解码线程正卡在 pop() 上等数据,直接 join 会死锁。先 abort() 让所有阻塞的 pop/push 立即返回,线程才能正常退出循环
停止消费者 :解码器和音频输出各自 stop() 内部会置 _running = false 并 join 自己的线程
中断 demuxer :demuxLoop 可能正卡在 av_read_frame() 上等磁盘 I/O。调用 _demuxer.interrupt() 通过中断回调让它返回,然后 join 解复用线程
清空队列 :线程都退出后再 clear(),安全释放所有残留的 AVPacket 和 AVFrame
提示 :pause() / resume() 的实现很简单——暂停/恢复时钟和音频输出即可,解码和渲染线程会自然地随时钟暂停而停止消费。
小结 PlayerController 本身逻辑不多,它的价值在于把前两篇讲到的 Demuxer 和 Decoder 组装成一个可工作的流水线 。读者可以把 play() 理解为一篇”安装说明书”:
1 stop → reset 队列 → open 文件 → init 解码器 → start 消费者线程 → start 生产者线程
到此为止,从文件读取到解码出 YUV/PCM 数据的完整链路就跑通了。下一篇将进入 A/V 同步 ,讲 Clock 如何协调音频和视频的播放节奏。
这次的插图来自画师DityPretty 图片地址:https://www.pixiv.net/artworks/112850569
本项目源码请看:https://github.com/DongGuZhengHuaJi/VideoPlayer