FFmpeg 9 集成与播放器封装实战:把 ffmpeg / ffplay 变成可嵌入 Qt 的 DLL
最近把项目里的 FFmpeg 从旧版整体升级到了 9.0.2,顺手把 ffmpeg 和 ffplay 两个命令行工具封装成可以被业务代码直接调用的 DLL,最后嵌进一个 Qt 播放器里。这篇文章把集成 → 封装 → 落地三个环节的关键改动和踩坑记录分享出来。
技术栈:Qt 5.15 + SDL2 + FFmpeg 9.0.2(libavcodec 63 / libavformat 63 / libavutil 61)+ MSVC。
一、整体思路
一句话概括:上层 Qt 只认"遥控器"(FFPlayer / FFmpegTask 两个干净接口),不关心 FFmpeg 内部怎么解码。
Qt UI (player.cpp)
│ FFPlayer (ffplayer.h) FFmpegTask (ffmpeg_dll.h)
▼
封装层 (ffplay.dll / ffmpeg.dll)
│ libavcodec / libavformat / libavutil / SDL2
▼
FFmpeg 9.0.2 预编译库
二、集成新版 FFmpeg:踩过的坑
2.1 目录结构大变
FFmpeg 7+ 把原来几个大文件拆成了一堆小模块,还引入了新的 Scheduler 调度器替代老版的手写转码大循环:
fftools/
├── ffmpeg.c / ffmpeg_dec.c / ffmpeg_demux.c / ffmpeg_enc.c
├── ffmpeg_filter.c / ffmpeg_hw.c / ffmpeg_mux.c / ffmpeg_mux_init.c
├── ffmpeg_opt.c / ffmpeg_sched.c ← 新的调度器
├── cmdutils.c / opt_common.c / sync_queue.c / thread_queue.c
└── graph/ textformat/ resources/
2.2 API 变更(重点)
| 旧 API | 新 API | 说明 |
|---|---|---|
int channels + channel_layout |
AVChannelLayout ch_layout |
声道布局结构体化 |
AVFifoBuffer / av_fifo_alloc() |
AVFifo / av_fifo_alloc2() / av_fifo_write() / av_fifo_read() |
FIFO 重写 |
av_rdft_init() |
av_tx_init() / AVTXContext / av_tx_fn |
FFT 统一为 av_tx |
av_init_packet() |
av_packet_alloc() / av_packet_free() / av_packet_move_ref() |
堆上分配 |
2.3 两个隐蔽坑
坑 1:av_err2str 在 C++ 下不能用(用了 C99 复合字面量),要手动替换:
#undef av_err2str
static inline const char* av_err2str_cpp(int err)
{
static char buf[256];
av_strerror(err, buf, sizeof(buf));
return buf;
}
#define av_err2str(err) av_err2str_cpp(err)
坑 2:av_gettime_relative / av_usleep 不再导出,Windows 下会 unresolved external symbol,用 Win32 包一层:
static int av_usleep_wrapper(unsigned usec)
{
HANDLE h = CreateWaitableTimer(NULL, TRUE, NULL);
LARGE_INTEGER li; li.QuadPart = -(int64_t)usec * 10;
SetWaitableTimer(h, &li, 0, NULL, NULL, FALSE);
WaitForSingleObject(h, INFINITE);
CloseHandle(h);
return 0;
}
#define av_usleep av_usleep_wrapper
2.4 编译策略
只编 fftools,不重编整个 libav:libav 系列直接用 gyan.dev 官方预编译库,把 fftools 源码拉进工程编成 DLL:
cl /c /nologo /std:c11 /DFFMPEG_BUILD_DLL fftools\ffmpeg.c
cl /c /nologo /std:c11 fftools\ffmpeg_dec.c
:: ...其余模块逐个编译
link /DLL /out:bin\ffmpeg.dll ffmpeg.obj ... \
avcodec.lib avformat.lib avutil.lib swscale.lib swresample.lib \
avfilter.lib avdevice.lib shell32.lib
三、封装 ffplay:FFPlayer 类
ffplay 天生是命令行程序,直接当库用有三大问题:全局状态(只能一个实例)、SDL 全局事件队列(事件会广播给所有实例)、exit() 直接结束进程。
3.1 全局状态 → 每实例状态
所有文件作用域全局变量塞进 VideoState,由 FFPlayer::Impl 持有:
struct FFPlayer::Impl {
std::string filename;
void *parent_hwnd; // 要嵌入的外部窗口句柄
VideoState *cur_stream; // 每个实例自己的播放状态
SDL_Thread *play_thread;
volatile int running, paused, muted;
FFPlayerCallback callback;
void *cb_userdata;
};
3.2 SDL 全局事件 → 每实例操作队列
这是多实例支持的关键:每个实例一个环形操作队列,替代 SDL 事件系统:
#define OP_QUEUE_SIZE 32
typedef struct PlayOperation {
int op; // PLAY/PAUSE/SEEK/VOICE/MUTE/FULL/STOP
double dval; // SEEK: 秒;VOICE: 音量
} PlayOperation;
typedef struct OperationQueue {
PlayOperation items[OP_QUEUE_SIZE];
int head, tail;
SDL_mutex *mutex;
} OperationQueue;
对外就是一个清爽的 C++ 类:
class FFPlayer {
public:
FFPlayer(const char* filename, void* hwnd, int width, int height);
int play(); void stop(); void pause(); void resume();
void seek(double seconds);
void setVolume(int v); void setMute(bool m); void toggleFullScreen();
void setCallback(FFPlayerCallback cb, void* userdata);
bool isRunning() const; bool isPaused() const;
double getDuration() const; double getPosition() const;
};
构造函数里的
void* hwnd是嵌入关键:内部用SDL_CreateWindowFrom(hwnd)把渲染窗口挂到外部窗口上,而不是自己开新窗口。
四、封装 ffmpeg:FFmpegTask 并发任务
ffmpeg 有一堆进程级全局:g_ctx、选项字典、信号/终端处理,还有 exit_program()。要支持一个进程里并发跑多个转码任务,核心是引入 FFmpegTask 对象隔离状态:
FFMPEG_API FFmpegTask *ffmpeg_task_create(void);
FFMPEG_API void ffmpeg_task_destroy(FFmpegTask **ptask);
FFMPEG_API int ffmpeg_task_run(FFmpegTask *task, int argc, char **argv); // 阻塞
FFMPEG_API void ffmpeg_task_cancel(FFmpegTask *task); // 请求中止
三个关键点:
① 信号/终端隔离:用 cli_mode 区分,库模式绝不碰进程级信号:
ctx->cli_mode = task == NULL; // 只有独立 CLI 才拥有进程级状态
② 选项字典加锁:解析 argv 期间持锁,转码阶段释放,让多个任务并行:
ffmpeg_options_lock(); // 解析期间加锁
if (task) cmdutils_set_argv(argc, argv);
// ...打开输入/输出...
ffmpeg_options_unlock(); // 释放后再转码,可并发
ret = transcode(ctx, sch);
③ 协作式取消:cancel 只设置标志,让 IO/解码循环自己停,绝不杀进程:
FFMPEG_API void ffmpeg_task_cancel(FFmpegTask *task)
{
FFmpegContext *ctx = task->ctx;
if (ctx) ctx->transcode_abort = 1;
}
// 该标志通过 AVIOInterruptCB 一路传到解码循环:
static int decode_interrupt_cb(void *opaque) {
FFmpegContext *ctx = opaque;
return ctx->transcode_abort || (ctx->received_nb_signals > ...);
}
五、集成播放器的主要改动点
这部分业务味最重,核心挑战是:SDL 窗口怎么嵌进 Qt,还不闪、不抢焦点、seek 要跟手。
5.1 渲染窗口嵌入
native child 会在 Qt resize 时和 Windows 抢像素导致闪烁,所以改用独立 owned 顶层窗口 + SDL_CreateWindowFrom,用 SetWindowPos 精确贴在 videoArea 上:
static HWND createVideoHostWindow(HWND owner)
{
// 黑底画刷,SDL 还没画的区域擦成黑色,避免闪烁
return CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
L"MyPlayerVideoHost", L"",
WS_POPUP | WS_THICKFRAME | WS_CLIPCHILDREN | WS_CLIPSIBLINGS,
0, 0, 16, 16, owner, nullptr, GetModuleHandleW(nullptr), nullptr);
}
细节:
WS_THICKFRAME只是为了让 SDL 判定窗口"可缩放",绑定完立刻摘掉,否则客户区会内缩一个边框厚度,和 videoArea 对不齐。
5.2 回调 marshal 回 UI 线程
FFPlayer 的进度回调跑在播放线程,直接更新控件会崩,用 Qt::QueuedConnection 排队回 UI:
static void playerCallback(void* userdata, int state, double position, double duration) {
player* self = static_cast<player*>(userdata);
QMetaObject::invokeMethod(self, [self, state, position, duration]() {
self->onPlayerProgress(state, position, duration);
}, Qt::QueuedConnection);
}
5.3 Seek 管线重构
两个体验问题:
问题 1:快速拖动不跟手 → 永远让最新目标获胜(coalesce),并立刻唤醒读线程:
static void stream_seek(VideoState *is, int64_t pos, int64_t rel, int by_bytes)
{
int pending = is->seek_req;
// ...非字节 seek 时开启帧级精确 seek...
is->seek_flags |= AVSEEK_FLAG_BACKWARD;
is->seek_target_ts = pos;
is->seek_accurate_video = (is->video_stream >= 0);
is->seek_pos = pos; // seek_pos 最后写,读线程先读它,避免新旧错配
is->seek_req = 1;
if (!pending) SDL_CondSignal(is->continue_read_thread);
}
问题 2:seek 后画面停在错误帧 → 解码器丢弃目标时间戳之前的帧:
if (is->seek_accurate_video && !isnan(dpts)) {
double target_sec = (double)is->seek_target_ts / AV_TIME_BASE;
if (dpts < target_sec - 0.01) {
is->frame_drops_early++;
av_frame_unref(frame); // 丢弃,继续解下一帧
got_picture = 0;
} else {
is->seek_accurate_video = 0;
}
}
5.4 播放线程生命周期
之前线程是 detached 的,stop() 返回后线程还活着,回调打空指针(use-after-free)。修复就是 join:
void FFPlayer::stop()
{
m->running = 0;
if (m->cur_stream) {
m->cur_stream->abort_request = 1;
push_operation(&m->cur_stream->opq, FFPLAYER_STOP, 0);
}
if (m->play_thread) {
SDL_WaitThread(m->play_thread, NULL); // join,确保线程已退出
m->play_thread = NULL;
}
}
六、总结
这次从"子进程跑 ffmpeg/ffplay"到"封装成库嵌入 Qt",本质是从进程级到库级的升级。几个最值得记下的点:
- 版本升级核心是改语义,不是改名字:
AVChannelLayout、AVFifo、av_tx这些替换,工作量在理解新语义; - 封装命令行工具 = 消灭全局状态:全局变量 → 每实例状态;SDL 全局事件 → 每实例队列;
g_ctx/信号 →cli_mode+ 选项锁; - 取消要协作式,不要强杀:
transcode_abort+ 中断回调,比杀线程安全; - 线程生命周期要闭环:
stop()必须 join; - SDL 嵌 Qt 用独立 owned 窗口 +
SDL_CreateWindowFrom,稳且不闪。
本文基于实际项目 myPlayer 整理:Qt 5.15 + FFmpeg 9.0.2 + SDL2 + MSVC(Windows)。核心封装见 ffplayer.h/cpp 与 ffmpeg_dll.h/ffmpeg.c。