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",本质是从进程级到库级的升级。几个最值得记下的点:

  1. 版本升级核心是改语义,不是改名字:AVChannelLayout、AVFifo、av_tx 这些替换,工作量在理解新语义;
  2. 封装命令行工具 = 消灭全局状态:全局变量 → 每实例状态;SDL 全局事件 → 每实例队列;g_ctx/信号 → cli_mode + 选项锁;
  3. 取消要协作式,不要强杀:transcode_abort + 中断回调,比杀线程安全;
  4. 线程生命周期要闭环:stop() 必须 join;
  5. SDL 嵌 Qt 用独立 owned 窗口 + SDL_CreateWindowFrom,稳且不闪。

本文基于实际项目 myPlayer 整理:Qt 5.15 + FFmpeg 9.0.2 + SDL2 + MSVC(Windows)。核心封装见 ffplayer.h/cpp 与 ffmpeg_dll.h/ffmpeg.c。