SuperAPI AI Directory

create-kinetic-typography

Design & creation

View repository

Updated 10-15 21:54

About this skill

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

  1. Read the brief; choose the appropriate animation type.
  2. 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.
  3. 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.
  4. 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
    
  5. Clean up _kf_frames/ (optional — keep for debugging).
  6. 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)
# Mux via ffmpeg concat → qtrle .mov