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
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.
| Setting | Default | What it does |
|---|---|---|
WORD | GLOW | The word clip B shows through. |
D | 1.5 | Length of the transition in seconds. |
WIDTH | 0.86 | Word width as a share of the frame width. |
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) | Color of the outline. |
FPS | 30 | Frame rate of the output. |
AA | 2 | How 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.mp4Show 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")





