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.

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.

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.

What's actually running under it.

Shell
Electron, with the main process owning every ffmpeg/Python subprocess and the renderer handling transcript editing and live preview.
Transcription
faster-whisper over CTranslate2, 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's ass filter (bundled libass), h264_nvenc when available and automatically retried on libx264 when 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-builder producing a single NSIS installer that bundles its own Python runtime, faster-whisper, and ffmpeg — nothing separate to install first.

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