I render short vertical recipe videos (1080×1920) from a Python pipeline, and I needed two things added after rendering, without touching the renderer:
- a banner that stays on screen from second 3 to the end, and
- a 3.5-second end card (a still image) appended after the last frame, with silent audio so the track lengths still match.
Doing it as a post-process step keeps the renderer simple and lets me swap the banner content later. Here is the single ffmpeg call I ended up with, plus the three mistakes I hit on the way.
The filter graph
Inputs:
-
0– the rendered video (with audio) -
1– the end card PNG, looped for 3.5 s -
2– generated silence for the end card -
3– the banner PNG (RGBA, transparent corners)
import subprocess, imageio_ffmpeg
FF = imageio_ffmpeg.get_ffmpeg_exe() # bundled ffmpeg, no system install needed
DUR = 3.5
def add_banner_and_endcard(src, endcard_png, banner_png, out, w, h, fps, sr):
sx, sy = int(w * 40 / 1080), int(h * 50 / 1920) # position scales with the video
sw = int(w * 1000 / 1080)
graph = (
rf"[3:v]scale={sw}:-1[s];"
rf"[0:v][s]overlay={sx}:{sy}:enable=gte(t\,3)[o];" # banner from t=3s
rf"[1:v]scale={w}:{h},fps={fps},format=yuv420p,setsar=1[e];" # end card matches main
rf"[o]format=yuv420p,setsar=1[m];"
rf"[m][0:a][e][2:a]concat=n=2:v=1:a=1[v][a]"
)
cmd = [FF, "-y",
"-i", src,
"-loop", "1", "-t", str(DUR), "-i", endcard_png,
"-f", "lavfi", "-t", str(DUR), "-i", f"anullsrc=r={sr}:cl=mono",
"-i", banner_png,
"-filter_complex", graph,
"-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", out]
subprocess.run(cmd, check=True)
w, h, fps and the audio sample rate sr come from probing the source (ffmpeg -i src and a regex over stderr is enough for this).
Mistake 1: the comma inside enable=
enable=gte(t,3) looks fine, but inside -filter_complex the comma is a filter separator, so ffmpeg splits the expression and fails with a confusing parse error. Escape it as gte(t\,3).
In Python, put the graph in a raw string (rf"...") so the backslash reaches ffmpeg unchanged. A normal f-string gives you a SyntaxWarning: invalid escape sequence today and a bug tomorrow.
Mistake 2: concat refuses the end card
concat needs every video segment to have the same size, frame rate, pixel format and sample aspect ratio. A PNG loop is RGB, has no fps, and may carry a different SAR. Normalising both sides with scale=…,fps=…,format=yuv420p,setsar=1 fixed every "Input link parameters do not match" error I got.
Mistake 3: audio channel layout
My renderer writes mono 24 kHz audio. I first generated the silence as cl=stereo, and concat complained about mismatched audio. Generate the silence with the same sample rate and layout as the source (anullsrc=r=24000:cl=mono), or probe it first.
Keep the original
The function above writes to a new file. In the pipeline I move the original to name.noend.mp4 first and only overwrite on success. If the process ever crashes halfway, the original is still there, and the presence of that file also makes the step idempotent: if it exists, the video was already processed, so skip it.
Result
Each video gets the banner from 3 s to the end plus the end card, in one encode, about 10–20 s per 40-second clip on a normal desktop CPU. The same pattern works for any "watermark from time X" or "append a slate" job.
Where this runs
This is the post-process step behind the recipe shorts for Ruri Jupjup, a free Korean site I build with an AI coding agent: an ingredient-based finder over ~1,350 home recipes, plus hourly-updated contest and government-support listings. Every short ends by pointing back to the matching recipe page, which is what the banner above is for. If you are curious how the rest is built (static pages, hourly rebuilds, zero server cost), the build notes are on the site's dev log.
This post was written with the help of an AI coding assistant, based on code running in my own pipeline.
Top comments (0)