How to add a text zoom transition with Claude

The word "GLOW" lands across the dimmed first clip with the next clip showing through its white-edged letters, then the camera rushes into the W until the second clip fills 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

    Join clipA.mp4 and clipB.mp4 with a text zoom transition
    Word: GLOW

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.

Put a sale badge on @clip1.mp4, give @clip2.mp4 a VHS look, then join them with a text zoom transition.

Settings you can change

Ask Claude to change any of these, or edit them at the top of the script.

SettingDefaultWhat it does
WORDGLOWThe word clip B shows through.
D1.5Length of the transition in seconds.
WIDTH0.86Word width as a share of the frame width.
DIM0.45How much clip A darkens around the word (0 = not at all, 1 = black).
EDGE3Width of the white outline round the letters, in pixels.
EDGE_COLOR(255, 255, 255)Color of the outline.
FPS30Frame rate of the output.
AA2How many times larger the word is drawn, for smooth edges.

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
Join clipA.mp4 and clipB.mp4 with a 1.5 second text zoom transition that starts 1.5 seconds before A ends, so the result is 10.5 seconds. Draw the word "GLOW" with Pillow (not ffmpeg's drawtext) in Arial Black (or any heavy bold font), sized so it spans 86% of the frame width, centered across and down, as a mask at 2x the frame size. Find the point inside the letters farthest from their edges (erode the mask one pixel at a time until it vanishes; the last pixels left, nearest the center) and call its distance from the edge `depth`. On each of the 45 transition frames, with progress p going 1/45 ... 1, zoom both the word and clip A by scale = S ** (p ** 3), where S = 1.15 * (half the frame diagonal) / depth, about that point, while the point drifts toward the frame's center by p * p of the way. Show clip B unzoomed inside the letters and the zoomed clip A outside them, darkened by 45% (ramp the darkening in over the first 0.2 seconds), and draw a 3 pixel pure white (255, 255, 255) outline just outside the letters' edges. By the last frame the letter covers the whole frame, so it is all B. Before the transition show only A, after it only B. Fit B to A's size (scale to cover, center crop) at 30fps. Mix the frames in Python with numpy, piping frames to and from ffmpeg. Crossfade the audio over the same 1.5 seconds, keep it, encode H.264 yuv420p and save it as output.mp4.

Run it without Claude

This is the exact script we tested. Save it as text-zoom-transition.py and run:

python3 text-zoom-transition.py clipA.mp4 clipB.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Text zoom transition: a huge word appears over clip A with clip B showing through its letters, then the camera flies into one letter until B fills the frame.

Usage: python3 text-zoom-transition.py clipA.mp4 clipB.mp4 output.mp4  (both clips need an audio track)
Needs ffmpeg, Pillow and numpy (pip install pillow numpy).
"""
import json
import math
import subprocess
import sys
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont

WORD = "GLOW"            # the word clip B shows through
D = 1.5                  # transition length in seconds
WIDTH = 0.86             # word width as a share of the frame width, centered
DIM = 0.45               # how much clip A darkens around the word (0 = not at all, 1 = black)
EDGE = 3                 # width of the white outline round the letters, in pixels
EDGE_COLOR = (255, 255, 255)   # outline color
FPS = 30                 # output frame rate
AA = 2                   # the word is drawn this many times larger, for smooth edges
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 probe(path):
    info = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                                               "stream=width,height:format=duration", "-of", "json", path]))
    return info["streams"][0]["width"], info["streams"][0]["height"], float(info["format"]["duration"])


def frames(path):
    """Every frame of a clip, fitted to A's size at 30fps, as an RGB array."""
    fit = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={FPS}"
    ff = subprocess.Popen(["ffmpeg", "-v", "error", "-i", path, "-vf", fit, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
                          stdout=subprocess.PIPE)
    while chunk := ff.stdout.read(w * h * 3):
        yield np.frombuffer(chunk, np.uint8).reshape(h, w, 3)


def word_mask():
    """The word in white on black at AA times the frame size, as wide as WIDTH of the frame and centered."""
    font = ImageFont.truetype(FONT_FILE, 100)
    x0, _, x1, _ = font.getbbox(WORD)
    font = ImageFont.truetype(FONT_FILE, round(100 * WIDTH * w * AA / (x1 - x0)))
    mask = Image.new("L", (w * AA, h * AA))
    ImageDraw.Draw(mask).text((w * AA / 2, h * AA / 2), WORD, font=font, anchor="mm", fill=255)
    return mask


def deepest_point(mask):
    """The point inside a letter stroke farthest from its edges (nearest the center on a tie), and that distance,
    found by peeling the strokes one pixel at a time. Zooming in on it is what fills the frame with B."""
    m = np.asarray(mask.resize((w, h)), np.uint8) > 127
    depth = 0
    while True:
        e = m.copy()
        e[1:] &= m[:-1]
        e[:-1] &= m[1:]
        e[:, 1:] &= m[:, :-1]
        e[:, :-1] &= m[:, 1:]
        if not e.any():
            break
        m, depth = e, depth + 1
    ys, xs = np.nonzero(m)
    k = np.argmin((xs - w / 2) ** 2 + (ys - h / 2) ** 2)
    return xs[k], ys[k], depth


def zoomed(img, scale, p, size):
    """img zoomed by `scale` about the anchor point, which drifts to the frame's center as p goes 0 -> 1."""
    k = img.width / w
    cx, cy = ax + (w / 2 - ax) * p * p, ay + (h / 2 - ay) * p * p
    return img.transform(size, Image.AFFINE, (k / scale, 0, k * (ax - cx / scale), 0, k / scale, k * (ay - cy / scale)),
                         Image.BILINEAR)


def text_zoom(a, b, p):
    """At progress p (0-1): A zooms in with the word, B shows through the letters unmoved. The zoom grows slowly at
    first, so the word reads, then rushes into the letter until it covers the frame."""
    scale = end_scale ** (p ** 3)
    inside = zoomed(mask, scale, p, (w, h))
    edge = np.asarray(inside.filter(ImageFilter.MaxFilter(2 * EDGE + 1)), np.float32)[..., None] / 255
    inside = np.asarray(inside, np.float32)[..., None] / 255
    a = np.asarray(zoomed(Image.fromarray(a), scale, p, (w, h)), np.float32) * (1 - DIM * min(1, p * D / 0.2))
    mix = a * (1 - inside) + b * inside
    outline = edge - inside
    return (mix * (1 - outline) + np.array(EDGE_COLOR) * outline).astype(np.uint8)


a_path, b_path, out = sys.argv[1:4]
w, h, a_len = probe(a_path)
mask = word_mask()
ax, ay, depth = deepest_point(mask)
end_scale = 1.15 * math.hypot(w, h) / 2 / depth    # big enough for the stroke to cover the whole frame
start = round((a_len - D) * FPS)    # first frame of the transition
ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}",
                       "-r", str(FPS), "-i", "-", "-i", a_path, "-i", b_path,
                       "-filter_complex", f"[1:a][2:a]acrossfade=d={D}[au]", "-map", "0:v", "-map", "[au]",
                       "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", out], stdin=subprocess.PIPE)
b_frames = frames(b_path)
for i, a in enumerate(frames(a_path)):
    if i >= start:
        a = text_zoom(a, next(b_frames), (i - start + 1) / round(D * FPS))
    ff.stdin.write(a.tobytes())
for b in b_frames:
    ff.stdin.write(b.tobytes())
ff.stdin.close()
if ff.wait():
    sys.exit("ffmpeg failed")

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