Skip to content

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

Description

@yaowenhu-pm

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:

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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions