Get this effect
Switch to Code in your Claude Desktop App
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/skillsAsk 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.
| Setting | Default | What it does |
|---|---|---|
CHUNK | 4 | Words per stack. |
WIDTH | 0.80 | Stack width as a share of the frame width. |
Y | 0.66 | Where the stack's center sits (0 = top, 1 = bottom). |
TILT | 8 | Rotation of the whole stack in degrees. |
ENTER | 0.25 | Seconds 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.mp4Show 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)





