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:

- Frames. A function that takes a frame number and returns an image.
- Audio. An array of samples written to a WAV file.
- 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.

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.



