captions
从词级别的转录生成 ASS 字幕文件并将其烧录到渲染视频中。
参数:$project 项目 slug,$preset 来自 presets/captions/*.json 的名称。完整请求在
$ARGUMENTS 中。空的 $ 占位符表示该技能是自动触发的,而不是通过斜杠调用:从会话上下文中的 PIPELINE_STATE 获取项目并说明选择了哪个项目。动词名称遵循构建契约;python engine/ve.py --help 为准。
决定此技能是否生效的两个德语特定规则
1. 永不使用 max_chars 分组
德语复合名词是单个不可分割的 token,会突破任何字符限制。
Geschwindigkeitsbegrenzung 有 26 个字符,在 90 像素字号下大约占据 1920 帧的 60%。
max_chars: 22 规则无法将其拆分,因此它要么溢出帧边界,要么分组器静默地发出一行它承诺不会发出的内容。
改为使用 像素宽度预算 进行分组,并对单个超出预算的词应用自动缩小规则。estimate_text_width_px() 负责测量;
group_words() 负责预算。详细信息位于 docs/fonts.md 和 engine/vedit/captions.py 的模块文档字符串中。
这是最常见的德语字幕失败原因,并且完全是可避免的。
2. 字体必须是静态单字重文件,以其精确的 nameID 1 命名
可变字体无法通过 ASS 进行字重选择。InterVariable.ttf 通过 libass 渲染时与 Arial 字节相同 - 相同的 md5 - 同时日志显示它加载了该文件。
返回码 0,无警告。Style: D,Inter,...,Bold=-1 会得到 DirectWrite Arial,直到导出到 YouTube 时才会有人注意到。
仅使用以下字体,并使用精确的 Fontname 字符串和 Bold 值:
| 文件 | ASS Fontname | Bold |
|---|---|---|
Inter-Regular.ttf | Inter | 0 |
Inter-Bold.ttf | Inter | -1 |
Inter-SemiBold.ttf | Inter SemiBold | 0 |
Inter-ExtraBold.ttf | Inter ExtraBold | 0 |
Montserrat-Bold.ttf | Montserrat | -1 |
Montserrat-ExtraBold.ttf | Montserrat ExtraBold | 0 |
assets/fonts/ 目录只包含字体文件。 libass 会加载它找到的每个文件,并且对任何其他文件都会报错 - 一个多余的 README.md 会产生 Error opening memory font 'README.md'。
该目录在设置中因此被列为 ask 权限;如果某个文档与字体相关,它应该放在 docs/fonts.md 中。
步骤 1 - 选择预设
presets/captions/*.json 包含样式、分组预算、位置和高亮行为。
根据 output.platform 选择,而不是凭喜好:
- 垂直平台需要大号、居中、1-3 个词、位于安全区域高处 - 底部三分之一被平台自身的 UI 占据。
- YouTube 长视频需要两行、底部锚定、大小适合手机观看者阅读。
在生成之前与用户确认选择,并说明更改的内容。
步骤 2 - 生成 ASS
python engine/ve.py captions $project --preset $preset
两个结构要求,违反时都会静默失败:
- 23 字段的 V4+
Style: Format:行(Name..Encoding,libass 自身的字段列表 - 第 24 个字段AlphaLevel是 SSA v4 而不是 ASS V4+)和 10 字段的 EventsFormat:行必须是规范的,并且其下的每一行必须有精确相等的列数。数量不匹配会导致 libass 返回码 0,在渲染文本前添加一个多余的逗号,并静默丢失轮廓和粗体。 使用 5 字段的 Format 对 6 字段的 Dialogue 可以复现。captions.validate_ass()在任何烧录之前检查这两个计数;它不是可选的,并且burn_in()在失败时会拒绝执行。 - 文件是 UTF-8 编码。当文件是 UTF-8 时,德语变音符号可以通过 libass 正确往返 -
Größe Straße Übermäßig äöüÄÖÜß已通过端到端验证。
辅助文件写入到 projects/<slug>/out/captions.ass,并从 captions.sidecar_path 引用。
时间码来自与剪切相同的词列表,并通过 _computed 中的时间线位置进行映射。因此,任何剪切更改都会使字幕失效 - 在重新剪切后重新生成,而不是修补。
步骤 3 - 修复文本,而不是转录
数字是可预见的麻烦。--suppress_numerals 在转录时是强制性的,因此当说话者说“20”时,转录会显示 zwanzig。在 captions.overrides[] 中修复为 {word_index, text},永远不要通过编辑转录来修复 - 转录是项目中每个词范围的锚点,重写它会取消剪切同步。
专有名词、产品名称和 GmbH 风格的缩写也是如此。通过扫描低 score 的词一次性收集它们:对齐分数作为填充信号是无用的,但它是“对齐器不确定这个词是什么”的良好信号,而这正是专有名词的样子。
步骤 4 - 烧录
python engine/ve.py render $project
烧录在最终的混流中一次性完成,在拼接后的视频上 - 永远不要在分段上进行。分段烧录会在每个边界处重新渲染相同的字幕,并在切口的每一侧产生部分行。
subtitles= 和 fontsdir= 的转义是精确且不明显的。全程使用正斜杠,并且值用引号括起来,驱动器冒号前加单个转义:
subtitles='C\:/Users/me/.../out/captions.ass':fontsdir='C\:/Users/me/.../assets/fonts'
vedit/ffmpeg.py 中的 escape_filter_path() 负责此操作,并返回包含其引号的字符串 - 不要添加自己的引号。决定因素在于引号,而不是嵌套深度:裸的单个转义形式会因 No option name near '/Users/...' 而失败,只有带引号的形式才能在路径包含逗号、分号或括号时幸存,而这些在 Windows 上都是合法的。
escape_filter_path_nested() 为任何需要的人返回加倍的未加引号变体 C\\:/...,并附带该限制的文档说明。
所有这一切都通过 Python 的 argv 列表进行。在 Git Bash 中构建的相同路径会变成 C\;C:\Program Files\Git\Users\... - 已现场复现,非纯理论。
设置 captions.enabled 和 captions.burn_in,并将 stage 设置为 "captions"。
步骤 5 - 证明字体实际已加载
QC 检查 (e) 是强制性的,这是捕获静默回退的唯一方法。 使用项目样式渲染一个字幕帧,并使用 Arial 控制样式渲染另一个,并断言哈希值不同。相同的哈希值意味着 libass 回退了,并且导出将使用错误的字体。
然后还要确认:Format: 行上的字段计数、没有行超出像素预算、并且没有字幕以留下片段的方式跨越分段边界。
失败模式
| 症状 | 原因 | 修复 |
|---|---|---|
| 字幕看起来像 Arial | 可变字体,或错误的 Fontname | 静态文件 + 精确的 nameID 1 字符串;运行 QC (e) |
| 文本中出现多余逗号,无轮廓 | 格式错误的 Format: 行 | 发出规范的 24/10 字段行 |
Error opening memory font 'README.md' | assets/fonts/ 中存在非字体文件 | 该目录只放字体 |
| 一个长词超出帧边界 | 使用 max_chars 分组 | 像素宽度预算加单字自动缩小 |
No option name near '/Users/...' | fontsdir 单个转义 | 在冒号前加双反斜杠 |
| 字幕拼出数字 | --suppress_numerals,按设计 | captions.overrides 条目 |
| 重新剪切后字幕不同步 | 过时的 ASS | 重新生成;永远不要手动修补时间码 |