年前帮朋友处理过一件事:他店里的萤石摄像头需要把过去半个月的监控录像整理成连续视频备份,但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 ffmpeg,Ubuntu/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, data=data, timeout=10) 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, data=data, timeout=10) result = resp.json() if result.get("code") == "200": return result.get("data", []) raise RuntimeError(f"查询设备失败: {result.get('msg')}")3.2 查询时间段内的录像文件
这是整套流程里最关键的一步。萤石开放平台的录像查询接口一般接收以下几个参数:设备序列号deviceSerial、通道号channelNo、查询开始时间startTime、结束时间endTime、录像类型recType(0表示普通录像,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, data=data, timeout=15) 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, stream=True, timeout=60) as r: r.raise_for_status() with open(save_path, "wb") as f: for chunk in r.iter_content(chunk_size=1024 * 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(key=lambda 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是不是传错了。很多摄像头默认只录报警事件,普通录像根本没开启,这时候传recType=0自然查不到。可以先调一次recType=1看看有没有报警录像。
时间对齐问题也很典型。摄像头本身可能和服务器时间有偏差,如果设备时钟快了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参数区分普通录像和报警录像,如果你的关注点只是有人经过、物品移动之类的异常事件,直接传recType=1就能拿到报警片段,再走一遍FFmpeg拼接,出来的视频几乎都是有效内容,备份体积能缩小一大截。
如果你连报警标记都没有,又想自动跳过无画面时间段,可以FFmpeg拼接后用场景检测或者帧差法做抽帧判断,不过这就属于另一套脚本了。在日常使用中,我一般还是会全量下载一个短时段,再根据实际需求对长时段做报警筛选。
6.2 统一音量与画质设置
不同摄像头因为增益设置不同,录出来的音量可能忽大忽小。拼接前如果发现音频响度差异明显,可以在重编码时顺手用loudnorm滤镜统一响度:
ffmpeg -f concat -safe 0 -i file_list.txt \ -af loudnorm=I=-16:TP=-1.5:LRA=11 \ -c:v copy \ output.mp4注意loudnorm属于音频滤波,会触发音频重编码,所以这里不要期待“纯copy”了。如果对音量只是想整体放大,用volume=2.0这种更直接的参数也行。
6.3 做成定时任务,无人值守自动备份
下载拼接流程全部跑通后,可以把它注册成系统定时任务。Linux下用crontab,Windows下用任务计划程序。比如每周日凌晨3点执行一次,自动把上一周的录像拉下来拼接成周报存档。
定时任务有一个坑:运行环境的PATH可能不包含FFmpeg。建议在脚本里直接写死FFmpeg的绝对路径,Python里则用subprocess.run(["ffmpeg", ...])并传入完整路径,否则定时任务手动执行正常,自动执行时却一直报找不到命令。
6.4 加密设备的特殊处理
萤石部分设备支持视频加密,下载下来的文件直接播放打不开,FFmpeg拼接会直接失败。解决办法是设备端先把加密功能关掉,或者去开放平台确认你的应用是否有视频解密权限。千万不要尝试绕过加密去解密文件,这类功能通常涉及版权和数据安全,正当场景下直接联系平台技术支持开放对应接口权限才是正确途径。
我在实际整理录像的过程中,最大的体会是“接口返回的数据结构一定要打日志”。刚开始做这套脚本时,遇到一次查询结果为空,排查半天发现是时间格式少了一个前导零,后来每次请求和响应都保留原始报文日志,再出问题直接看日志定位,省了很多力气。另外,下载完所有分段后不要急着删源文件,等拼接输出验证播放没有异常再清理,避免一次误操作导致辛苦拉回来的素材全没了。这套流程我已经稳定跑了一年多,家里和门店的摄像头备份都靠它,希望对你也有用。