| name | create-kinetic-typography |
| description | render animated on-screen text (typewriter, scale-in, slam, drop-in, punch-in) as RGBA .mov sequences via PIL for ffmpeg overlay with alpha. |
create-kinetic-typography
Purpose
Render animated kinetic text overlays as frame-by-frame PIL PNG sequences, then mux into qtrle-codec ARGB .mov files that ffmpeg can overlay on a video master with alpha. The atom replaces static red-Impact-text hyperframes (the create-motion-graphics-hyperframes atom) for cuts where motion lifts the perceived production value — typically intros, countdowns, slam-in stamps, and end-card line builds.
Why not use an AI video model for animated text? Because (a) i2v models hallucinate letter shapes and produce ugly typography drift across frames; (b) reroll rates for clean kinetic text are coin flips; (c) PIL is free, deterministic, infinitely tweakable, and ships in under 200 lines of Python. See LEARNINGS.md #23.
Pairs with:
create-motion-graphics-hyperframes (the static counterpart — use for held text)
add-captions-burn (for VO-paced subtitle captions, not animation)
Inputs
- Text content (single string or list of strings for staggered animations).
- Animation type (one of:
typewriter, scale-in-bounce, slam-with-shake, drop-in-staggered, punch-in-line-by-line).
- Position on frame (x, y in 1080×1920 master coords, anchor
mm / mt / etc.).
- Duration in seconds (output .mov length).
- Font path (default:
/System/Library/Fonts/Supplemental/Impact.ttf on macOS).
- Font size (in pt).
- Color (fill, optional shadow color + offset).
- Output directory path (relative to project root).
Workflow
- Read the brief; choose the appropriate animation type.
- Compute per-frame parameters as a function of
t (frame_num / fps):
- typewriter:
n_chars = int((t / reveal_time) * len(text)) + 1; optional blinking cursor at the current write position.
- scale-in-bounce:
scale = 0.3 + (1.05 - 0.3) * (1 - (1 - p)^3) over scale_in_d, then bounce back to 1.0. Add Gaussian blur fading from (1-p)*6 to 0 for motion blur.
- slam-with-shake: scale-in like above plus
shake = sin(t*100) * (12 * (1-p)) decaying offset; reset to 0 on settle.
- drop-in-staggered: each character has its own
in_time = idx * stagger; y_offset = -180 * (1 - (1 - p)^3) for ease-out drop from above.
- punch-in-line-by-line: each line punches in with vertical shake + slight scale overshoot; lines stagger ~0.15s.
- For each frame
i in range(int(duration * fps)):
- Create transparent RGBA canvas at 1080×1920.
- Draw text with
PIL.ImageDraw.text(anchor="mm") at computed parameters.
- Apply
PIL.ImageFilter.GaussianBlur(radius=blur_r) when blur_r > 0.1.
- Save as
_kf_frames/{label}_{i:04d}.png.
- Build ffmpeg concat list and mux PNG sequence to RGBA .mov:
ffmpeg -f concat -safe 0 -i list.txt -c:v qtrle -pix_fmt argb out.mov
- Clean up
_kf_frames/ (optional — keep for debugging).
- Write
manifest.json with status, output path, frame count, duration, animation type.
Output
- Primary artifact: one
.mov file per text element, qtrle codec, ARGB pixel format (alpha preserved).
- Output naming convention:
scene-{NN}-{animation-type}.mov (e.g. scene-01-typewriter.mov).
manifest.json — status, animation type, text, font params, output path, duration, frame count.
- Optional: keep
_kf_frames/ PNG sequence for debugging or for further edits.
Composition
These overlays mux onto a video master via:
ffmpeg -i base.mp4 -itsoffset <start_t> -i overlay.mov \
-filter_complex "[0:v][1:v]overlay=0:0" \
-t <duration_to_keep> out.mp4
Critical: always pass explicit -t <dur> — ffmpeg overlay matches the LONGEST input by default and will inflate output duration if the .mov is longer than the base. See LEARNINGS.md #26.
Quality Checks
.mov exists and is decodable by ffmpeg.
- Reported duration matches requested duration ± 0.05s.
- Pixel format is
argb (or rgba) — verify with ffprobe -show_streams.
- Spot-check 2–3 frames at frame_count/4, /2, /4×3 boundaries — letters should be visible, no clipping at frame edges, no transparency leaking into the text body.
- Test by overlaying on a single test frame and visually confirming alpha compositing works.
Failure Modes
- Output .mov plays as black-on-black instead of with alpha — pixel format is not argb/rgba. Re-encode with
-c:v qtrle -pix_fmt argb.
- Letters jitter or have inconsistent positioning across frames — anchor mismatch; use
anchor="mm" (middle-middle) consistently, not the default la (left-ascend).
- Output duration is longer than expected — frame count overshot; recompute as
int(duration * fps) (not round).
- Output .mov has the right duration but starts black — first frame computed at
t=0 but animation begins at t=in_time; either offset the .mov with -itsoffset in the overlay step, or pad the first frames with the visible-at-in_time state.
- Letters overlap on
drop-in-staggered — character widths not measured correctly; use d.textbbox((0, 0), char, font=fnt)[2] per char and advance x-cursor by exact width.
- Output file is huge — qtrle is lossless; that's normal. Don't switch to libx264 with
-pix_fmt yuva420p — most builds drop alpha.
Reference implementation
fightcamp/video-01-last-bag-standing/working/gen_kinetic_text.py — produces 7 .mov files (typewriter intro, scale-in countdown 3/2/1, slam-with-shake X stamps, line-by-line punch-in tagline, drop-in scene-3 toptext) for the Fightcamp "Last Bag Standing" ad. ~320 lines, no dependencies beyond PIL + ffmpeg + Python stdlib.
Example invocation pattern
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import subprocess
from pathlib import Path
W, H, FPS = 1080, 1920, 24
font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Impact.ttf", 92)
frames = []
for i in range(int(3.7 * FPS)):
t = i / FPS
img = Image.new("RGBA", (W, H), (0,0,0,0))
d = ImageDraw.Draw(img)
n_chars = int((t / 1.0) * len("TODAY: ONE TEST.")) + 1 if t < 1.0 else len("TODAY: ONE TEST.")
d.text((W // 2, 140), "TODAY: ONE TEST."[:n_chars], font=font, fill=(230,30,30,255), anchor="mm")
p = Path(f"_kf/f{i:04d}.png"); p.parent.mkdir(parents=True, exist_ok=True)
img.save(p)
frames.append(p)