ffmpegcaptionsapi

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

·Javid Jamae·9 min read
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/transcribe runs Whisper against your video and returns an SRT, then POST /v1/transcodes burns that SRT into the video with FFmpeg's subtitles filter. 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-v3 wants 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.en is fast but noticeably worse on proper nouns.
  • The hosted API has a 25 MB ceiling. OpenAI's /v1/audio/transcriptions endpoint 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 subtitles filter reads from disk, not from a URL. This is the one nobody mentions. -vf subtitles=out.srt opens 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 + FFmpegTwo API calls
Transcription hardwareGPU, or slower-than-real-time CPUNone on your side
Max input25 MB on OpenAI's API, or unlimited with local chunking code250 MB on the free tier, 1,024 MB on Starter
SRT locationLocal disk, same box as the videoObject storage, addressed by URL
Install footprintFFmpeg binary, Python, PyTorch, ggml model`curl`
Long-video handlingChunk, transcribe, re-time offsets, concatenateOne call
Cost to startA 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:

  1. HTTP Request to POST /v1/transcribe with media_url set to the uploaded file.
  2. Wait node, 15 seconds.
  3. HTTP Request to GET /v1/transcribe/{{ $json.id }}, with an IF node routing back to the Wait node while status is pending or processing.
  4. HTTP Request to POST /v1/transcodes with 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

  • fileSize sent 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 of X-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 copy on 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.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Skip the command line

The Auto Captions blueprint transcribes your video and burns the captions in. You just review the transcript.

Run it (free)