Auto translate subtitles for video? The burn-in is what breaks

You have one finished video and seven markets waiting on it. Translation itself is a solved API call: the SRT comes back in Spanish, Portuguese, German, Japanese, Chinese, and Arabic in about four seconds. Then the Spanish render ships with the last word of every line hanging off the right edge of the frame, and the Arabic one shows a row of empty boxes.
Quick answer: An auto translate subtitles video pipeline runs in three passes: transcribe the source once with Whisper to get an SRT, translate only the text lines of that SRT while leaving the index numbers and timecodes untouched, then run one FFmpeg burn-in render per language. Doing it yourself means hosting the transcription model, installing CJK and Arabic fonts inside the render container, and re-encoding N times on your own hardware. FFmpeg Micro runs the transcribe and the burn-in as repeated API calls, one per language, with no encoder to host and no fonts to install.
Transcribe once, then translate the file, not the video
Conventional wisdom says a multi-language version of a video means running the whole caption pipeline again per language. Most workflows do exactly that, and the transcription bill is not the reason it's a mistake. OpenAI lists whisper-1 at $0.006 per minute, so re-transcribing a 10-minute video into 8 languages costs $0.48 instead of $0.06. Nobody's budget dies there.
The real cost is timestamp drift. Whisper segments audio into cues based on pauses and its own confidence, and two runs over the same file won't always split at the same millisecond. Run it per language and you get eight subtly different cue grids. When a reviewer says "the line at 0:42 is cut off," you have to find 0:42 in eight files that disagree about where 0:42 starts. One transcript, translated eight ways, keeps every render on an identical grid, so a fix to cue 137 is a fix to cue 137 everywhere.
The n8n template gallery has a Whisper plus LibreTranslate workflow that does the first half well. It hands you a folder of translated .srt files and stops. Nothing puts the text back on the pixels, the step your viewers actually see.
Translate an SRT without breaking the timecodes
An SRT cue is three parts: an index number, a timecode line, and one or more text lines, separated by a blank line. Only the text lines get translated. Send the whole file to a translation model as raw text and it will helpfully reformat the timecodes, merge cues, or drop the blank-line separators, and the result won't parse.
137
00:00:42,120 --> 00:00:44,880
We shipped the whole thing in a weekend.
Parse first, translate the extracted strings as an ordered list, then rebuild:
import srt_helper # or parse by hand, it's 10 lines
blocks = open("en.srt", encoding="utf-8").read().strip().split("\n\n")
cues = []
for b in blocks:
lines = b.split("\n")
cues.append({"i": lines[0], "t": lines[1], "text": " ".join(lines[2:])})
texts = [c["text"] for c in cues]
translated = translate_batch(texts, target="es") # DeepL, Google, LibreTranslate, or an LLM
assert len(translated) == len(texts) # do not skip this
That assert is not decoration. DeepL and Google Translate both silently collapse empty or whitespace-only strings, and an LLM returning a JSON array will occasionally merge two short cues into one sentence. If the lengths don't match, your captions are offset for the rest of the video and you won't notice until minute six. DeepL's free API tier covers 500,000 characters a month, roughly 40 to 60 hours of dialogue.
Give the translator the full ordered list in one request, not one cue at a time. Cue-by-cue translation strips context, and a line like "That's it." comes back gendered wrong in French because nothing told the model what "it" was.
Burn each language in with the subtitles filter
The burn-in is one FFmpeg command per language, and only the input SRT and the output name change between runs. Use the subtitles filter, which routes through libass for cue timing, styling, and text layout:
for lang in es de ja ar; do
ffmpeg -i source.mp4 \
-vf "subtitles=${lang}.srt:fontsdir=/usr/share/fonts:force_style='FontName=Noto Sans,FontSize=22,Alignment=2,MarginV=70,MarginL=60,MarginR=60,BorderStyle=3,Outline=1'" \
-c:v libx264 -crf 20 -preset medium -c:a copy \
out_${lang}.mp4
done
-c:a copy matters here. The audio is identical across all N renders, so re-encoding it N times is wasted CPU. Check that your build has libass with ffmpeg -filters | grep subtitles; a static build without --enable-libass fails with "No such filter" and nothing else.
The DIY path gets expensive in wall-clock time, not dollars. Eight renders of a 10-minute 1080p video at -preset medium is roughly 25 to 40 minutes of continuous CPU on a 4-core box, and that box has to exist, stay patched, and carry the right fonts. FFmpeg Micro does the same eight renders as eight API calls with no encoder to run, and the Auto Captions blueprint is the one-click version of the transcribe-then-burn half, including the review step before the render. More on the caption job at /for/video-captions.
Where multi-language hardsubs actually break
Three failure modes account for nearly every bad localized render, and none show up when you test in English.
Translated lines get longer than the safe area
Translated text expansion is the most common one: German, Russian, and Spanish typically run 20 to 35 percent longer than the same English sentence, so a line that fit on two lines at FontSize=24 now spills to three and pushes off the bottom of a 9:16 crop. Set MarginL and MarginR to 60 or more so libass wraps earlier, drop FontSize by two or three points for the expanding languages, and lift MarginV to 70 to keep the third line clear of platform UI. Test the longest cue, not the first one: sort your translated cues by character count and check a frame from the top of the list.
CJK and Arabic need a font the container doesn't have
A missing font is why Japanese and Arabic renders come back as rows of empty rectangles. libass falls back to whatever fontconfig can find, and slim Docker base images ship almost nothing. Install font-noto-cjk and font-noto-arabic on Alpine, or fonts-noto-cjk and fonts-noto-core on Debian, then confirm with fc-list | grep -i noto inside the container, not on your laptop. Point the filter at the directory with fontsdir= and name the family exactly as fontconfig reports it: FontName=Noto Sans CJK JP and FontName=Noto Sans CJK are not the same string to libass.
RTL needs libass, not drawtext
Arabic and Hebrew are the reason to avoid drawtext entirely. drawtext only reorders bidirectional text if FFmpeg was compiled with libfribidi and you set text_shaping=1, and even then you'd time every line yourself with enable=between(t,...) expressions. The subtitles and ass filters get bidi reordering, Arabic glyph shaping, and cue timing from libass for free. Our Whisper burn-in walkthrough covers the styling side of the same filter in more depth.
Wiring it into n8n without melting the instance
The n8n version of this is a loop, not eight branches: transcribe once, split the language list, iterate.
- Trigger on the new source video and pass its URL downstream, never the binary.
- Call transcription once to get
en.srt, and store the parsed cue array in a Set node. - Split In Batches over your language list so each iteration handles one target.
- Translate the cue text array for that language, assert the count matches, rebuild the SRT.
- Submit a burn-in job per language and take a webhook back with the output URL.
Two n8n facts change how you build this in 2026. The Execute Command node is disabled by default in v2.0+ because arbitrary shell execution is a security problem on shared instances, so calling a local ffmpeg binary from a workflow is no longer the easy path tutorials describe. And the official docker.n8n.io/n8nio/n8n image is now distroless, so the USER root; RUN apk add --no-cache ffmpeg recipe in older guides fails: there's no package manager inside. The workaround is a multi-stage build that copies Alpine's apk and libapk.so* into the distroless base, and you re-verify it on every n8n release.
The other trap is memory. Routing a 500 MB video through n8n's binary data as eight separate render outputs is how instances get OOM-killed, which we broke down in fixing n8n memory blowups on large video files. Pass URLs between nodes and let the render service move the bytes.
| Step | Self-hosted | FFmpeg Micro |
|---|---|---|
| Transcription | Host whisper.cpp or pay $0.006/min | One API call |
| Translation | DeepL free tier: 500k chars/month | Your own translator, same as DIY |
| Fonts for CJK/RTL | Install and verify Noto in the container | Included in the render environment |
| 8 renders of a 10-min clip | 25 to 40 min of your CPU | 8 parallel API calls |
| Ops surface | Dockerfile, disk, queue, timeouts | Submit, webhook, download |
When burning in is the wrong call
Hardsubs are the wrong choice when the player can handle subtitle tracks, because burned-in text can't be turned off, can't be searched, and can't be swapped without a re-encode. If you're delivering to your own web player or an HLS stream, ship one video and N WebVTT tracks instead: it's one encode total rather than eight, and the viewer picks the language. For MP4 downloads, -c:s mov_text will carry soft subtitles that QuickTime and VLC can read.
Burn in when the destination strips subtitle tracks, which covers TikTok, Instagram Reels, YouTube Shorts, most ad platforms, and anything autoplaying muted in a feed. That's also why the chained clip-caption-reformat pattern in one n8n workflow API chain ends in a hardsub render, not a sidecar file.
FAQ
Can I translate subtitles automatically without re-transcribing the video?
You can, and you should. Transcribe the source audio once to produce a master SRT, then translate only the text lines of that file into each target language, which keeps every localized version on identical cue timings and cuts your transcription cost to a single run.
Which FFmpeg filter handles Arabic or Hebrew subtitles correctly?
The subtitles and ass filters handle right-to-left languages correctly because both render through libass, which does bidirectional reordering and Arabic glyph shaping. The drawtext filter needs a libfribidi-enabled build plus text_shaping=1 and still gives you no cue timing, so it's the wrong tool for translated captions.
How do I stop translated captions from overflowing the frame?
Set MarginL and MarginR to at least 60 in force_style so libass wraps lines earlier, reduce FontSize by two or three points for languages that expand, and raise MarginV to about 70. Then preview the longest translated cue rather than the first one, since expansion problems only appear on your densest lines.
Do I need one video file per language?
You need one rendered file per language only if you're burning the captions into the pixels, which is required for TikTok, Reels, and Shorts because those platforms strip subtitle tracks. For your own player or an HLS stream, one encode plus multiple WebVTT tracks covers every language in a single file.
What does it cost to caption a 10-minute video in eight languages?
Transcribing a 10-minute video once costs about $0.06 at Whisper's listed $0.006 per minute, and translating roughly 1,500 words of dialogue into eight languages fits inside DeepL's free 500,000-character monthly tier. The eight burn-in renders are the part that costs you either CPU time on your own box or a per-job fee on a render API.
Point the loop at api.ffmpeg-micro.com instead of a local binary and the whole thing becomes one transcribe call plus one render call per language, on the free tier while you get the margins right. Sign up free and run the Spanish version first.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Auto-Add Captions to Every Video Your Team Uploads (n8n + FFmpeg)
Build an n8n workflow that auto-transcribes and burns captions into every video your team uploads. Zero code, fully automated.

FFmpeg fontconfig error isn't fatal. It ships the wrong font.
The FFmpeg fontconfig error is a warning, not a failure: the job exits 0 and ships the wrong font. Fix it in Alpine, Debian slim, and distroless images.

Auto-Generate and Burn In Captions with Whisper + FFmpeg (No Server Required)
Whisper FFmpeg burn in captions API: transcribe to SRT and burn subtitles into video with two HTTP calls. No GPU, no local FFmpeg build, no 25 MB cap.
Skip the command line
The Auto Captions blueprint transcribes your video and burns the captions in. You just review the transcript.
Run it (free)