How to add size-emphasis captions with Claude

Spoken words appear in small white capitals while the key words, like SERUM and TWO WEEKS, pop up on their own line nearly three times as tall and stretch across the frame.

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 size-emphasis captions to input.mp4
    Keywords: {"SERUM", "TWO", "WEEKS"}

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 size-emphasis captions 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
KEYWORDS{"SERUM", "TWO", "WEEKS"}Words drawn big; have Claude pick them from the transcript.
SMALL0.075Height of the other words, as a share of the frame width.
BIG0.22Height of the key words, shrunk so a line fits in 90% of the width.
Y0.70Where the caption block's center sits (0 = top, 1 = bottom).
MAX_WORDS3Most words in one phrase.
POP0.15Seconds a key word takes to grow to full size.
STROKE0.08Black outline, as a share of the text height.

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 captions to input.mp4 where the key words are far bigger than the rest. Transcribe its voice with faster-whisper (model base.en, device cpu, compute_type int8, word_timestamps=True) to get each word's start and end time, then uppercase the words and strip punctuation. The key words are SERUM, TWO and WEEKS. Split the words into phrases of at most 3 words, ending a phrase early right after a key word when the next word isn't one. Within a phrase, each run of consecutive key words gets its own line and each run of other words gets its own line, stacked in spoken order. Other words are 7.5% of the frame width tall; key words are 22% of the frame width tall, shrunk until their line fits in 90% of the width. Use Arial Black (or another heavy bold font), pure white (255, 255, 255) text with a black outline 8% of the text height thick. Center each line horizontally, make each line 1.15 times its text height tall, and center the phrase's block of lines 70% of the way down the frame. Lay out the whole phrase up front, but draw each word only from its own start time; a key word grows from 60% to full size over 0.15 seconds (ease out) when it appears. A phrase stays up from its first word's start to its last word's end, and nothing shows after the last word. Use Python with Pillow to draw the text (don't use ffmpeg's drawtext filter) and ffmpeg to put it 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 size-emphasis-captions.py and run:

python3 size-emphasis-captions.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Size-emphasis captions: spoken words appear in small white capitals, and the key words land three times bigger.

Usage: python3 size-emphasis-captions.py input.mp4 output.mp4
Whisper transcribes the voice for the word timings, so no subtitle file is needed. Put the words worth shouting
(the product, numbers, the claim) in KEYWORDS; ask Claude to pick them from the transcript. Needs ffmpeg,
faster-whisper and Pillow (pip install faster-whisper pillow); the first run downloads Whisper's base.en model
(about 140 MB).
"""
import json
import math
import subprocess
import sys
from itertools import groupby
from pathlib import Path

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

KEYWORDS = {"SERUM", "TWO", "WEEKS"}  # words drawn big
SMALL = 0.075               # height of the other words, as a share of the frame width
BIG = 0.22                  # height of the key words (shrinks so a line fits in 90% of the width)
Y = 0.70                    # where the caption block's center sits (0 = top, 1 = bottom)
MAX_WORDS = 3               # most words in one phrase; a phrase also ends after its key words
POP = 0.15                  # seconds a key word takes to grow from 60% to full size
STROKE = 0.08               # black outline, 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 with its start and end time, uppercased and without punctuation."""
    model = WhisperModel("base.en", device="cpu", compute_type="int8")
    segments, _ = model.transcribe(path, word_timestamps=True)
    return [{"text": w.word.strip().strip(".,!?;:\"").upper(), "start": w.start, "end": w.end}
            for s in segments for w in s.words]


def phrases(words):
    """Split the words into phrases of up to MAX_WORDS, each ending after a run of key words."""
    groups, current = [], []
    for i, word in enumerate(words):
        current.append(word)
        nxt = words[i + 1]["text"] if i + 1 < len(words) else None
        ends_keywords = word["text"] in KEYWORDS and nxt not in KEYWORDS
        if ends_keywords or len(current) == MAX_WORDS or nxt is None:
            groups.append(current)
            current = []
    return groups


def font(size):
    return ImageFont.truetype(FONT_FILE, max(12, round(size)))


def lines_of(group):
    """Runs of key words and runs of other words each get a line: [(words, is_key, font), ...]."""
    lines = []
    for key, run in groupby(group, key=lambda word: word["text"] in KEYWORDS):
        run = list(run)
        text = " ".join(word["text"] for word in run)
        size = w * (BIG if key else SMALL)
        while font(size).getlength(text) > w * 0.9 and size > 12:
            size -= 2
        lines.append((run, key, font(size)))
    return lines


def draw_phrase(draw, group, t):
    lines = lines_of(group)
    heights = [f.size * 1.15 for _, _, f in lines]
    y = h * Y - sum(heights) / 2
    for (line, key, f), height in zip(lines, heights):
        space = f.getlength(" ")
        x = (w - f.getlength(" ".join(word["text"] for word in line))) / 2
        for word in line:
            width = f.getlength(word["text"])
            if t >= word["start"]:
                grow = min(1.0, (t - word["start"]) / POP) if key else 1.0
                g = f if grow >= 1 else font(f.size * (0.6 + 0.4 * (1 - (1 - grow) ** 2)))
                draw.text((x + width / 2, y + height / 2), word["text"], font=g, anchor="mm", fill="white",
                          stroke_width=max(2, round(g.size * STROKE)), stroke_fill="black")
            x += width + space
        y += height


def overlay(t):
    img = Image.new("RGBA", (w, h))
    for group in groups:
        if group[0]["start"] <= t < group[-1]["end"]:
            draw_phrase(ImageDraw.Draw(img), group, t)
    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]
groups = phrases(transcribe(src))
w, h, rate, fps, duration = probe(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