Checked November 9, 2026 (12:00 CST, UTC+8) against the live pages linked below. I build tinydeskoutstanding.video, an independent studio that turns one to five photos into a 15-second vertical parody of the "Outstanding" Tiny Desk meme. The script was written for this post and tested on synthetic clips and solid-colour stand-in photos, not real faces.
Once a group clip of the Tiny Desk meme lands in a chat, the first reply is usually a question: "wait, who's on sax?" In the wide band shot the faces are small, and by the time people work out who is who, the clip has ended.
One fix that works well is a reveal: in the last few seconds, a card with each person's original photo and name slides in next to the band. This post builds that reveal with one ffmpeg filter graph, generated by about 80 lines of Python, plus a checker that proves the cards land where they should.
What the clip gives you
From the site's own pages:
- Every clip is "15-second vertical video, 9:16", at 480p, 720p, 1080p or 4k, with newly generated sound (FAQ).
- The stage has five faces: "lead, sax, keys, back left, and back right", and "a face you leave empty keeps the performer already in the scene" (band and guest slots).
- The price doesn't change with the number of faces. Today a clip is $9.98 at 480p, $12.98 at 720p, $24.98 at 1080p or $39.98 at 4k (pricing).
So you get one to five cards to show, and a frame that may be anywhere from about 480 to 4k pixels wide. The script reads the real size with ffprobe and does not assume any of it.
The input: a tiny cast file
{"reveal_at": 11.5,
"cards": [
{"spot": "lead", "name": "Maya", "photo": "maya.jpg"},
{"spot": "sax", "name": "Jordan", "photo": "jordan.jpg"},
{"spot": "back left", "name": "Biscuit", "photo": "biscuit.jpg"}
]}
reveal_at is in seconds. Pick it by watching your own clip: the cards should arrive after the joke has landed and before it ends. Use the same photos you uploaded to the studio, so people see the face that went into each spot.
The filter graph, one piece at a time
For each card:
-
scale=...:force_original_aspect_ratio=increase,crop=...fills a 3:4 box with the photo and centres it, so a landscape photo is cropped, not squashed. -
padadds a white border and a strip at the bottom;drawtextwrites "Maya ยท lead" into the strip. -
overlayputs it on the video with an x expression that changes every frame:
x = if(lt(t, start), W, max(W - w - margin, W - (t - start) * speed))
Before start, the card sits just off the right edge (x = W, invisible). After start, it moves left at speed pixels per second until it reaches its resting place, then max() holds it there. Each card starts 0.35 s after the previous one, which gives the staggered "one, two, three" feel. Overlays are chained, so the graph is [0:v][c0]overlay[v0]; [v0][c1]overlay[v1]; ....
Two details that bite:
-
Commas inside expressions must be escaped (
\,) inside a filter graph, or ffmpeg reads them as filter separators. -
eval=framemakes overlay re-evaluate x on every frame. Without it the card would be placed once and never move.
Cards start at a quarter of the frame width and shrink until the whole stack fits the height, so five cards on a 9:16 frame still fit.
The script
#!/usr/bin/env python3
"""Slide a 'who's who' card for each cast member into the last seconds of a clip.
usage: python3 reveal.py clip.mp4 cast.json out.mp4
cast.json: {"reveal_at": 11.5, "cards": [{"spot": "lead", "name": "Maya", "photo": "maya.jpg"}, ...]}
Cards stack down the right edge, each sliding in 0.35 s after the previous one.
Audio is copied untouched; video is re-encoded with libx264 (CRF 18).
"""
import json, os, shutil, subprocess, sys
FONTS = ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/Library/Fonts/Arial Bold.ttf", "C:/Windows/Fonts/arialbd.ttf"]
def probe(path):
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height:format=duration",
"-of", "json", path], capture_output=True, text=True, check=True).stdout
j = json.loads(out)
return j["streams"][0]["width"], j["streams"][0]["height"], float(j["format"]["duration"])
def esc(text): # drawtext text= escaping
return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\u2019")
def layout(W, H, n):
"""Card width, photo height, label height, gap, margin, top for n cards."""
gap, margin = max(4, H // 120), max(6, W // 40)
cw = W // 4 # start at a quarter of the frame width
while cw > 40:
ph, label = cw * 4 // 3, max(14, cw // 7)
top = (H - n * (ph + label) - (n - 1) * gap) // 2
if top >= margin:
return cw, ph, label, gap, margin, top
cw -= 2 # shrink until the stack fits
sys.exit("frame too small for this many cards")
def build(W, H, dur, cast, font):
t0 = float(cast["reveal_at"])
cards = cast["cards"]
if not 1 <= len(cards) <= 5:
sys.exit("1 to 5 cards (the stage has five spots)")
if not 0 < t0 < dur - 0.5:
sys.exit(f"reveal_at must be inside the clip (duration {dur:.2f} s)")
cw, ph, label, gap, margin, top = layout(W, H, len(cards))
ch = ph + label
parts, last = [], "[0:v]"
for i, c in enumerate(cards):
txt = f"{c['name']} \u00b7 {c['spot']}"
card = (f"[{i+1}:v]scale={cw-4}:{ph-4}:force_original_aspect_ratio=increase,"
f"crop={cw-4}:{ph-4},pad={cw}:{ch}:2:2:white")
if font:
card += (f",drawtext=fontfile='{font}':text='{esc(txt)}':fontsize={int(label*0.62)}:"
f"fontcolor=black:x=(w-tw)/2:y={ph}+({label}-th)/2")
parts.append(card + f"[c{i}]")
start = t0 + 0.35 * i
x = f"if(lt(t\\,{start:.3f})\\,W\\,max(W-w-{margin}\\,W-(t-{start:.3f})*{W*3}))"
y = top + i * (ch + gap)
out = f"[v{i}]"
parts.append(f"{last}[c{i}]overlay=x='{x}':y={y}:eval=frame{out}")
last = out
return ";".join(parts), last
def main():
if len(sys.argv) != 4:
sys.exit(__doc__)
clip, cast_path, out = sys.argv[1:]
for tool in ("ffmpeg", "ffprobe"):
if not shutil.which(tool):
sys.exit(f"{tool} not found")
cast = json.load(open(cast_path, encoding="utf-8"))
base = os.path.dirname(os.path.abspath(cast_path))
W, H, dur = probe(clip)
font = next((f for f in FONTS if os.path.exists(f)), None)
graph, last = build(W, H, dur, cast, font)
cmd = ["ffmpeg", "-v", "error", "-y", "-i", clip]
for c in cast["cards"]:
cmd += ["-i", os.path.join(base, c["photo"])]
cmd += ["-filter_complex", graph, "-map", last, "-map", "0:a?",
"-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "copy",
"-movflags", "+faststart", out]
subprocess.run(cmd, check=True)
print(f"wrote {out}: {len(cast['cards'])} cards from {cast['reveal_at']} s"
+ ("" if font else " (no font found, labels skipped)"))
if __name__ == "__main__":
main()
Audio is copied untouched (-c:a copy), so the generated sound is exactly what the studio delivered. The video has to be re-encoded, because you're drawing on it; CRF 18 keeps it close to the original.
If no font file is found, the cards still appear, just without names. Add your system's bold font to FONTS if you're on something other than Debian, macOS or Windows.
Testing it without real faces
A synthetic 15-second clip and three solid-colour "photos" make the check easy: if the red card is where Maya's card should be, the area there should average to Maya's red.
#!/usr/bin/env bash
# Synthetic stand-ins: a 15 s vertical 480x854 clip with a tone, and three
# solid-colour "photos". No real faces involved.
set -euo pipefail
ffmpeg -v error -y -f lavfi -i "testsrc2=size=480x854:rate=30:duration=15" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=15" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -shortest clip.mp4
python3 - <<'PY'
from PIL import Image
for name, rgb in [("maya", (220, 40, 40)), ("jordan", (40, 160, 60)), ("biscuit", (40, 70, 220))]:
Image.new("RGB", (600, 800), rgb).save(f"{name}.jpg", quality=95)
PY
cat > cast.json <<'JSON'
{"reveal_at": 11.5,
"cards": [
{"spot": "lead", "name": "Maya", "photo": "maya.jpg"},
{"spot": "sax", "name": "Jordan", "photo": "jordan.jpg"},
{"spot": "back left", "name": "Biscuit", "photo": "biscuit.jpg"}
]}
JSON
echo "made clip.mp4, 3 photos, cast.json"
The checker imports the same layout() the renderer uses, grabs one frame before the reveal and one at the end, and checks two things: before the reveal the output matches the source (nothing leaked in early), and at the end each card's photo area matches its photo's colour.
#!/usr/bin/env python3
"""Check reveal.py output: no card before reveal_at, every card in place at the end.
usage: python3 check_reveal.py clip.mp4 out.mp4 cast.json
Reads the average colour of each card's photo area from two frames.
"""
import json, os, subprocess, sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from reveal import layout # same geometry as the renderer
def frame(path, t, W, H):
raw = subprocess.run(["ffmpeg", "-v", "error", "-ss", str(t), "-i", path, "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, np.uint8).reshape(H, W, 3).astype(int)
def main():
clip, out, cast_path = sys.argv[1:4]
cast = json.load(open(cast_path))
base = os.path.dirname(os.path.abspath(cast_path))
j = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=width,height:format=duration", "-of", "json", out],
capture_output=True, text=True, check=True).stdout)
W, H = j["streams"][0]["width"], j["streams"][0]["height"]
dur = float(j["format"]["duration"])
cw, ph, label, gap, margin, top = layout(W, H, len(cast["cards"]))
ch = ph + label
early_src = frame(clip, cast["reveal_at"] - 1, W, H)
early_out = frame(out, cast["reveal_at"] - 1, W, H)
late = frame(out, dur - 0.2, W, H)
ok = True
diff = np.abs(early_src - early_out).mean()
print(f"before reveal: mean abs diff vs source {diff:.2f} (want < 3)")
ok &= diff < 3
for i, c in enumerate(cast["cards"]):
want = np.array(Image.open(os.path.join(base, c["photo"])).convert("RGB")).reshape(-1, 3).mean(0)
x0, y0 = W - margin - cw + 10, top + i * (ch + gap) + 10
got = late[y0:y0 + ph - 20, x0:x0 + cw - 20].reshape(-1, 3).mean(0)
err = np.abs(got - want).max()
print(f"card {i} {c['name']:<8} expected {want.round()} got {got.round()} err {err:.1f}")
ok &= err < 12
print("PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
Output on the box I tested on (ffmpeg 7.1, Python 3.13):
$ bash make_test_media.sh
made clip.mp4, 3 photos, cast.json
$ python3 reveal.py clip.mp4 cast.json out.mp4
wrote out.mp4: 3 cards from 11.5 s
$ python3 check_reveal.py clip.mp4 out.mp4 cast.json
before reveal: mean abs diff vs source 0.30 (want < 3)
card 0 Maya expected [220. 40. 41.] got [220. 39. 41.] err 1.0
card 1 Jordan expected [ 40. 160. 60.] got [ 38. 159. 59.] err 1.6
card 2 Biscuit expected [ 40. 70. 220.] got [ 39. 69. 219.] err 1.1
PASS
I also ran it on a 1080x1920 test clip with all five spots filled and names containing a colon and an apostrophe ("Dr: Kim", "O'Neil"), which are the two characters that break drawtext most often. All five cards passed. The first version of the script failed that test with "too many cards for this frame height", which is why the card size now shrinks to fit.
Things to decide before you post
- Ask again. Everyone in the clip agreed to be in it; a card with their real photo and name is a second, more personal step. The site's spots are for "one adult who agreed, or one pet" (band and guest slots), and the same courtesy applies to the reveal.
-
Nicknames are fine. The card text is whatever you put in
name. - Keep it labelled as a parody. The sound is newly generated and "not the Gap Band recording, Charlie Wilson's performance or NPR audio" (FAQ).
- Watch the clip first. Check the face in the band shot and the close-up before adding anything (check before posting). Cards draw attention to faces, including a face that came out wrong.
Variations
-
Cards on the left: swap the x expression to start at
-wand stop atmargin. -
Fade instead of slide: keep x fixed and put
format=rgba,fade=in:st=START:d=0.3:alpha=1on each card. -
Only the lead: one card, larger. Set the width to
W // 2inlayout().
The full clip, the band and the scene come from the studio (tinydeskoutstanding.video); the reveal is a local edit you add afterwards, on your own machine, with your own photos.
Top comments (1)
Good idea to read the real size with ffprobe. One thing that keeps the cards consistent: people upload portrait, landscape and square photos, so scaling each face with scale=W:H:force_original_aspect_ratio=increase followed by crop=W:H gives every card the same size without stretching. Staggering each card's start by about 0.15 s (reveal_at plus index times 0.15) also reads much better than all of them sliding in at once, and an ease-out in the x expression instead of a linear slide makes it feel less mechanical.