第26章:字幕断句与导出
本章分析 internal/pkg/asr/split_utterance.go 与 subtitle_txt.go:把 ASR 分句加工成「适合视频字幕」的短行——按标点断句、超长均分(英文词与数字词不拆)、词级时间对齐或比例分配兜底。这是一个算法密度很高的小模块,读它可以学到「产品规则如何翻译成纯函数」。
流程
一句话从 ASR 到字幕行的旅程:SplitUtteranceForCaptions(u Utterance) []TimedSegment 输入一条分句(可能长达几十字),输出若干字幕行(每行 ≤ 12 字,MaxCaptionRunes 常量)。处理分四步:
- 标点断句:
splitByPunctuation按断句标点(。!?;等,isBreakPunctRune 判定)切成子句; - 边缘标点清理:
trimCaptionEdgePunct剥掉行首行尾的空白与断句标点(含省略号…/...的特判)——注释明示产品规则:「成片每行会剥离首尾断句标点,保证行首行尾均非标点」; - 超长均分:子句仍超 12 字则
splitBalancedPreferIntact均分,但英文词与数字词(captionAtom 的keepIntact标记)整段不拆——「Beautiful」不能被劈成「Beauti」+「ful」,「2024」不能变「20」+「24」; - 时间分配:优先
assignTimesWithWords——用词级时间戳按各行的字符覆盖区间精确切时间;无词数据时退化assignTimesProportional按字符数比例线性插值。
字幕导出(subtitle_txt.go)则在段落层把全文拼成带说话人标记的纯文本 TXT(供素材页下载,第 21 章的 DownloadASRSubtitle)。
实现
算法实现的三处巧思:
- captionAtom 三元组:
{text, keepIntact, runes}把「原子不可拆」的语义建模成数据——英文词/数字词整段成一个 atom(keepIntact=true),中文每字一个 atom。均分算法面对的是同构的原子序列,不用特判文种。 SplitHostPort式的 rune 操作:全部字符串处理按[]rune(unicode/utf8),中文、emoji、英文混排的字数统计按 Unicode 码点——与 model 包的VideoProjectTitleMaxRunes同一套计数观(第 11 章),中英文混合不出「字节计费」事故。- 两级时间分配:词级对齐是理想路径(ASR 开了 show_utterances 必有 words),比例分配是兜底(老数据或上游降级)——精度能力逐级降级而不失败。
设计上该模块是纯函数集合:输入 Utterance 输出切片,无 IO、无状态,split_utterance_test.go 用表驱动测试覆盖中文长句、英文句、数字、无词数据、全标点等边界——算法正确性靠穷举用例保证。
字幕行长度 12 字的选择:竖屏短视频(9:16)单行可视宽度约 12~16 字,12 字是安全上限;这与剪映草稿字幕轨的默认样式参数(第 41 章 CaptionsStep)配合,成片无需人工调字幕。
📌 设计决策
- 断句规则在 Go 侧实现而非交给剪映:剪映自动字幕不可控,Go 侧纯函数可测试、可迭代、跨项目复用(字幕 TXT 导出也用同一套)。
- keepIntact 保护西文词:中文可任意断(读者可容忍),西文断词不可读——一行规则避免大量返工。
- 词级时间优先、比例兜底:精确度取决于上游能力但不被其绑架。
代码示例
断句主流程(四步管线):
// internal/pkg/asr/split_utterance.go(节选)
// MaxCaptionRunes 单条字幕最大字数(按 Unicode rune 计,含标点与英文字母)。
const MaxCaptionRunes = 12
// SplitUtteranceForCaptions 将一句 ASR 按标点断句,超长则均分
//(英文词与数字词不拆),并切分时间。
func SplitUtteranceForCaptions(u Utterance) []TimedSegment {
text := strings.TrimSpace(u.Text)
if text == "" {
return nil
}
var lines []string
for _, clause := range splitByPunctuation(text) {
clause = trimCaptionEdgePunct(clause) // 行首行尾非标点
if clause == "" {
continue
}
for _, line := range splitBalancedPreferIntact(clause, MaxCaptionRunes) {
line = trimCaptionEdgePunct(line)
if line == "" {
continue
}
lines = append(lines, line)
}
}
if segs, ok := assignTimesWithWords(u, lines); ok {
// 词级时间对齐
return segs
}
return assignTimesProportional(u, lines) // 比例插值兜底
}
边缘标点清理(含省略号特判):
// trimCaptionEdgePunct 去掉首尾空白与断句标点(含 … / ...),
// 使成片行首行尾非标点。
func trimCaptionEdgePunct(s string) string {
runes := []rune(strings.TrimSpace(s))
for len(runes) > 0 {
if len(runes) >= 3 && runes[0] == '.' &&
runes[1] == '.' && runes[2] == '.' {
runes = runes[3:]
continue
}
if runes[0] == '…' || isBreakPunctRune(runes[0]) {
runes = runes[1:]
continue
}
break
}
// 尾部同理省略……
return string(runes)
}
小结
- 字幕断句 = 标点切分 → 边缘清理 → 12 字均分(西文词不拆)→ 词级/比例两级时间分配。
- 纯函数 + 表驱动测试,算法规则全部产品语义化(行首非标点、西文不断词)。
- 12 字上限与竖屏字幕样式、剪映轨参数三方对齐。
思考题
- 均分算法遇到「一个英文词就超 12 字」(如超长 URL 朗读文本)会怎样?该加什么兜底?
- 词级时间对齐在多行共享一个词的时间窗时如何避免行间重叠?找出 assignTimesWithWords 的处理方式。
项目信息
- GitHub仓库:Chyona/live-mixer