← posts / agents

Rendering video with code only: frames, ffmpeg and numpy-synthesised audio, step by step

A promo video needs no editor: draw frames in code, synthesise the soundtrack with numpy, join them with ffmpeg, then check loudness. A minimal pipeline modelled on an open-source Claude skill.

if.codesNov 6, 2026 · 4 min read#video#ffmpeg#python#automationAI-assisted

A recent open-source project, motion-promo-reels, builds short promo videos entirely from code: frames are drawn on a canvas, assembled with ffmpeg, and the music and sound effects are synthesised rather than sampled. Its README says it uses no video templates, no After Effects and no third-party music. This post pulls the idea apart into the smallest pipeline that works, so you can see each stage and decide whether it fits your own batch jobs.

The three stages

Every code-only video pipeline has the same shape:

A conceptual view of the three-stage video pipeline.
A conceptual view of the three-stage video pipeline.
  1. Frames. A function that takes a frame number and returns an image.
  2. Audio. An array of samples written to a WAV file.
  3. Mux. ffmpeg joins numbered frames and the audio into an MP4.

The repo draws frames on a canvas in a headless browser (it lists Node 18+, Chrome or Edge, ffmpeg, and Python 3 with numpy, scipy and Pillow as requirements). The example below keeps the same shape but uses Pillow for frames, so everything is one Python file. It is a teaching sketch, not the project's code, and I have not benchmarked it.

Setup

python3 -m venv .venv
source .venv/bin/activate
pip install numpy pillow
ffmpeg -version | head -n 1

Stage 1 and 2: frames and audio

The video is 1080×1920 (9:16), 30 fps, 4 seconds, so 120 frames. A dot travels across the screen while a progress bar fills. The audio is a 220 Hz tone that decays once per second, like a pluck.

Individual frames generated by code before assembly.
Individual frames generated by code before assembly.
import os
import wave

import numpy as np
from PIL import Image, ImageDraw

W, H, FPS, SECONDS = 1080, 1920, 30, 4
N = FPS * SECONDS
SR = 44100


def frame(i):
    t = i / (N - 1)
    img = Image.new('RGB', (W, H), (18, 18, 28))
    d = ImageDraw.Draw(img)
    x = int(120 + t * (W - 240))
    d.ellipse((x - 60, 900 - 60, x + 60, 900 + 60), fill=(255, 190, 60))
    d.rectangle((80, 1500, 80 + int(t * (W - 160)), 1530), fill=(90, 200, 160))
    return img


def audio():
    t = np.arange(SR * SECONDS) / SR
    tone = 0.3 * np.sin(2 * np.pi * 220 * t) * np.exp(-3 * (t % 1.0))
    return (tone * 32767).astype('<i2')


os.makedirs('frames', exist_ok=True)
for i in range(N):
    frame(i).save(f'frames/f{i:04d}.png')

with wave.open('audio.wav', 'wb') as w:
    w.setnchannels(1)
    w.setsampwidth(2)
    w.setframerate(SR)
    w.writeframes(audio().tobytes())
print('frames:', N, 'audio seconds:', SECONDS)

Because the frame is a pure function of its index, you can re-render one frame to debug it, or render frames in parallel. That is the main reason to prefer code over an editor for batches: a change to the function changes every video the same way.

Stage 3: mux with ffmpeg

ffmpeg -y -framerate 30 -i frames/f%04d.png -i audio.wav \
  -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest out.mp4
ffprobe -v error -show_entries stream=width,height,duration -of default=nw=1 out.mp4

-pix_fmt yuv420p matters: without it some players, phones included, refuse to play H.264 files produced from RGB PNGs. The ffprobe line confirms size and duration, one of the checks the repo's skill file lists.

Want an agent pipeline you can actually inspect?I build multi-step agents with human approval steps. The estimate is free.

Check loudness before you post

The skill targets about −14 LUFS and measures it with ffmpeg's ebur128 filter. You can do the same:

Measuring audio loudness for platform consistency.
Measuring audio loudness for platform consistency.
ffmpeg -hide_banner -i out.mp4 -af ebur128=peak=true -f null - 2>&1 | tail -n 12

The summary block at the end reports integrated loudness (I), loudness range and true peak. If I is far from your target, scale the amplitude in audio() and render again. Note that −14 LUFS is the skill's own target; platforms publish no single required number for uploads, so treat it as a sensible default.

What the real project adds

According to its README and skill file, the full project goes well beyond this sketch:

  • A seeded planner gives each video a different construction (scene order, layouts, transitions, colour theme, music style and tempo) so a batch does not look or sound alike.
  • Ten music styles and five sound-effect packs, all synthesised.
  • Quick checks: Instagram safe zones, a contact sheet of stills for review, loudness, duration and resolution.
  • A rule that every price, colour or feature comes from a verified source, and anything unconfirmed is flagged as pending.

It is MIT-licensed code; bundled fonts are under the SIL Open Font License, and the generated videos belong to the user.

When this approach is the wrong tool

Code-only rendering shines for templated, data-driven clips: product reels, changelog videos, dashboards in motion. It is a poor fit for footage-heavy edits or anything needing taste calls frame by frame. And rendering PNG-per-frame is disk-heavy at scale; if that bites, pipe raw frames to ffmpeg's stdin instead.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.