← Back to blogYouTube Video

November 13, 2026 • By

Auto caption videos for free with faster-whisper and ffmpeg

Can't play the video or having issues? Here's the direct link.

The short version

  • You can auto caption videos for free on your own machine: faster-whisper turns the speech into captions with a time for every word, and ffmpeg burns them into the video.
  • For regular English videos the medium.en model is good enough. On a 4-core CPU it transcribed a 40-second clip in about 42 seconds, while large-v3 took about 2 minutes and 4.2 GB of RAM for the same words.
  • Short-form captions work best at three to five words each. Also start a new caption whenever the speaker pauses, or one caption can stay on screen through a long silence.
  • Save the captions as an .srt file before burning them in, so you can fix Whisper's mistakes by hand and burn the corrected file without transcribing again.
  • Convert the captions to ASS to style them, and run ffmpeg's subtitles filter from the folder that holds the .ass file, because a colon or quote in the path breaks the filter.

You can auto caption a video for free with two open-source tools: faster-whisper turns the speech into timed captions on your own machine, and ffmpeg burns those captions into the picture in whatever font and style you pick. Nothing gets uploaded anywhere and there's no subscription.

Below I go through how the pipeline works, which Whisper model to use, and a complete Python script that captions a vertical short a few words at a time. The video above covers the same process.

Why I built my own caption tool

I post a lot of Shorts now, and a good Short needs captions. If you scroll Shorts at all, you've seen the style: a few words at a time, big and centered, near the bottom of the frame.

I couldn't find a free tool that does this. The AI video editors online all charge a subscription, and CapCut is a really good editor, but it's paid too. I'm not paying someone every month for something I can build myself, so I built it.

How auto captioning works

You start with the raw video, the file you recorded on your phone or wherever. Then:

  1. Generate subtitles. Run the video through a speech-to-text model that gives you every word with a start and end time. I use faster-whisper for this. Group the words into short captions and save them as an .srt subtitle file.
  2. Burn the subtitles into the video with ffmpeg, which draws each caption onto the frames at its timestamps. This is also where the styling happens: font family, font size, where on the screen the text sits, borders, backgrounds and shadows.
  3. Save the final video file, and you're done.

Burning in means the captions become part of the picture. They show up on every platform and in every player, styled exactly how you set them, and viewers can't turn them off. The cost is that ffmpeg has to re-encode the whole video, which takes longer than copying it.

faster-whisper runs OpenAI's Whisper models on CTranslate2, a faster inference engine, with less memory than the original implementation. It runs fine on a CPU, so you don't need a GPU for any of this.

Which Whisper model to use

Before you write any code, pick the model. faster-whisper downloads it from Hugging Face the first time you use it. These are the three I've used, timed on a 40-second clip of me talking, on my 4-core CPU with no GPU:

ModelDownloadTimePeak RAMLanguages
small.en484 MBabout 17 sabout 1.2 GBEnglish only
medium.en1.5 GBabout 42 sabout 2.3 GBEnglish only
large-v33.1 GBabout 2 minabout 4.2 GBMultilingual

I've had good results with small and medium, and luckily they're also the ones my old machine can actually run. Large-v3 gives really good transcriptions, but it takes way too long. I haven't found much difference between medium and large-v3, probably because my videos are in normal English, so there's not a lot of room for error. On my test clip all three got the same words, apart from small details: small.en wrote "gonna" where medium.en wrote "going to", and only small.en put a dollar sign in front of "430,000 Canadian dollars". Large-v3 took two and a half to three times as long as medium, and on this clip it wrote the whole transcript in lowercase with no punctuation, which the smaller models didn't do.

So if you make regular English videos like me, medium is enough. If you want the best transcription there is, or your videos are in other languages, use large-v3, and make sure your machine has the RAM for it.

Install faster-whisper and ffmpeg

You need an ffmpeg built with libass, the library that draws styled subtitles. The ffmpeg in most Linux package managers has it. Check yours:

ffmpeg -hide_banner -filters | grep subtitles

If it lists a subtitles filter, you're set. Then create a virtual environment and install faster-whisper:

python3 -m venv .venv
source .venv/bin/activate
pip install faster-whisper "av<19"

faster-whisper decodes audio with PyAV. Version 1.2.1 passes PyAV an argument that PyAV 19 removed, so without the av<19 pin the first transcription crashes with TypeError: open() got an unexpected keyword argument 'metadata_errors'.

The whole tool is one file:

auto-captions/
└── auto_caption.py

The script

import argparse
import subprocess
import tempfile
from pathlib import Path

from faster_whisper import WhisperModel


def srt_time(seconds):
    ms = round(seconds * 1000)
    h, ms = divmod(ms, 3_600_000)
    m, ms = divmod(ms, 60_000)
    s, ms = divmod(ms, 1000)
    return f"{h:02}:{m:02}:{s:02},{ms:03}"


def transcribe(video, srt_path, model_size, max_words, vad, max_pause=0.5):
    model = WhisperModel(model_size, device="cpu", compute_type="int8")
    segments, info = model.transcribe(
        str(video), beam_size=5, word_timestamps=True, vad_filter=vad
    )
    print(f"Language: {info.language} ({info.language_probability:.2f})")

    captions = []

    def add(chunk):
        text = " ".join(w.word.strip() for w in chunk)
        captions.append((chunk[0].start, chunk[-1].end, text))

    for segment in segments:
        chunk = []
        for word in segment.words or []:
            # Start a new caption when this one is full or the speaker paused.
            if chunk and (len(chunk) == max_words or word.start - chunk[-1].end > max_pause):
                add(chunk)
                chunk = []
            chunk.append(word)
        if chunk:
            add(chunk)

    with open(srt_path, "w", encoding="utf-8") as f:
        for n, (start, end, text) in enumerate(captions, start=1):
            f.write(f"{n}\n{srt_time(start)} --> {srt_time(end)}\n{text}\n\n")
    print(f"Wrote {len(captions)} captions to {srt_path}")


def read_srt(srt_path):
    captions = []
    for block in srt_path.read_text(encoding="utf-8").strip().split("\n\n"):
        lines = block.strip().splitlines()
        if len(lines) < 3:
            continue
        start, end = (t.strip() for t in lines[1].split("-->"))
        captions.append((start, end, " ".join(lines[2:])))
    return captions


def ass_time(srt_stamp):
    # 00:00:01,240 (SRT) -> 0:00:01.24 (ASS uses hundredths of a second)
    hms, ms = srt_stamp.split(",")
    h, m, s = hms.split(":")
    return f"{int(h)}:{m}:{s}.{int(ms) // 10:02}"


def video_size(video):
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=width,height", "-of", "csv=p=0", str(video)],
        capture_output=True, text=True, check=True,
    ).stdout
    width, height = out.strip().split(",")
    return int(width), int(height)


def write_ass(captions, ass_path, width, height, font, font_size, margin, uppercase, pop):
    # Colours are &HAABBGGRR. BorderStyle 3 draws an opaque box in OutlineColour
    # behind the text, and Outline sets how far the box reaches past the letters.
    header = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {width}
PlayResY: {height}
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Caption,{font},{font_size},&H00000000,&H00000000,&H00FFFFFF,&H80000000,-1,0,0,0,100,100,0,0,3,{font_size // 4},0,2,80,80,{margin},1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
    # Pop-in: start at 95% size, grow to 105% in 100 ms, settle at 100%.
    effect = r"{\fscx95\fscy95\t(0,100,\fscx105\fscy105)\t(100,200,\fscx100\fscy100)}" if pop else ""
    with open(ass_path, "w", encoding="utf-8") as f:
        f.write(header)
        for start, end, text in captions:
            if uppercase:
                text = text.upper()
            f.write(f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Caption,,0,0,0,,{effect}{text}\n")


def burn(video, ass_path, output):
    # Run ffmpeg inside the folder that holds the .ass file, so the subtitles
    # filter only ever sees a plain file name (colons and quotes in a path break it).
    subprocess.run(
        ["ffmpeg", "-y", "-i", str(video.resolve()),
         "-vf", f"subtitles={ass_path.name}",
         "-c:v", "libx264", "-crf", "20", "-preset", "veryfast",
         "-c:a", "aac", str(output.resolve())],
        cwd=ass_path.parent, check=True,
    )


def main():
    parser = argparse.ArgumentParser(description="Burn word-by-word captions into a video.")
    parser.add_argument("video", type=Path)
    parser.add_argument("--model", default="medium.en",
                        help="tiny.en, base.en, small.en, medium.en, large-v3 ... (default: medium.en)")
    parser.add_argument("--max-words", type=int, default=3)
    parser.add_argument("--font", default="Arial")
    parser.add_argument("--font-size", type=int, default=72)
    parser.add_argument("--margin", type=int, default=400, help="distance from the bottom in pixels")
    parser.add_argument("--uppercase", action="store_true")
    parser.add_argument("--pop", action="store_true", help="pop-in animation on each caption")
    parser.add_argument("--no-vad", dest="vad", action="store_false")
    parser.add_argument("--output", type=Path)
    args = parser.parse_args()

    srt_path = args.video.with_suffix(".srt")
    if srt_path.exists():
        print(f"{srt_path} exists, skipping transcription")
    else:
        transcribe(args.video, srt_path, args.model, args.max_words, args.vad)

    output = args.output or args.video.with_name(f"{args.video.stem}_captioned.mp4")
    width, height = video_size(args.video)
    with tempfile.TemporaryDirectory() as tmp:
        ass_path = Path(tmp) / "captions.ass"
        write_ass(read_srt(srt_path), ass_path, width, height, args.font,
                  args.font_size, args.margin, args.uppercase, args.pop)
        burn(args.video, ass_path, output)
    print(f"Saved {output}")


if __name__ == "__main__":
    main()

transcribe loads the model with int8 weights, which keeps it fast and light on a CPU. word_timestamps=True gives every word its own start and end time, and the loop groups the words into captions of at most max_words, so each caption appears when its first word is spoken and disappears when its last word ends. The captions go into a standard .srt file next to the video.

The loop also starts a new caption whenever the speaker pauses for more than half a second. Counting words alone isn't enough. On a test clip with an 8-second pause, a words-only version put "So" from before the pause and "companies and" from after it in one caption, which then sat on screen through the whole silence.

vad_filter=True turns on voice activity detection, which is built into faster-whisper. If you sit in your raw recording thinking about what to say next, it finds that silence and cuts it out before the model sees the audio. Whisper can invent words for stretches with no speech in them, especially with background noise. On my test clip it didn't, with or without VAD, so I keep it on as insurance, and --no-vad turns it off.

write_ass converts the captions to ASS, the subtitle format libass draws, because SRT can't hold any styling. The Style: line is where the look lives. The canvas (PlayResX and PlayResY) is set to the video's own size, so the font size and margin are in real pixels. Uppercase is applied here and not in the .srt, so the .srt stays easy to read and edit. --pop adds the pop-in animation from my own version: each caption starts at 95% size, grows to 105% over 100 ms, then settles at 100%.

burn runs ffmpeg with the subtitles filter and re-encodes the video with x264. It runs ffmpeg from inside the temporary folder that holds the .ass file, because the filter reads its argument as a list of options, and a colon or a quote in the path breaks it. I tried both, and ffmpeg refused to start each time.

Run it

python auto_caption.py clip.mp4 --uppercase --pop

The first run downloads the model. ffmpeg prints its progress as it goes, and the script's own output is:

Language: en (1.00)
Wrote 51 captions to clip.srt
Saved clip_captioned.mp4

The whole run took about a minute for my 40-second clip with the medium model. The .srt looks like this:

12
00:00:07,300 --> 00:00:08,180
thinking yeah maybe

13
00:00:08,180 --> 00:00:08,580
we should

14
00:00:08,580 --> 00:00:09,360
hire this guy.

And clip_captioned.mp4 has each caption in a white box with black text, about a fifth of the way up from the bottom of the frame.

If Whisper gets a word wrong (it will now and then, mostly names and jargon), open the .srt, fix the text and run the same command again. The script sees that the .srt exists, skips the transcription and only does the burn, which took about 10 seconds for this clip.

Change how the captions look

Every option is a flag:

python auto_caption.py clip.mp4 --model small.en --max-words 5 --font "Montserrat" --font-size 90 --margin 300

--model and --max-words only apply when the script transcribes, so delete the .srt before you change either of them.

--max-words matters most for short-form video. Most Shorts show three to five words at a time, and I keep mine at three. --margin moves the captions up from the bottom edge, which you'll want so they don't end up under the platform's buttons and video title. The font has to be installed on your system. If it isn't, libass picks a similar one, and the only sign is a fontselect line in ffmpeg's output naming the file it used instead.

For anything the flags don't cover, edit the Style: line. Colours are written as &HAABBGGRR: alpha, then blue, green and red, with 00 alpha for fully opaque. For white text with a black outline instead of a box, set PrimaryColour to &H00FFFFFF, OutlineColour to &H00000000, BorderStyle to 1 and Outline to 4.

You can vibe code this yourself

The script I showed in the video was about 400 lines, and I'll be honest, I haven't read any of it. I explained what I needed to my coding agent and let it write whatever it came up with. For a little tool that only runs on my machine and never goes near production, I'm completely fine with not reviewing the code. It has a few extras I added for my own convenience, like presets for Shorts and long videos, rounded caption boxes, a check that the font is installed, and a preview mode that only captions the first 5 seconds. It's in my phantom-editor repo along with a lot of other video automations (that link is the last version that runs Whisper locally).

You don't need my code, though. Explain the three steps to whatever agent you code with. For the first step, tell it which Whisper model to use and how many words you want per caption. For the second, describe the look in detail: the font, the size, the position, the background, the shadows. The agent will write the ffmpeg commands for you, and that's the part you most want to hand off, because, honestly, none of us know ffmpeg commands. There's a reason tools like HandBrake exist.

It's a simple process, and you only build it once. After that, every Short you post gets captions for free.

Video transcript

Show
So if you've been following my channel recently, you'll notice I started posting Shorts a lot now. And one of the things that a good Short requires is proper captions, right? And if you're someone with an attention span of a goldfish and you do scroll a lot of Shorts, you'll notice that most of them have captions like these, right? And so, since I'm posting a lot of Shorts, I need to have this as well. The only problem is there's no free tool for me to do this. There's a couple of AI video editors online, and they all charge me money, right? And I'm not paying for a subscription when I can build the same thing myself. There's also CapCut, which is a really awesome video editor, don't get me wrong, but it's paid. And again, since I can build it myself, why do I need to pay someone else to do it for me? And so that's exactly what I did. I just built it myself. And so right here, I have the entire process of how I did this. I am going to give you guys the code as well. I'll put it in some GitHub repository and link it in the description or something. But you don't really need the code for this. You can literally just vibe code this yourself once you understand the process and what kind of prompt to give to your AI. And so let me walk you through exactly what happens when I want to add captions. So we've got a raw video, right? This is the raw file that you've recorded on your phone or whatever, right? The first thing you need to do is you need to generate the subtitles for this, right? And so for this, what I'm using is an AI model called Faster Whisper, right? And it has plenty of different variants over here that we're gonna go through. But yeah, the first thing you need to do is generate subtitles. Once you've done that, you need to burn those subtitles into the video at the proper timestamps, and for that, you need to use FFmpeg, right? And during this step, you can also adjust the styling, the placement, right? The where exactly you're going to place the font or the text in the video, the font family, the font size, etc., right? All of those things, you can add borders and backgrounds and text shadows and whatever you want to do, right? And once that's done, you literally just save the final video file, and you're pretty much done, right? Super simple process, and you can vibe code this very easily in a single one-shot prompt, right? And before you do any of that, you need to decide which variant of the Faster Whisper model you guys are going to use. Now, I've had good results with the small model and the medium model, and luckily for me, these are also models that I can actually run on my old machine. I've tried the large V3 model as well, and it has really good quality in the transcription, but it just takes way too long to run. And mostly, I haven't found a lot of difference between the medium and the large V3 models. Probably because most of my videos are just normal English, right? So there's not really a lot of margin for error, right? And if you guys are just doing regular English videos just like me, you can get away with using just the medium model. But if you want to have the absolute best performance there is, or if you want to support other languages, not just English, you should probably go with the large V3 model. Keep in mind, it's going to use a lot more RAM as well. So you better have something good over there in your machine, right? And yeah, that about it. Let me also just show you the code over here as well, just real quick. So yeah, this right here is the entire script file that I use to add captions to my Shorts, and I'll be honest, I haven't read this code at all, right? This is 400 lines of code that I have not reviewed at all. I just explained what I needed to this agent over here, and I let it generate whatever it came up with, right? And for something that isn't gonna hit production, that's just a little tool that I'm using myself locally on my machine, I'm completely fine with not reviewing the code. And if I show you what arguments this takes, this is the entire thing, right? Mainly, you just need to pass in a video file, right? The raw file that you've got so that it can generate a transcript and then burn that transcript into the video. But you can specify the model size you want to use as well. I mostly just go with medium because it's just good enough for my needs. You can specify the max words per caption. This is really useful for short-form videos because most of them, you'll see, have like three or five words per caption, right? Not a lot of words. Output video if you want to specify a different output. Uppercase if you want to convert all captions to uppercase, right? Voice activity detection. This is a little something built into the Faster Whisper AI models that just helps you to ignore silences, right? So if in your raw recording you're, I don't know, thinking about what to say, and you're just sitting there not saying anything, this is going to detect that, right? And it's going to detect and ignore that silence, right? And of course, you can go over here and adjust the font size, the bottom margin. I have a little preset options as well that just applies a set of predefined options, right? So something like, let me zoom in as well. Something like a preset for Shorts where I only allow a maximum 5 words, a 12 font size, 50 margin, right? And all uppercase captions. Again, these are just little features that I added into this script for my own, you know, convenience. All you need to do is this little process over here. You have the raw video, you generate subtitles using Faster Whisper, you burn it into the video using FFmpeg, and you save the final video file. How do you create this? You go over to whatever agent or LLM you're using for coding, and you explain this entire process. When you get to this step, you tell the agent which model you want to use. And when you get to this step over here with FFmpeg, you explain in detail to the agent what kind of font you want to use, what the font size should be, the styling, the background, the shadows, everything. You explain all of that, and the agent will write the appropriate FFmpeg commands to achieve that. Because let's be honest, none of us know FFmpeg commands. There's a reason why tools like Handbrake and others exist. And then finally, just save the final video file, and you're done. And then you can easily automatically generate Shorts like these with all these captions over here. Super useful. So yeah, just a little helpful automation I built for myself. If you enjoyed this, like, comment, subscribe. I don't know. All those things. And check out the actual GitHub for this project as well. I just open sourced it last night. And there's so many cool features and automations that I built in there for my own work that I think you can get inspired by too. So do check that out as well.

Share this article

Hassan Aziz pointing up

Dude, you’re at the bottom of my landing page.

Just book a call already if you’re that interested.

You scrolled all the way here.