Checked November 10, 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 a flat-colour stand-in photo, not real faces.
The reaction you want to an "Outstanding" Tiny Desk remake is the double take: wait, that's Dave? The quickest way to get it is to show Dave first. Two seconds of the plain photo you started from, a slow push-in, then a fade straight into the band behind the red keyboard.
That's a "before → after" intro, and it's one ffmpeg filter graph. This post builds it with a ~70-line Python wrapper that reads the clip's own size, frame rate and audio rate, and a checker that proves the result lines up with the original clip frame for frame.
What you're starting from
From the site's own pages:
- "Every clip is 15-second vertical video, 9:16, at 480p, 720p, 1080p, or 4k, with sound" (FAQ). The sound is newly generated, not the Gap Band recording.
- Each spot takes "one clear, front-facing photo of one adult who agreed, or one pet", "JPEG, PNG or WebP up to 10 MB" (photo guide).
- A clip costs the same for one face or five: today $9.98 at 480p, $12.98 at 720p, $24.98 at 1080p or $39.98 at 4k (pricing).
So you have a vertical clip of unknown exact size and a photo that is probably not 9:16 (most phone portraits are 3:4). The intro has to deal with both without stretching anything.
The idea in four steps
- Fill the frame without black bars. Scale a copy of the photo to cover the 9:16 frame, crop it, and blur it hard. This is the background.
- Fit the real photo on top. Scale a second copy to fit inside the frame and overlay it in the centre. Nothing is cropped from the face.
- Push in slowly.
zoompanturns that single still into N frames, zooming from 1.0 to 1.15 around the centre. - Cross-fade into the clip.
xfadeblends the last half second of the intro with the first half second of the clip. The clip's sound is delayed to start exactly when the clip appears.
The output is hold seconds longer than the clip (2 s by default): the fade overlaps, so it doesn't add its own length.
Three traps
zoompan on a looped image multiplies frames. If you feed the photo with -loop 1, zoompan's d= applies to every input frame, and every input frame becomes d output frames. (Checked: one second of looped input at 30 fps with d=30 produced 900 frames, not 30.) Read the photo once, as a single frame, and let d= produce the whole intro.
xfade wants matching timebases. My first run failed with "First input link main timebase (1/1000000) do not match the corresponding second input link xfade timebase (1/30)". The fix is order: fps=... then settb=AVTB on both inputs, because fps resets the timebase you just set.
Render the zoom big. zoompan crops into its input and scales back up. If the input is already at output size, a 15% zoom is a 15% upscale and looks soft. The script works at twice the clip's size and lets zoompan scale down.
The script
#!/usr/bin/env python3 """Prepend a "before" intro to a finished clip: the original photo with a slow Ken Burns push-in, cross-faded into the clip. Size, frame rate and audio rate are read from the clip, so the output matches it. usage: python3 intro.py photo.jpg clip.mp4 out.mp4 [--hold 2.0] [--fade 0.5] [--zoom 1.15] """ import argparse, json, subprocess, sys def probe(path): out = subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", path], capture_output=True, text=True, check=True).stdout info = json.loads(out) v = next(s for s in info["streams"] if s["codec_type"] == "video") a = next((s for s in info["streams"] if s["codec_type"] == "audio"), None) num, den = map(int, v["r_frame_rate"].split("/")) return {"w": int(v["width"]), "h": int(v["height"]), "fps": num / den, "fps_str": v["r_frame_rate"], "dur": float(info["format"]["duration"]), "ar": int(a["sample_rate"]) if a else None} def build(photo, clip, out, hold, fade, zoom): c = probe(clip) w, h, fps = c["w"], c["h"], c["fps"] frames = round((hold + fade) * fps) # the intro also covers the fade # 1) blurred, cropped copy of the photo fills the 9:16 frame (no black bars) # 2) the sharp photo is fitted on top, then zoompan pushes in slowly # zoompan on a single input frame: d=frames outputs that many frames. big_w, big_h = w * 2, h * 2 # render big, so the zoom stays sharp g = ( f"[0:v]split[p1][p2];" f"[p1]scale={big_w}:{big_h}:force_original_aspect_ratio=increase," f"crop={big_w}:{big_h},boxblur=40:2[bg];" f"[p2]scale={big_w}:{big_h}:force_original_aspect_ratio=decrease[fg];" f"[bg][fg]overlay=(W-w)/2:(H-h)/2,setsar=1," f"zoompan=z='1+({zoom}-1)*on/{frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'" f":d={frames}:s={w}x{h}:fps={c['fps_str']},format=yuv420p,settb=AVTB[intro];" f"[1:v]format=yuv420p,setsar=1,fps={c['fps_str']},settb=AVTB[main];" f"[intro][main]xfade=transition=fade:duration={fade}:offset={hold}[v]" ) # the photo is read once as a single frame; zoompan turns it into `frames` frames args = ["ffmpeg", "-v", "error", "-y", "-i", photo, "-i", clip] if c["ar"]: # the clip's own sound starts when the clip starts to show (at `hold`) delay_ms = int(round(hold * 1000)) g += f";[1:a]adelay={delay_ms}:all=1,afade=t=in:st={hold}:d={fade}[a]" maps = ["-map", "[v]", "-map", "[a]", "-c:a", "aac", "-b:a", "192k", "-ar", str(c["ar"])] else: maps = ["-map", "[v]"] args += ["-filter_complex", g] + maps + ["-c:v", "libx264", "-crf", "18", "-preset", "medium", "-pix_fmt", "yuv420p", "-r", c["fps_str"], "-movflags", "+faststart", out] subprocess.run(args, check=True) return c if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("photo"); ap.add_argument("clip"); ap.add_argument("out") ap.add_argument("--hold", type=float, default=2.0, help="seconds of photo before the fade") ap.add_argument("--fade", type=float, default=0.5) ap.add_argument("--zoom", type=float, default=1.15, help="final zoom of the push-in") a = ap.parse_args() if not (0 < a.fade <= a.hold): sys.exit("--fade must be > 0 and <= --hold") c = build(a.photo, a.clip, a.out, a.hold, a.fade, a.zoom) o = probe(a.out) print(f"clip {c['w']}x{c['h']} {c['fps']:.3f} fps {c['dur']:.2f}s -> " f"out {o['w']}x{o['h']} {o['fps']:.3f} fps {o['dur']:.2f}s " f"(expected ~{c['dur'] + a.hold:.2f}s)") Run it with the photo you uploaded to the studio and the clip you downloaded:
python3 intro.py dave.jpg tinydesk.mp4 tinydesk_intro.mp4 --hold 2 --fade 0.5 The video is re-encoded once (CRF 18), because the first seconds are new pixels. The clip's audio is re-encoded to AAC with an adelay so the first hold seconds are silent and the generated sound starts with the band.
Checking it
"It plays" is not a test. The checker compares the output with its inputs:
- Same size and frame rate as the clip; duration = clip + hold, within a frame.
- Frame 0 looks like the photo. It compares a small patch in the middle of the frame with the middle of the photo. (My first version compared the middle third and failed on a landscape photo, because that third includes the blurred background. A smaller patch fixed it.)
- After the fade, the output is the clip. Output time T must show clip time T − hold. It checks just after the fade and half a second before the end.
- The intro is silent, then the clip's sound arrives.
#!/usr/bin/env python3 """Check an intro.py output against its inputs: - same size and frame rate as the clip, duration = clip + hold (+-1 frame) - frame 0 looks like the photo (centre colour), the frame after the fade matches the clip's first frame, the last frame matches the clip's last frame - the first `hold - 0.1` seconds are silent and the clip's sound arrives after usage: python3 check_intro.py photo.jpg clip.mp4 out.mp4 [--hold 2.0] [--fade 0.5] """ import argparse, subprocess, sys import numpy as np from intro import probe def frame_at(path, t, w, h): raw = subprocess.run(["ffmpeg", "-v", "error", "-ss", f"{t:.3f}", "-i", path, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout if len(raw) < w * h * 3: sys.exit(f"FAIL\n- no frame at {t:.2f}s in {path} (wrong --hold, or the file is short)") return np.frombuffer(raw[: w * h * 3], np.uint8).reshape(h, w, 3).astype(float) def rms_db(path, start, dur): raw = subprocess.run(["ffmpeg", "-v", "error", "-ss", f"{start:.3f}", "-t", f"{dur:.3f}", "-i", path, "-vn", "-ac", "1", "-f", "f32le", "-"], capture_output=True, check=True).stdout x = np.frombuffer(raw, np.float32) return -120.0 if x.size == 0 else 20 * np.log10(np.sqrt(np.mean(x ** 2)) + 1e-6) ap = argparse.ArgumentParser() ap.add_argument("photo"); ap.add_argument("clip"); ap.add_argument("out") ap.add_argument("--hold", type=float, default=2.0); ap.add_argument("--fade", type=float, default=0.5) a = ap.parse_args() c, o = probe(a.clip), probe(a.out) fails = [] if (o["w"], o["h"]) != (c["w"], c["h"]): fails.append(f"size {o['w']}x{o['h']} != {c['w']}x{c['h']}") if abs(o["fps"] - c["fps"]) > 0.01: fails.append(f"fps {o['fps']} != {c['fps']}") want = c["dur"] + a.hold if abs(o["dur"] - want) > 1.5 / c["fps"] + 0.05: fails.append(f"duration {o['dur']:.3f} != ~{want:.3f}") w, h = c["w"], c["h"] # a small patch in the middle of the frame (the zoom is centred, so it stays put) centre = lambda f: f[int(h * .45): int(h * .55), int(w * .45): int(w * .55)].reshape(-1, 3).mean(0) from PIL import Image ph = np.asarray(Image.open(a.photo).convert("RGB"), float) H, W = ph.shape[:2] ph_c = ph[int(H * .45): int(H * .55), int(W * .45): int(W * .55)].reshape(-1, 3).mean(0) d0 = np.abs(centre(frame_at(a.out, 0, w, h)) - ph_c).max() if d0 > 20: fails.append(f"frame 0 centre differs from photo by {d0:.1f}") # out time T shows clip time T - hold: compare just after the fade and near the end t1, t2 = a.fade + 0.2, c["dur"] - 0.5 d1 = np.abs(frame_at(a.out, a.hold + t1, w, h) - frame_at(a.clip, t1, w, h)).mean() if d1 > 6: fails.append(f"after the fade, out differs from clip by {d1:.1f}") d2 = np.abs(frame_at(a.out, a.hold + t2, w, h) - frame_at(a.clip, t2, w, h)).mean() if d2 > 6: fails.append(f"near the end, out differs from clip by {d2:.1f}") if c["ar"]: q, loud = rms_db(a.out, 0, a.hold - 0.1), rms_db(a.out, a.hold + a.fade + 0.2, 1.0) if q > -60: fails.append(f"intro not silent ({q:.1f} dB)") if loud < -40: fails.append(f"clip sound missing after intro ({loud:.1f} dB)") print(f"frame0 vs photo {d0:.1f}, after-fade diff {d1:.2f}, near-end diff {d2:.2f}") print("FAIL\n- " + "\n- ".join(fails) if fails else "PASS") sys.exit(1 if fails else 0) And the synthetic test media, so you can try it without a real clip:
#!/usr/bin/env bash # Synthetic stand-ins (no real faces): a 15 s vertical clip with a tone and a # landscape "photo" (orange centre block on teal) to show the blurred fill. set -euo pipefail size=${1:-480x854}; rate=${2:-30} ffmpeg -v error -y -f lavfi -i "testsrc2=size=${size}:rate=${rate}: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, ImageDraw im = Image.new("RGB", (1200, 900), (20, 130, 140)) ImageDraw.Draw(im).rectangle([350, 200, 850, 700], fill=(240, 140, 30)) im.save("photo.jpg", quality=95) PY echo "made clip.mp4 (${size} @ ${rate}) and photo.jpg" Results
$ bash make_test_media.sh && python3 intro.py photo.jpg clip.mp4 out.mp4 made clip.mp4 (480x854 @ 30) and photo.jpg clip 480x854 30.000 fps 15.00s -> out 480x854 30.000 fps 17.02s (expected ~17.00s) $ python3 check_intro.py photo.jpg clip.mp4 out.mp4 frame0 vs photo 2.0, after-fade diff 1.66, near-end diff 1.86 PASS $ bash make_test_media.sh 1080x1920 24 && python3 intro.py photo.jpg clip.mp4 out2.mp4 --hold 1.5 --fade 0.4 clip 1080x1920 24.000 fps 15.00s -> out 1080x1920 24.000 fps 16.52s (expected ~16.50s) $ python3 check_intro.py photo.jpg clip.mp4 out2.mp4 --hold 1.5 --fade 0.4 frame0 vs photo 2.0, after-fade diff 1.12, near-end diff 1.19 PASS The ~0.02 s extra is AAC padding at the end of the audio track, not a timing error. A deliberately wrong --hold 1.2 makes the checker fail on both the duration and the after-fade frame (difference 22.9), so it really is checking alignment.
Choosing the numbers
- hold 1.5–2 s. Long enough to recognise the face, short enough that nobody swipes. Feeds count those seconds as part of your video.
- fade 0.4–0.5 s. Longer fades make the band appear "through" the photo, which looks like a glitch on a phone.
- zoom 1.10–1.20. More than that and a 3:4 portrait starts cropping the hairline.
Before you post
Use the photo of someone who agreed to be in the clip, which is also what the studio requires. The intro makes the face more prominent, not less, so it's worth asking twice. And keep the "AI parody" label in your caption: the check-before-posting guide asks you to share it "as a generated parody" and not as an NPR concert or Charlie Wilson's performance.
If you haven't made the clip yet, the studio is at tinydeskoutstanding.video: pick a spot, upload a photo, choose a resolution, and the price shows on the button before you pay.