用户视角:voiceover_align 跑完,视频上的字幕全是豆腐框。一路从 ElevenLabs 拉到最后烧字幕,证据链摆出来 —— 上游 SRT 干净,bug 在 subtitle-burn-rendi v14 的 Mode B 那一步。
同一份韩文字幕,应该长这样,实际烧出来长那样:
.notdef 方块 —— 也就是俗称的豆腐字。日文同理。font-family: 'Montserrat' + 真的韩文字符现场渲染出来的,不是 emoji 模拟 —— 用浏览器审查元素看就懂了。voiceover_align 是个 5 段流水线。把每一段的"输入 / 输出 / 我们能不能验证它干净"列出来,bug 就只能在某一段里:
这一节只为一件事:证明从 ElevenLabs 出来、到我们上传 R2 之间,韩文字符全程 UTF-8 没坏。
| 检查点 | 证据 | 结论 |
|---|---|---|
| R2 上 SRT 的字节 | file voiceover-subtitles.srt → UTF-8 text · 1138 bytes · 195 个非 ASCII 字节 |
clean |
| SRT sha256 | 1f73ab6eb5ea4efb33959563d2c9b7c8faa62ab4c3efd77e7a8047009ec0143b |
match |
| SRT 前几条 cue 文本 | 见下方 | 真韩文 |
| ElevenLabs voice / engine | engine=elevenlabs_v3 · voice_id=EkK5I93UQWFDigLMpZcX |
无报错 |
| 语言字段 | subtitle.language="ko" · font_name=null (caller 没指定字体) |
caller 没指定字体 → 完全依赖 burn 自己选 |
这意味着 ElevenLabs 的 alignment 接口、我们 helper 的 build_word_level_srt_from_elevenlabs、tobatsu-agent output upload 这一整段,全部没问题。豆腐不是从这里出来的。
SRT 干净了,问题只能在烧字幕这一步。把 burn.py v14 在 Mode B 走的 4 步逐行拆开:
def load_default_sample(aspect): p = SAMPLES_DIR / f"pop_kara-{aspect}.ass" return p.read_text(encoding="utf-8") # 文件里的关键一行: Style: PopKara,Montserrat,96,&H00FFFFFF,...
portrait / landscape / square 各有一个 sample 文件,三个文件的 Fontname 全写死成 Montserrat。这一步还看不见韩文字符,跟语言无关。
def apply_layout_overrides(ass_text, args, aspect): overrides = {} if args.override_fontsize: overrides["fontsize"] = ... if args.override_marginv: overrides["marginv"] = ... if args.override_primary_color: ... # Fontname 字段从来不在这里被改
CLI 上有 --override-fontsize / --override-marginv / --override-primary-color,没有 --override-fontname。caller 没办法显式指字体。
def _scale_style_block(block, scale): for line in block.splitlines(): if not line.startswith("Style:"): continue parts = line.split(":", 1)[1].split(",") # 索引 0=Name, 1=Fontname, 2=Fontsize, ..., 21=MarginV # 只对 fontsize/outline/shadow/marginL/R/V 这几个乘 scale # parts[1] (Fontname) 始终原封不动
这一步就是按视频分辨率把 Style 行里的像素值缩一缩。Fontname (索引 1) 完全不碰。
def render_full_ass(words, sample, video_w, video_h, aspect, script): scaled_style_block = _scale_style_block(sample.style_block, scale) # script 在下面只用了 3 次: cpl = CHARS_PER_LINE[aspect].get(script, ...) # 每行字数 line_idxs = layout_lines(chunk, cpl, script) # 行布局 if script != "cjk" and j > 0: parts.insert(-1, " ") # 词间空格 # 然后直接把 scaled_style_block 写出来 —— Fontname 仍是 Montserrat out.write(scaled_style_block + "\n\n")
关键发现:script 参数在 render 路径里被精确地路由到"切几行"、"加不加空格",就是没被路由到 Style 的 Fontname。最终落地的 subtitles/final.ass Style 行仍是 Montserrat。
FONT_BY_SCRIPT = {
"cjk": "Noto Sans CJK SC",
"cyrillic": "Montserrat",
"latin": "Montserrat",
"arabic": "Noto Sans Arabic",
"thai": "Noto Sans Thai",
"hebrew": "Noto Sans Hebrew",
}
# grep -n "FONT_BY_SCRIPT" burn.py
# 70:FONT_BY_SCRIPT = { ...
# ↑ 只有定义这一行,整个文件里再也没引用过
字典定义放在 FONT_DIR、CHARS_PER_LINE 旁边,意图非常清楚 —— 检测到 cjk 就用 CJK 字体、阿拉伯就用 Arabic 字体。问题就在定义完之后没人去 import / reference 它,render_full_ass 拿到 script 也只是去查 CHARS_PER_LINE,没去查 FONT_BY_SCRIPT。
字体文件本身在 /opt/tobatsu/assets/subtitle-fonts/ 全装好了(NotoSansCJK-Bold.ttc、NotoSansArabic-Bold.ttf…),缺的就是把 Fontname 改一行的代码。
谁修、修哪儿、付代价:
把 parts[1] = FONT_BY_SCRIPT[script] 接上 —— 一两行的事。
--override-fontname CLIcaller 按 subtitle.language 自己映射后显式传字体名。
调 burn.py 之前 / 之后把 Style.Fontname 替换掉。
两件事,等你决定走哪条:
选项: (a) 我直接动手发 v15;(b) 你去找 voice-over-cc-fd18 那个 pane / 操作员让那边改。subtitle-burn-rendi 不在我管辖范围,所以默认要你说一声。
日文 task 6302c174 root cause 几乎肯定一样 (日文也走 cjk 分支)。要不要按同样姿势再拉一遍它的 SRT + final.ass 确认,还是先停在这里等方案 1 决定?