简介:这份资源面向具备一定 C++ 与 Windows 开发基础、希望深入理解播放器底层实现的开发者,核心内容是将 FFmpeg 原生命令行播放器 ffplay 移植到 VC++ 环境,并借助 MFC 框架封装出带图形界面的多媒体播放器。压缩包共 218 个文件,约 12.66MB,以 150 个 h 头文件、15 个 c 与 8 个 cpp 源码为主体,另含 10 个 lib、9 个 dll 运行库、3 个 makefile 与 3 个 asm 汇编文件,以及 bmp、ico、rc 等界面资源,完整覆盖编解码、音频混合、格式转换与 MFC 窗口绘制等模块。已有 303 人学习下载。读者可从中获取 FFmpeg 在 Windows 下的编译配置思路、解码回调与 MFC 事件机制的对接方式、播放暂停与音量控制等 UI 交互实现,以及音视频同步、多线程渲染和错误调试的参考代码,适合作为学习播放器架构与跨平台移植的实践样本。
1. 从 ffplay 到 MFC:把命令行播放器塞进 Windows 窗口里
很多人第一次接触 FFmpeg 播放器,都是从命令行敲下ffplay input.mp4开始的。画面出来了,声音也有了,但问题随之而来:这东西怎么放进自己的 Windows 程序里?尤其是用 MFC 做界面的老项目,客户要的是一个带菜单栏、进度条、能拖拽文件的窗口,而不是一个黑框。标题里的ffplayformfc-code.zip指向的正是这个场景——把 ffplay 的解码播放能力,移植到 MFC 的窗口框架中,用 VC 编译出一套可复用的播放器骨架。
这件事的价值在于:你不需要从零手写 DirectShow 滤镜,也不用啃 VLC 的插件体系,而是直接复用 FFmpeg 的解码、同步、渲染逻辑,只把输出目标从 SDL 窗口换成 MFC 的控件句柄。适合谁?适合手里有 MFC 遗留项目、需要嵌入视频播放能力的 C++ 开发者,以及想理解“播放器到底怎么把帧刷到屏幕上”的中间层工程师。接下来的内容,我会按“先跑通最小链路,再处理同步和消息循环,最后填坑”的顺序展开,每一步都给出可抄的代码结构和参数说明。
2. 拆解 ffplay 的播放链路:哪些能直接搬,哪些必须换
2.1 ffplay 的四个核心模块与 MFC 的对应关系
ffplay 的源码结构其实很清晰,核心就四块:解复用与解码(demuxer+decoder)、音视频同步(sync)、渲染输出(video/image+audio)、事件循环(event_loop)。在命令行版本里,渲染输出直接调 SDL 的SDL_RenderCopy,事件循环用 SDL 的SDL_WaitEvent。搬到 MFC 里,这两块必须换掉,其余部分可以大量复用。
具体来说,解复用和解码部分几乎原封不动:avformat_open_input、av_read_frame、avcodec_send_packet、avcodec_receive_frame这套流程跟平台无关。同步逻辑里的get_clock、compute_target_delay也能直接抄,只要把SDL_GetTicks换成GetTickCount64或timeGetTime。真正要重写的是渲染:视频帧不能再往 SDL 纹理上刷,而要转成 MFC 能画的位图;音频输出不能再走 SDL Audio,要换成waveOut或者 DirectSound。
我一般会先把 ffplay 的video_thread和audio_thread单独拎出来,确认它们只依赖VideoState结构体和几个全局时钟,然后把VideoState里的SDL_Window、SDL_Renderer、SDL_Texture替换成 MFC 的HWND、CDC和CBitmap。这一步做完,编译能过,但画面大概率是黑的或者花的,因为像素格式和步长对不上。
2.2 用 MFC 的 CWnd 接管视频输出:最小可跑代码
下面这段代码展示的是视频帧渲染的核心替换逻辑。假设你已经把 ffplay 的VideoState改成了CPlayerState,并且把SDL_Texture换成了CBitmap和CDC。
// 在 MFC 的播放器窗口类中,比如 CMyPlayerWnd // 成员变量:CPlayerState* m_pState; CBitmap m_bmp; CDC m_memDC; // 假设解码线程已经拿到 AVFrame* pFrame,格式为 YUV420P void CMyPlayerWnd::RenderFrame(AVFrame* pFrame) { // 1. 准备 SwsContext,只创建一次,缓存在 m_pState 里 if (!m_pState->sws_ctx) { m_pState->sws_ctx = sws_getContext( pFrame->width, pFrame->height, (AVPixelFormat)pFrame->format, pFrame->width, pFrame->height, AV_PIX_FMT_BGRA, // MFC 兼容 BGRA SWS_BILINEAR, nullptr, nullptr, nullptr); } // 2. 分配目标缓冲区,大小 = 宽 * 高 * 4 int dstStride = pFrame->width * 4; uint8_t* dstData[4] = { nullptr }; dstData[0] = new uint8_t[dstStride * pFrame->height]; // 3. 转换像素格式 sws_scale(m_pState->sws_ctx, pFrame->data, pFrame->linesize, 0, pFrame->height, dstData, &dstStride); // 4. 创建 DIB 位图并拷贝数据 BITMAPINFO bmi = { 0 }; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = pFrame->width; bmi.bmiHeader.biHeight = -pFrame->height; // 负号表示自上而下 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; CDC* pDC = GetDC(); if (m_memDC.GetSafeHdc() == nullptr) { m_memDC.CreateCompatibleDC(pDC); } if (m_bmp.GetSafeHandle() == nullptr) { m_bmp.CreateCompatibleBitmap(pDC, pFrame->width, pFrame->height); } m_memDC.SelectObject(&m_bmp); SetDIBitsToDevice(m_memDC.GetSafeHdc(), 0, 0, pFrame->width, pFrame->height, 0, 0, 0, pFrame->height, dstData[0], &bmi, DIB_RGB_COLORS); // 5. 贴到窗口上 pDC->BitBlt(0, 0, pFrame->width, pFrame->height, &m_memDC, 0, 0, SRCCOPY); ReleaseDC(pDC); delete[] dstData[0]; }这段代码的逻辑说明:sws_scale负责把解码出来的 YUV 数据转成 BGRA,因为 MFC 的SetDIBitsToDevice对 32 位 BGRA 支持最稳。biHeight设为负数是关键,否则图像会上下颠倒。m_memDC和m_bmp要作为窗口类的成员缓存起来,不要每帧创建销毁,否则播放 1080p 时 CPU 会飙到 100%。参数上,SWS_BILINEAR在缩放时够用,如果窗口大小固定且等于视频尺寸,可以改成SWS_POINT省一点 CPU。
2.3 音频输出从 SDL 换成 waveOut 的步骤
音频这块,ffplay 用的是 SDL_OpenAudioDevice,回调里把 PCM 数据喂给声卡。MFC 环境下,最省事的替代是 Windows 自带的waveOut系列 API。步骤不复杂,但有几个参数必须对齐。
第一步,在CPlayerState里定义WAVEFORMATEX,采样率、声道数、位深从AVCodecContext里取。常见做法是统一转成 16 位 PCM,因为waveOut对浮点格式支持不好。
// 初始化 waveOut WAVEFORMATEX wfx = { 0 }; wfx.wFormatTag = WAVE_FORMAT_PCM; wfx.nChannels = pCodecCtx->channels; wfx.nSamplesPerSec = pCodecCtx->sample_rate; wfx.wBitsPerSample = 16; wfx.nBlockAlign = wfx.nChannels * wfx.wBitsPerSample / 8; wfx.nAvgBytesPerSec = wfx.nSamplesPerSec * wfx.nBlockAlign; HWAVEOUT hWaveOut; waveOutOpen(&hWaveOut, WAVE_MAPPER, &wfx, (DWORD_PTR)AudioCallback, (DWORD_PTR)this, CALLBACK_FUNCTION);第二步,准备WAVEHDR缓冲区。waveOut是异步的,你需要至少两个缓冲区轮换,否则会断音。每个缓冲区的大小建议取nAvgBytesPerSec / 10,也就是 100 毫秒的数据量。回调函数里收到WOM_DONE消息后,把用完的WAVEHDR重新填数据再waveOutWrite回去。
第三步,重采样。如果解码出来的音频不是 16 位或者采样率跟waveOut不匹配,需要用swr_convert做转换。这一步跟视频的sws_scale类似,创建SwrContext时指定输出格式为AV_SAMPLE_FMT_S16,输出采样率跟wfx.nSamplesPerSec一致。
注意:
waveOut的缓冲区不要开太小,低于 50 毫秒在低端声卡上容易爆音;也不要开太大,超过 500 毫秒会导致暂停响应迟钝。
3. 消息循环与线程同步:MFC 和 ffplay 最大的冲突点
3.1 为什么不能直接在解码线程里调 MFC 的绘图函数
ffplay 的架构是 SDL 事件循环跑在主线程,解码跑在子线程,子线程通过SDL_PushEvent通知主线程刷新。MFC 这边,所有窗口绘制必须在主线程(UI 线程)完成,子线程直接调CDC::BitBlt或者SetDIBitsToDevice会随机崩溃或者画面撕裂。血泪经验:我见过有人在解码线程里直接GetDC然后画,单核机器上看着没事,一到多核就翻车,因为 MFC 的HWND和CDC不是线程安全的。
正确的做法是:解码线程只负责把AVFrame转成 RGB 数据,存到一个线程安全的队列里,然后PostMessage给主窗口一个自定义消息,比如WM_USER + 100。主窗口的消息处理函数里从队列取最新的一帧,调RenderFrame画上去。队列要限制长度,比如最多缓存 3 帧,满了就丢最旧的,否则解码快了会吃光内存。
3.2 用 PostMessage 做帧同步的代码模板
下面是一个简化的帧队列和消息通知实现。假设CPlayerState里有一个std::deque<FrameData>和一把CRITICAL_SECTION。
// 解码线程中,拿到 RGB 数据后 void CPlayerState::PushFrame(uint8_t* data, int width, int height, int stride) { EnterCriticalSection(&m_cs); // 队列超过 3 帧,丢最旧的 while (m_frameQueue.size() >= 3) { FrameData& old = m_frameQueue.front(); delete[] old.data; m_frameQueue.pop_front(); } FrameData fd; fd.data = new uint8_t[stride * height]; memcpy(fd.data, data, stride * height); fd.width = width; fd.height = height; fd.stride = stride; m_frameQueue.push_back(fd); LeaveCriticalSection(&m_cs); // 通知 UI 线程 ::PostMessage(m_hWnd, WM_PLAYER_FRAME_READY, 0, 0); } // 主窗口消息处理 LRESULT CMyPlayerWnd::OnFrameReady(WPARAM, LPARAM) { EnterCriticalSection(&m_pState->m_cs); if (!m_pState->m_frameQueue.empty()) { FrameData& fd = m_pState->m_frameQueue.front(); // 这里调 RenderFrame 或直接画 DrawFrameData(fd); delete[] fd.data; m_pState->m_frameQueue.pop_front(); } LeaveCriticalSection(&m_pState->m_cs); return 0; }逻辑说明:PostMessage是异步的,不会阻塞解码线程,适合高频调用。WM_PLAYER_FRAME_READY要定义在WM_USER以上,避免和系统消息冲突。队列长度设为 3 是经验值:太小容易卡顿,太大延迟明显。参数上,stride就是width * 4,但如果你用了SetDIBitsToDevice,其实可以直接传data和BITMAPINFO,不需要单独存stride。
3.3 音视频同步的时钟怎么接
ffplay 的同步策略是:视频时钟向音频时钟对齐,音频时钟是主时钟。搬到 MFC 后,音频时钟的来源要从 SDL 的SDL_GetTicks换成waveOutGetPosition。waveOutGetPosition返回的是已经播放的字节数,除以nAvgBytesPerSec就是秒数。视频线程每解码一帧,算一下当前视频时钟和音频时钟的差值,如果视频快了就Sleep几毫秒,慢了就丢帧。
具体实现时,在CPlayerState里维护audio_clock和video_clock两个double。音频回调里更新audio_clock,视频线程里用av_gettime_relative算当前帧的 PTS,然后调compute_target_delay。这个函数直接从 ffplay 抄,不用改。
注意:
waveOutGetPosition在有些虚拟声卡上返回不准,如果发现同步总是漂,可以改用timeGetTime自己算,但精度会差一点。
4. 避坑与排查:MFC 播放器最容易翻车的五个地方
4.1 画面花屏或颜色错乱
现象:视频能播,但颜色是反的,或者一片花绿。原因通常是像素格式没转对,或者BITMAPINFOHEADER的biHeight符号搞反了。解决:确认sws_scale的输出格式是AV_PIX_FMT_BGRA,biBitCount设为 32,biHeight用负数。如果颜色还是不对,检查sws_scale的dstStride是不是等于width * 4,有时候对齐问题会导致错位。
4.2 播放几秒后卡死
现象:刚开始正常,几秒后界面无响应。原因多半是解码线程和 UI 线程抢锁,或者帧队列没有限制长度导致内存暴涨。解决:用CRITICAL_SECTION而不是CMutex,后者在 MFC 里开销大。帧队列严格限制在 3 帧以内,每帧用完立即delete[]。另外检查PostMessage是不是发得太频繁,可以在解码线程里加一个Sleep(1)降频。
4.3 音频爆音或断断续续
现象:声音有噼啪声,或者每隔几秒断一下。原因通常是waveOut的缓冲区太小,或者重采样没做对。解决:把WAVEHDR的缓冲区大小调到 100 毫秒以上,并且至少准备两个缓冲区轮换。如果解码出来的音频是浮点格式,必须用swr_convert转成 16 位整数,不能直接喂给waveOut。
4.4 窗口拉伸后视频变形
现象:拖动窗口边框改变大小,视频被拉扁或拉长。原因是没有保持宽高比。解决:在OnSize里算一下视频原始宽高比,然后调整BitBlt的目标矩形,让它在窗口内居中显示,多余部分填黑。不要直接StretchBlt到整个客户区。
4.5 编译时报 SDL 相关符号找不到
现象:从 ffplay 抄代码时,一堆SDL_开头的函数报未定义。原因是你没把 SDL 的依赖去掉。解决:全局搜索SDL_,把SDL_Delay换成Sleep,SDL_GetTicks换成GetTickCount64,SDL_LockMutex换成EnterCriticalSection。渲染和音频相关的 SDL 调用全部删掉,用前面章节的 MFC 替代方案。
5. 进阶技巧:用双缓冲和硬件加速把 1080p 播放压到 5% CPU
5.1 双缓冲绘图消除闪烁
MFC 默认的OnPaint直接画会闪,尤其是视频这种高频刷新场景。标准做法是双缓冲:在内存 DC 里画好一整帧,再一次性BitBlt到屏幕 DC。前面RenderFrame里已经用了m_memDC,但那只缓冲了视频帧本身。如果窗口上还有进度条、时间文字,要把它们也画到同一个内存 DC 里,最后统一贴出去。这样能彻底消除闪烁,代价是多占一块内存位图,1080p 下大约 8MB,可以接受。
5.2 用 D3D9 或 D3D11 做硬件加速渲染
SetDIBitsToDevice是纯 CPU 拷贝,1080p 60 帧下 CPU 占用大概 15% 到 20%。如果想再降,可以把渲染后端换成 Direct3D。FFmpeg 解码出来的AVFrame如果带AV_PIX_FMT_D3D11或者AV_PIX_FMT_DXVA2,可以直接映射到 D3D 纹理,然后全屏画一个四边形。这样 CPU 只负责解码,渲染交给 GPU,1080p 能压到 5% 以下。代价是代码复杂度上升,要处理设备丢失、纹理格式转换等问题。我一般建议先用双缓冲方案跑通,确认功能没问题,再考虑换 D3D。
5.3 一个验证同步精度的具体方法
想知道你的音视频同步到底准不准,可以做一个简单的测试:播放一段带秒表画面的视频,同时用手机慢动作拍屏幕和扬声器。如果画面上的秒表和声音里的滴答声偏差在 50 毫秒以内,人眼基本看不出来。更工程化的做法是在代码里打日志,每秒钟记录一次audio_clock和video_clock的差值,跑十分钟看最大偏差。如果偏差超过 100 毫秒,检查compute_target_delay里的阈值是不是设得太宽松,ffplay 默认是 0.01 秒,可以调到 0.005。
5.4 我踩过的最大的一个坑
早期做这个移植的时候,我图省事,直接在解码线程里调InvalidateRect让窗口重绘,然后在OnPaint里从解码器拿最新帧。单线程测试没问题,一上多核就随机崩溃,调试了整整两天才发现是AVFrame被解码线程释放了,UI 线程还在读。后来改成帧队列加PostMessage,问题消失。这个教训让我养成了一个习惯:只要涉及多线程和界面,数据所有权必须明确,谁分配谁释放,跨线程只传拷贝或者引用计数。希望帮到你。
本文还有配套的精品资源,点击获取