Auto-Generate and Burn In Captions with Whisper + FFmpeg (No Server Required)

You followed the CLI guide. whisper audio.mp3 --output_format srt, then ffmpeg -i in.mp4 -vf subtitles=out.srt out.mp4. It works on your laptop, and it falls apart the first time you try to run it forty times a day from an n8n workflow.
Quick answer: With a hosted Whisper FFmpeg burn in captions API, the whole job is two HTTP requests.POST /v1/transcriberuns Whisper against your video and returns an SRT, thenPOST /v1/transcodesburns that SRT into the video with FFmpeg'ssubtitlesfilter. No GPU, no self-compiled FFmpeg, and no 25 MB upload cap.
Why the Whisper to FFmpeg pipeline breaks when you scale it
Conventional wisdom says auto-captioning is a two-tool problem: Whisper writes the SRT, FFmpeg burns it in. That's correct, and every tutorial stops there. But the part that breaks in production isn't the transcription quality or the filter syntax. It's that both tools assume a local filesystem, and a workflow runner doesn't have one worth using.
Three specific walls, in the order people hit them:
- The model needs hardware. Whisper
large-v3wants roughly 10 GB of VRAM. On CPU it runs slower than real time, so a 20-minute podcast can take longer to transcribe than to record.base.enis fast but noticeably worse on proper nouns. - The hosted API has a 25 MB ceiling. OpenAI's
/v1/audio/transcriptionsendpoint rejects files over 25 MB. That's about 26 minutes of 128 kbps MP3, which is why half the tutorials online include a chunk-and-stitch script that re-times SRT offsets by hand. - The
subtitlesfilter reads from disk, not from a URL. This is the one nobody mentions.-vf subtitles=out.srtopens a path. So even after Whisper hands you a transcript, the video file and the SRT file have to be sitting on the same machine, at the same time, with FFmpeg installed on it.
FFmpeg 8.0 added a native whisper audio filter built on whisper.cpp, which is why articles like Rendi's "Using Whisper for Native Video Transcription in FFmpeg 8.0" got traction. It's genuinely nice:
ffmpeg -i input.mp4 \
-af "whisper=model=/models/ggml-base.en.bin:language=en:format=srt:destination=out.srt" \
-f null -
The catch is that the filter isn't in any stock build. Homebrew, apt, and the static builds don't ship it, so you have to compile FFmpeg with --enable-whisper against whisper.cpp and download a ggml model. You've replaced a Python dependency with a C++ one and still need a box to run it on.
The fix isn't a bigger container. It's not running either half locally.
Auto generate subtitles with FFmpeg and no server: the two-call version
Upload the video once, then address it by URL from both steps. Whisper reads it, FFmpeg reads it, and neither one needs to live on your machine.
Step 1: Upload the video
Get a presigned URL, PUT the file to it, then confirm.
curl -X POST https://api.ffmpeg-micro.com/v1/upload/presigned-url \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $FFMPEG_MICRO_KEY" \
-d '{"filename":"episode-142.mp4","contentType":"video/mp4","fileSize":184320000}'
{
"success": true,
"result": {
"uploadUrl": "https://storage.googleapis.com/...",
"filename": "1234567890-episode-142.mp4"
}
}
PUT the bytes straight to uploadUrl (no auth header, the signature carries it), then POST /v1/upload/confirm with the returned filename and fileSize.
Step 2: Transcribe with Whisper
Point /v1/transcribe at the video. You don't need to extract the audio first, and there's no 25 MB wall.
curl -X POST https://api.ffmpeg-micro.com/v1/transcribe \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $FFMPEG_MICRO_KEY" \
-d '{
"media_url": "gs://<YOUR_BUCKET>/1234567890-episode-142.mp4",
"language": "en",
"task": "transcribe"
}'
{
"id": "job-uuid",
"status": "pending",
"media_url": "gs://<YOUR_BUCKET>/1234567890-episode-142.mp4",
"output_format": "srt",
"created_at": "2026-04-19T17:22:44.396Z"
}
Poll GET /v1/transcribe/:id until status is completed. language is optional and auto-detects, but pin it when you know it. Auto-detect on a video that opens with music is the most common cause of a garbage first cue. Setting "task": "translate" gives you English output from non-English audio in the same call.
Step 3: Burn the SRT into the video
The transcribe job drops an SRT in your output bucket. That path goes straight into a subtitles filter on a transcode job.
{
"inputs": [{ "url": "gs://<YOUR_BUCKET>/1234567890-episode-142.mp4" }],
"outputFormat": "mp4",
"options": [
{ "option": "-vf", "argument": "subtitles='gs://<YOUR_BUCKET>/job-uuid.srt':force_style='FontName=DejaVu Sans,FontSize=22,PrimaryColour=&H00FFFFFF,OutlineColour=&H90000000,BorderStyle=3,Alignment=2,MarginV=70'" },
{ "option": "-c:v", "argument": "libx264" },
{ "option": "-crf", "argument": "20" },
{ "option": "-c:a", "argument": "copy" }
]
}
force_style takes ASS style overrides, so you get real control without touching a template editor. BorderStyle=3 draws an opaque box behind the text instead of an outline, which is what makes captions readable over bright B-roll. Alignment=2 is bottom-center and MarginV=70 lifts the text above the TikTok and Reels UI, which eats roughly the bottom 250 px of a 1920-tall frame.
Then GET /v1/transcodes/:id until it reports completed, and GET /v1/transcodes/:id/download for a signed URL.
CLI versus API, side by side
| Local Whisper + FFmpeg | Two API calls | |
|---|---|---|
| Transcription hardware | GPU, or slower-than-real-time CPU | None on your side |
| Max input | 25 MB on OpenAI's API, or unlimited with local chunking code | 250 MB on the free tier, 1,024 MB on Starter |
| SRT location | Local disk, same box as the video | Object storage, addressed by URL |
| Install footprint | FFmpeg binary, Python, PyTorch, ggml model | `curl` |
| Long-video handling | Chunk, transcribe, re-time offsets, concatenate | One call |
| Cost to start | A GPU instance or a beefy runner | $0/month, 100 processing minutes |
Copy-paste n8n workflow
Four nodes, no Execute Command node, no self-hosted runner with a custom Docker image:
- HTTP Request to
POST /v1/transcribewithmedia_urlset to the uploaded file. - Wait node, 15 seconds.
- HTTP Request to
GET /v1/transcribe/{{ $json.id }}, with an IF node routing back to the Wait node whilestatusispendingorprocessing. - HTTP Request to
POST /v1/transcodeswith the-vf subtitles=options array above, then the same poll loop against/v1/transcodes/:id.
Poll on a Wait loop rather than holding the HTTP connection open. A 40-minute video will blow past n8n's default timeout, and the failure looks like a network error rather than a queued job. The n8n timeout recipe covers the loop in detail. Agent builders can skip the HTTP nodes entirely and call the same jobs through the MCP server.
Common pitfalls
fileSizesent as a string. It has to be a JSON number in bytes."184320000"gets rejected by the presigned-URL endpoint, and the error doesn't always make the type obvious.- Reaching for
filter_complex. It isn't supported. Subtitle burn-in only needs-vf, so this rarely bites, but a copied multi-input filtergraph from Stack Overflow will fail. - Unescaped characters in the subtitle path. The filtergraph parser treats
:and,as separators. A signed HTTPS URL full ofX-Goog-Signature=query params needs every colon backslash-escaped. Use the bucket path instead and save yourself the debugging. - Trimming after transcribing. Cut the video first, then transcribe. If you transcribe a 20-minute source and burn the SRT into a 45-second clip, every cue is offset and it looks like the captions are broken.
- Whisper hallucinating over silence. Long music beds and dead air produce phantom cues, often a stray "Thank you." or a subtitle credit line. Strip cues shorter than 300 ms with no adjacent speech before burning.
- Re-encoding audio for no reason.
-c:a copyon a burn-in job is free. Re-encoding AAC you never touched just spends processing minutes.
When not to use this
Burned-in SRT captions are static blocks of text with timing and styling. If you need word-by-word karaoke highlighting, bouncing emoji, or animated caption templates, an SRT burn is the wrong tool and a template renderer like Creatomate or a purpose-built editor will get you there faster. Same for live streaming, where you want a real-time ASR feed instead of a batch job.
If you need speaker diarization ("Speaker 1:" labels), Whisper alone won't give it to you. AssemblyAI and Deepgram handle that better, and you can still bring their SRT output to the burn-in step here.
And if you already run a GPU box with a warm model loaded, keep it. The two-call version wins when you don't want to own that box.
FAQ
Can FFmpeg generate subtitles by itself?
Only since FFmpeg 8.0, which added a native whisper audio filter backed by whisper.cpp. It requires a build compiled with --enable-whisper plus a downloaded ggml model file, and no standard package-manager build ships it enabled. Older FFmpeg versions can burn in or convert subtitle files, but can't create them from audio.
Do I need a GPU to auto-generate captions with Whisper?
Not if the transcription runs server-side. Locally, large-v3 effectively needs about 10 GB of VRAM to run at a usable speed, and CPU inference on long files runs slower than real time. Calling POST /v1/transcribe moves that requirement off your machine entirely.
How do I burn an SRT into a video without installing FFmpeg?
Send a transcode job with -vf subtitles='<path-to-srt>' in the options array. The service runs the same FFmpeg filter you'd run locally, so the syntax and force_style overrides transfer exactly from any CLI guide you've already read.
Should I burn captions in or use a soft subtitle track?
Burn them in for social platforms, since Instagram, TikTok, and LinkedIn either ignore embedded tracks or bury them behind a tap. Use a soft track (-c:s mov_text for MP4) when the viewer controls playback, like a course player or an internal video library, because it keeps the text selectable and re-editable.
What's the largest file I can caption?
250 MB per input on the free tier and 1,024 MB on Starter at $19/month, well past the 25 MB ceiling on OpenAI's hosted Whisper endpoint. A 45-minute 1080p talking-head export at 4 Mbps lands around 1.3 GB, so compress the source first or split it before transcribing.
The free tier includes 100 processing minutes, which is enough to caption a few dozen short-form clips end to end and see whether the styling holds up on your own footage. Sign up free, grab an API key, and run the transcribe call against a video you've already published. From there, the same pattern extends to AI voiceover with ElevenLabs and long-form to Shorts, or see the video captions page for the full picture.
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

FFmpeg Concat Different Resolutions: Normalize, Then Copy
FFmpeg concat different resolutions fails with -c copy. Two fixes that work: normalize then demux, or a one-pass filter_complex concat, plus how to pick.

Put Two Videos Side by Side with FFmpeg: hstack, vstack, xstack
The ffmpeg side by side recipe that actually works: hstack needs matching heights, vstack matching widths, and shortest=1 stops the short clip freezing.

Crossfade Between Clips with FFmpeg xfade (No Editor)
Crossfade clips with ffmpeg xfade: transition types, the offset math people get wrong, carrying audio with acrossfade, and chaining more than two clips.
Skip the command line
The Auto Captions blueprint transcribes your video and burns the captions in. You just review the transcript.
Run it (free)