TL;DR
- Ask an LLM for a scene plan in JSON, and check every field before you use it.
- Crop each image to 9:16 first, then add a slow zoom with ffmpeg's
zoompanfilter. - Make each scene as long as its voice-over (measured with
ffprobe), not as long as the LLM guessed. - Split the narration into short caption chunks, time them by length, and burn them into the video.
- Encode every scene the same way, then join them with no re-encoding.
- Retry rate limits and server errors. Fail fast on bad requests and bad keys.
Calling an image API and a text-to-speech API is the easy part of an "AI video generator". Turning their output into a video people will actually watch is the work. This post builds that pipeline with Node and plain ffmpeg. Each step is one ffmpeg command you can paste into a terminal first, then wrap in code.
A few words first
- LLM: a large language model, the kind of AI that writes text (and JSON).
- TTS (text-to-speech): a service or program that turns text into a voice recording.
- ffmpeg / ffprobe: free command-line tools. ffmpeg converts and edits video and audio. ffprobe reads facts about a file, such as its length.
- 16:9 and 9:16: width-to-height ratios. 16:9 is a landscape YouTube video. 9:16 is a vertical Short, Reel or TikTok.
-
Filter: an ffmpeg step that changes the picture or sound, like
scale(resize) orcrop.
The whole pipeline
One brief, like "3 tips for better sleep", goes in. The LLM breaks it into scenes. Each scene gets an image and a voice-over. ffmpeg turns each scene into a short clip, then joins the clips into one MP4.
Step 1: get a scene plan you can trust
What goes wrong: the LLM's answer crashes your code, or produces a video that's far too long.
Everyday example: you ask for 6 scenes in JSON. The model wraps the JSON in a code fence, adds "Here's your plan!" before it, returns 14 scenes, and writes "durationSec": "3s" as text instead of a number.
Why it happens. An LLM predicts likely text. It usually follows your format, but nothing forces it to.
The fix: ask for JSON, then check every field before you use it. On OpenAI-compatible APIs, response_format: { type: "json_object" } asks the model for JSON only. The plan looks like this:
{
"title": "3 tips for better sleep",
"scenes": [
{ "imagePrompt": "dim bedroom, warm lamp, cinematic", "narration": "Keep the same wake-up time, even on weekends.", "durationSec": 3 },
{ "imagePrompt": "phone face down on a nightstand", "narration": "Put screens away thirty minutes before bed.", "durationSec": 3 }
]
}
Then, in your code:
- Strip any code fences and text around the JSON.
-
JSON.parseit. - Check every field has the right type.
- Clamp the numbers: for example 2 to 12 seconds per scene, and a maximum scene count.
- If anything is still wrong, throw an error your job queue will retry.
Step 2: turn a still image into a moving 9:16 clip
What goes wrong: the Short looks stretched, or looks frozen.
Everyday example: your image API returns a 16:9 landscape picture. You resize it to 720×1280, and the round sun becomes a tall oval. Or you keep it sharp and still, and viewers swipe away because nothing moves.
Why it happens. Resizing a landscape image to a portrait size stretches it, because the shapes don't match. The right move is to crop: keep a 9:16 slice from the middle and drop the sides.
For motion, the cheapest effect that looks deliberate is the Ken Burns effect: a slow zoom into a still photo. It's named after the documentary maker who used it heavily. ffmpeg's zoompan filter does it. Each frame shows a slightly smaller, centred part of the image, stretched to fill the screen, so the picture slowly pushes in.
The fix: scale up, crop to 9:16, then zoom. Here's the whole command for one scene:
W=720; H=1280; FPS=25; DUR=3.4
FRAMES=85 # DUR × FPS
ffmpeg -y -loop 1 -i scene.png -i voice.mp3 -filter_complex "\
[0:v]scale=900:1600:force_original_aspect_ratio=increase,crop=900:1600,\
zoompan=z='min(zoom+0.0008,1.15)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=$FRAMES:s=${W}x${H}:fps=$FPS,\
format=yuv420p[v]" \
-map "[v]" -map 1:a -af apad -t $DUR \
-c:v libx264 -preset veryfast -c:a aac -ar 44100 -ac 2 segment.mp4
What each part does:
-
-loop 1 -i scene.pngrepeats the still image as a video stream.-i voice.mp3adds the voice-over. -
scale=900:1600:force_original_aspect_ratio=increase,crop=900:1600grows the image until it covers 900×1600, then cuts away what sticks out. That gives a 9:16 picture whatever shape the source was. It's 1.25× bigger than the output on purpose:zoompanjitters when it zooms into a small image. -
z='min(zoom+0.0008,1.15)'zooms in 0.08% per frame, which is 2% per second at 25 fps, and stops at 15%. Thexandyparts keep the zoom centred. -
d=$FRAMES:s=${W}x${H}:fps=$FPSsets the clip's frame count, output size and frame rate. -
format=yuv420puses the pixel format phones and browsers can play. -
-af apadpads the voice with silence, and-t $DURcuts the clip to the exact length.
For 16:9, swap the numbers: 1600:900 and s=1280x720.
Step 3: make each scene as long as its voice
What goes wrong: the narration gets cut off mid-word at the end of a scene.
Everyday example: the LLM planned 3 seconds for "Keep the same wake-up time, even on weekends." The voice takes 3.11 seconds to say it, so "weekends" gets clipped.
Why it happens. The LLM has no idea how fast your TTS voice speaks. Its durationSec is a guess.
The fix: generate the voice first, measure it with ffprobe, then set the scene length:
ffprobe -v error -show_entries format=duration -of csv=p=0 voice.mp3
# 3.108571
const speech = await probeDuration(audioPath); // runs the ffprobe command above
const dur = Math.max(scene.durationSec, speech + 0.25); // never shorter than the voice, plus a breath
That one line is the difference between a clip that sounds edited and one that sounds broken.
Step 4: burn in captions that follow the voice
What goes wrong: viewers with the sound off don't know what the video says.
Everyday example: someone scrolls past your Short on a train, with their phone on mute. There's no text on screen, so they never hear your three tips.
Why it happens. Many people watch Shorts on mute, and a voice-over alone tells them nothing.
The fix: split the narration into chunks of 3 or 4 words, give each chunk a start and end time, and draw them onto the video. The timed chunks are saved as an SRT file, a plain-text subtitle format. ffmpeg's subtitles filter draws ("burns") them into the picture. It uses libass, a subtitle library that standard ffmpeg builds include.
Most TTS services don't tell you when each word is spoken. Giving each chunk time in proportion to its length is close enough to read along:
function captionCues(text: string, duration: number, maxWords = 4) {
const words = text.trim().split(/\s+/);
const chunks: string[] = [];
for (let i = 0; i < words.length; ) {
let n = Math.min(maxWords, words.length - i);
for (let k = 0; k < n - 1; k++) if (/[.,!?;:]$/.test(words[i + k])) { n = k + 1; break; } // break at punctuation
chunks.push(words.slice(i, i + n).join(" "));
i += n;
}
const total = chunks.reduce((s, c) => s + c.length + 2, 0);
let t = 0;
return chunks.map((c) => { const d = (duration * (c.length + 2)) / total; const cue = { start: t, end: t + d, text: c }; t += d; return cue; });
}
const ts = (s: number) => new Date(s * 1000).toISOString().slice(11, 23).replace(".", ",");
const toSrt = (cues: { start: number; end: number; text: string }[]) =>
cues.map((c, i) => `${i + 1}\n${ts(c.start)} --> ${ts(c.end)}\n${c.text}\n`).join("\n");
For the example line, this writes:
1
00:00:00,000 --> 00:00:01,576
Keep the same wake-up
2
00:00:01,576 --> 00:00:02,056
time,
3
00:00:02,056 --> 00:00:03,358
even on weekends.
Then add the subtitles filter to the Step 2 command, just before format=yuv420p:
,subtitles=scene-0.srt:force_style='FontName=DejaVu Sans,FontSize=10,Bold=1,Outline=1.6,Shadow=0.8,Alignment=2,MarginV=64'
Two things that cost me time:
-
FontSizeis not in pixels. For SRT files, libass lays text out on a canvas 288 units tall, then scales it to the video. SoFontSize=10on a 1280-pixel-tall video is about 44 px. For text about 6% of the short side, useFontSize = 0.06 × min(W, H) × 288 / H. - File paths inside a filter string are painful. Colons, backslashes and quotes all need escaping, and Windows drive letters make it worse. Run ffmpeg from the folder that holds the SRT and pass just the file name. Never put user text directly into a filter string; write it to a file first.
Step 5: a free voice for drafts
What goes wrong: every test render costs money.
Everyday example: you're tuning caption sizes and render the same video 30 times. With a paid voice API, you pay for the same three sentences 30 times.
Why it happens. Paid TTS bills per character, even for drafts nobody will see.
The fix: use an offline voice for drafts. If your ffmpeg build includes flite (a small offline speech engine), ffmpeg can speak on its own:
ffmpeg -y -f lavfi -i "flite=textfile=line.txt:voice=slt" -af loudnorm=I=-16 -ar 44100 voice.mp3
On macOS, say -o voice.aiff -f line.txt works too. On Linux and Windows, espeak-ng -w voice.wav -f line.txt does. These voices sound robotic, so keep them for drafts. Put them behind the same interface as your paid voice, so switching is one environment variable:
interface VoiceProvider { name: string; speak(text: string, outPath: string): Promise<void> }
Step 6: join the scenes without re-encoding
What goes wrong: joining the clips is slow and loses quality, or the audio drifts out of sync.
Everyday example: joining a 40-scene video crawls along because every frame is compressed again, or halfway through, the voice starts to lag behind the picture.
Why it happens. Re-encoding means decoding the video and compressing it again, which is slow and loses a bit of quality each time. ffmpeg's concat demuxer can join clips without re-encoding, but only if every clip was encoded the same way.
The fix: encode every scene with identical settings (resolution, frame rate, pixel format, codec, audio sample rate and channel count), then join them:
printf "file '%s'\n" scene-0.mp4 scene-1.mp4 scene-2.mp4 > list.txt
ffmpeg -y -f concat -safe 0 -i list.txt -c copy -movflags +faststart final.mp4
-
-c copycopies the video and audio as they are, so it's almost instant. -
+faststartmoves the file's index to the front, so the video starts playing in a browser before it has fully downloaded.
If concat complains or the audio drifts, one clip was encoded differently. Usually it's a mono voice file that skipped -ac 2.
Step 7: survive real APIs
What goes wrong: renders that work on your laptop fail in production.
Everyday example: the image API answers "429 Too Many Requests" on scene 12 of 40, and the whole job dies. You retry it, and it pays for a new LLM plan before failing again.
Why it happens. Real APIs have rate limits and bad moments. Some errors fix themselves if you wait. Others never will, like a wrong API key.
The fix: three rules.
- Retry only errors that can fix themselves. Retry 429 and 5xx with exponential backoff (wait 1 s, then 2 s, then 4 s) plus jitter (a small random extra wait, so many jobs don't retry at the same instant). Don't retry 400, 401 or 403. Fail the job at once with a clear message instead of burning credits.
- Save finished steps on the job. Store the validated plan on the job record. When the job retries after an image failure, it skips the LLM call it already paid for.
-
Limit how much runs at once. Render, say, 3 scenes at a time and 2 jobs at a time, and make both configurable. Firing 40 requests at once with
Promise.allis how you get rate-limited.
For one machine, a simple file-based job store is enough: one job.json per job, written atomically, plus a worker loop that picks up queued jobs. On startup, put any job still marked running back in the queue, so a crash doesn't strand it. When you outgrow one machine, put Redis/BullMQ or Postgres SKIP LOCKED behind the same interface.
The checklist
- Validate the LLM's JSON. Clamp scene count and durations.
- Scale up, crop to the target shape, then
zoompan. - Measure the voice with ffprobe. Scene length = max(planned, speech + 0.25 s).
- SRT chunks of 3 to 4 words, burned in with
subtitles. Font size is in 288-unit canvas terms, not pixels. - Run ffmpeg in the asset folder. Text goes in files.
- Identical encode settings, then
concat -c copywith+faststart. - Retry only what's retryable, keep the plan on the job, limit concurrency.
Questions about any of the ffmpeg filters above? Ask in the comments and I'll answer.
Want this wired together? The AI Video Pipeline Kit ($49) is a Next.js + TypeScript + ffmpeg pipeline with a parallel job queue and 37 tests, and it runs with zero API keys (mock art and an offline voice).






Top comments (0)