How to add a kinetic typography with Claude

Each word of the voiceover flies into a tilted stack as it is spoken, slamming, sliding, spinning or dropping in, with the newest word in yellow, and the stack zooms away when the next phrase begins.

Get this effect

  1. Switch to Code in your Claude Desktop App

  2. Install the skill (once)

    Show the prompt
    Install the Recharm video effects skill: download https://recharm-growth-staging.s3.us-east-2.amazonaws.com/website/downloads/recharm-video-effects.zip and unzip it into ~/.claude/skills
  3. Ask for the effect

    Add a kinetic typography to input.mp4
    Words per stack: 4
    Stack width as a share of the frame width: 0.80

Several videos, several effects, one ask

Put your clips in the folder, mention them with @, and ask for everything at once. Claude runs one tested script after another on each clip, then joins them.

Add a kinetic typography to @clip1.mp4, put a sale badge on @clip2.mp4, give @clip3.mp4 a VHS look, then join all three with crossfades.

Settings you can change

Ask Claude to change any of these, or edit them at the top of the script.

SettingDefaultWhat it does
CHUNK4Words per stack.
WIDTH0.80Stack width as a share of the frame width.
Y0.66Where the stack's center sits (0 = top, 1 = bottom).
TILT8Rotation of the whole stack in degrees.
ENTER0.25Seconds each word takes to fly in.
NOW(255, 214, 0)Color of the word being said.
SHADOW(40, 10, 60)Drop shadow color.

Without the skill

Don’t want to install the skill? Paste this prompt into Claude Code and Claude writes the script itself.

Show the prompt
Add kinetic typography to input.mp4. Transcribe its voiceover with faster-whisper (model "base.en", device cpu, compute_type int8, word_timestamps=True) and group the words into phrases of 4. Draw each phrase as a stack, one word per line in capitals, in Arial Black (or another heavy bold font) with a black outline 1.2% of the frame width thick and a solid dark purple (40, 10, 60) drop shadow offset down and right by 7% of the text height. Size each word so it is 80% of the frame width wide, but never taller than a 20%-of-the-frame-width font; leave a gap of 2% of the frame width between lines. Center each line across, and keep the stack's middle 66% of the way down the frame, sliding the stack up smoothly over 0.25 seconds as each new line arrives. Rotate the whole stack 8 degrees counterclockwise. Each word appears at its spoken start time and takes 0.25 seconds to fly in, the moves taking turns: the 1st word slams down from 3 times its size while fading in, the 2nd slides in from the right edge with a slight overshoot, the 3rd spins up from nothing through half a turn, the 4th drops in from 40% of the frame height above while fading in. The newest word is yellow (255, 214, 0); the others are white. A phrase stays until the next phrase starts (the last one until 0.5 seconds after its last word), then grows 40% about its center and fades out over 0.2 seconds while the next one comes in. After that nothing is on screen. Use Python with Pillow to draw the text and ffmpeg to put it on the video (don't rely on ffmpeg's drawtext filter), keep the size and audio, and save it as output.mp4.

Run it without Claude

This is the exact script we tested. Save it as kinetic-typography.py and run:

python3 kinetic-typography.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Kinetic typography: each spoken word flies into a tilted stack as it is said, sliding, spinning or slamming in.

Usage: python3 kinetic-typography.py input.mp4 output.mp4
No subtitle file needed: faster-whisper listens to the audio on your computer and times every word.
Needs ffmpeg, faster-whisper and Pillow (pip install faster-whisper pillow). The first run downloads
the "base.en" speech model (about 140 MB); use "base" or "small" for speech that isn't English.
"""
import json
import math
import subprocess
import sys
from pathlib import Path

from faster_whisper import WhisperModel
from PIL import Image, ImageDraw, ImageFont

MODEL = "base.en"        # Whisper model: bigger ("small.en", "medium.en") is more accurate but slower
CHUNK = 4                # words per stack; the next stack replaces it
WIDTH = 0.80             # stack width as a share of the frame width (each word is sized to fill it)
MAX_SIZE = 0.20          # largest text height as a share of the frame width, so short words don't get huge
Y = 0.66                 # where the stack's center sits (0 = top, 1 = bottom)
TILT = 8                 # the whole stack's rotation in degrees (counterclockwise)
ENTER = 0.25             # seconds each word takes to fly in
HOLD = 0.5               # seconds the last stack stays after its last word
EXIT = 0.2               # seconds a stack takes to zoom up and fade out, as the next one starts
COLOR = (255, 255, 255)  # words already said
NOW = (255, 214, 0)      # the word being said
STROKE = 0.012           # black outline thickness as a share of the frame width
SHADOW = (40, 10, 60)    # drop shadow color
SHADOW_OFFSET = 0.07     # how far the shadow sits down and right, as a share of the text height
FONTS = ["/System/Library/Fonts/Supplemental/Arial Black.ttf",    # macOS
         "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",  # Linux
         "C:/Windows/Fonts/ariblk.ttf"]                           # Windows
FONT_FILE = next((f for f in FONTS if Path(f).exists()), None) or sys.exit("No bold font found: add one to FONTS")


def transcribe(path):
    """Every spoken word as (TEXT, start, end), times in seconds."""
    model = WhisperModel(MODEL, device="cpu", compute_type="int8")
    segments, _ = model.transcribe(path, word_timestamps=True)
    return [("".join(c for c in word.word if c.isalnum() or c in "'%$").upper(), word.start, word.end)
            for segment in segments for word in segment.words]


def word_image(text, color):
    """One word, sized to fill the stack's width, outlined in black over a solid drop shadow, cropped to its ink."""
    size = round(w * MAX_SIZE)
    while ImageFont.truetype(FONT_FILE, size).getlength(text) > w * WIDTH:
        size -= 2
    font = ImageFont.truetype(FONT_FILE, size)
    stroke = round(w * STROKE)
    drop = round(size * SHADOW_OFFSET)
    img = Image.new("RGBA", (round(font.getlength(text)) + 4 * stroke + drop, size * 2))
    draw = ImageDraw.Draw(img)
    draw.text((2 * stroke + drop, size + drop), text, font=font, anchor="lm", fill=SHADOW,
              stroke_width=stroke, stroke_fill=SHADOW)
    draw.text((2 * stroke, size), text, font=font, anchor="lm", fill=color, stroke_width=stroke, stroke_fill="black")
    return img.crop(img.getbbox())


def stacks(words):
    """Group the words into stacks of CHUNK: each a dict of words (with their images and line positions) and times."""
    gap = w * 0.02
    groups = [words[i:i + CHUNK] for i in range(0, len(words), CHUNK)]
    out = []
    for i, group in enumerate(groups):
        lines, y = [], 0
        for text, start, _ in group:
            white, yellow = word_image(text, COLOR), word_image(text, NOW)
            # y: the line's center; mid: the middle of the stack once this line is in
            lines.append({"start": start, "white": white, "yellow": yellow, "y": y + white.height / 2,
                          "mid": (y + white.height) / 2})
            y += white.height + gap
        end = groups[i + 1][0][1] if i + 1 < len(groups) else group[-1][2] + HOLD
        out.append({"lines": lines, "start": group[0][1], "end": end})
    return out


def ease_out_back(p):
    """0 -> 1 with a small overshoot past 1 before settling."""
    p = min(max(p, 0), 1)
    return 1 + 2.70158 * (p - 1) ** 3 + 1.70158 * (p - 1) ** 2


def ease_out(p):
    p = min(max(p, 0), 1)
    return 1 - (1 - p) ** 3


def fly_in(n, p):
    """How word number n enters at progress p (0-1): (dx, dy, scale, angle, opacity). The four moves take turns."""
    e, b = ease_out(p), ease_out_back(p)
    if n % 4 == 0:   # slams down from three times its size
        return 0, 0, 3 - 2 * e, 0, e
    if n % 4 == 1:   # slides in from the right
        return (1 - b) * w, 0, 1, 0, 1
    if n % 4 == 2:   # spins up from nothing
        return 0, 0, b, (1 - e) * -180, 1
    return 0, (e - 1) * h * 0.4, 1, 0, e   # drops in from above


def place(img, word, x, y):
    """alpha_composite, allowing the word to hang off the top or left edge."""
    word = word.crop((max(0, -x), max(0, -y), word.width, word.height))
    if word.width and word.height:
        img.alpha_composite(word, (max(0, x), max(0, y)))


def fade(img, alpha):
    img.putalpha(img.getchannel("A").point(lambda a: round(a * alpha)))
    return img


def draw_stack(stack, t):
    """One stack at time t: its words so far, each flying in, the whole stack tilted."""
    img = Image.new("RGBA", (w, h))
    lines = [line for line in stack["lines"] if line["start"] <= t]
    # keep the stack centered on Y: it slides up as each new line arrives
    center = lines[0]["mid"]
    for prev, line in zip(lines, lines[1:]):
        center += (line["mid"] - prev["mid"]) * ease_out((t - line["start"]) / ENTER)
    for n, line in enumerate(lines):
        dx, dy, scale, angle, alpha = fly_in(n, (t - line["start"]) / ENTER)
        if scale <= 0.02 or alpha <= 0.01:
            continue
        word = line["yellow"] if line is lines[-1] else line["white"]
        word = word.resize((max(1, round(word.width * scale)), max(1, round(word.height * scale))), Image.BICUBIC)
        word = fade(word.rotate(angle, resample=Image.BICUBIC, expand=True), alpha)
        x = w / 2 + dx - word.width / 2
        y = h * Y + line["y"] - center + dy - word.height / 2
        place(img, word, round(x), round(y))
    return img.rotate(TILT, resample=Image.BICUBIC, center=(w / 2, h * Y))


def zoom_out(img, p):
    """A stack leaving at progress p (0-1): it grows 40% about the stack's center and fades away."""
    grow = 1 + 0.4 * p
    big = img.resize((round(w * grow), round(h * grow)), Image.BICUBIC)
    left, top = round(w / 2 * grow - w / 2), round(h * Y * grow - h * Y)
    return fade(big.crop((left, top, left + w, top + h)), 1 - p)


def overlay(t):
    img = Image.new("RGBA", (w, h))
    for stack in stacks_:
        if stack["start"] <= t < stack["end"]:
            img.alpha_composite(draw_stack(stack, t))
        elif stack["end"] <= t < stack["end"] + EXIT:
            img.alpha_composite(zoom_out(draw_stack(stack, t), ease_out((t - stack["end"]) / EXIT)))
    return img


def probe(path):
    info = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                                               "stream=width,height,r_frame_rate:format=duration", "-of", "json", path]))
    s = info["streams"][0]
    num, den = s["r_frame_rate"].split("/")
    return s["width"], s["height"], s["r_frame_rate"], float(num) / float(den), float(info["format"]["duration"])


def render(src, out):
    """Draw overlay(t) on every frame of src and save to out, keeping the audio."""
    ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-i", src,
                           "-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-r", rate, "-i", "-",
                           "-filter_complex", "[0:v][1:v]overlay=shortest=1,format=yuv420p[v]",
                           "-map", "[v]", "-map", "0:a?", "-c:v", "libx264", "-crf", "18", "-c:a", "copy", out],
                          stdin=subprocess.PIPE)
    for i in range(math.ceil(duration * fps)):
        ff.stdin.write(overlay(i / fps).tobytes())
    ff.stdin.close()
    if ff.wait():
        sys.exit("ffmpeg failed")


src, out = sys.argv[1:3]
w, h, rate, fps, duration = probe(src)
stacks_ = stacks(transcribe(src))
render(src, out)

Find 3x winning ads

Recharm cuts your ad footage into tagged, searchable clips: hooks, testimonials, product shots. Find the shots that win, add the effect with Claude, and ship 3x more ad edits.

Start 14-day free trial