tobatsu / voiceover-align / subtitle-burn-rendi

韩文 / 日文字幕烧出来是 □□□□

用户视角:voiceover_align 跑完,视频上的字幕全是豆腐框。一路从 ElevenLabs 拉到最后烧字幕,证据链摆出来 —— 上游 SRT 干净,bug 在 subtitle-burn-rendi v14 的 Mode B 那一步。

bug · subtitle-burn-rendi v14 Mode B root cause 韩文 task ce64140e 日文 task 6302c174

01现象

同一份韩文字幕,应该长这样,实际烧出来长那样:

期望 · Noto Sans CJK KR 渲染
자재
这是韩语「자재」(意思是材料)。字体里有 Hangul 字形,libass 正常渲染。
实际 · Montserrat 渲染
□□
同一段字符喂给 Montserrat(拉丁字体),没有韩文 glyph 就回退到 .notdef 方块 —— 也就是俗称的豆腐字。日文同理。
* 这一栏的"豆腐"是用 font-family: 'Montserrat' + 真的韩文字符现场渲染出来的,不是 emoji 模拟 —— 用浏览器审查元素看就懂了。

02流水线 · 5 个阶段,哪个在掉链子

voiceover_align 是个 5 段流水线。把每一段的"输入 / 输出 / 我们能不能验证它干净"列出来,bug 就只能在某一段里:

01
ElevenLabs v3 合成
OK
02
我们生 word-level SRT
OK
03
SRT 上传 R2 + Rendi 合视频
OK
04
subtitle-burn-rendi v14 Mode B
bug
05
回调 Mai
N/A
流水线视角的关键问:每一段的产物我们能不能拿到、能不能验证它"对"。能验证 + 验证过了 = 这一段不背锅。能验证 + 验证不过 = 锅在它。

03上游证据 · SRT 是干净的

这一节只为一件事:证明从 ElevenLabs 出来、到我们上传 R2 之间,韩文字符全程 UTF-8 没坏。

检查点证据结论
R2 上 SRT 的字节 file voiceover-subtitles.srtUTF-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 自己选
자재 1 · 00:00:00,000 → 00:00:00,221
옮기면 2 · 00:00:00,441 → 00:00:00,882
돈이 3 · 00:00:00,993 → 00:00:01,250
팡팡! 4 · 00:00:01,471 → 00:00:02,353
5 · 00:00:02,427 → 00:00:02,500

这意味着 ElevenLabs 的 alignment 接口、我们 helper 的 build_word_level_srt_from_elevenlabstobatsu-agent output upload 这一整段,全部没问题。豆腐不是从这里出来的。

04subtitle-burn-rendi Mode B 实际代码路径

SRT 干净了,问题只能在烧字幕这一步。把 burn.py v14 在 Mode B 走的 4 步逐行拆开:

step 1 · burn.py L689

读默认 sample ASS — Style 行写死 Montserrat

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。这一步还看不见韩文字符,跟语言无关。

step 2 · burn.py L696

apply_layout_overrides — 只动数字字段

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 没办法显式指字体。

step 3 · burn.py L507

_scale_style_block — 只按比例乘数字字段

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) 完全不碰

step 4 · burn.py L437

render_full_ass — 拿到 script 参数但没拿来选字体

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。

05反讽 · FONT_BY_SCRIPT 这字典定义了但从未被用过

⚠ dead code — designed but never wired

burn.py L70-78:明显是为这件事准备的,但接线漏了

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_DIRCHARS_PER_LINE 旁边,意图非常清楚 —— 检测到 cjk 就用 CJK 字体、阿拉伯就用 Arabic 字体。问题就在定义完之后没人去 import / reference 它render_full_ass 拿到 script 也只是去查 CHARS_PER_LINE,没去查 FONT_BY_SCRIPT

字体文件本身在 /opt/tobatsu/assets/subtitle-fonts/ 全装好了(NotoSansCJK-Bold.ttcNotoSansArabic-Bold.ttf…),缺的就是把 Fontname 改一行的代码。

06修法 · 3 个候选 + 我推荐 A

谁修、修哪儿、付代价:

方案 A★ 推荐

在 subtitle-burn-rendi 里修

burn.py · render_full_ass / _scale_style_block

parts[1] = FONT_BY_SCRIPT[script] 接上 —— 一两行的事。

  • 一处修,所有 caller 受益 (含 Mode C)
  • FONT_BY_SCRIPT 字典就是为这事准备的
  • burn.py 的 script 检测已经覆盖 Hangul 范围 (0xAC00–0xD7AF)
  • 需要 subtitle-burn-rendi owner 同意 + 发 v15
方案 Bworkaround

--override-fontname CLI

burn.py + voiceover_align WORKFLOW.md

caller 按 subtitle.language 自己映射后显式传字体名。

  • voiceover_align 这边能自己兜住韩日中
  • burn.py 还是要改加 CLI
  • Mode C 用户仍然中招
  • 把映射责任泄露到每个 caller
方案 Chack

caller 自己改最终 ASS

voiceover_align 里再加一道 sed

调 burn.py 之前 / 之后把 Style.Fontname 替换掉。

  • 不用动 burn.py
  • 把 burn.py 的内部细节泄露到 caller
  • burn.py 哪天改了 sample 文件就崩
  • 不优雅,纯救火

07下一步 · 等你拍板

两件事,等你决定走哪条:

* 如果走方案 A,建议同步在 SKILL.md 里加一句"Mode B 会按 SRT 文本自动选字体,caller 不用手动指定",免得下一个调用方又重复踩这个坑。