
年前帮朋友处理过一件事他店里的萤石摄像头需要把过去半个月的监控录像整理成连续视频备份但SD卡里视频是按报警事件一段一段存的官方App只能一条一条点下载几百个片段手动保存再拖进剪辑软件光想想就头大。后来我换了个思路直接用萤石开放平台的OpenAPI把录像文件按时间段批量拉回来再用FFmpeg一次性拼接成整段视频。整个过程脚本化之后几十个GB的监控素材十几分钟就能处理完连我自己家的两台摄像头后来也一直用这套流程定期备份。这篇文章就把这条完整链路拆开讲讲从申请接口权限、批量下载到FFmpeg拼接命令和避坑经验适合手上有多台萤石设备、经常需要整理录像的运维人员、安防集成商以及喜欢折腾脚本的开发者参考。1. 为什么选择“API批量下载 FFmpeg拼接”这条路1.1 官方客户端逐个下载的痛点萤石摄像头的录像存储有两个典型来源一是设备本地SD卡二是开通云存储服务后的云端录像。无论哪种方式在官方App里查看录像时系统都是按照报警事件或者时间段切分成一个个短片来展示的。实际需求往往是“某个连续时间段内的完整视频”比如一天从早到晚、某个异常事件前后两小时或者长达数周的长期备份。用App手动点先不说几百个片段操作起来有多繁琐单是下载到手机再传到电脑这一步就足够折磨。而且监控录像文件普遍存在时间戳重叠的现象相邻两个片段的边界可能重复几秒也可能因为设备端存储策略漏了几帧。人工拼接很容易因为排序错乱、漏文件导致最后成片时间线跳跃非常影响后续的取证、剪辑或长期存档。与之相比用API脚本批量处理的最大好处是所有操作有日志、有记录文件命名按时间戳规范最终拼接顺序可控重新做一遍的成本也几乎为零。1.2 整体链路的技术选型思路这套方案的核心链路是这样的先通过萤石开放平台拿到API访问令牌再根据设备序列号查询指定时间范围内的录像文件列表从列表里取出每个文件的下载地址批量下载到本地最后按时间顺序写入一个文件清单交给FFmpeg无损拼接。为什么选FFmpeg而不是直接用剪辑软件因为监控视频拼接是典型的“机械性重复劳动”帧率、分辨率、编码格式都相对固定这正是FFmpeg最擅长的场景。Premiere、剪映这类软件更适合精细剪辑放到批量处理上效率反而低。FFmpeg命令行一条命令就能处理几十上百个文件还能保持原视频编码直接copy不损失画质。至于为什么选Python来写下载脚本纯粹因为它处理HTTP请求、JSON解析、文件路径这些脏活非常顺手调试也快换成Node、Java也完全没问题核心逻辑是一样的。1.3 这个方案适合谁来参考如果你对命令行不陌生会一点Python或者其他脚本语言完全可以照着做。如果完全没有编程经验也可以把下面的请求脚本当作“黑盒”用只要会改文件路径、时间和设备序列号就行。整套方案的优点是不依赖任何付费插件只要摄像头接入萤石云就能用官方接口实现。唯一的门槛是申请开发者账号这步需要实名注册、创建应用流程本身是免费的也不涉及额外套餐。2. 环境准备与必要工具安装2.1 FFmpeg的安装与验证先用最直接的方式把FFmpeg装好。Windows用户可以去FFmpeg官网下载已经编译好的release版本一般选择essentials_build的7z压缩包即可解压后把bin目录加到系统环境变量Path里。macOS用户用Homebrew执行brew install ffmpegUbuntu/Debian用sudo apt install ffmpeg。装完之后打开命令行输入ffmpeg -version能输出版本信息就说明环境变量没问题。我踩过的坑是解压路径千万别带中文和空格有些调用方式解析路径会出问题另外Windows下如果提示“ffmpeg不能识别为cmdlet、函数”几乎都是Path没配好或者命令行没重新打开导致的。2.2 Python环境与请求库Python建议装3.8以上版本只需要一个requests库。如果你用的是miniconda或venv虚拟环境先激活环境pip install requests脚本里主要用到requests的POST和GET请求。有个小建议监控录像文件通常不小下载时用流式读取避免一次性把整个文件塞进内存。2.3 萤石开放平台的账号准备去萤石开放平台官网注册开发者账号创建一个应用后系统会分配appKey和appSecret这两个凭证。这里的appSecret相当于密码一定不要提交到公开代码仓库。创好应用后需要把你要操作的摄像头设备绑定到该账号下。绑定成功后在控制台能看到设备序列号和通道号后续所有查询接口都依赖这两个参数。获取凭证的时候我还遇到过一个小坑有些设备是别人先绑定的自己再去开放平台绑定会被提示“设备已被添加”。这种情况需要先让原账号解绑或者用设备本地验证码重置绑定关系。确保设备在开放平台显示“在线”状态再继续往下操作。3. 批量下载录像的核心实现3.1 先搞清楚令牌与接口调用流程萤石OpenAPI的绝大多数业务接口都需要携带访问令牌accessToken。获取令牌的接口是/api/lapp/token/get用appKey和appSecret换取返回的数据里包含accessToken和过期时间expireTime。令牌有效期一般只有几天所以脚本里最好做缓存避免每次运行都重复申请接口有频率限制。import requests def get_token(app_key: str, app_secret: str) - tuple[str, int]: url https://open.ys7.com/api/lapp/token/get data { appKey: app_key, appSecret: app_secret, } resp requests.post(url, datadata, timeout10) result resp.json() if result.get(code) 200: token result[data][accessToken] expire_time result[data][expireTime] # 毫秒时间戳 return token, expire_time raise RuntimeError(f获取token失败: {result.get(msg)})查询设备列表的接口是/api/lapp/device/list分页参数用pageStart和pageSize。如果你的摄像头数量不多其实可以不调这个接口直接把平台后台的设备序列号写死在配置里更方便。但封装成一个查询函数有个好处万一以后换设备或者新增设备脚本不用改逻辑def list_devices(token: str) - list: url https://open.ys7.com/api/lapp/device/list data { accessToken: token, pageStart: 0, pageSize: 50, } resp requests.post(url, datadata, timeout10) result resp.json() if result.get(code) 200: return result.get(data, []) raise RuntimeError(f查询设备失败: {result.get(msg)})3.2 查询时间段内的录像文件这是整套流程里最关键的一步。萤石开放平台的录像查询接口一般接收以下几个参数设备序列号deviceSerial、通道号channelNo、查询开始时间startTime、结束时间endTime、录像类型recType0表示普通录像1表示报警录像。时间格式通常是YYYY-MM-DD HH:MM:SS注意时区是东八区务必和摄像头本地时间对齐。有一个经验是单次查询的时间跨度不要太大。我一开始直接把整天的开始和结束时间传进去返回结果偶尔会超时或者数据被截断。后来改成按小时或者按半天去分段查询每段单独请求稳定很多。def list_videos(token: str, device_serial: str, channel_no: int, start_time: str, end_time: str, rec_type: int 0) - list: url https://open.ys7.com/api/lapp/video/list data { accessToken: token, deviceSerial: device_serial, channelNo: channel_no, startTime: start_time, endTime: end_time, recType: rec_type, } resp requests.post(url, datadata, timeout15) result resp.json() if result.get(code) 200: return result.get(data, []) raise RuntimeError(f查询录像失败: {result.get(msg)})返回的列表中每条记录都包含startTime、endTime、fileSize、url等字段。url字段就是录像文件的下载地址。大多数情况下它是HTTP地址注意这个地址有时效性别提前请求一堆放着不用等真正要下载的时候再查询列表最保险。3.3 流式下载与文件命名规范拿到下载地址后循环请求就行。因为监控文件往往几十到几百MB一定要用流式下载def download_file(url: str, save_path: str) - None: with requests.get(url, streamTrue, timeout60) as r: r.raise_for_status() with open(save_path, wb) as f: for chunk in r.iter_content(chunk_size1024 * 1024): f.write(chunk)文件名建议按设备序列号_日期_开始时间_结束时间.mp4这种格式来命名。不要直接用接口返回的原始文件名因为平台侧的命名规则不统一而且不按时间排序。规范化命名是后续生成FFmpeg列表文件的基础排序正确与否直接决定拼接结果。批量下载时并发数建议控制在3到5个。我试过开10个线程结果把平台接口请求频率打爆了触发限流后大量下载地址失效反而要重新查询。更稳妥的做法是串行下载或者用一个简单的信号量控制并发配合失败重试机制最多重试3次。4. FFmpeg拼接的两种方式对比4.1 concat协议和concat demuxer到底用哪个FFmpeg拼接视频有几种方式常用的其实是两个concat协议和concat demuxer。我在这个项目里一开始用的是concat协议命令大概长这样ffmpeg -i concat:video1.mp4|video2.mp4|video3.mp4 -c copy output.mp4这种方式速度极快因为它是纯文件流级别拼接完全不重新编码。但它有个硬性前提所有视频的编码参数编码器、分辨率、帧率、时间基必须完全一致而且对MP4这类封装格式兼容性很差经常报错或输出文件无法播放。更适合它的场景是TS流文件比如直播录制的分段切片。后来我改用concat demuxer也就是通过一个文件列表来拼接ffmpeg -f concat -safe 0 -i file_list.txt -c copy output.mp4# file_list.txt file /mnt/videos/device_20240101_080000_081500.mp4 file /mnt/videos/device_20240101_081500_090000.mp4这个方式会先解封装每个文件读取时间戳信息后重新对齐再封装输出因此对MP4兼容性更好。实测下来只要来源视频的编码参数一致-c copy仍然能保持无损拼接且速度很快。两种方式对比对比项concat协议concat demuxer命令形式-i concat:a.mp4|b.mp4-f concat -safe 0 -i list.txt适用格式TS等流式格式MP4、MKV、FLV等灵活性低要求极高高支持更多封装是否适合本项目不推荐推荐4.2 文件列表的生成细节用concat demuxer有个细节容易忽略file_list.txt里的路径如果包含特殊字符比如单引号、空格、中文处理不好很容易报错。FFmpeg官方规则是路径放在单引号里如果路径本身包含单引号需要写成\这种转义形式。我平时为了省事直接在生成列表前把文件重命名成纯数字加时间戳的格式尽量避开特殊字符。生成列表时一定要保证文件顺序按录像时间排序。Python里直接对文件名做sorted()是字典序排序如果文件命名像..._10_...和..._2_...混在一起10会排在2前面顺序就乱了。解决办法是命名时统一补零或者生成列表前解析出时间戳字段再排序from datetime import datetime items.sort(keylambda x: (x[start_time]))4.3 什么时候必须重编码虽然无损拼接很香但实际项目中我遇到过好几次-c copy直接报错的情况典型错误是Packet mismatch或者Non-monotonous DTS。导致这个问题的核心原因是两段视频编码参数实际上不一致可能分辨率相同但帧率差0.01或者摄像头的编码profile在某个时段发生了变化。碰到这种情况就别硬扛了直接重新编码一次输出参数统一拼接就稳定了ffmpeg -f concat -safe 0 -i file_list.txt \ -c:v libx264 -preset medium -crf 18 \ -c:a aac -b:a 128k \ -r 25 -pix_fmt yuv420p \ output.mp4-crf 18是肉眼几乎无损的画质档位-preset medium在速度与体积之间比较平衡。如果录像没有音频轨建议把音频参数换成-an避免FFmpeg因为找不到音轨而报错。重编码速度会比直接copy慢不少但胜在兼容性稳定适合批量处理时想要“一次跑通”的场景。5. 实操中遇到的坑与排查指南5.1 令牌失效与接口参数错误运行脚本第一类高频问题就是accessToken失效。平台返回的expireTime是毫秒时间戳在脚本里最好预留一小时作为提前量——也就是说剩余有效期小于3600秒时就主动重新申请。否则在大批量下载的中途令牌过期前面跑了一半的进度全浪费了。还有一个容易忽略的点appSecret、accessToken、设备序列号这些参数拼POST请求时全部用表单格式data{}传而不是JSON格式。萤石OpenAPI对Content-Type比较敏感我之前用json传参导致过几次“签名错误”之类的报错换回表单就正常了。5.2 查询不到录像或者查到的片段对不上查询录像返回空列表我们一般先检查三件事时间范围是否正确、设备是否在对应时间段有存储记录、recType是不是传错了。很多摄像头默认只录报警事件普通录像根本没开启这时候传recType0自然查不到。可以先调一次recType1看看有没有报警录像。时间对齐问题也很典型。摄像头本身可能和服务器时间有偏差如果设备时钟快了2分钟那么查询区间也会错位。我的处理办法是查询的时候把开始时间提前5分钟、结束时间延后5分钟下载后再用FFmpeg的-ss和-t参数精确裁剪这样即使有少量偏移也能覆盖完整。5.3 下载地址失效与防盗链问题录像文件下载地址有时效性短则几分钟长则几十分钟过期后访问会返回403。这通常不是脚本Bug而是下载前的准备时间太长。我踩过最惨的一次是查询了三个月的录像地址后统一丢进任务队列结果执行到后面的地址早就过期了。解决办法很简单边查询边下载查到一段就立刻下载不要批量查询后再集中下载。如果你的设备开启了增强访问控制或者你是在有IP白名单限制的办公网络里调用API也可能出现下载地址被拒绝的情况。检查一下开放平台后台的应用配置确认IP白名单包含当前出口IP。5.4 视频拼接后的时间线与音画状态异常拼接后最常见的问题是视频长度比原始总时长少了几秒或者画面卡顿、音画不同步。大多数原因是相邻两个片段存在重叠或者丢帧。重叠的解决方法是生成列表前对起止时间做一次“接缝处理”当前片段如果开始时间早于上一个片段的结束时间就裁剪掉重叠部分。我这里给一个简单的处理思路还是以每段录像的startTime和endTime为准多个文件之间如果startTime小于上一个endTime说明有重叠直接用FFmpeg对前一个文件的末尾做裁剪就能解决ffmpeg -ss 0 -t duration_without_overlap -i input.mp4 -c copy output.mp45.5 常见问题速查表问题现象常见原因处理办法脚本报 accessToken 过期令牌未缓存或缓存时间过长提前检查expireTime提前1小时刷新查询录像列表返回空recType用错、时间段无录像切换录像类型扩展查询区间下载地址返回403地址过期或IP白名单受限查询后立刻下载检查平台白名单FFmpeg报 Packet mismatch各片段编码参数不一致改用重编码方式统一参数拼接后时间线跳跃片段间存在重叠或漏帧按起止时间裁剪重叠边界拼接后音画不同步源文件时间戳不稳定统一帧率和采样率重新封装ffmpeg不是内部或外部命令Windows环境变量未配置检查Path设置并新开命令行文件名排序错乱导致顺序反转字典序排在数字序前按时间字段解析后重新排序6. 流程扩展让脚本更贴合日常使用6.1 按报警事件过滤只下载有内容的片段监控视频大部分时间画面都是静止的全量下载备份很浪费空间。萤石接口支持recType参数区分普通录像和报警录像如果你的关注点只是有人经过、物品移动之类的异常事件直接传recType1就能拿到报警片段再走一遍FFmpeg拼接出来的视频几乎都是有效内容备份体积能缩小一大截。如果你连报警标记都没有又想自动跳过无画面时间段可以FFmpeg拼接后用场景检测或者帧差法做抽帧判断不过这就属于另一套脚本了。在日常使用中我一般还是会全量下载一个短时段再根据实际需求对长时段做报警筛选。6.2 统一音量与画质设置不同摄像头因为增益设置不同录出来的音量可能忽大忽小。拼接前如果发现音频响度差异明显可以在重编码时顺手用loudnorm滤镜统一响度ffmpeg -f concat -safe 0 -i file_list.txt \ -af loudnormI-16:TP-1.5:LRA11 \ -c:v copy \ output.mp4注意loudnorm属于音频滤波会触发音频重编码所以这里不要期待“纯copy”了。如果对音量只是想整体放大用volume2.0这种更直接的参数也行。6.3 做成定时任务无人值守自动备份下载拼接流程全部跑通后可以把它注册成系统定时任务。Linux下用crontabWindows下用任务计划程序。比如每周日凌晨3点执行一次自动把上一周的录像拉下来拼接成周报存档。定时任务有一个坑运行环境的PATH可能不包含FFmpeg。建议在脚本里直接写死FFmpeg的绝对路径Python里则用subprocess.run([ffmpeg, ...])并传入完整路径否则定时任务手动执行正常自动执行时却一直报找不到命令。6.4 加密设备的特殊处理萤石部分设备支持视频加密下载下来的文件直接播放打不开FFmpeg拼接会直接失败。解决办法是设备端先把加密功能关掉或者去开放平台确认你的应用是否有视频解密权限。千万不要尝试绕过加密去解密文件这类功能通常涉及版权和数据安全正当场景下直接联系平台技术支持开放对应接口权限才是正确途径。我在实际整理录像的过程中最大的体会是“接口返回的数据结构一定要打日志”。刚开始做这套脚本时遇到一次查询结果为空排查半天发现是时间格式少了一个前导零后来每次请求和响应都保留原始报文日志再出问题直接看日志定位省了很多力气。另外下载完所有分段后不要急着删源文件等拼接输出验证播放没有异常再清理避免一次误操作导致辛苦拉回来的素材全没了。这套流程我已经稳定跑了一年多家里和门店的摄像头备份都靠它希望对你也有用。