本节摘要:转写段离"好字幕"还差三步:切、装、检。切,是把秒级 segment 按每行字数上限、标点气口与镜头锚点切成 cue,并用最短与最长停留时长钳制——一条 cue 是观众的一次注视,不能太长也不能一闪而过。装,是选容器与样式:SRT 简单通用适合外挂,ASS 带完整样式层适合烧录;字体、字号、描边、边距收进一份 style 模板,改一处全片生效。检,是静态校验加人工快放:重叠、越界、超字数由脚本拦截,观感由人眼最后把关。本节末尾辨析烧录与外挂的取舍,并给出竖屏安全区的口径建议。
转写给的 segment 是"声学段落",不是"阅读单位"。观众读字幕的速度有限,经验口径是:中文每行不超过十四到十八字,一条 cue 最短停留约一点二秒、最长约六秒,切分点优先选标点与气口,且不跨越镜头边界(用第 6.3 节锚点表判定)。三条规则的优先级:镜头边界高于标点,标点高于字数——宁可让一条 cue 短一点,也不能让它横跨一次画面切换。
切分示意(一条 segment 的三种命运) segment 原文:暴雨里的最后一单,备注写着没有收件人。 │ ┌──────────┼───────────────┐ 字数超限? 有标点? 跨镜头锚点? 否→整段一条 是→按标点切 是→在锚点处强制切 ▼ cue1: 暴雨里的最后一单, [0.8s, 2.6s] cue2: 备注写着没有收件人。 [2.6s, 4.9s]
# make_cues.py —— 转写段加工为字幕 cue:切长、并短、钳时长、不跨锚点 import json, re MAX_CHARS, MIN_DUR, MAX_DUR = 16, 1.2, 6.0 def split_by_punct(text): parts = re.split(r"(?<=[,。?!;、])", text) return [p for p in (s.strip() for s in parts) if p] def shot_at(t, anchors): for a in anchors: if a["start"] <= t < a["end"]: return a["shot"] return anchors[-1]["shot"] def build(segs, anchors): cues, cur = [], None def flush(): nonlocal cur if cur: cues.append(cur) cur = None for seg in segs: start, end = seg["start"], seg["end"] for piece in split_by_punct(seg["text"]): if cur and shot_at(start, anchors) != shot_at(cur["start"], anchors): flush() if (cur and len(cur["text"]) + len(piece) <= MAX_CHARS and start - cur["end"] <= 0.4): cur["text"] += piece cur["end"] = end else: flush() cur = {"start": start, "end": end, "text": piece} flush() for c in cues: c["end"] = min(c["start"] + MAX_DUR, c["end"] + MIN_DUR) \ if c["end"] - c["start"] < MIN_DUR else min(c["end"], c["start"] + MAX_DUR) return cues def main(src="build/asr_fixed.json", anchors="build/anchors.json", out="build/cues.json"): doc = json.load(open(src, encoding="utf-8")) anchor_list = json.load(open(anchors, encoding="utf-8")) cues = build(doc["segments"], anchor_list) with open(out, "w", encoding="utf-8") as f: json.dump(cues, f, ensure_ascii=False, indent=2) print(f"生成 {len(cues)} 条 cue → {out}") if __name__ == "__main__": main()
钳制逻辑读作:短于最短停留的拉到下限,长于最长停留的截到上限。宁可字幕比声音早消失半秒,也不要让它拖到下一个画面里。
SRT 是最小公约数:序号、起止时间、一行文字,三件事。它没有样式层,颜色字体全由播放端决定,适合作为外挂字幕与平台上传的通用格式。
# output.srt —— SRT 结构示意 1 00:00:01,240 --> 00:00:03,120 暴雨里的最后一单。 2 00:00:03,400 --> 00:00:05,900 备注写着:没有收件人。
ASS 在时间轴之外增加了样式层:一个 Style 行定义字体、字号、颜色、描边、边距与对齐锚点,正文通过样式名引用。烧录场景几乎都用它,因为烧录后样式就凝固在画面里,必须精确可控。
# output.ass —— ASS 样式层结构示意(字段以 ASS 规范为准) [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, Outline, MarginV, Alignment Style: Main,思源黑体 CN,54,&H00FFFFFF,&H00101010,2,60,2 [Events] Format: Layer, Start, End, Style, Text Dialogue: 0,0:00:01.24,0:00:03.12,Main,暴雨里的最后一单。 Dialogue: 0,0:00:03.40,0:00:05.90,Main,备注写着:没有收件人。
| 对比项 | 烧录(进画面) | 外挂(独立文件) |
|---|---|---|
| 兼容性 | 任何播放器都带字幕 | 依赖播放器与平台字幕轨支持 |
| 发布后修改 | 改一个字要重新出片 | 换文件即可 |
| 样式控制 | 完全可控,所见即所得 | 受播放端渲染影响 |
| 适合场景 | 分发到不受控环境 | 多语言版本、平台支持字幕轨时 |
实操建议:保留 ASS 母版与外挂 SRT 作为资产,分发版本按平台要求决定是否烧录。烧录用 ffmpeg 的 subtitles 滤镜:
# burn_sub.sh —— 烧录字幕(建议相对路径,规避盘符冒号的转义问题) ffmpeg -y -i build/rough_cut.mp4 \ -vf "subtitles=build/output.ass:fontsdir=assets/fonts" \ -c:a copy deliverables/burned.mp4
两个经典坑都在命令细节里:其一,滤镜参数里的路径要避开 Windows 盘符冒号(它会被当作选项分隔符),进工作目录用相对路径最省心;其二,中文字体必须可用,fontsdir 指向资产库字体目录(第 11.2 节的资产库结构),否则烧出来的字幕是方块。
字幕样式的需求是典型的"晚定型、常微调":片子快做完了才知道字体偏小、描边偏脏。把样式收进模板文件,而不是散在几十条 Dialogue 里:
# style.json —— 字幕样式模板(数值为竖屏 1080x1920 的口径示例) { "font": "思源黑体 CN", "fontsize": 54, "primary": "&H00FFFFFF", "outline_color": "&H00101010", "outline": 2, "margin_v": 60, "alignment": 2, "bilingual": false, "safe_note": "竖屏下避开右侧互动热区与底部进度条,以各平台界面现状为准" }
模板由生成脚本读入并渲染成 ASS 的 Style 行,全片只有这一处定义。竖屏的额外注意:字幕区既要离底边足够远(进度条与手势条会压过来),也要避开右侧点赞评论热区——具体热区随平台版本变动,以各平台界面现状为准,模板里的 safe_note 就是提醒发布前实测一次。
交片前跑一遍 cue 体检:相邻 cue 不得重叠、结束不得越过片长、单条不超字数与时长上限。
# validate_cues.py —— cue 静态校验:重叠、越界、超字数、超时长 import json, sys MAX_CHARS, MAX_DUR = 16, 6.0 def main(cues_file="build/cues.json", total=None): if total is None and len(sys.argv) > 1: total = float(sys.argv[1]) cues = json.load(open(cues_file, encoding="utf-8")) problems = [] for i, c in enumerate(cues): if len(c["text"]) > MAX_CHARS: problems.append(f"{i}: 超过每行 {MAX_CHARS} 字「{c['text']}」") if c["end"] - c["start"] > MAX_DUR: problems.append(f"{i}: 停留超过 {MAX_DUR}s") if total and c["end"] > total: problems.append(f"{i}: 结束时间越界") if i and cues[i - 1]["end"] > c["start"]: problems.append(f"{i}: 与上一条重叠") print("\n".join(problems) or f"{len(cues)} 条 cue 全部通过") raise SystemExit(1 if problems else 0) if __name__ == "__main__": main()
脚本测得出重叠,测不出"读着累"。人工环节固定为一次一点二五倍速快放:只看字幕,跟不上就说明切分或停留有问题。快放通过,字幕环节收口;该校脚本与流程在第 10.3 节质量门禁中作为常驻检查项。
至此,画面、声音、字幕三层俱全,一条单片的流水线已经闭环。但每一步仍是手工挡位。下一章挂上自动挡:把流水线封装成技能,让 Agent 负责路由与重试,并解决长任务最怕的半途而废。