#118·video-use

Subtitle burn-in is broken two ways: crashes on a documented macOS install, and renders tofu for non-Latin scripts

Author: yaowenhu-pmCreated Aug 5, 2026Updated Aug 5, 2026

Two bugs in the same code path, both reproducible from a clean install following install.md. Happy to send a PR for either.

1. brew install ffmpeg gives you an ffmpeg without libass, so --build-subtitles crashes

install.md says brew install ffmpeg. On current Homebrew (ffmpeg 8.1.2, arm64 macOS) the default bottle is not built with --enable-libass, so the subtitles, ass, and drawtext filters do not exist at all:

$ ffmpeg -hide_banner -version | grep -o enable-libass
(no output)

$ ffmpeg -hide_banner -filters | grep -E ' (subtitles|ass|drawtext) '
(no output)

Any run with --build-subtitles (or an EDL with a subtitles field) dies with an unhandled CalledProcessError:

subprocess.CalledProcessError: Command '['ffmpeg', '-y', '-i', '.../base.mp4',
 '-filter_complex', "[0:v]subtitles='.../master.srt':force_style='FontName=Helvetica,...'[outv]",
 ...]' returned non-zero exit status 234.

The underlying ffmpeg error is a filter-graph parse failure, which is confusing because it looks like a quoting problem rather than a missing filter:

[AVFilterGraph] No option name near '/.../master.srt:force_style=FontName=Helvetica,FontSize=18'
[AVFilterGraph] Error parsing a filter description around: [outv]

Note that master.srt is generated correctly before this — only the burn-in step fails, so everything up to the last stage looks like it worked.

Suggested fix: preflight the filter before rendering, and fail with an actionable message instead of a traceback. Something like:

python
def has_subtitles_filter() -> bool:
    out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"],
                         capture_output=True, text=True).stdout
    return bool(re.search(r"^\s*\S+\s+subtitles\s", out, re.M))

and in install.md, document that the default Homebrew bottle lacks libass. Working options on macOS are brew install ffmpeg-full (bottled, has libass — but it is keg-only, so /opt/homebrew/opt/ffmpeg-full/bin has to come first on PATH for helpers to pick it up), or building ffmpeg from source against libass.

2. SUB_FORCE_STYLE hardcodes FontName=Helvetica, so any non-Latin script renders as tofu

render.py:51:

python
SUB_FORCE_STYLE = (
    "FontName=Helvetica,FontSize=18,Bold=1,"
    ...
)

Because this goes through force_style, it overrides whatever the subtitle file specifies, and libass does no glyph fallback here. With a Chinese transcript the cues render as empty boxes.

This is worth fixing because it is an internal inconsistency rather than a niche gap: Scribe transcribes 90+ languages, and the README lists travel and interview footage as target use cases, but the subtitle layer only renders Latin script. Anyone editing CJK, Thai, Arabic, Hebrew, or Devanagari footage gets a clean transcript and unreadable subtitles.

Suggested fix: let the EDL override the style, e.g. an optional subtitle_style or subtitle_font key merged over the default, so the skill can pick a font per project without patching the module constant. A sensible default on macOS would be a font with wide coverage that is present out of the box.

One gotcha worth documenting if you do add font selection: on macOS, PingFang SC is not resolvable through fontconfig (fc-match "PingFang SC" silently returns Verdana), so it still renders tofu. Heiti SC resolves to /System/Library/Fonts/STHeiti Medium.ttc and works. Arial Unicode MS also has broad coverage.

For reference, this is the only change I needed — same geometry, font swapped:

force_style='FontName=Heiti SC,FontSize=18,Bold=1,PrimaryColour=&H00FFFFFF,
OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,Alignment=2,MarginV=90'

FontSize=18 and MarginV=90 are correct as-is, by the way — libass resolves them against its default script resolution, which lands the baseline right about where the comment above the constant says it should.

Minor, related: the caption chunker assumes word-delimited Latin text

build_master_srt groups into 2-word chunks and calls .upper() (render.py:370). For CJK, .upper() is a no-op, and 2-token chunks cut across utterance boundaries, so unrelated phrases get merged into one cue. On a 31s clip with 47 word tokens I got 24 cues, several of which joined two separate utterances.

Grouping on inter-word gaps instead (plus a per-cue character cap) gave 14 cues that matched what was actually said. Probably worth gating the chunking rule on language_code rather than changing the English default.

Environment

  • macOS arm64 (Darwin 27.0), Homebrew ffmpeg 8.1.2 (default bottle), Python 3.12
  • Source: 720x1280 H.265 portrait clip, Chinese speech