---
title: "How to add emoji captions with Claude (tested prompt and script)"
description: "Each spoken line appears in white outlined type with a matching emoji popping in at its end: a green check mark after \"cleared\", sparkles after \"skin\". Install one skill and ask Claude Code, or run the Python script we tested yourself."
url: https://www.recharm.com/effects/emoji-captions
---

# How to add emoji captions with Claude

Each spoken line appears in white outlined type with a matching emoji popping in at its end: a green check mark after "cleared", sparkles after "skin".

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 emoji captions to input.mp4
   Emoji: cleared → ✅, skin → ✨, love → 😍, ...
   ```

## 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 emoji 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                                                                 |
| ------------ | --------------------------------------- | ---------------------------------------------------------------------------- |
| `EMOJI`      | `cleared → ✅, skin → ✨, love → 😍, ...` | Words that get an emoji, and which one. The first word found in a line wins. |
| `SIZE`       | `0.075`                                 | Text height as a share of the frame width.                                   |
| `EMOJI_SIZE` | `2.0`                                   | Emoji height as a multiple of the text height.                               |
| `Y`          | `0.8`                                   | Where the caption sits, from 0 (top) to 1 (bottom).                          |
| `POP`        | `0.25`                                  | Seconds the emoji takes to pop in.                                           |
| `MAX_WORDS`  | `4`                                     | Most words on one line.                                                      |

## Without the skill

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

```
Add emoji 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). Group the words in order into lines of 4 words, and show each line from its first word's start until 0.4 seconds after its last word ends, or until the next line starts if that is sooner, with nothing on screen after the last line. Give a line an emoji when one of its words (lowercased, punctuation removed) is in this map, first match wins: cleared or clear -> ✅, skin or glow -> ✨, love -> 😍, money or cash -> 💰, fire or hot -> 🔥. Draw the line in white Arial Bold 7.5% of the frame width tall with a black outline 14% of the text height thick, and put the emoji right after the last word, 0.3 text heights away, 2 text heights tall, drawn in color from Apple Color Emoji (load it at size 160 and scale the image; use embedded_color=True). Center the text and emoji together horizontally with their middle 80% of the way down the frame, shrinking both if they are wider than 90% of the frame. The emoji pops in over the line's first 0.25 seconds, scaling from 0 with an ease-out-back overshoot to about 110% and settling at 100%. Use Python with Pillow to draw the text and emoji (don't use ffmpeg's drawtext or subtitles 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 `emoji-captions.py` and run:

```
python3 emoji-captions.py input.mp4 output.mp4
```

```
#!/usr/bin/env python3
"""Emoji captions: Whisper transcribes the speech, and each caption line ends with a matching emoji that pops in.

Usage: python3 emoji-captions.py input.mp4 output.mp4
faster-whisper listens to the audio on your computer and times every word; a line that contains a word from
EMOJI gets that word's emoji. Add your own words to EMOJI.
Needs ffmpeg, faster-whisper and Pillow (pip install faster-whisper pillow) and a color emoji font (Apple Color
Emoji on macOS, Noto Color Emoji on Linux, Segoe UI Emoji on Windows). 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
EMOJI = {"cleared": "✅", "clear": "✅", "skin": "✨", "glow": "✨", "love": "😍", "money": "💰", "cash": "💰",
         "fire": "🔥", "hot": "🔥", "fast": "⚡", "free": "🎁", "sale": "🏷"}  # word -> emoji; first word found wins
SIZE = 0.075          # caption text height as a share of the frame width (shrinks to fit long lines)
Y = 0.8               # where the caption's center sits (0 = top, 1 = bottom)
MAX_WIDTH = 0.9       # widest line, emoji included, as a share of the frame width
EMOJI_SIZE = 2.0      # emoji height as a multiple of the text height
GAP = 0.3             # space between the last word and the emoji, as a multiple of the text height
POP = 0.25            # seconds the emoji takes to pop in, overshooting a little
MAX_WORDS = 4         # most words on one line
HOLD = 0.4            # seconds a line stays up after its last word, unless the next line starts sooner
STROKE = 0.14         # black outline round the letters, as a share of the text height
FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",     # macOS
         "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",  # Linux
         "C:/Windows/Fonts/arialbd.ttf"]                          # Windows
EMOJI_FONTS = [("/System/Library/Fonts/Apple Color Emoji.ttc", 160),           # macOS (only draws at 160)
               ("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", 109),     # Linux (only draws at 109)
               ("C:/Windows/Fonts/seguiemj.ttf", 160)]                         # 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")
EMOJI_FONT = next((f for f in EMOJI_FONTS if Path(f[0]).exists()), None) or sys.exit("No emoji font: add one to EMOJI_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 [(word.word.strip(), word.start, word.end) for segment in segments for word in segment.words]

def caption_lines(words):
    """Lines of up to MAX_WORDS words: [(text, emoji or None, start, end)]."""
    groups = [words[i:i + MAX_WORDS] for i in range(0, len(words), MAX_WORDS)]
    lines = []
    for i, group in enumerate(groups):
        end = group[-1][2] + HOLD
        if i + 1 < len(groups):
            end = min(end, groups[i + 1][0][1])
        keys = ["".join(ch for ch in text.lower() if ch.isalpha()) for text, _, _ in group]
        emoji = next((EMOJI[k] for k in keys if k in EMOJI), None)
        lines.append((" ".join(text for text, _, _ in group), emoji, group[0][1], end))
    return lines

def emoji_image(emoji, height):
    """The emoji drawn at the font's own size, trimmed and scaled to height pixels."""
    path, size = EMOJI_FONT
    img = Image.new("RGBA", (size * 2, size * 2))
    ImageDraw.Draw(img).text((size, size), emoji, font=ImageFont.truetype(path, size), anchor="mm", embedded_color=True)
    img = img.crop(img.getbbox())
    scale = height / max(img.size)
    return img.resize((round(img.width * scale), round(img.height * scale)), Image.LANCZOS)

def caption(text, emoji):
    """The line drawn once, centered together with its emoji: (image, emoji, where the emoji's center goes)."""
    size = w * SIZE
    width = ImageFont.truetype(FONT_FILE, round(size)).getlength(text) + (size * (GAP + EMOJI_SIZE) if emoji else 0)
    size *= min(1, w * MAX_WIDTH / width)
    font = ImageFont.truetype(FONT_FILE, round(size))
    pic = emoji_image(emoji, round(size * EMOJI_SIZE)) if emoji else None
    width = font.getlength(text) + (size * GAP + pic.width if pic else 0)
    x = (w - width) / 2
    img = Image.new("RGBA", (w, h))
    ImageDraw.Draw(img).text((x, h * Y), text, font=font, anchor="lm", fill="white",
                             stroke_width=round(size * STROKE), stroke_fill="black")
    return img, pic, (x + width - (pic.width / 2 if pic else 0), h * Y)

def pop_scale(p):
    """Ease-out-back: 0 -> about 1.1 -> 1 as p goes from 0 to 1."""
    return 1 + 2.7 * (p - 1) ** 3 + 1.7 * (p - 1) ** 2

def overlay(t):
    for i, (text, emoji, start, end) in enumerate(lines):
        if start <= t < end:
            img, pic, (cx, cy) = drawn.get(i) or drawn.setdefault(i, caption(text, emoji))
            if not pic:
                return img
            scale = pop_scale(min((t - start) / POP, 1))
            pic = pic.resize((max(1, round(pic.width * scale)), max(1, round(pic.height * scale))), Image.LANCZOS)
            img = img.copy()
            img.alpha_composite(pic, (round(cx - pic.width / 2), round(cy - pic.height / 2)))
            return img
    return blank

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)
lines = caption_lines(transcribe(src))
blank = Image.new("RGBA", (w, h))
drawn = {}
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)
