我把字幕翻译做成了一个 Skill
我原本只想从视频中取出字幕,交给大模型翻译,再生成简体中文 SRT。上下文分块、时间轴绑定、模型输出错位、失败重试和术语一致性,却让这个简单流程逐渐长成了一个可验证、可恢复的字幕翻译 Skill。
我最近开源了一个面向 Codex 的视频字幕 Skill。它可以列出或提取视频中的文本字幕,也可以把电影、单集或整季电视剧翻译成简体中文 SRT 外挂字幕。
一开始,我并没有打算把它做得这么复杂。
为什么是 SRT
我一直对 SRT 格式比较情有独钟。
它的内容很简单,基本上只有序号、时间和文本。它不能指定字体,我实际保留的样式控制,也只有许多播放器能够识别的 {\an8}:把字幕放到画面上方。
功能少,在这里反而是一件好事。字体、字号、颜色和位置都由观看者在播放器里决定。SRT 支持广泛,渲染轻便,也不会把制作者的显示环境强加给用户。
最开始只是抽出来,再放回去
一开始,我只是想做一个很简单的东西:把 SRT,或者视频文件中的其他文本字幕流抽出来,交给大模型翻译,最后生成一个简体中文 SRT 文件,重新放回视频所在的文件夹,作为外挂字幕使用。
源视频不需要修改,也不需要重新封装。整个过程看起来只有三步:
- 取得字幕
- 翻译字幕
- 写回外挂字幕
真正开始处理自己的媒体库以后,第一个问题很快出现了:视频不一定在本机。它可能放在 NAS、挂载硬盘、SMB 或 WebDAV 上,一次断网、休眠或者挂载失效,都可能让长时间的翻译停下来。
于是 Skill 有了本地工作区。它先把完整的字幕源复制或提取到本机,再开始处理;远端随后即使离线,翻译也可以继续,完成以后再把 SRT 同步回视频旁边。
这是第一次复杂度上升。
字幕不只有对白
普通字幕轨道可能只有对白,而 CC 或 SDH 字幕会包含更多信息。因此 Skill 会优先选择完整、非强制的英文 CC/SDH 文本轨道。
其中的说话人标签、笑声和脚步声等辅助说明,需要在翻译时清理。但 CC/SDH 有时还会收录歌词,以及招牌、纸张、手机和电视画面里出现的文字。这些不是应该删除的噪音。Skill 会保留并翻译真正的歌词和画面文字,再用 {\an8} 把它们显示在画面上方。
双关则更麻烦。如果第二层含义无法自然地进入中文,Iris 会把正常或直译台词留在下方,再生成一条很短的中文说明放在上方,只解释丢失的那层关系,不在字幕里讲一堂语言课。
内部模型仍然把原字幕视为一条稳定记录,只允许它携带一个受约束的 pun_note。最后渲染时,程序才把这条说明展开成额外的顶部 SRT,并为它重新安排编号和阅读时间。这样可以增加一条字幕,又不破坏原来的 ID 和时间轴关系。
字幕还要保留原文的信息揭示顺序。中文可以调整语序,却不能把后一句才公开的人名、关系或证据提前说出来。这部分我在上一篇字幕翻译中的信息揭示顺序里详细写过。
时间码是一种脆弱的绑定
SRT 是文本,但它不是普通文本。每一句话都通过时间码和画面建立联系。
一整集字幕不适合一次全部交给大模型,因此必须少量多次地输入。现在每个块翻译 50 条目标字幕,并带上前后最多各 10 条只读字幕作为上下文。模型可以参考它们,却只能翻译当前块。
分块解决了上下文窗口和失败范围的问题,重叠又尽量补回了分块切掉的语境。这是第二次复杂度上升。
但分块还不够。大模型是语言模型,不是数据库。输入 50 条字幕,不代表它一定会输出 50 条。它可能漏掉、合并或者重复,也可能把下一句话提前翻译到上一句。
所以,字幕不能再依赖“第几行”这种位置关系。
Skill 会先给整集字幕中的每一条台词分配一个从 1 开始递增的 ID。模型收到的是带有 ID 的记录,返回时也必须为每个目标 ID 提供且只提供一条结果。最终合并依靠 ID,而不是模型输出的顺序。
只要源字幕不变,这些 ID 就是稳定的。
怎样知道源字幕有没有改变?Skill 会根据规范化后的时间和文本计算 SHA-256 指纹。时间码或文本一旦变化,旧的分块、重试和翻译记录就不会再被误用。
ID 解决一句字幕是谁的问题,指纹解决这些 ID 属于哪个版本的问题。这是第三次复杂度上升。
允许 AI 提反对
翻译一整集字幕已经要消耗大量 token。如果再请另一个大模型逐条审查,消耗几乎还要增加一倍。所以第二遍检查由 Python 完成:它检查 ID、结构和时间轴,也会寻找没有清掉的说话人标签、笑声和外语残留。
检查失败以后,因为每句字幕都有 ID,Skill 可以只让 Iris 重试出问题的一句或一小片,再把结果按 ID 合并回去。
但程序检查也会误判。这就引出了整个设计里我觉得最有趣的部分:Iris 可以申诉。
有些约束是绝对不能讨论的。Iris 不能申诉说自己可以漏掉一条字幕,也不能绕过 ID、删除、附加内容结构和时间轴的规则。但有些检查来自启发式判断。例如一段正常译文可能恰好被识别成外语残留,角色名字可能看起来像没有删除的说话人标签,或者 Iris 认为原来的表达并没有破坏双关和信息揭示顺序。
在重试时,Iris 可以针对某条字幕和某个具名检查提出异议,要求保留原来的结果。Python 会先确认这项检查是否允许申诉,再把通过的申诉记进翻译记录。程序守住协议,模型则可以质疑带有语义判断、可能误报的规则。
这让校验不必退回到“再找一个模型完整看一遍”,也没有把 Python 的启发式判断假装成永远正确的裁决。
通过校验的分块会立刻保存。只有输出已经无法按 ID 对回原文时,才会重试完整分块;前面完成的工作不会因此丢失。
翻译很慢,等待又很容易让人焦虑。一个任务运行了几十分钟,如果界面上什么都没有,很难判断它是在工作、卡住,还是已经失败。
所以工作区里同时保留两种进度:一份 REPORT.md 给人看,另一份紧凑的 JSON 状态给机器恢复和监控。翻译可以中断,也可以从已经通过校验的分块继续。
整个翻译过程,其实就是不断地中断、重试、检查、写进度,然后继续。循环可以被打断,但总有状态把下一次继续接回上一次。
重试和进度并不是附属功能。对一个可能运行数小时甚至更久的任务来说,它们本身就是正确性的一部分。
一部剧需要同一本词典
单集翻译稳定以后,又出现了更长距离的一致性问题。
同一个人名、地名或者组织名,可能在不同集数里出现不同译法。于是每部电影或电视剧在工作区中都有一份共享的 Markdown 术语表;电视剧的不同季也会共用它。
术语表的维护需要观察更长范围内的候选词,也值得使用成本更高的模型。到这里,Skill 里出现了两个角色:
- Iris 使用
gpt-5.6-luna,负责逐块翻译字幕 - Atlas 使用
gpt-5.6-terra,负责维护整部作品共享的术语表
Iris 提交翻译和术语候选,Atlas 只编辑那一份 Markdown 术语表。术语维护失败时,已经通过校验的翻译仍然保留;Atlas 可以单独重试,不需要让 Iris 重新翻译一遍。
这不是让两个模型重复审稿,而是让它们分别处理局部翻译和长期一致性。
最后还是一个 SRT
现在回头看,这个 Skill 已经远远超过最初的“抽出来,翻译,再放回去”。它有本地暂存、来源指纹、稳定 ID、重叠分块、确定性校验、缓存、局部重试、进度状态和术语表。
这些复杂度并不是一开始设计出来的。每一层都来自一个真实发生过的问题:远端会断开,上下文会被切断,模型会漏行,内容会提前,调用会失败,人名会漂移。
它就这样一点点滚成了雪球。按照开源时的版本计算,Python 源码和测试已经有 5826 行;再算上规则文档、Schema 和其他文本文件,整个 Skill 超过 6400 行。一个原本只有三步的小工具,最后变成了这么一个大东西。
但它最后交给播放器的,仍然只是一个简单的 SRT 文件。
这也是我喜欢这个格式的原因。生成过程可以足够谨慎,工作区可以保留足够多的状态和校验,最终结果却不需要把这些复杂度交给观看者。
项目已经开源在 EdgarDegas/subtitle-skills。把仓库地址交给 Codex,让它安装 video-subtitles Skill,就可以从列出字幕轨道、提取文本字幕开始,也可以直接为一部电影或一整季电视剧生成简体中文外挂字幕。