Animated captions with FFmpeg in Node.js
Burn word-by-word animated captions into an MP4 from Node.js: no headless browser, no ASS files, the same renderer as the browser preview.
@captionsjs/server draws captions.js frames with skia-canvas and pipes them straight
into FFmpeg. You get the kinetic, word-level captions people know from TikTok and Reels,
rendered on your server, with output that matches the browser preview frame for frame.
Why not drawtext or ASS?
| captions.js | FFmpeg drawtext | ASS / libass | |
|---|---|---|---|
| Word-by-word highlight | yes | build it from many filters | karaoke tags only |
| Per-word scale, bounce, box animations | yes | no | no |
| Same look as a live browser preview | yes, same renderFrame | no | depends on the player |
| Headless Chrome needed | no | no | no |
Install
npm install @captionsjs/serverYou need ffmpeg and ffprobe on PATH (or point FFMPEG_PATH / FFPROBE_PATH at them).
On macOS: brew install ffmpeg. On Debian/Ubuntu: apt-get install ffmpeg.
Burn captions
import { burnCaptions } from "@captionsjs/server";
const result = await burnCaptions({
video: "talk.mp4", // path or http(s) URL
captions: "words.json", // path, URL, JSON string or parsed array
preset: "Focus Box", // any preset name, case-insensitive
output: "talk.captions.mp4", // default: <input>.captions.mp4
onProgress: ({ frame, totalFrames }) =>
process.stdout.write(`\r${Math.round((frame / totalFrames) * 100)}%`),
});
console.log(result); // { output, frames, width, height, fps, durationSec }captions accepts plain [{ word, start, end }] (or startTime / endTime), OpenAI
Whisper verbose_json and Deepgram responses. See
Animated captions from Whisper for getting word timings.
Options
| Option | Default | What it does |
|---|---|---|
preset | "Karaoke" | Preset name or a full preset object (clone one with getPreset() and edit it) |
fps | source fps | Frame rate of the caption overlay |
crf | 20 | x264 quality, lower is better and bigger |
x264Preset | "veryfast" | x264 speed/size trade-off |
scale | height / 480 | Font-size multiplier; the browser overlay uses the same rule |
fonts.fontsDir | none | Folder with TTFs in Google Fonts layout, searched first |
fonts.download | true | Set false to fail instead of downloading missing fonts |
Preset fonts are downloaded from Google Fonts on first use and cached in
~/.cache/captionsjs/fonts (set CAPTIONSJS_CACHE_DIR to move it).
Custom styles
A preset is a plain object, so brand it in code and pass it in:
import { getPreset } from "captions.js";
import { burnCaptions } from "@captionsjs/server";
const base = getPreset("Karaoke");
const branded = {
...base,
captionsSettings: {
...base.captionsSettings,
style: { ...base.captionsSettings.style, highlightColor: "#ff2e88" },
},
};
await burnCaptions({ video: "talk.mp4", captions: "words.json", preset: branded });Without writing code
npx captions.js burn talk.mp4 words.json --preset Karaoke -o out.mp4
npx captions.js presets # list every preset nameOr run the Docker image, which has FFmpeg and every preset font baked in and works offline:
docker run --rm -v "$PWD:/data" maskin25/captions.js-render \
burn /data/talk.mp4 /data/words.json --preset Karaoke -o /data/out.mp4More on the image in Docker Render Service.
Rather not run it yourself?
captions.js Cloud is the same renderer as a hosted API: send a video URL, get the captioned MP4 back by webhook. It's in early access; founding members get free render minutes.
For AI agents and LLMs: this page as Markdown · llms.txt · all docs in one file
Animated captions from Deepgram transcripts
Feed a Deepgram pre-recorded response straight into captions.js: word timings, sentences and paragraphs, rendered as animated captions.
Word-by-word captions in React and Next.js
Add TikTok-style animated captions over a video in a React or Next.js app: a small component around captions.js, with live preset switching.