本节摘要:本节把"先配音、后剪辑"从主张变成脚本。先用对比表说明这一变体与"先画面后配音"的适用边界;然后给出核心一跃——台词实测时长加余量反推镜头的画面时长,写成 plan_shot_lengths.py,直接生成喂给第 03 章生成参数的镜头计划;接着用 build_voiceover.py 把逐句配音拼成全片音轨,并顺带产出每个镜头的音频起止锚点表;再以三层校验收口:文件级查存在与采样率,时长级查"视频时长不小于台词时长",内容级预告第 07 章用 ASR 回听核对;最后给出不同步时的四种修复手段与优先级。至此,音频层与画面层在数据结构上正式咬合。
第 6.1 节已经倾向先配音,这里把账算完整。先配音的代价是:剧本必须先行定稿,中途大改台词会牵动分镜与镜头计划。它的收益是:镜头时长有依据、节奏有锚、返工粒度小。综合下来:叙事片(短剧、解说、科普)用先配音;音乐驱动或纯氛围片(视觉 MV、空镜合集)用先画面后配音,音乐本身就是时间轴。
| 维度 | 先配音后画面(本章主线) | 先画面后配音 |
|---|---|---|
| 镜头时长 | 台词实测时长加余量 | 按 BGM 小节或感觉估 |
| 台词改动 | 重跑该句及其镜头 | 只重跑配音 |
| 节奏 | 音频定节奏,画面贴 | 画面定节奏,配音凑 |
| 典型作品 | 短剧、解说、科普 | MV、氛围短片 |
这是本章最值钱的一张表。输入是第 02 章的剧本结构与第 6.1 节的逐句时长清单,输出是每个镜头的画面时长——也就是第 03 章文生视频与图生视频的时长参数来源。规则很简单:台词实测总时长,加一段留白余量,再夹在最小与最大镜头时长之间。留白让镜头有呼吸,最小值保证转场不显突兀,最大值约束生成成本与叙事密度。
# plan_shot_lengths.py —— 台词实测时长反推每个镜头的画面时长 import json PAD, MIN_SEC, MAX_SEC = 0.8, 2.0, 8.0 def plan(script="build/script.json", durations="build/voice_durations.json", out="build/shotplan.json"): doc = json.load(open(script, encoding="utf-8")) dur = json.load(open(durations, encoding="utf-8")) shots = [] for shot in doc["shots"]: speech = sum(dur[f"{shot['id']}_{line['id']}.wav"] for line in shot.get("lines", [])) sec = min(MAX_SEC, max(MIN_SEC, speech + PAD)) shots.append({"id": shot["id"], "speech_sec": round(speech, 2), "video_sec": round(sec, 1)}) with open(out, "w", encoding="utf-8") as f: json.dump({"shots": shots}, f, ensure_ascii=False, indent=2) print(f"镜头计划已生成:{len(shots)} 个镜头 → {out}") if __name__ == "__main__": plan()
三个口径值都可调:对话密集的快节奏剧把 PAD 降到 0.5 秒、MAX_SEC 降到 6 秒;抒情段落放宽。改口径只改常量,不改逻辑——这也是把经验值放显眼位置的意义。
镜头时长定下后,把逐句配音按镜头顺序拼成一条连续音轨,镜头之间垫固定静音。拼接脚本顺带做一件更重要的事:记录每个镜头在整条音轨上的起止时间,形成锚点表。锚点表是后续所有时间相关环节的公共参照——字幕分段不跨锚点、转场音对齐锚点、第 07 章转写结果按锚点归属镜头。
# build_voiceover.py —— 拼接台词与镜头间静音,产出全片音轨与锚点表 import json, os, struct, wave VOICE_DIR, GAP_SEC, RATE = "audio/voice", 0.6, 16000 def load(path): with wave.open(path, "rb") as w: assert w.getframerate() == RATE and w.getnchannels() == 1, path return w.readframes(w.getnframes()) def silence(sec): return struct.pack("h", 0) * int(sec * RATE) def main(plan_file="build/shotplan.json", out="audio/voice_full.wav", anchors_out="build/anchors.json"): plan = json.load(open(plan_file, encoding="utf-8"))["shots"] anchors, buf = [], b"" for shot in plan: start = len(buf) / (2 * RATE) for name in sorted(os.listdir(VOICE_DIR)): if name.startswith(shot["id"] + "_"): buf += load(os.path.join(VOICE_DIR, name)) buf += silence(GAP_SEC) anchors.append({"shot": shot["id"], "start": round(start, 3), "end": round(len(buf) / (2 * RATE) - GAP_SEC, 3)}) with wave.open(out, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(RATE) w.writeframes(buf) with open(anchors_out, "w", encoding="utf-8") as f: json.dump(anchors, f, ensure_ascii=False, indent=2) print(f"全片音轨 {len(buf) / (2 * RATE):.2f}s → {out};锚点表 → {anchors_out}") if __name__ == "__main__": main()
拼接用的 voice_full.wav 还是"干声",第 6.2 节的混音与归一化在其上进行,顺序不可颠倒——先混乐再拼接会让闪避的时间点全部错位。
对齐不是"拼完了就算对",要按层验收。文件级查存在性与采样率一致性——采样率不一致时 wave 与 ffmpeg 都会给出含糊的报错,越早拦越便宜;时长级查每个镜头的实际视频时长不小于其台词时长加容差——这是硬性错误,画面短于台词必然吃字;内容级用 ASR 把成片音轨转写回来,与台本比对——它同时校验 TTS 是否漏读、跳读,这一层在第 07 章实现。
三层校验的拦截目标 文件级 存在?非零字节?采样率一致? ──▶ 拦"文件坏了" 时长级 视频时长 ≥ 台词时长 − 容差 ──▶ 拦"画面吃字" 内容级 ASR 回听转写 ≈ 台本(第 07 章) ──▶ 拦"读错漏读"
时长级的实现:
# check_alignment.py —— 时长级对齐校验:视频时长不小于台词时长减容差 import json, subprocess TOLERANCE = 0.3 def probe_duration(path): cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", path] info = json.loads(subprocess.run(cmd, capture_output=True, text=True).stdout) return float(info["format"]["duration"]) def main(plan_file="build/shotplan.json", clips_dir="build/clips"): bad = 0 for shot in json.load(open(plan_file, encoding="utf-8"))["shots"]: video = f"{clips_dir}/{shot['id']}.mp4" actual = probe_duration(video) need = shot["speech_sec"] ok = actual + TOLERANCE >= need bad += not ok print(f"{shot['id']}: 台词 {need:.2f}s / 视频 {actual:.2f}s " f"{'通过' if ok else '不足'}") raise SystemExit(1 if bad else 0) if __name__ == "__main__": main()
退出码非零即失败,这让本脚本能直接挂进第 8.1 节的技能验收条件与第 10.3 节的质量门禁,而不需要人盯着看输出。
校验失败时,按以下优先级处置,从便宜到贵:
| 优先级 | 手段 | 适用情形 | 代价 |
|---|---|---|---|
| 一 | 镜头间补静音或缩短静音 | 整体漂移、镜头边界错位 | 近零 |
| 二 | 画面延长或裁短几帧 | 视频略长于台词,节奏略松 | 剪辑层解决 |
| 三 | atempo 微调音速(正负一成以内) | 台词与画面差半秒内,听不出变速 | 重混一次 |
| 四 | 改分镜重生成 | 结构性不匹配 | 重跑镜头 |
# fix_tempo.sh —— 台词微变速贴合画面(atempo 建议控制在 0.9 到 1.1 之间) ffmpeg -y -i audio/voice_full.wav -af "atempo=1.05" audio/voice_retimed.wav
超过一成的变速会明显改变语感,此时该走第四级:改分镜。宁可承认镜头时长规划错了,也不要让角色像被快进。
音频层至此闭环:能配音、会混音、对得上。但观众还要"看得懂"台词——下一章把成片音轨交给 Whisper 转写回文字,既产出字幕,也反向校验配音质量。