---
title: "How to add mixed-font captions with Claude (tested prompt and script)"
description: "Each spoken word pops on in a different typeface, size and color, so \"This serum CLEARED my\" builds up as a collage of white heavy sans, big yellow italic serif, pink condensed capitals and light-blue typewriter type. Install one skill and ask Claude Code, or run the Python script we tested yourself."
url: https://www.recharm.com/effects/mixed-font-captions
---

# How to add mixed-font captions with Claude

Each spoken word pops on in a different typeface, size and color, so "This serum CLEARED my" builds up as a collage of white heavy sans, big yellow italic serif, pink condensed capitals and light-blue typewriter type.

Made by the script on this page, from our own test clip.

## Get this effect

1. ### Switch to Code in your [Claude Desktop App](https://claude.com/download)

2. ### Install the skill (once)

   ```
   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 mixed-font captions to input.mp4
   Model: "base.en"
   Styles: Arial Black white, Georgia Bold Italic yellow, Impact pink capitals, Courier New Bold light blue
   ```

## 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 mixed-font 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.

| Setting     | Default                                                                                            | What it does                                                                       |
| ----------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `MODEL`     | `"base.en"`                                                                                        | Whisper model; bigger ones are more accurate but slower.                           |
| `CHUNK`     | `4`                                                                                                | Words per caption.                                                                 |
| `Y`         | `0.76`                                                                                             | Where the caption block's center sits, from top (0) to bottom (1).                 |
| `MAX_WIDTH` | `0.9`                                                                                              | Widest row as a share of the frame width; longer captions wrap.                    |
| `GAP`       | `0.025`                                                                                            | Space between words as a share of the frame width.                                 |
| `HOLD`      | `0.4`                                                                                              | Seconds a caption stays up after its last word, unless the next one starts sooner. |
| `OUTLINE`   | `0.07`                                                                                             | Black outline round each word, as a share of its size.                             |
| `STYLES`    | `Arial Black white, Georgia Bold Italic yellow, Impact pink capitals, Courier New Bold light blue` | The font, size and color each word takes, used in turn.                            |

## Without the skill

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

```
Add mixed-font captions to input.mp4 from its speech, with no script or subtitle file. Transcribe the audio with faster-whisper (model "base.en", device cpu, compute_type int8, word_timestamps=True) and drop punctuation from the words. Group the words in order into captions of 4 words. Show each caption from its first word's start until 0.4 seconds after its last word ends, or until the next caption starts if that is sooner, with nothing on screen after the last caption; within a caption, each word appears at its own start time and stays until the caption ends. Give the words four styles in turn, counting across the whole transcript: 1st white (255, 255, 255) Arial Black at a font size of 11% of the frame width; 2nd yellow (255, 214, 0) Georgia Bold Italic at 17%; 3rd pink (255, 92, 170) Impact in capitals at 15%; 4th light blue (120, 230, 255) Courier New Bold at 14%; then start again. Give every word a black outline 7% of its size thick. Lay out each caption's words left to right on a shared baseline with a gap of 2.5% of the frame width, wrapping to a new row when a row would be wider than 90% of the frame width, rows 1% of the frame height apart, each row centered horizontally and the whole block centered 76% of the way down the frame. Work out the layout from all four words so words don't move as the others appear. Use Python with Pillow to draw the text (don't use ffmpeg's drawtext or subtitles 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 `mixed-font-captions.py` and run:

```
python3 mixed-font-captions.py input.mp4 output.mp4
```

```
#!/usr/bin/env python3
"""Mixed-font captions: Whisper times the speech, then each spoken word pops on in its own font, size and color.

Usage: python3 mixed-font-captions.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).
"""
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 caption
Y = 0.76              # where the caption block's center sits (0 = top, 1 = bottom)
MAX_WIDTH = 0.9       # widest row as a share of the frame width; longer captions wrap
GAP = 0.025           # space between words, as a share of the frame width
HOLD = 0.4            # seconds a caption stays up after its last word, unless the next one starts sooner
OUTLINE = 0.07        # black outline round each word, as a share of its size
# Word styles, used in turn: (fonts to try: macOS, Linux, Windows), size as a share of the frame width, color, capitals
STYLES = [
    (["/System/Library/Fonts/Supplemental/Arial Black.ttf",
      "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "C:/Windows/Fonts/ariblk.ttf"],
     0.11, (255, 255, 255), False),
    (["/System/Library/Fonts/Supplemental/Georgia Bold Italic.ttf",
      "/usr/share/fonts/truetype/dejavu/DejaVuSerif-BoldItalic.ttf", "C:/Windows/Fonts/georgiaz.ttf"],
     0.17, (255, 214, 0), False),
    (["/System/Library/Fonts/Supplemental/Impact.ttf",
      "/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-Bold.ttf", "C:/Windows/Fonts/impact.ttf"],
     0.15, (255, 92, 170), True),
    (["/System/Library/Fonts/Supplemental/Courier New Bold.ttf",
      "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", "C:/Windows/Fonts/courbd.ttf"],
     0.14, (120, 230, 255), False),
]

def transcribe(path):
    """Every spoken word as (text, start, end), times in seconds, without punctuation."""
    model = WhisperModel(MODEL, device="cpu", compute_type="int8")
    segments, _ = model.transcribe(path, word_timestamps=True)
    return [(word.word.strip(" .,!?"), word.start, word.end) for segment in segments for word in segment.words]

def load_styles():
    styles = []
    for files, size, color, caps in STYLES:
        file = next((f for f in files if Path(f).exists()), None) or sys.exit(f"None of {files} found: edit STYLES")
        styles.append((ImageFont.truetype(file, round(w * size)), color, caps))
    return styles

def layout(group, first):
    """Place a caption's words in centered rows: [(text, font, color, x, baseline, start)]."""
    words = []
    for i, (text, start, _) in enumerate(group):
        font, color, caps = styles[(first + i) % len(styles)]
        text = text.upper() if caps else text
        x0, top, x1, bottom = font.getbbox(text, anchor="ls")
        words.append((text, font, color, start, x1 - x0, top, bottom))
    rows = [[]]
    for word in words:
        row_width = sum(wd[4] for wd in rows[-1]) + GAP * w * len(rows[-1]) + word[4]
        if rows[-1] and row_width > w * MAX_WIDTH:
            rows.append([])
        rows[-1].append(word)
    heights = [(-min(wd[5] for wd in row), max(wd[6] for wd in row)) for row in rows]
    spacing = 0.01 * h
    y = h * Y - (sum(up + down for up, down in heights) + spacing * (len(rows) - 1)) / 2
    placed = []
    for row, (up, down) in zip(rows, heights):
        x = (w - sum(wd[4] for wd in row) - GAP * w * (len(row) - 1)) / 2
        for text, font, color, start, width, _, _ in row:
            placed.append((text, font, color, x, y + up, start))
            x += width + GAP * w
        y += up + down + spacing
    return placed

def captions(words):
    """Group the words into captions of CHUNK: [(placed words, start, end)]."""
    groups = [words[i:i + CHUNK] for i in range(0, len(words), CHUNK)]
    out = []
    for i, group in enumerate(groups):
        end = group[-1][2] + HOLD
        if i + 1 < len(groups):
            end = min(end, groups[i + 1][0][1])
        out.append((layout(group, i * CHUNK), group[0][1], end))
    return out

def overlay(t):
    img = Image.new("RGBA", (w, h))
    draw = ImageDraw.Draw(img)
    for placed, start, end in lines:
        if start <= t < end:
            for text, font, color, x, baseline, word_start in placed:
                if t >= word_start:
                    draw.text((x, baseline), text, font=font, anchor="ls", fill=color,
                              stroke_width=round(font.size * OUTLINE), stroke_fill="black")
    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)
styles = load_styles()
lines = captions(transcribe(src))
render(src, out)
```

## More captions and text

- [AI chat reply animation **AI chat reply animation**](https://www.recharm.com/effects/ai-chat-typing)
- [Animated bar chart **Animated bar chart**](https://www.recharm.com/effects/animated-bar-chart)
- [Animated checklist **Animated checklist**](https://www.recharm.com/effects/animated-checklist)
- [Animated line graph **Animated line graph**](https://www.recharm.com/effects/animated-line-graph)
- [Animated pie chart **Animated pie chart**](https://www.recharm.com/effects/animated-pie-chart)

## 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](https://www.recharm.com/get-started)
