ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FFmpeg API实战:采集摄像头与麦克风并封装为MP4

FFmpeg API实战:采集摄像头与麦克风并封装为MP4

简介:这是一份基于FFmpeg API采集摄像头视频和麦克风音频的完整工程资料,面向熟悉C++并希望简化设备采集流程的开发者。包内共162个文件,约22.75MB,以.h头文件、.cpp源文件为主,附带.lib/.dll库文件、编译中间文件与可执行程序,属于可打开、可二次开发的VC++项目。作者依次讲解ffmpeg.exe命令行枚举DShow设备并采集摄像头图像、自研程序的功能与用法,以及采集、编码、封装、录制各模块的实现细节。阅读后可掌握FFmpeg+DShow设备枚举、音视频采集管线搭建、编码器配置与封装输出的完整思路,降低对DirectShow框架细节的依赖。该资源已有1304人学习,适合正在研究FFmpeg设备采集、希望获得可运行示例的开发者参考。

1. 用FFmpeg API采集摄像头和麦克风:这个Demo解决的三个痛点

某开发者在做一套巡检记录软件时,需要在程序内部录制“摄像头画面+现场声音”,录成一段视频交给后端分析。第一反应是调用 FFmpeg 命令行子进程,结果设备名对不上、出错日志看不到、音画不同步也没法在代码里补救。这个项目直接使用 FFmpeg 的 C API,把采集能力拆开自己控制:打开摄像头和麦克风、读取 AVPacket、编码成 H.264 和 AAC、封装成 MP4。它不是又一个命令行的包装脚本,而是可以嵌入到现有程序里的采集模块,适合有 C/C++ 基础、想把音视频采集真正写进自己应用的开发者。接下来按照实际开发顺序,从工程结构、核心实现、编译运行到常见坑位,把整个链路讲清楚。

2. 项目结构与技术选型:FFmpeg API 不是命令行

2.1 工程目录与模块划分

这个 Demo 的目录结构不复杂,但每一层的职责必须清晰。拿到源码后第一件事不是看代码,而是看文件归属,确定哪里是入口、哪里是 FFmpeg 调用集中地。

ffmpeg_capture_demo/ ├── CMakeLists.txt ├── src/ │ ├── main.c // 解析参数、初始化上下文、启动采集与编码线程 │ ├── capture.c // 打开设备、读取数据、编码写文件的实现 │ ├── capture.h // 数据结构与接口声明 │ └── util.c // 错误码转字符串、时间基换算等辅助函数 └── README.md

main.c 里只做三件事:解析命令行参数、初始化 CaptureContext、拉起采集线程和编码线程。capture.c 是 FFmpeg 调用的集中地,后面要讲的核心链路基本都在这里。util.c 放两个辅助函数,一个是把 AVERROR 转成可读字符串,另一个是把AVRational转成浮点数方便打印。

这样拆分之后,后续加功能时入口不用大改。比如,想把输出从本地文件改成推流,只需要替换 capture.c 里创建输出上下文和写包的函数,main.c 几乎不动。这是比较常规的 FFmpeg 二次开发工程组织方式。

2.2 技术选型:为什么用 libavdevice 而不是摄像头 SDK

这个项目没有用 Windows 的 DirectShow API、Linux 的 V4L2 原生接口,而是全部通过 FFmpeg 的 libavdevice 模块访问设备。原因很直接:FFmpeg 把摄像头和麦克风抽象成“输入格式”,调用方式与打开一个媒体文件几乎一样,而且天然支持跨平台。

初始化代码非常固定,一般放在程序启动阶段:

#include <libavdevice/avdevice.h> #include <libavformat/avformat.h> // 注册所有设备输入格式,必须在打开设备前调用 avdevice_register_all(); avformat_network_init();

avdevice_register_all 的作用是把所有平台相关的设备输入模块注册到 FFmpeg 框架里,没有这一步,后面的 avformat_open_input 找不到 dshow。avformat_network_init 为网络流预留,本地录制不是必须,但加上不会有什么副作用。

真正打开设备前还要找到对应的 AVInputFormat。Windows 平台用的是 dshow:

AVInputFormat *input_fmt = av_find_input_format("dshow"); if (!input_fmt) { fprintf(stderr, "dshow not found, check ffmpeg build config\n"); return -1; }

av_find_input_format 返回一个“输入格式句柄”,后续打开设备时要传给它。如果以后要切到 Linux,把字符串换成 v4l2,macOS 则用 avfoundation,设备 URL 格式也要跟着变,但整体逻辑完全一致。这就是技术选型的收益:一套代码结构覆盖三个平台,具体差异被 FFmpeg 的 AVIO 层挡住。

2.3 核心数据结构:流与 AVPacket

要理解这个项目,必须习惯 FFmpeg 的“流”模型。一个摄像头是一个输入上下文 AVFormatContext,一个麦克风是另一个输入上下文。调用 avformat_find_stream_info 之后,可以从 ctx->streams 里拿到视频流或音频流的信息,每个流有自己的 index 和 codecpar。

常用工具函数是查找流索引,这个函数在很多 FFmpeg 项目里都会出现:

static int find_stream_index(AVFormatContext *ctx, enum AVMediaType type) { for (unsigned i = 0; i < ctx->nb_streams; ++i) { if (ctx->streams[i]->codecpar->codec_type == type) { return i; } } return -1; }

返回的 index 用于后续判断 av_read_frame 读出来的数据到底属于视频还是音频。codecpar 是 AVCodecParameters,保存了编码格式、分辨率、采样率、帧率这些关键参数,后面创建编码器时要直接从里面拷贝信息。

容易混淆的一点:这里的 AVFormatContext 是输入容器,不是输出容器。摄像头和普通文件的最大区别是它没有明确终点,所以 av_read_frame 会一直阻塞,直到新数据到来。正因如此,读取循环所在的线程不能做其他耗时操作,否则设备内部缓冲会被占满,表现就是掉帧或卡顿。

3. 核心模块实现:从打开设备到写入 MP4 的完整链路

3.1 打开设备并完成初始化

整个项目的数据载体是一个 CaptureContext,它同时持有视频和音频两个输入上下文:

typedef struct CaptureContext { AVFormatContext *video_ctx; // 摄像头输入上下文 AVFormatContext *audio_ctx; // 麦克风输入上下文 int video_stream_idx; // 视频流索引 int audio_stream_idx; // 音频流索引 Queue *video_queue; // 视频包队列 Queue *audio_queue; // 音频包队列 int stop; // 停止标志 } CaptureContext;

打开摄像头的核心代码放在 open_capture 里:

int open_capture(CaptureContext *cap, const char *video_dev, const char *audio_dev, int width, int height, int fps) { AVInputFormat *video_fmt = av_find_input_format("dshow"); AVDictionary *opts = NULL; // 设置采集分辨率、帧率,这些是输入层参数 char video_size[64]; snprintf(video_size, sizeof(video_size), "%dx%d", width, height); av_dict_set(&opts, "video_size", video_size, 0); char frame_rate[16]; snprintf(frame_rate, sizeof(frame_rate), "%d", fps); av_dict_set(&opts, "framerate", frame_rate, 0); // rtbufsize 设置 DirectShow 的实时缓冲大小,默认值小时容易掉帧 av_dict_set(&opts, "rtbufsize", "1024000", 0); // DirectShow 的 URL 规则是 video=设备名,设备名必须与系统列表一致 char url[128]; snprintf(url, sizeof(url), "video=%s", video_dev); int ret = avformat_open_input(&cap->video_ctx, url, video_fmt, &opts); if (ret < 0) { fprintf(stderr, "video open failed: %s\n", av_err2str(ret)); return ret; } ret = avformat_find_stream_info(cap->video_ctx, NULL); if (ret < 0) return ret; cap->video_stream_idx = find_stream_index(cap->video_ctx, AVMEDIA_TYPE_VIDEO); // 打开发完摄像头后稍作等待,再打开麦克风,避免两个设备抢占 av_usleep(200000); // 音频打开逻辑类似,URL 前缀改成 audio=,输入格式仍然用 dshow // cap->audio_ctx = ... }

这里重点说三个参数。video_size 和 framerate 会直接影响 dshow 内部输出,如果设备不支持设置的分辨率或者帧率,打开时会直接报错。rtbufsize 是 DirectShow 的实时缓冲大小,单位是字节。默认值在部分机器上偏小,采集线程稍一卡顿就丢包,表现是画面突然卡住一会儿又恢复。1MB 是一个比较通用的起点值。

音频设备打开顺序有讲究。先打开视频,等待 200ms 再打开音频,可以避免部分机器上两个设备同时初始化时互相抢占资源导致的后打开者失败。这是我拆过类似项目后总结的经验,放在初始化阶段很有效。

3.2 用两个线程循环读取数据包

摄像头和麦克风是两个独立的输入源,所以必须分两个线程读取。单线程顺序读两个上下文是不现实的,因为 av_read_frame 是阻塞的,视频没有新帧时,音频永远读不到,音频延迟会越积越多,最终音画不同步。

视频读取线程的代码:

void *video_read_thread(void *arg) { CaptureContext *cap = (CaptureContext *)arg; AVPacket *pkt = av_packet_alloc(); while (!cap->stop) { int ret = av_read_frame(cap->video_ctx, pkt); if (ret < 0) { break; // 设备断开时 av_read_frame 返回错误 } if (pkt->stream_index == cap->video_stream_idx) { queue_push(cap->video_queue, pkt); } av_packet_unref(pkt); } av_packet_free(&pkt); return NULL; }

av_packet_alloc 会分配一个引用计数管理的 AVPacket,av_read_frame 成功后会填充 pkt 的数据和时间戳。注意 push 到队列之后要立即 av_packet_unref,否则 packet 内部引用计数一直不释放,内存只增不减。队列可以用互斥锁加条件变量实现,容量建议至少能装几十个包,足够把采集和生产解耦。

音频读取线程结构完全相同,区别是使用 cap->audio_ctx。两个线程之间不需要额外同步,因为后面写文件时,封装器会根据包里的时间戳排序。读取线程只要保证各自的包按原始顺序入队,就满足要求。

3.3 编码与封装:把原始数据变成 MP4

采集到的数据包属于“已编码”或“半编码”状态,摄像头 dshow 出来的通常是 MJPEG 或 YUYV,麦克风出来的是 PCM。这些格式不能直接进 MP4,需要先解码成原始帧,再编码成 H.264 和 AAC。这里以视频编码为例。

创建编码器上下文:

AVCodecContext *create_video_encoder(int width, int height, int fps) { const AVCodec *codec = avcodec_find_encoder_by_name("libx264"); if (!codec) return NULL; AVCodecContext *ctx = avcodec_alloc_context3(codec); ctx->width = width; ctx->height = height; ctx->time_base = (AVRational){1, fps}; ctx->framerate = (AVRational){fps, 1}; ctx->pix_fmt = AV_PIX_FMT_YUV420P; ctx->bit_rate = 2000000; // 2Mbps if (avcodec_open2(ctx, codec, NULL) < 0) { avcodec_free_context(&ctx); return NULL; } return ctx; }

libx264 只支持 YUV420P 像素格式,而 dshow 输出的原始帧通常是 MJPEG 流,解码后可能是 YUYV。所以中间必须做一次像素格式转换,这里用 libswscale:

struct SwsContext *sws = sws_getContext( src_width, src_height, src_pix_fmt, width, height, AV_PIX_FMT_YUV420P, SWS_BILINEAR, NULL, NULL, NULL); // 每帧转换 uint8_t *dst_data[4]; int dst_linesize[4]; av_image_alloc(dst_data, dst_linesize, width, height, AV_PIX_FMT_YUV420P, 32); sws_scale(sws, src_frame->data, src_frame->linesize, 0, src_height, dst_data, dst_linesize);

音频侧类似,AAC 编码器一般要求 FLTP 浮点格式、48000Hz、双声道,而 dshow 采集出来的 PCM 通常是 S16 格式。如果参数不匹配,需要用 libswresample 的 swr_convert 做一次重采样。这个步骤省略的话,avcodec_send_frame 会返回参数错误。

输出文件的创建和写入可以放在同一个函数里:

AVFormatContext *out_ctx = NULL; avformat_alloc_output_context2(&out_ctx, NULL, NULL, "output.mp4"); AVStream *out_video = avformat_new_stream(out_ctx, NULL); // 从编码器上下文拷贝参数到输出流 avcodec_parameters_from_context(out_video->codecpar, video_enc); // 音频流同样处理 avformat_write_header(out_ctx, NULL);

主循环不断从队列里取帧,交给编码器:

while (!cap->stop) { // 从视频队列取解码后的视频帧,从音频队列取音频帧 // 这里为了展示核心调用,省略了队列细节 AVFrame *frame = queue_pop(video_queue); avcodec_send_frame(video_enc, frame); AVPacket *enc_pkt = av_packet_alloc(); while (avcodec_receive_packet(video_enc, enc_pkt) == 0) { av_packet_rescale_ts(enc_pkt, video_enc->time_base, out_video->time_base); av_interleaved_write_frame(out_ctx, enc_pkt); av_packet_unref(enc_pkt); } av_frame_free(&frame); } av_write_trailer(out_ctx);

这里有两个很容易忽略的点。av_packet_rescale_ts 负责把编码器输出包的时间戳从编码器时间基换成输出流时间基,不做这一步,写进 MP4 的播放时长会全部乱掉。av_interleaved_write_frame 内部会按 dts 排序后交错写入,所以不要求视频和音频包严格按时间顺序送入,只要时间戳正确,封装器就能处理。

4. 编译运行与验证:让 Demo 跑起来

4.1 用 CMake 组织 FFmpeg 依赖

项目使用 CMake 构建,依赖 FFmpeg 的动态库。CMakeLists.txt 里最关键的片段是 pkg-config 的封装:

cmake_minimum_required(VERSION 3.16) project(ffmpeg_capture_demo) find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavdevice libavutil libswscale libswresample ) add_executable(capture_demo src/main.c src/capture.c src/util.c ) target_link_libraries(capture_demo PkgConfig::FFMPEG)

pkg_check_modules 会负责把 FFmpeg 各库的头文件路径和链接路径带进来,不需要手动写 include 和 lib 路径。如果编译时找不到库,优先排查 pkg-config 的 PKG_CONFIG_PATH 是否指向 FFmpeg 安装目录。

不用 CMake 也可以,直接命令行编译:

gcc -o capture_demo src/*.c \ $(pkg-config --cflags --libs libavformat libavcodec libavdevice libavutil libswscale libswresample)

注意库的顺序,一般把 avdevice 放在前面不会吃亏,因为链接器按顺序解析符号。Windows 下如果用 MinGW,需要确保 FFmpeg 的 bin 目录里有对应的 dll,运行时也要在 PATH 里能找到。

4.2 运行参数与设备枚举

编译成功后,程序的使用方式设计成命令行参数式:

./capture_demo --video "USB Camera" --audio "麦克风阵列" --out record.mp4 \ --width 1280 --height 720 --fps 30

参数含义如下:

参数含义示例
--video摄像头设备名USB Camera
--audio麦克风设备名麦克风阵列
--out输出文件路径record.mp4
--width / --height采集分辨率1280x720
--fps采集帧率30

设备名必须与系统设备列表完全一致。在 Windows 上,可以直接用 FFmpeg 命令行枚举 dshow 设备,这一步只做查询,不影响本项目:

ffmpeg -list_devices true -f dshow -i dummy

输出的列表中,视频设备名形如 “USB Camera”,音频设备名形如 “麦克风阵列”。如果设备名里有空格,在命令行参数层面不需要额外转义,程序内部拼 URL 时会自动处理,但设备名字符串必须原样传给 --video 或 --audio。

4.3 用 ffprobe 验证录制结果

采集结束后,用 ffprobe 检查文件是最直观的验证方式:

ffprobe -show_streams -show_format record.mp4

正常输出里能看到两条流:视频流是 h264,音频流是 aac,且两者的时长接近。重点看视频流和音频流的 nb_frames 是否与采集时长匹配。某开发者在普通 Windows 开发机上用 640x480、30fps 跑通后,录了 30 秒的视频文件,ffprobe 显示的输入时长是 00:00:30.03,视频帧率 30fps,音频采样率 48000Hz,播放时音画同步正常。

如果文件里只有一条流,先检查设备枚举阶段是不是只成功打开了一个设备。如果时长相差明显,按第 5 章排查时间戳问题。

5. 常见问题排查:设备采集最头疼的几个坑

5.1 设备打开失败与设备占用

现象:avformat_open_input 返回负数,错误信息包含 “No such device” 或者 “Could not open video capture device”。

原因:最常见的是设备名拼写不一致。DirectShow 的 URL 严格匹配系统设备列表里的名称,哪怕少一个空格都会失败。另一种情况是系统里有多个同型号摄像头,程序硬编码的设备名指向了不存在的那一个。

解决:先运行ffmpeg -list_devices true -f dshow -i dummy,把当前机器上所有设备名原样复制出来,再传给程序。如果设备列表能看到设备但打开失败,检查是否有杀毒软件或视频会议软件占用了摄像头,临时关闭后再试。Linux 平台 v4l2 下不是名称问题,多半是 /dev/video0 权限不足,把当前用户加入 video 组即可。

现象:视频打开成功,音频打开失败或者打开后采到极低音量。

原因:有些麦克风支持独占模式,被其它应用占用后,第二个程序无法打开。dshow 对音频设备的抢占策略比较严格。

解决:在 Windows 声音设置里关闭“独占模式”,或者先确保调用环境的其它音频程序退干净。这里还要注意,音频打开顺序和视频打开顺序要保持一致平台经验,不是所有机器都一样,我习惯先视频后音频,中间短延时,成功率更高。

5.2 时间戳与音画不同步

现象:录出来的 MP4 播放时声音正常,画面逐渐变慢或者反过来,时间越长偏差越大。

原因:采集包的初始时间戳不是从 0 开始的,视频和音频的起始 pts 不同。直接把原始 pts 写进输出时,封装器会按照各自流的时间轴计算,导致两轨起点不对齐。

解决:在编码前对每个包做时间戳归一化。一种简单做法是记录每个流第一个包的 pts,作为该流的 offset,后续所有包都减去 offset,再做 rescale:

// 视频流第一个包来的时候记录 offset if (video_pts_offset == AV_NOPTS_VALUE) { video_pts_offset = pkt->pts; } pkt->pts -= video_pts_offset; pkt->dts -= video_pts_offset; av_packet_rescale_ts(pkt, input_stream_time_base, output_stream_time_base);

这个逻辑对音频包同样适用。做完之后,两轨起点都接近 0,音画同步问题基本消失。

现象:视频正常,音频听起来音调变高或变低,但播放速度正确。

原因:这里涉及采样率不匹配。dshow 麦克风输出 44100Hz,而编码器被硬设成 48000Hz,没有经过重采样。没有转换时,编码器可能拒绝工作,或者采样率设置被悄悄忽略,导致播放时长和音调错乱。

解决:先通过 codecpar->sample_rate 拿到采集的实际采样率,再用 swr_convert 统一重采样到编码器要求的数值。不要直接修 codecpar,必须真正做一次音频转换。

5.3 编码参数错误与 CPU 占用偏高

现象:avcodec_send_frame 返回 “invalid argument”,程序没有输出任何文件。

原因:多数情况下是像素格式不匹配。dshow 采集到的格式是 MJPEG 时,解码后可能是 YUV422 或 YUV420,而 libx264 只认 YUV420P。直接拿 YUYV 帧塞给 libx264,编码器会直接拒绝。

解决:在编码前用 sws_scale 转成 AV_PIX_FMT_YUV420P。这个转换已经是项目默认流程,排查时先确认转换后的 frame 的 format 字段确实是 YUV420P。

现象:程序运行正常,但 CPU 占用瞬间接近 100%,画面开始卡顿。

原因:采集帧率没有设置,dshow 某些摄像头会以最大帧率输出,可能是 60fps 甚至更高;同时 x264 默认 preset 是 medium,在 1080p 下软编压力很大。如果分辨率又高,CPU 自然跑满。

解决:在 av_dict_set 里强制设置 framerate,先用 640x480x30 跑通流程,后续再提升分辨率。编码器设置里把 preset 改为 ultrafast 或 veryfast,可以大幅降低 CPU 开销,代价是压缩率低一点,测试阶段完全可行。另一个常用优化是限制输出码率,bit_rate 设低一点对 CPU 也有帮助。

现象:程序退出时线程卡住无法结束。

原因:读取线程阻塞在 av_read_frame 上,主线程把 stop 置位后,读取线程不会立刻感知。

解决:在 stop 之后不要直接 join,而是先调用 avformat_close_input 或给设备发送一个中断信号,让阻塞的 av_read_frame 返回错误。简单方案是退出没有任何强制超时,但开发阶段可以借助 pthread_cond_timedwait 控制退出等待时间。这个坑在设备实时采集里几乎必踩。

6. 二次开发改造:从本地录像到推流与预览

6.1 输出端改成 RTMP 推流

本地录制的核心是 avformat_open_input + av_interleaved_write_frame。改成推流时,只需要替换输出端参数:输出路径换成 RTMP 地址,输出封转格式改成 flv。

AVFormatContext *out_ctx = NULL; avformat_alloc_output_context2(&out_ctx, NULL, "flv", "rtmp://example.com/live/stream");

然后在写入循环里不再调用 av_write_trailer,而是调用 av_write_frame 实时发送。代码里原本的 avformat_write_header 保留,因为客户端网络握手也需要它。需要额外处理的是推流断线重连,常见做法是循环里检测 av_write_frame 返回的错误码,遇到断网就关闭当前输出上下文,稍微等待后重新创建。

我在识别到这种需求时,会把输出端封装成独立的 send_packet 函数,本地录制和推流都走同一个调用点,只是上传前多一步网络协议处理。这样改造量很小。

6.2 加一个窗口预览:用 SDL2 显示画面

第二个改造点是在编码前把帧拷贝一份给预览窗口。实现思路是最小侵入:在编码主循环里,复制一帧 YUV420P 到 SDL 纹理,用 SDL_RenderCopy 显示到窗口,不做任何 UI 依赖。

SDL_UpdateTexture(preview_tex, NULL, frame->data[0], frame->linesize[0]); SDL_RenderClear(preview_renderer); SDL_RenderCopy(preview_renderer, preview_tex, NULL, NULL); SDL_RenderPresent(preview_renderer);

预览线程和编码线程共用同一帧数据时,要确保 frame 还没被释放,可以把拷贝放在编码之前。SDL 2 的纹理创建参数使用 SDL_PIXELFORMAT_IYUV 和 YUV420P 对应,不需要再转 RGB,性能开销很小。这个功能对调试设备角度特别有用,可以少录制大量废视频。

6.3 改造后的验证方法

改造完成后,验证建议按三层过一遍:先看命令行日志里有没有错误提示,再看 ffprobe 里的流信息是否完整,最后用播放器或推流客户端实际上屏确认。三个环节的日志要对应上时间戳,哪里不对,就回看哪一步。从那以后,我每次做采集项目都会强制走完这条链路:枚举设备、开视频、验音频、检查流信息、再改编码。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进