FFmpeg API实战:采集摄像头与麦克风并封装为MP4 简介这是一份基于FFmpeg API采集摄像头视频和麦克风音频的完整工程资料面向熟悉C并希望简化设备采集流程的开发者。包内共162个文件约22.75MB以.h头文件、.cpp源文件为主附带.lib/.dll库文件、编译中间文件与可执行程序属于可打开、可二次开发的VC项目。作者依次讲解ffmpeg.exe命令行枚举DShow设备并采集摄像头图像、自研程序的功能与用法以及采集、编码、封装、录制各模块的实现细节。阅读后可掌握FFmpegDShow设备枚举、音视频采集管线搭建、编码器配置与封装输出的完整思路降低对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.mdmain.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 平台用的是 dshowAVInputFormat *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把字符串换成 v4l2macOS 则用 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 会分配一个引用计数管理的 AVPacketav_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。所以中间必须做一次像素格式转换这里用 libswscalestruct 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 里的流信息是否完整最后用播放器或推流客户端实际上屏确认。三个环节的日志要对应上时间戳哪里不对就回看哪一步。从那以后我每次做采集项目都会强制走完这条链路枚举设备、开视频、验音频、检查流信息、再改编码。希望帮到你。本文还有配套的精品资源点击获取