Case study
The bug wasn't in the subtitle renderer. It was in how I was testing it.
A build story about the difference between "the animation looks frozen" and "the animation is broken" — and why chasing the wrong root cause almost cost hours of good, working code.
The problem
Good captioning tools want your video first. That's the one thing I wasn't willing to hand over.
Auto-captioning a video well needs three things: accurate speech-to-text, a transcript you can actually edit before it's final, and captions that look designed instead of default. Every tool that does all three properly is a web upload or a paid API — the video leaves your machine before a single word gets transcribed. For anything that isn't meant for a stranger's server, that's disqualifying on its own, cloud pricing aside.
The alternative wasn't really an alternative: hand-timing subtitles in Aegisub, one line at a time. Fine for someone who already knows ASS subtitle syntax. Not fine as a repeatable workflow.
The approach
Electron for the shell, faster-whisper for the ears, ffmpeg for everything else.
The shape of it is simple: an Electron app extracts the audio with ffmpeg, hands it to a Python subprocess running faster-whisper (Whisper on CTranslate2) for word-level timestamped transcription, and the renderer turns the edited transcript into an .ass subtitle file — the same format tools like Aegisub produce by hand — which ffmpeg then burns into the final video via its bundled libass renderer. Every animation (fade, pop, karaoke word-highlight, word-by-word Shorts-style reveal, typewriter, and a handful of motion and glow effects) is just a different combination of ASS override tags generated per caption, not a video-editing timeline bolted on afterward.
The part worth being honest about: I spent real time convinced that most of those animations were completely broken — frozen, showing only their starting frame no matter how far into the clip I seeked. I rewrote the whole animation system around that "finding" before checking the assumption underneath it. Extracting a single frame with ffmpeg's -ss flag before the input, on the specific test clip I was using, doesn't seek reliably — it kept handing back frame zero no matter what timestamp I asked for. The bug was in my test harness, not in libass's \t transform or \move. Rebuilding frame extraction around ffmpeg's select filter (which picks a frame by evaluating the actual decoded timestamp, not by seeking) proved every animation had been working correctly the entire time. The rewrite got reverted; the real lesson — verify the measurement before trusting what it measured — stuck.
The architecture
What's actually running under it.
- Shell
Electron, with the main process owning everyffmpeg/Python subprocess and the renderer handling transcript editing and live preview.- Transcription
faster-whisperoverCTranslate2, CUDA when an NVIDIA GPU is present, falling back to CPU (int8) automatically when it isn't.- Subtitle format
- Generated
.ass(Advanced SubStation Alpha) — the only common subtitle format with per-word karaoke timing and pixel-level positioning. - Rendering
ffmpeg'sassfilter (bundledlibass),h264_nvencwhen available and automatically retried onlibx264when it isn't.- Font coverage
- A bundled Noto Sans Devanagari font registered with libass via
fontsdir, so Hindi captions render correctly regardless of what's installed on the target machine. - Packaging
electron-builderproducing a single NSIS installer that bundles its own Python runtime,faster-whisper, andffmpeg— nothing separate to install first.
The outcome
Honest about where it actually is.
It transcribes, edits, styles, animates, and exports end to end, fully offline, on a machine with or without a GPU. The Hindi font-fallback bug was a real one, caught after a user report of tofu boxes in the exported video — fixed by bundling the font instead of trusting whatever happened to be installed on any given Windows machine, which is a more general lesson than just this app: don't rely on system state you don't control for something visitors or users will actually see.
What it isn't yet: cross-platform (Windows only, since the packaging leans on Windows-specific paths and an NSIS installer), and it processes one video at a time — no batch queue. The installer is also close to 1.4GB, because bundling a full local speech-recognition stack instead of calling an API is the whole point, not a compromise, but it does mean this isn't a five-megabyte utility.
Need something that has to run entirely on a client's own infrastructure, with no third-party service in the data path?
Get in touch