FFmpeg API实战:从零构建摄像头音视频采集管线
2026/9/9 23:55:30 网站建设 项目流程

简介:这是一份基于FFmpeg API实现摄像头视频与麦克风音频采集的完整C++工程源码包,面向希望避开DirectShow繁琐框架、用FFmpeg统一完成采集编码录制或推流的开发者。作者结合一周实战经验,先通过ffmpeg.exe命令行演示如何枚举DShow设备并测试采集,再给出程序功能与用法,最后拆解采集、编码、封装、录制各模块实现,能帮助读者快速建立起基于DShow的FFmpeg采集方案。资源共162个文件,以102个h头文件和11个cpp源文件为主体,同时附带lib/dll依赖库、工程配置与项目文件,压缩包22.75MB,属于可直接打开查看的VS工程;代码中涉及主程序框架、设备选择对话框、视频显示窗口、音视频输入输出封装等模块,便于对照学习。目前已有1303人学习下载,适合有一定FFmpeg基础、想用DShow源头做音视频采集的开发者参考。 做采集这块,我一直有个结论:命令行 ffmpeg 适合临时抓个摄像头、录个屏幕,但要真正把采集能力嵌进自己的产品,比如给实时视频叠加识别结果、按业务逻辑动态切换设备、采集的同时做流媒体分发,你必须直接用 FFmpeg API。这篇文章我用一个实际项目的思路,把“用 FFmpeg API 采集摄像头视频和麦克风音频”的完整链路讲透:设备枚举、打开采集、音视频同步、编码保存,以及我在 Windows dshow、Linux v4l2/alsa、树莓派 ov5647 上踩过的那些坑。

内容主要面向对 FFmpeg 命令行有基本了解、想往底层 API 走的开发者,也适合正在做视频会议、安防监控、图像识别、直播推流的工程师。文章以 Windows 平台 dshow 为主,但整体思路可以平移。

1. 整体设计思路:先搞懂采集链路再写代码

1.1 为什么非要 FFmpeg API,而不是直接拉命令行

命令行做采集很简单,一条-f dshow -i video="USB Camera"就能出流。但命令行的本质是一个完整封装好的可执行程序,你能做的只有传参,拿不到中间每一帧,没法在采集同时插入算法,设备异常时无法精细控制重试策略。API 调用的价值就是把黑盒拆开:av_read_frame读到一个 AVPacket 后,你既可以存文件,也可以马上解码成 AVFrame 转给 OpenCV、TensorRT,还可以把同一个包同时丢给推流器。

我之前做过一个防疫测温小工具,要用普通 USB 摄像头取流,每帧同步喂给人脸检测模型做识别,再叠加上温度框推给客户端。命令行方案根本做不了这种“边采集边处理”的流水线,API 方案才能真正把“取流—处理—输出”的节奏握在自己手里。

1.2 采集链路的四个核心模块

很多新手第一次看 FFmpeg源码时被一堆libav*库搞晕,其实采集链路只涉及四个模块:

模块作用生活类比
libavdevice设备输入抽象层,dshow、v4l2、alsa、avfoundation 都挂在这一层相机镜头
libavformat打开输入、探测流信息、读取 AVPacket相机机身
libavcodec把压缩数据解码成原始图像/音频修图软件
libavutil提供帧结构、像素格式、时间基等基础工具胶卷

一句话总结:libavdevice 负责“认识设备”,libavformat 负责“从设备读数据”,libavcodec 负责“把数据变成能用的样子”,libavutil 是所有环节的地基。你要做的就是在初始化时把前三者串起来,形成一个“镜头—机身—修图软件”的完整管线。

1.3 先做减法:采集不一定非要解码

很多人一上来就准备解码器、编码器、滤镜,其实纯采集场景可以非常轻。如果你的需求只是“把摄像头原始流存成文件”或者“把流转发出去”,你完全不需要解码:av_read_frame拿到的 AVPacket 本身就是压缩后的数据,直接写给 muxer 就行。

但如果你要“实时分析图像”或“做本地预览”,那就一定要解码,并且要做像素格式转换。这个取舍会影响整个程序的结构,动手写代码前一定先想清楚。

2. 环境准备与设备枚举:找到摄像头和麦克风

2.1 开发库安装与头文件配置

Windows 上推荐从 BtbN 或 gyan.dev 下载 FFmpeg 的 shared+dev 版本,dev 包里包含头文件和导入库。下载后需要做的三件事:

  1. bin目录下所有 dll 放到可执行文件旁边,或者加入系统 PATH;
  2. 在 IDE 里设置头文件搜索路径为 dev 包里的include目录;
  3. 链接器里加入avformat.libavdevice.libavcodec.libavutil.lib

Linux 上更简单:

sudo apt install libavdevice-dev libavformat-dev libavcodec-dev libavutil-dev

用 vcpkg 也可以,vcpkg install ffmpeg:x64-windows,但我个人觉得 Windows 上直接下二进制包最省事,不用折腾源码编译。源码编译适合要做裁剪的嵌入式环境,桌面开发直接用官方构建版本就行。

2.2 用 avdevice_list_devices 枚举设备

打开设备之前,你得先知道设备叫什么名字。命令行时代可以用ffmpeg -list_devices true -f dshow -i dummy查设备列表,API 环境下对应的函数是avdevice_list_devices

#include <libavdevice/avdevice.h> #include <libavformat/avformat.h> #include <libavutil/avutil.h> void list_dshow_devices() { avdevice_register_all(); AVFormatContext* fmt_ctx = avformat_alloc_context(); const AVInputFormat* ifmt = av_find_input_format("dshow"); if (!ifmt) { fprintf(stderr, "当前 FFmpeg 不支持 dshow\n"); return; } fmt_ctx->iformat = ifmt; AVDeviceInfoList* device_list = NULL; int ret = avdevice_list_devices(fmt_ctx, &device_list); if (ret < 0) { fprintf(stderr, "枚举设备失败,错误码:%d\n", ret); avformat_free_context(fmt_ctx); return; } for (int i = 0; i < device_list->nb_devices; i++) { AVDeviceInfo* dev = device_list->devices[i]; printf("设备[%d]: %s\n", i, dev->device_name); if (dev->device_description) printf(" -> 描述:%s\n", dev->device_description); } avdevice_free_list_devices(&device_list); avformat_free_context(fmt_ctx); }

这个函数输出的是设备名,而不是完整的video=地址。在 dshow 下,摄像头设备名通常是Integrated CameraUSB Camera之类的名称;麦克风则是Microphone (Realtek High Definition Audio)这种。拿到名字后,打开输入时要用video=设备名audio=设备名拼成 URL。

Linux 下的 v4l2 设备名通常是/dev/video0,Alsa 设备名是hw:0,open_input 时直接传这些路径即可,不需要枚举,所以我的实际项目中通常只在 Windows 上保留枚举逻辑。嵌入式平台比如树莓派,v4l2 下常见ov5647摄像头设备,同样是直接传/dev/video0,但要注意格式列表检查,这部分我用单独代码演示。

2.3 设备命名里的坑

dshow 设备名如果包含冒号,会导致 URL 解析错乱,因为 dshow 的 URL 本身就是video=xxx:audio=xxx这种冒号分隔的结构。遇到这种设备名,需要在设备名里对冒号做转义(用\:或者通过options传),但我实际测试下来某些设备驱动对转义的处理很迷,更可靠的方式是只开视频不混音,分别建两个采集上下文,最后在业务层合并时间戳。这个方法后面会细说。

3. 核心采集流程:从打开设备到拿到每一帧

3.1 打开摄像头与麦克风

设备枚举做好后,就可以正式打开设备了。以 Windows dshow 同时采集视频和音频为例:

AVFormatContext* create_dshow_ctx(const char* video_dev, const char* audio_dev) { AVFormatContext* fmt_ctx = NULL; const AVInputFormat* ifmt = av_find_input_format("dshow"); if (!ifmt) return NULL; // 拼接视频和音频设备地址 char url[512]; if (video_dev && audio_dev) { snprintf(url, sizeof(url), "video=%s:audio=%s", video_dev, audio_dev); } else if (video_dev) { snprintf(url, sizeof(url), "video=%s", video_dev); } else { snprintf(url, sizeof(url), "audio=%s", audio_dev); } AVDictionary* options = NULL; // 环形缓冲区大小:摄像头 1080p 30fps 下建议给足,不然容易丢帧 av_dict_set(&options, "rtbufsize", "128M", 0); // 指定视频分辨率与帧率,避免驱动用默认的奇怪参数 av_dict_set(&options, "video_size", "1280x720", 0); av_dict_set(&options, "framerate", "30", 0); // 指定采样率:如果声卡驱动默认 48k 但你要 44.1k,可以在这里定 av_dict_set(&options, "sample_rate", "48000", 0); av_dict_set(&options, "channels", "2", 0); int ret = avformat_open_input(&fmt_ctx, url, ifmt, &options); if (ret < 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, "打开设备失败:%s\n", errbuf); return NULL; } av_dict_free(&options); return fmt_ctx; }

注意rtbufsize很关键。dshow 内部用环形缓冲保存数据,缓冲小了,如果主线程来不及读,就会直接丢包,表现就是画面卡顿、花屏、音频断续。我的经验是 720p 30fps 至少给 64M,1080p 直接给 128M,别省这一点内存。

3.2 定位视频流与音频流

设备打开后,要拿到内部的流信息,再分别找到视频和音频对应的 stream index:

void find_stream_indexes(AVFormatContext* fmt_ctx, int* video_idx, int* audio_idx) { *video_idx = -1; *audio_idx = -1; for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) { AVStream* stream = fmt_ctx->streams[i]; if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { *video_idx = (int)i; } else if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { *audio_idx = (int)i; } } }

这一步没什么花活,但一定要判断返回的 index 是否为 -1,因为有些廉价 USB 摄像头只出视频流不带音频,直接对 -1 取 stream 会段错误。我见过太多线上事故都是没做这个判断。

3.3 读取数据包主循环

核心采集循环其实非常朴素:

int capture_loop(AVFormatContext* fmt_ctx, int video_idx, int audio_idx) { AVPacket pkt; av_init_packet(&pkt); int64_t video_count = 0; int64_t audio_count = 0; while (1) { int ret = av_read_frame(fmt_ctx, &pkt); if (ret == AVERROR(EAGAIN)) { continue; } if (ret == AVERROR_EOF) { break; } if (ret < 0) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, "读取数据包失败:%s\n", errbuf); break; } if (pkt.stream_index == video_idx) { // 这里拿到视频帧。如果只是存文件,直接写 muxer; // 如果需要分析,则解码为 AVFrame printf("视频帧 count=%lld, pts=%lld\n", ++video_count, pkt.pts); } else if (pkt.stream_index == audio_idx) { printf("音频帧 count=%lld, pts=%lld\n", ++audio_count, pkt.pts); } av_packet_unref(&pkt); } return 0; }

很多新手写的第一个版本会把av_packet_unref漏掉,这个东西不调用,内存在长时间采集时会上涨到吓人。AVPacket 在av_read_frame内部可能分配了缓冲区,处理完不释放,下一轮就会覆盖指针,导致内存泄漏。我自己的规矩是:处理完一包,立刻av_packet_unref,没有例外。

3.4 编码保存为 MP4 文件

采集到的裸码流,如果直接存文件,dshow 输出的往往是原始数据或者 MJPEG,体积巨大。正常的做法是解码后重新编码,或者用 FFmpeg 的转封装能力直接接一个编码器。

我更推荐“解码后重编码”的通用流程,因为这样能做更多处理(缩放、加水印、降噪)。核心步骤是:

  1. avcodec_find_decoder找解码器,avcodec_open2打开;
  2. avcodec_send_packetavcodec_receive_frame循环解码;
  3. 建立SwsContext做像素格式转换(摄像头常见YUYV422MJPG,编码器常用YUV420P);
  4. 对音频做SwrContext重采样(设备常见s16交错,编码器常用fltp平面);
  5. avformat_write_headerav_interleaved_write_frameav_write_trailer写文件。

这部分代码量比较大,我建议按两个函数拆分:transcode_video_frametranscode_audio_frame,分别处理视频像素转换和音频重采样,最后都统一转成AVFrame送给 muxer。这样主循环保持干净,也方便日后扩展滤镜。

4. 音视频同步:两种时钟怎么对齐

4.1 dshow 的时钟机制

摄像头和麦克风本质是两个独立的硬件设备,dshow 虽然把两者组合成了一个AVFormatContext,但内部的时间戳来源并不完全统一。FFmpeg 的 dshow demuxer 默认会把音频设备当作主时钟,视频包会基于音频时间戳做校正。

对这个机制,实操中你的代码不需要管太多,把它当成“帧率可能有轻微抖动”即可。真正要命的是有些摄像头驱动不给pts,或者给的是从 0 开始的系统启动时间,这种情况下录出来的视频在用播放器拖进度条时会抽风。

面对这种驱动不给时间戳的情况,有一个通用方案:用墙上时钟换算。

// 在采集循环里拿到包后做一次统一时间基的转换 if (pkt.pts == AV_NOPTS_VALUE) { // 没有时间戳,就用自己的计数器生成 pkt.pts = (video_count * 1000000) / fps; // 单位 us,按你设定的帧率 pkt.dts = pkt.pts; } // 把所有时间戳统一到 AV_TIME_BASE 微秒 int64_t pts_us = av_rescale_q(pkt.pts, fmt_ctx->streams[pkt.stream_index]->time_base, (AVRational){1, AV_TIME_BASE});

这里video_count是已采集的视频帧计数,fps是你在打开设备时请求的帧率。这样生成的pts是从 0 开始的均匀时间戳,虽然丢掉了真实采集时刻的语义,但至少保证音视频相对顺序可预期,播放器拖进度条也不会乱跳。

4.2 音视频不同步的排查思路

一次成都客户现场的摄像头,录出来声音和画面差距肉眼可见,大约有 100ms。我排查后发现不是时间戳的问题,而是声卡驱动的缓冲默认较大,导致音频流延迟比视频流多出了几十毫秒。解决方法是给 dshow 设置audio_buffer_size参数:

av_dict_set(&options, "audio_buffer_size", "50", 0);

这个参数单位是毫秒,值越大音频缓冲越大,延迟越高、越不容易爆音;值越小延迟越低,但太小时在某些声卡上会出现断续。50ms 是我试出来比较折中的值,存文件、做监控都够用。如果你的场景是直播、视频会议这种低延迟要求高的场景,可以再往下压到 20ms,同时配合后续的av_interleaved_write_frame写入策略。

还需要注意一个细节:当你在 Windows 上看到音画不同步,先别急着改代码,先用 ffprobe 看输入流的 parser 时间和解码时间。pkt.ptspkt.dtspkt.duration这三者的关系能透露很多驱动底层的行为。我见过一些摄像头驱动把 dts 设得乱糟糟,pts 正常,如果只按 pts 处理问题不大,但如果用av_rescale_q同时算了 dts,反而会把音频轨节奏带乱。

4.3 视频和音频分开采集的兜底方案

有些设备(特别是部分安防摄像头、采集卡转接环境)在 dshow 下同时video=xxx:audio=xxx打开会失败,或者打开后音频死活不出流。我的兜底方案是拆成两个独立的AVFormatContext:一个开视频,一个开音频,然后在主循环里用poll或者多线程分别对两个上下文调av_read_frame,最后统一按pts_us(墙上时钟换算)做对齐。

这个方法灵活,代价是你要自己维护两条流的生命周期,代码复杂度翻倍。我建议只有当合路打开方式明确失败时才考虑,不要一上来就上多线程,不然会把自己绕进去。

5. 常见问题与排查技巧实录

5.1 设备打不开,返回 AVERROR_INVALIDDATA

症状是调用avformat_open_input失败,errbuf 里提示Invalid data。绝大多数情况是设备名不对。Windows dshow 的设备名是包含空格的,比如Integrated Camera,你拼 URL 时写成video=IntegratedCamera就会失败。

另一个常见原因是设备被占用。Windows 的摄像头默认开启了隐私权限限制,或者在调试时你的程序还在跑,另一个进程又去打开同一个设备,就会拿到权限拒绝。处理办法:先确认设备名通过枚举得到,不要手打;再检查是否有其他进程占用。可以在设备管理器里禁用再启用一次设备来恢复。

5.2 有视频流但画面是全黑的

多半是像素格式不匹配。dshow 设备默认可能输出 MJPEG 或 YUYV422,如果你的下游解码器只认 NV12,出来的画面就是黑屏或者花屏。解决方式是在打开设备时指定pixel_format

av_dict_set(&options, "pixel_format", "yuyv422", 0);

注意:MJPGYUYV422两种格式在 dshow 中如果都支持,驱动会优先选择第一个你指定的。如果你指定了yuyv422,带宽会高一倍,USB2.0 下 1080p 30fps 可能跑不满;若是 USB3.0 则没问题。嵌入式设备像树莓派的 ov5647,在 v4l2 下常见格式是YUYV422JPEG,可以用v4l2-ctl --list-formats-ext查看设备支持的所有格式,再决定采集参数。

5.3 麦克风采集无声

程序跑起来没有报错,音频包也在走,但播放文件没声音。先做二分排除法:

  1. 录出来的文件单独用播放器验证是“整个静音”还是“根本没有音频流”;
  2. 如果根本没有音频流,说明audio_idx一直是 -1,设备没被识别,检查枚举列表里有没有麦克风;
  3. 如果有音频流但静音,多半是采样率或通道数不对。

针对采样率问题,dshow 下可以通过sample_ratechannels强制指定。注意 Windows 的 WASAPI 在某些声卡上会做采样率转换,如果你程序里请求 44100Hz 而设备默认是 48000Hz,FFmpeg 可能拿到的是经过驱动混洗的数据,采样率标记却是 44100,这时建议统一用 48000Hz 采集,避免隐式重采样带来的音质损失。

5.4 av_read_frame 长时间阻塞不动

这个在 Windows dshow 下算是经典问题。av_read_frame是同步阻塞调用,如果设备那边不送数据,它会一直等。造成阻塞的原因通常是摄像头被系统相机应用占用、休眠恢复后驱动状态异常、或 USB 带宽不够导致驱动挂死。

我总结的排查优先级:

现象排查思路处理手段
打开之后第一帧就卡死设备被占用或驱动异常换 USB 口,关闭其他相机应用
运行一段时间后卡死USB 带宽不足,丢包后驱动卡死调低分辨率、帧率,加 rtbufsize
休眠唤醒后卡死设备驱动状态未恢复增加设备重开重试逻辑

重度使用场景下,建议把采集循环放到独立线程,主线程通过消息队列取帧。这样即使av_read_frame阻塞,也不至于把整个程序的 UI 或网络通信卡死。

5.5 树莓派 ov5647 和 Linux 设备的差异

在树莓派上用 v4l2 采集 ov5647 模块时,有一个容易被忽略的点:v4l2 设备节点/dev/video0可能是 CSI 接口的 raw 设备,输出格式是SGBRG10这种 Bayer 格式,而不是常见的 YUV。如果直接用 FFmpeg 的 v4l2 输入,不做格式协商,会得到一片紫色的画面。

正确的做法是先用v4l2-ctl --list-formats-ext -d /dev/video0查看驱动实际支持的输出格式。树莓派官方驱动通常会注册两个节点:一个输出 raw Bayer,一个输出 YUYV 合成格式。FFmpeg 里要用输出 YUYV 的那个节点,或者用libcamera应用层转换后再交给 FFmpeg。这块的坑比较多,我在树莓派上做 ov5647 采集时,最终稳定方案是让 libcamera 出 RGB 数据,再用 FFmpeg 的 rawvideo 输入封装成流,而不是直接用 v4l2 裸抓。

Linux 桌面环境下,视频用 v4l2,音频用 alsa,代码结构和 dshow 基本相同,只是打开格式名从dshow换成v4l2alsa,URL 从设备名换成/dev/video0hw:0。核心的av_read_frame主循环完全一致。

6. 一些实战经验和收尾

最后分享一个我自己反复踩过的坑:dshow 采集时,不要在拿到 AVPacket 后同步做重量级处理(比如人脸检测、目标跟踪),否则av_read_frame内部的环形缓冲会被塞满,然后驱动开始丢帧,画面就会变成“一卡一卡但 CPU 占用率却很低的诡异状态”。正确做法永远是“采集线程只做拷贝和解码,把数据丢给工作队列,业务分析放别的线程”。

另一个细节是异常退出。很多嵌入式设备或者廉价 USB 摄像头,在程序不调avformat_close_input直接退出后,驱动资源不会立刻释放,紧接着下一次打开就会失败。以前做项目时经常遇到“程序崩溃重启后摄像头打不开要重启设备”的怪问题,后来规范了退出逻辑,先关流再释放上下文,情况好了很多。

如果你现在还在用命令行做设备采集,我建议抽个时间把 API 版本的采集骨架搭起来,哪怕只是打印每一帧的 pts 和流索引。这个骨架以后能复用到视频推流、AI 识别、远程监控几乎所有项目里,投入产出比非常高。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询