Ffmpeg Subtitles
Context: $ARGUMENTS
Quick start
- Burn (hardcode) SRT into MP4: → Step 1 (mode = burn-in), Step 3 example A
- Soft-embed SRT into MP4: → Step 1 (mode = soft-mux), Step 3 example B
- Extract embedded track from MKV: → Step 1 (mode = extract), Step 3 example C
- Convert SRT → ASS or VTT: → Step 1 (mode = convert), Step 3 example D
- Style caption fonts / colors: → Step 3 example A +
references/formats.mdforce_style section - Fix garbled accented characters: → Gotchas (charset) + Troubleshooting
When to use
- You have a video and an external subtitle file (
.srt,.ass,.vtt,.sub) and need them merged or displayed. - You need to extract a subtitle track from an MKV/MP4 to edit or re-use.
- You need to convert between subtitle formats while preserving timing.
- You need to sync / shift subtitle timing to match a re-cut video.
- You need to restyle captions (font, size, color, outline, margin) before burning in.
Step 1 — Pick mode
Decide one of the four modes. Every decision downstream depends on this:
| Mode | Use when | Re-encodes video? |
|---|---|---|
burn-in |
Player won't support soft subs (social media upload, VP9/AV1 on some devices), or you want captions baked in permanently. | Yes (must re-encode video) |
soft-mux |
You want a toggleable subtitle track the player can turn off. | No (video can be -c:v copy) |
extract |
Pull an embedded sub track out to a standalone file. | No (no video path at all) |
convert |
Change subtitle format only (SRT ↔ ASS ↔ VTT ↔ SUB). | No (no video path at all) |
Step 2 — Match container to subtitle codec
Containers only accept certain subtitle codecs. Pick the right -c:s for the output extension:
| Output container | Valid -c:s values |
Notes |
|---|---|---|
.mp4 / .mov / .m4v |
mov_text only |
-c:s copy from SRT/ASS into MP4 fails — always transcode to mov_text. |
.mkv |
copy, srt / subrip, ass, ssa, webvtt, dvdsub, dvbsub |
MKV is the friendliest container. |
.webm |
webvtt only |
Must convert SRT/ASS → WebVTT first (or let ffmpeg do it via -c:s webvtt). |
.ts / .m2ts |
dvbsub, dvdsub, hdmv_pgs_subtitle |
Bitmap subs only in transport streams. |
See references/formats.md for the full matrix.
Step 3 — Run the command
All examples produce output you can verify with ffprobe -v error -show_streams -select_streams s <file>.
A) Burn-in SRT with styling
ffmpeg -i in.mp4 \
-vf "subtitles=subs.srt:force_style='Fontname=Arial,Fontsize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H80000000,BorderStyle=1,Outline=2,MarginV=60'" \
-c:v libx264 -crf 20 -preset medium -c:a copy out.mp4
Burn-in always re-encodes video — -c:v copy is not possible. Use the ass filter instead when the subtitle file is ASS/SSA (it respects the file's own styling):
ffmpeg -i in.mp4 -vf "ass=subs.ass" -c:v libx264 -crf 20 -c:a copy out.mp4
B) Soft-mux
MP4 (SRT source → mov_text):
ffmpeg -i in.mp4 -i subs.srt -c:v copy -c:a copy -c:s mov_text \
-metadata:s:s:0 language=eng -disposition:s:0 default out.mp4
MKV (keep original sub format):
ffmpeg -i in.mkv -i subs.srt -c copy -c:s srt -metadata:s:s:0 language=eng out.mkv
WebM:
ffmpeg -i in.webm -i subs.srt -c:v copy -c:a copy -c:s webvtt out.webm
Multiple tracks at once:
ffmpeg -i in.mp4 -i en.srt -i fr.srt \
-map 0 -map 1 -map 2 -c:v copy -c:a copy -c:s mov_text \
-metadata:s:s:0 language=eng -metadata:s:s:1 language=fra out.mp4
C) Extract embedded subtitle track
ffmpeg -i in.mkv -map 0:s:0 -c:s copy subs.srt # copy if already SRT
ffmpeg -i in.mkv -map 0:s:0 -c:s srt subs.srt # transcode ASS → SRT
ffmpeg -i in.mkv -map 0:s:0 -c:s webvtt subs.vtt # transcode → VTT
D) Convert format
ffmpeg -i subs.srt subs.ass # SRT → ASS
ffmpeg -i subs.ass subs.vtt # ASS → VTT
ffmpeg -sub_charenc CP1252 -i subs.srt -c:s srt out.srt # re-save non-UTF-8 as UTF-8
Step 4 — Verify
ffprobe -v error -show_entries stream=index,codec_type,codec_name:stream_tags=language \
-of csv=p=0 out.mp4
For burn-in, visually spot-check one segment:
ffmpeg -ss 00:00:30 -i out.mp4 -frames:v 1 frame.png
Available scripts
scripts/subs.py— stdlib-only wrapper over the four modes (burn,mux,extract,convert). Picks the correct-c:sfrom the output extension, escapes filter paths for burn-in, supports--dry-runand--verbose.
Workflow
# Burn SRT into MP4 with styling
python3 ${CLAUDE_SKILL_DIR}/scripts/subs.py burn \
--video in.mp4 --subs subs.srt --output out.mp4 \
--font Arial --size 28 --color white
# Soft-mux an English SRT into MP4
python3 ${CLAUDE_SKILL_DIR}/scripts/subs.py mux \
--video in.mp4 --subs subs.srt --output out.mp4 --lang eng
# Extract first embedded subtitle stream
python3 ${CLAUDE_SKILL_DIR}/scripts/subs.py extract \
--input movie.mkv --stream 0 --output movie.srt
# Convert SRT → ASS (extension-driven)
python3 ${CLAUDE_SKILL_DIR}/scripts/subs.py convert \
--input subs.srt --output subs.ass
Reference docs
- Read
references/formats.mdfor the container × codec matrix, the fullforce_styleparameter list, ASS color hex format, per-format feature comparison, common fontfile paths, and filter-path escaping rules.
Gotchas
- MP4 only supports
mov_textfor subs.-c:s copyfrom SRT/ASS to MP4 fails withSubtitle codec X not supported by output format. Always use-c:s mov_text. - WebM only supports WebVTT. Same class of error — use
-c:s webvtt. - Burn-in always re-encodes video. You cannot use
-c:v copywith-vf subtitles=.... - Filter path escaping is three-level (shell → filter graph → libavfilter). A Windows path like
C:\subs\foo.srtmust becomesubtitles='C\\:\\\\subs\\\\foo.srt'. On POSIX, wrap paths with colons or commas in single quotes:-vf "subtitles='my:file.srt'". The includedsubs.pydoes this for you. force_styleuses ASS colors in&HAABBGGRRorder — alpha + BGR, not RGB. Pure red is&H000000FF. Half-transparent black outline is&H80000000. Omit the&Hat your peril.- Styling inside an SRT file is ignored. SRT tags like
<font>/{\b1}are not reliably rendered; convert to ASS first (ffmpeg -i x.srt x.ass) and edit there, then-vf ass=x.ass. - Non-UTF-8 SRTs render as mojibake. Western European files are often CP1252 or ISO-8859-1. Prepend input with
-sub_charenc CP1252(only works on-i file.srt, not on the-vf subtitles=filter). For burn-in with a legacy charset, first re-save the SRT as UTF-8. fontconfigmust be available at runtime. Without it,Fontname=Arialsilently falls back to a bundled default and Unicode glyphs may drop. Either install fontconfig or usefontfile=with an absolute path.stream_indexselects a track when the subtitle file itself has multiple streams:subtitles=movie.mkv:stream_index=2burns in the third embedded track.- Default subtitle disposition: players auto-enable the track with
disposition default. Set explicitly with-disposition:s:0 default(or-disposition:s:0 0to clear). -itsoffsetdoes not shift thesubtitles=filter.-itsoffsetshifts an input; thesubtitlesfilter re-reads the file from scratch and ignores demuxer offsets. To shift a burn-in SRT, either (a) rewrite the SRT timestamps, or (b) soft-mux with-itsoffset 2 -i subs.srtand re-extract.subripandsrtare the same codec — ffmpeg accepts either name for-c:s.- MKV auto-selects on first matching language tag. If you mux two English tracks, the second one won't be picked by default — use
-disposition:s:N defaultand clear the other.
Examples
Example 1: YouTube upload with burned-in captions
ffmpeg -i raw.mp4 \
-vf "subtitles=captions.srt:force_style='Fontname=Arial,Fontsize=22,PrimaryColour=&H00FFFFFF,OutlineColour=&H80000000,BorderStyle=1,Outline=2,Shadow=0,MarginV=80,Alignment=2'" \
-c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p \
-c:a aac -b:a 192k out.mp4
Result: YouTube-safe H.264/AAC MP4 with always-visible bottom-center captions (Alignment=2 = bottom-center).
Example 2: Archival MKV with three soft subtitle tracks
ffmpeg -i film.mkv -i en.srt -i es.srt -i fr.srt \
-map 0:v -map 0:a -map 1 -map 2 -map 3 \
-c:v copy -c:a copy -c:s srt \
-metadata:s:s:0 language=eng -metadata:s:s:0 title="English" \
-metadata:s:s:1 language=spa -metadata:s:s:1 title="Español" \
-metadata:s:s:2 language=fra -metadata:s:s:2 title="Français" \
-disposition:s:0 default out.mkv
Example 3: Extract, shift by +2 s, re-mux
ffmpeg -i in.mkv -map 0:s:0 -c:s srt orig.srt
# shift SRT timestamps with any tool, then:
ffmpeg -i in.mkv -i shifted.srt -map 0:v -map 0:a -map 1 \
-c copy -c:s srt out.mkv
Troubleshooting
Error: Subtitle codec 94213 [0][0][0][0] / 0x17F05 is not supported by this version of FFmpeg
Cause: you tried -c:s copy into an MP4 with a non-mov_text subtitle.
Solution: use -c:s mov_text.
Error: Unable to parse option value "&H00FFFFFF" as image size
Cause: missing quotes around force_style — the comma inside it is being parsed as a filter separator.
Solution: wrap the whole force_style block in single quotes: force_style='Fontname=Arial,Fontsize=24'.
Error: No such filter: 'subtitles'
Cause: ffmpeg built without --enable-libass.
Solution: install a full build (brew install ffmpeg, apt install ffmpeg) — the "minimal" / ffmpeg-lite builds omit libass.
Symptom: accented characters display as é, è, etc.
Cause: SRT is CP1252/Latin-1 but ffmpeg is reading it as UTF-8.
Solution: add -sub_charenc CP1252 before the -i subs.srt. For the subtitles= filter (burn-in), convert the file to UTF-8 first: iconv -f CP1252 -t UTF-8 in.srt > out.srt.
Symptom: subtitles render in the wrong font or drop Unicode glyphs
Cause: fontconfig missing, or the named font not installed.
Solution: either install the font / fontconfig, or switch to fontfile=: subtitles=subs.srt:fontsdir=/System/Library/Fonts:force_style='Fontname=Helvetica'.
Symptom: soft-muxed subs don't show up in QuickTime / iOS
Cause: only mov_text works in MP4 on Apple players, and the track needs a language tag + default disposition.
Solution: -c:s mov_text -metadata:s:s:0 language=eng -disposition:s:0 default.