本节摘要:本节上剪辑段的编排层。先分清分工:ffmpeg 是引擎(像素与格式),moviepy 是编排(读分镜 JSON、排时间线、叠图层)——编排层的价值是把「剪辑决定」从手工拖拽变成数据驱动,分镜改一版,时间线自动重排。核心代码是从分镜 JSON 驱动时间线的完整骨架:逐镜头载入片段、按分镜时长截齐、有台词的镜头叠字幕、拼接成轨、按配置导出——它就是 1.2 节时间线 schema 的可执行形态。然后是三个高频操作:叠化转场的交叠实现、文字叠加的参数要点、画中画的图层写法。最后是导出参数与性能提示,以及 moviepy 大版本间 API 有更迭的提醒(示例按广泛流传的 1.x 风格书写,2.x 以官方文档为准)。音轨本章刻意留空,成片是无声轨——那是后半本第 6 章的入口。
| 关注点 | ffmpeg(第 5.1 节) | moviepy(本节) |
|---|---|---|
| 工作对象 | 单个流的像素与格式 | 多个片段的时间关系与图层 |
| 驱动方式 | 命令与参数 | Python 代码读写 JSON |
| 强项 | 快、稳、底层 | 可编程、可组合、贴近流水线 |
| 典型场景 | 转码、统一参数、抽帧 | 按分镜排片、叠字幕、画中画 |
编排层的真正价值:剪辑决定数据化。字幕位置、转场时长、镜头顺序全部来自分镜与配置文件,2.3 节「改一场不改全片」落到剪辑段就是「重跑一遍 main 函数」——没有手工工程文件需要人肉同步。
# timeline_build.py —— 分镜 JSON 驱动的时间线骨架(写法示意,1.x API 风格;2.x 差异以官方文档为准) import json from moviepy.editor import CompositeVideoClip, TextClip, VideoFileClip, concatenate_videoclips def load_board(path): with open(path, encoding="utf-8") as f: return json.load(f) def build_clip(shot, clips_dir): clip = VideoFileClip("%s/shot_%03d.mp4" % (clips_dir, shot["shot_id"])) clip = clip.subclip(0, shot["duration"]) # 按分镜时长截齐(片段不足时先走 5.1 的补齐) if shot.get("line"): caption = ( TextClip(shot["line"], fontsize=44, font="msyh", color="white", method="caption", size=(clip.w - 120, None)) .set_position(("center", 0.82), relative=True) .set_duration(clip.duration) ) clip = CompositeVideoClip([clip, caption]) return clip def main(): board = load_board("build/02_storyboard/approved_board.json") clips = [build_clip(shot, "build/04_clips") for shot in board["shots"]] final = concatenate_videoclips(clips, method="compose") final.write_videofile( "build/05_timeline/video_track.mp4", fps=30, codec="libx264", audio_codec="aac", ) if __name__ == "__main__": main()
骨架的四个细节。镜头文件名由 shot_id 推出(1.3 节命名规约的回报);截齐对应分镜时长,片段短缺时先用 5.1 的 tpad 克隆补齐再进来;字幕叠加只在 line 非空时发生(空串镜头不叠白条);拼接用 compose 方法——各镜头经过图层合成后参数可能不一致,compose 兼容性最好,代价是慢一点,对流水线的分钟级任务可接受。
「片段是否短缺」这件事,也可以提前到渲染之前做成年检——逐镜头用 ffprobe 量实际时长,与分镜时长对账,短缺的列成补齐清单:
# audit_timeline.py —— 分镜时长与片段实际时长对账(纯标准库,调用 ffprobe) import json import subprocess from pathlib import Path def duration_of(path): proc = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path)], capture_output=True, text=True, check=True) return float(proc.stdout.strip()) def audit(board_path, clips_dir): board = json.loads(Path(board_path).read_text(encoding="utf-8")) shortage = [] for shot in board["shots"]: clip = Path(clips_dir) / ("shot_%03d.mp4" % shot["shot_id"]) if not clip.exists(): shortage.append((shot["shot_id"], "缺片段")) continue gap = shot["duration"] - duration_of(clip) if gap > 0.05: # 容差 50 毫秒(示意值) shortage.append((shot["shot_id"], "短 %.2f 秒,需 tpad 补齐" % gap)) for sid, msg in shortage: print("镜头 %s:%s" % (sid, msg)) print("对账完成:%d 个镜头,%d 个待处理" % (len(board["shots"]), len(shortage))) return shortage if __name__ == "__main__": audit("build/02_storyboard/approved_board.json", "build/04_clips")
年检的价值是把「补齐」从渲染时的隐性依赖变成开工前的显性清单:tpad 命令(第 5.1 节)逐条套用,补完再进骨架,渲染一遍过。反向的差额(片段比分镜长)不需要处理,subclip 截齐自然吃掉。
再串一次 2.3 节那轮回改在剪辑段的模样:分镜 v3 只动了场 2 的两个镜头,生成段重跑后 04_clips 里只有两个文件换了新;剪辑段重跑 main 函数——载入、截齐、叠字幕、拼接、导出,全程没有一处需要人手调整,因为所有剪辑决定都来自 approved 分镜;年检先确认全部镜头时长达标,渲染完成的视频轨替换 build/05_timeline 里的旧文件,deliver 落一枚新的时间戳制品。剪辑段对「改一场不改全片」的贡献就在这里:它重跑的成本与改动大小成正比,而不是与全片长度成正比。
# transitions.py —— 叠化转场与硬切(写法示意) from moviepy.editor import CompositeVideoClip, concatenate_videoclips def crossfade(clip_a, clip_b, seconds=0.5): """叠化:b 从 a 的尾部交叠入场。""" clip_b = clip_b.set_start(clip_a.duration - seconds).crossfadein(seconds) return CompositeVideoClip([clip_a, clip_b]) def hard_cut(clips): """硬切:顺序拼接,分镜 transition 为「切」时的默认。""" return concatenate_videoclips(clips, method="compose")
转场的工程要点:叠化要吃时长——两镜总长会缩短交叠那零点几秒,排时间线时要么从分镜预算里预留,要么接受总时长微缩;时间线 JSON 的对账(1.2 节校验)允许 1.5 秒容差,就是为了给转场留活口。文字叠加的要点:中文字体必须显式指定(font 参数给到含中文字形的字体名或路径),否则字幕是方框;caption 模式自动换行,宽度给画面宽减去两侧留白。
# pip_layer.py —— 画中画:主画面右下角叠小窗(写法示意) from moviepy.editor import CompositeVideoClip def picture_in_picture(main_clip, small_clip, ratio=0.28): small = small_clip.resize(width=int(main_clip.w * ratio)) small = small.set_position(("right", "bottom")).set_duration(main_clip.duration) return CompositeVideoClip([main_clip, small])
画中画在本书主线里用得不多(知识短片偶尔叠演示窗口),但在口播加演示的内容形态里是标配:主画面是生成或实拍,小窗是讲者或屏幕录制,两层各自独立成轨,时长以主画面为准。
导出参数与 5.1 的成片档对齐:libx264、CRF 18 到 23、帧率与目标平台一致。性能三提示:其一,moviepy 的渲染是逐帧过 Python,比裸 ffmpeg 慢一个量级,长片先把纯拼接部分交给 ffmpeg(流复制),只把需要图层的镜头留给 moviepy;其二,TextClip 与 CompositeVideoClip 是性能大头,字幕量大的片子考虑改成 5.1 的 subtitles 滤镜烧录;其三,moviepy 1.x 到 2.x 的 API 有明显更迭(导入路径与链式方法改名),升级版本时以官方迁移说明为准,本书示例锁定「写法示意」口径。
| 症状 | 根因 | 处置 |
|---|---|---|
| 中文字幕全是方框 | font 未指定含中文字形的字体 | 给 font 传中文字体名或字体文件路径 |
| 渲染慢得离谱 | compose 拼接加逐帧过 Python | 纯拼接分流给 ffmpeg 流复制;字幕改 subtitles 滤镜 |
| 内存占用随镜头数一路涨 | 片段对象与解码资源未释放 | 渲染完显式关闭对象,长片分批渲染再拼 |
| 代码换台机器就报属性错误 | 1.x 与 2.x 的 API 更迭 | 锁定版本;按官方迁移说明改导入与方法名 |
| 导出的成片没有声音 | 本章刻意不挂音轨 | 属预期;有声流程在后半本第 6 章 |
| 叠化后总时长比预期短 | 交叠部分被两个镜头共用 | 分镜预算预留,或接受容差内微缩 |
这张表里一半的问题其实属于「分层没分对」:该给 ffmpeg 的活(转码、烧字幕、流复制)交给了 moviepy,慢和怪就会一起来。编排层只做编排,是本节从头到尾的那条底线。
镜头拼成轨了,但有一类画面根本不该进 moviepy——动态图形。下一节的 HTML 渲染路线把字幕条与数据可视化交还给 Web 技术。