批量视频裁剪的难点通常不是会不会用剪辑软件,而是素材多、参数重复、时间轴需要可回溯。把区间写进 JSON 配置,让 Python 调用 FFmpeg 执行,改需求只改配置并重跑,比在时间线上反复拖拽更稳。下面按元数据探测、裁剪命令、批量校验、封面与字幕几个环节拆解,代码基于原文示例,FFmpeg 版本请以本机 ffmpeg -version 为准。
环境侧以 Python 3.8 及以上为基础,核心依赖是 FFmpeg、ffprobe 和 Pillow。FFmpeg 不固定版本号,但字幕烧录依赖 libass,需要单独确认;封面文字和字幕渲染中文还需要系统里有可用中文字体。安装后先验证命令:- ffmpeg -version
- ffprobe -version
- ffmpeg -hide_banner -filters | grep subtitles
- fc-list :lang=zh | head -n 20
复制代码 前两条确认主程序和探测器可用,第三条确认是否有 subtitles 滤镜,没有输出通常说明该构建未编译 libass。fc-list 在 macOS 和多数 Linux 发行版可用,Windows 没有该命令,需要到系统字体目录查找可用字体文件。Pillow 代码中使用 ImageDraw.textbbox 计算文字边界,该方法从 Pillow 8.0 开始提供,旧环境需要升级或改用旧接口。
核心概念先对齐:.mp4、.mov 是容器,H.264、AAC 是编码;视频帧分 I 帧、P 帧、B 帧,复制流裁剪的起点必须落在 I 帧,否则开头可能花屏或解码异常。FFmpeg 的 -ss 放在 -i 前是输入选项,靠文件索引快速定位,可能对齐到关键帧;放在 -i 后则从文件头解码到目标点,帧级精确但速度慢。工程上常用 -ss 前置快速定位配合 -c copy;需要精确起点或叠加滤镜时则重编码。判断标准只有一条:操作需要看到画面像素,比如水印、字幕烧录,就必须解码再编码;纯粹搬数据才可以复制流。
正式裁剪前先用 ffprobe 探素材。下面的探针函数把日志级别压到 error,避免无关输出混进 JSON;capture_output 配合 check=True 让非零退出码直接抛异常;encoding 指定 utf-8,避免 Windows 下含中文路径的素材解码失败:- import json
- import subprocess
- from pathlib import Path
- def probe_media(path: Path) -> dict:
- cmd = [
- 'ffprobe',
- '-v', 'error',
- '-print_format', 'json',
- '-show_format',
- '-show_streams',
- str(path),
- ]
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- encoding='utf-8',
- check=True,
- )
- return json.loads(result.stdout)
复制代码 原始 JSON 层级较深,再抽出时长、分辨率、码率等常用字段。注意 format.duration 在部分录屏或未正确收尾的文件中可能为空,需要回退到视频流的 duration;fps 常是 30000/1001 这种分数形式,后续算总帧数要做除法转换:- def summarize(info: dict) -> dict:
- fmt = info.get('format', {})
- streams = info.get('streams', [])
- video = next((s for s in streams if s.get('codec_type') == 'video'), None)
- audio = next((s for s in streams if s.get('codec_type') == 'audio'), None)
- summary = {
- 'duration': float(fmt.get('duration', 0.0) or 0.0),
- 'size_bytes': int(fmt.get('size', 0) or 0),
- 'bit_rate': int(fmt.get('bit_rate', 0) or 0),
- 'format_name': fmt.get('format_name', ''),
- 'has_video': video is not None,
- 'has_audio': audio is not None,
- 'width': int(video.get('width', 0)) if video else 0,
- 'height': int(video.get('height', 0)) if video else 0,
- 'video_codec': video.get('codec_name', '') if video else '',
- 'fps': video.get('r_frame_rate', '') if video else '',
- }
- if summary['duration'] <= 0 and video:
- summary['duration'] = float(video.get('duration', 0.0) or 0.0)
- return summary
复制代码 批量扫描目录时可把结果写成 CSV,编码用 utf-8-sig,方便 Excel 直接打开中文不乱码。这张表不仅给人看,也应被主流程用于时间轴校验,配置里的结束时间超过素材时长时,应在裁剪前报错退出。
时间轴配置建议一条片段一个对象,字段包括 id、start、duration、title、cover_at、precise。用起点加时长而不是起点加终点,是因为 FFmpeg 的 -t 正好接收时长,也减少减法与精度损失;cover_at 必须小于 duration,否则抽帧可能落在黑屏;precise 控制是否重编码。裁剪函数根据 precise 拼两条路径,重编码分支可叠加滤镜并保证起点精确,复制流分支速度优先但起点会对齐关键帧:- def build_clip_cmd(src: Path, dst: Path, start: float,
- duration: float, precise: bool) -> list:
- base = [
- 'ffmpeg', '-hide_banner', '-y',
- '-ss', f'{start:.3f}',
- '-i', str(src),
- '-t', f'{duration:.3f}',
- ]
- if precise:
- tail = [
- '-c:v', 'libx264',
- '-preset', 'veryfast',
- '-crf', '20',
- '-pix_fmt', 'yuv420p',
- '-c:a', 'aac',
- '-b:a', '192k',
- '-movflags', '+faststart',
- ]
- else:
- tail = ['-c', 'copy', '-avoid_negative_ts', 'make_zero']
- return base + tail + [str(dst)]
- def run_clip(src: Path, dst: Path, start: float,
- duration: float, precise: bool) -> bool:
- dst.parent.mkdir(parents=True, exist_ok=True)
- cmd = build_clip_cmd(src, dst, start, duration, precise)
- print(f'[CMD]', ' '.join(cmd))
- proc = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
- if proc.returncode != 0:
- print(f'[ERROR] 裁剪失败:{dst.name}')
- print(proc.stderr[-2000:])
- return False
- return True
复制代码 几个参数值得说明:-y 在批量重跑时避免输出文件已存在导致交互卡死;-pix_fmt yuv420p 是兼容性保险,规避部分播放器遇到 yuv444p 或 yuvj420p 时黑屏只有声音;-movflags +faststart 把索引移到文件头,方便网络边下边播;-avoid_negative_ts make_zero 用于复制流模式,防止输出时间戳从负数开始。错误日志只截取 stderr 尾部,是因为 FFmpeg 真正原因通常在最后几十行。
批量执行前先做整体校验,快速失败比跑到一半再报错更好。下面校验起点为负、区间越界、cover_at 超出片段范围:- def validate(config: dict, media: dict) -> None:
- total = media['duration']
- for seg in config['segments']:
- end = seg['start'] + seg['duration']
- if seg['start'] < 0:
- raise ValueError(f"片段 {seg['id']} 起点为负:{seg['start']}")
- if end > total + 0.5:
- raise ValueError(
- f"片段 {seg['id']} 区间越界:end={end:.2f}s,素材总长={total:.2f}s"
- )
- if seg.get('cover_at', 0) >= seg['duration']:
- raise ValueError(f"片段 {seg['id']} 的 cover_at 超出片段范围")
复制代码 批量调度可用 ThreadPoolExecutor,因为每次 FFmpeg 调用本质是启动子进程并等待,Python 侧几乎没有 CPU 计算,属于 IO 等待,线程池足够且开销比进程池小。并发数不宜盲目开大,磁盘和编码器会成为瓶颈。
封面环节要在片段内选时间点抽帧,cover_at 不要等于或超过 duration。抽出的图片再用 Pillow 叠加标题文字,ImageDraw.textbbox 负责计算文字边界。中文字体是常见坑:系统没有可用字体时输出方块或直接报错,需要有字体路径配置和缺字降级策略。字幕环节可从同一份时间轴生成 SRT;软字幕封装是把字幕作为独立流放进容器,由播放器渲染;硬字幕烧录走 -vf subtitles,依赖 libass。若前面检查发现没有 subtitles 滤镜,应改用软字幕封装或更换带 libass 的 FFmpeg 构建。
高频问题可以按固定顺序排查:先确认 ffmpeg、ffprobe 版本和 subtitles 滤镜,再确认中文字体;接着跑 probe,检查 JSON 是否能解析、duration 是否回退成功;然后跑 validate,排除时间轴越界和 cover_at 越界;最后单条试跑,确认 -ss 位置、是否复制流、输出像素格式和 faststart 是否符合预期。复制流开头花屏优先怀疑起点未对齐关键帧;输出黑屏只有声音优先检查 yuv420p 兼容性;字幕烧录失败优先检查 libass;中文乱码优先检查子进程 encoding 和 CSV 的 utf-8-sig。
工程化上建议把输入、中间产物、输出分层,原始素材只读,重跑时只清理中间目录;日志记录每条命令、返回码和错误尾部;裁剪函数按输出路径判断是否已存在,配合 -y 做可重复执行;性能上先小批量试跑,再根据磁盘和 CPU 调整并发。这样一套脚本化的批量裁剪流程,才能把重复剪辑变成可配置、可验证、可回溯的代码任务。 |