How to add falling letters with Claude

Tilted yellow letters drop in from the top one at a time, bounce off the floor and pile up into "NEW" stacked on "FORMULA".

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 falling letters to input.mp4
    Rows: [("FORMULA", 0.5), ("NEW", 2.3)]

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 falling letters 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
ROWS[("FORMULA", 0.5), ("NEW", 2.3)]Each word and the second its first letter drops, bottom row first; later rows land on top.
STAGGER0.1Seconds between one letter dropping and the next, in a shuffled order.
FLOOR0.88Where the bottom row comes to rest (0 = top, 1 = bottom).
WIDTH0.88The widest row's width as a share of the frame width.
GRAVITY3.2How fast letters speed up, in frame heights per second squared.
BOUNCE0.4Share of its speed a letter keeps after hitting the floor.
TILT25Most a letter is tilted while it falls, in degrees.
COLOR(255, 214, 0)Letter color.
OUTLINE(35, 15, 55)Outline color.
SEED7Changes the drop order and tilts.

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 falling letters to input.mp4: the word "FORMULA" in Arial Black (or any heavy bold font) in yellow (255, 214, 0) with a deep ink (35, 15, 55) outline 1.2% of the frame width thick, sized so the word is 88% of the frame width wide, centered across with its baseline resting 88% of the way down. "NEW" in the same font and size rests directly on top of it, centered, its baseline one capital-letter height plus two outline widths higher. Each letter falls on its own: its baseline starts 2% of the frame height above the top edge with no speed, it falls under gravity of 3.2 frame heights per second squared, and when it reaches its resting place it bounces back up keeping 40% of its speed, again and again, until a bounce would be slower than 0.15 frame heights per second, then it stays put. While falling each letter is tilted by a random angle up to 25 degrees either way; each bounce flips the tilt and shrinks it to 40%, and it is upright once at rest. The letters of FORMULA start falling one every 0.1 seconds from 0.5 seconds in the order R, O, M, F, U, A, L; the letters of NEW start one every 0.1 seconds from 2.3 seconds in the order N, W, E. Nothing shows before 0.5 seconds and everything stays to the end. Use Python with Pillow to draw the letters (don't rely on ffmpeg's drawtext filter) and ffmpeg to put them on the video, keep the audio, and save it as output.mp4.

Run it without Claude

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

python3 falling-letters.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Falling letters: letters drop in from the top one by one, bounce off the floor and pile up into a headline.

Usage: python3 falling-letters.py input.mp4 output.mp4
Needs ffmpeg and Pillow (pip install pillow).
"""
import json
import math
import random
import subprocess
import sys
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

ROWS = [("FORMULA", 0.5),     # (word, second its first letter drops), bottom row first: later rows land on top
        ("NEW", 2.3)]
STAGGER = 0.1                 # seconds between one letter dropping and the next, in a shuffled order
FLOOR = 0.88                  # where the bottom row comes to rest (0 = top, 1 = bottom)
WIDTH = 0.88                  # the widest row's width as a share of the frame width
GRAVITY = 3.2                 # frame heights per second squared
BOUNCE = 0.4                  # share of its speed a letter keeps after hitting the floor
TILT = 25                     # most a letter is tilted while it falls, in degrees; each bounce straightens it
COLOR = (255, 214, 0)         # letter color
OUTLINE = (35, 15, 55)        # outline color, a deep ink
SEED = 7                      # changes the drop order and tilts
FONTS = ["/System/Library/Fonts/Supplemental/Arial Black.ttf",    # macOS
         "/System/Library/Fonts/Supplemental/Arial Bold.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 letters():
    """Each letter as (tile, center x, resting baseline y, drop time, starting tilt)."""
    widest = max((word for word, _ in ROWS), key=len)
    font = ImageFont.truetype(FONT_FILE, 100)
    font = ImageFont.truetype(FONT_FILE, round(100 * w * WIDTH / font.getlength(widest)))
    stroke = max(2, round(w * 0.012))
    cap = -font.getbbox("".join(word for word, _ in ROWS), anchor="ls")[1]
    pad = stroke + round(cap * 0.15)
    rng = random.Random(SEED)
    out = []
    for row, (word, start) in enumerate(ROWS):
        baseline = h * FLOOR - row * (cap + 2 * stroke)
        left = (w - font.getlength(word)) / 2
        order = rng.sample(range(len(word)), len(word))
        for i, ch in enumerate(word):
            size = (round(font.getlength(ch)) + 2 * pad, cap + 2 * pad)
            tile = Image.new("RGBA", size)
            ImageDraw.Draw(tile).text((pad, pad + cap), ch, font=font, anchor="ls", fill=COLOR,
                                      stroke_width=stroke, stroke_fill=OUTLINE)
            x = left + font.getlength(word[:i]) + font.getlength(ch) / 2
            out.append((tile, x, baseline, start + order.index(i) * STAGGER, rng.uniform(-TILT, TILT), cap))
    return out


def fall(drop, baseline, tilt):
    """Baseline y and tilt of a letter on every frame: it starts just above the frame and falls under gravity,
    bouncing off its resting place with a little less speed each time until it stops."""
    y, v, steps = -0.02 * h, 0.0, 20
    g, floor, path = GRAVITY * h, baseline, []
    for i in range(frames):
        t = i / fps
        if t < drop:
            path.append(None)
            continue
        for _ in range(steps):
            if v == 0 and y == floor:
                break
            v += g / fps / steps
            y += v / fps / steps
            if y >= floor:
                y, v, tilt = floor, -v * BOUNCE, -tilt * BOUNCE
                if abs(v) < 0.15 * h:
                    v, tilt = 0.0, 0.0
        path.append((y, tilt))
    return path


def overlay(i):
    img = Image.new("RGBA", (w, h))
    for (tile, x, _, _, _, cap), path in zip(tiles, paths):
        if path[i] is None:
            continue
        y, tilt = path[i]
        pic = tile.rotate(tilt, resample=Image.BICUBIC, expand=True) if tilt else tile
        img.alpha_composite(pic, (round(x - pic.width / 2), round(y - cap / 2 - pic.height / 2)))
    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(frame) 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(frames):
        ff.stdin.write(overlay(i).tobytes())
    ff.stdin.close()
    if ff.wait():
        sys.exit("ffmpeg failed")


src, out = sys.argv[1:3]
w, h, rate, fps, duration = probe(src)
frames = math.ceil(duration * fps)
tiles = letters()
paths = [fall(drop, baseline, tilt) for _, _, baseline, drop, tilt, _ in tiles]
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