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 zoom into screen Seconds the phone takes to rise from below the frame: 0.5 Seconds the phone sits still before the push: 0.4
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 zoom into screen.
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 |
|---|---|---|
RISE | 0.5 | Seconds the phone takes to rise from below the frame. |
HOLD | 0.4 | Seconds the phone sits still before the push. |
PUSH | 1.1 | Seconds the camera takes to push into the screen. |
SCREEN | 0.44 | Screen width at rest as a share of the frame width; the screen keeps the video's shape. |
BEZEL | 0.026 | Bezel thickness at rest as a share of the frame width. |
RADIUS | 0.06 | Screen corner radius at rest as a share of the frame width. |
BODY | (18, 18, 22) | Phone body color. |
RIM | (92, 92, 104) | Color of the thin edge around the body and of the side buttons. |
SHADOW | 0.55 | Shadow strength (0-1). |
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 (both 540x960 at 30fps, 6 seconds) with a 2-second zoom-into-screen transition over the last 2 seconds of A, so the result is 10 seconds: A alone until 4.0s, then B playing from its start on a phone screen, and B alone from 6.0s (B at 2.0s) to its end. The phone: its screen is 44% of the frame width with the clip's 9:16 shape (238x422 pixels at rest), centered in the frame (x=151 to 389, y=269 to 691), with corners rounded to 32 pixels, and clip B plays on it scaled to fit exactly, uncropped. Around the screen draw a near-black (18, 18, 22) body with a 14-pixel bezel on every side, edged by a thin gray-blue (92, 92, 104) rim, with small gray-blue side buttons (two volume buttons on the left, power on the right) and a soft black shadow under it at 55% opacity, shifted down 16 pixels and blurred about 22 pixels. From 4.0s to 4.5s the phone rises from fully below the frame into its centered spot (cubic ease-out), clip A still playing behind it. It holds until 4.9s. From 4.9s to 6.0s the camera pushes in (cubic ease-in-out): the screen grows about the frame's center until it is exactly 540x960, its corner radius shrinking to 0 as it does, while clip A behind the phone is zoomed in by the same factor about the frame center (1.0x at 4.9s, 2.27x at 6.0s), so the phone and the scene move together like one camera. Draw the phone with Pillow (draw anything with Pillow, not ffmpeg's drawtext), scale with OpenCV and read and write the video with ffmpeg. Crossfade the audio over the same 2 seconds, and save it as output.mp4.Run it without Claude
This is the exact script we tested. Save it as zoom-into-screen.py and run:
python3 zoom-into-screen.py clipA.mp4 clipB.mp4 output.mp4Show the script
#!/usr/bin/env python3
"""Zoom into screen: a phone playing clip B rises over clip A, then the camera pushes into its screen until B fills the frame.
Usage: python3 zoom-into-screen.py clipA.mp4 clipB.mp4 output.mp4 (both clips need an audio track)
Needs ffmpeg, OpenCV, numpy and Pillow (pip install opencv-python-headless numpy pillow).
"""
import json
import subprocess
import sys
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
RISE = 0.5 # seconds the phone takes to rise from below the frame
HOLD = 0.4 # seconds the phone sits still before the push
PUSH = 1.1 # seconds the camera takes to push into the screen
SCREEN = 0.44 # screen width as a share of the frame width at rest (the screen keeps the video's shape)
BEZEL = 0.026 # bezel thickness as a share of the frame width at rest
RADIUS = 0.06 # screen corner radius as a share of the frame width at rest
BODY = (18, 18, 22) # phone body color (near-black)
RIM = (92, 92, 104) # thin metal edge around the body and the side buttons
SHADOW = 0.55 # shadow strength (0-1)
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 frames(path, start):
"""The transition's frames from path, starting at `start` seconds, fitted to A's size and frame rate."""
raw = subprocess.check_output(["ffmpeg", "-v", "error", "-ss", f"{start:.3f}", "-i", path, "-frames:v", str(n),
"-vf", f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={rate}",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"])
return np.frombuffer(raw, np.uint8)[:n * h * w * 3].reshape(n, h, w, 3)
def ease_out(p):
return 1 - (1 - min(max(p, 0), 1)) ** 3
def ease_in_out(p):
p = min(max(p, 0), 1)
return 4 * p ** 3 if p < 0.5 else 1 - (-2 * p + 2) ** 3 / 2
def phone_body():
"""The phone at full size (its screen exactly w x h at (pad, pad)) with its shadow, as an RGBA array."""
k = 1 / SCREEN # the phone is drawn at the size it has when the screen fills the frame
bezel, radius = round(w * BEZEL * k), round(w * RADIUS * k)
x0, y0, x1, y1 = pad - bezel, pad - bezel, pad + w - 1 + bezel, pad + h - 1 + bezel
layer = Image.new("RGBA", (w + 2 * pad, h + 2 * pad))
shadow = Image.new("L", layer.size)
drop = w * 0.03 * k
ImageDraw.Draw(shadow).rounded_rectangle([x0, y0 + drop, x1, y1 + drop], radius + bezel, fill=round(255 * SHADOW))
layer.paste((0, 0, 0, 255), (0, 0), shadow.filter(ImageFilter.GaussianBlur(w * 0.04 * k)))
draw = ImageDraw.Draw(layer)
button, ph = round(w * 0.008 * k), y1 - y0
draw.rounded_rectangle([x0 - button, y0 + ph * 0.17, x0 + 2, y0 + ph * 0.24], button, fill=RIM) # volume up
draw.rounded_rectangle([x0 - button, y0 + ph * 0.27, x0 + 2, y0 + ph * 0.34], button, fill=RIM) # volume down
draw.rounded_rectangle([x1 - 2, y0 + ph * 0.22, x1 + button, y0 + ph * 0.32], button, fill=RIM) # power
draw.rounded_rectangle([x0, y0, x1, y1], radius + bezel, fill=RIM)
draw.rounded_rectangle([x0 + 3, y0 + 3, x1 - 3, y1 - 3], radius + bezel - 3, fill=BODY)
return np.asarray(layer, np.float32), radius
def zoomed(img, z):
"""img with the camera pushed in z times on its center."""
cw, ch = w / z, h / z
x0, y0 = (w - cw) / 2, (h - ch) / 2
m = np.float32([[z, 0, -x0 * z], [0, z, -y0 * z]])
return cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR)
def screen_frame(i):
"""Frame i of the transition: A behind, the phone playing B, risen by RISE and pushed into after HOLD."""
t = i / fps
rise = ease_out(t / RISE)
push = ease_in_out((t - RISE - HOLD) / PUSH)
scale = SCREEN + (1 - SCREEN) * push # screen width as a share of the frame width
lift = (1 - rise) * h * (0.5 + SCREEN / 2 + 0.1) # starts fully below the frame
phone = body.copy()
mask = Image.new("L", (w, h))
ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], round(corner * (1 - push)), fill=255)
m = np.asarray(mask, np.float32)[..., None] / 255
screen = phone[pad:pad + h, pad:pad + w]
screen[..., :3] = b_frames[i] * m + screen[..., :3] * (1 - m)
screen[..., 3:] = 255 * m + screen[..., 3:] * (1 - m)
# shrink the phone so its screen is `scale` of the frame wide, then place it centered and lifted by `lift`
size = (round(phone.shape[1] * scale), round(phone.shape[0] * scale))
small = cv2.resize(phone, size, interpolation=cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR)
move = np.float32([[1, 0, (w - size[0]) / 2], [0, 1, (h - size[1]) / 2 + lift]])
layer = cv2.warpAffine(small, move, (w, h), flags=cv2.INTER_LINEAR)
alpha = layer[..., 3:] / 255
back = zoomed(a_frames[i], scale / SCREEN).astype(np.float32)
return (layer[..., :3] * alpha + back * (1 - alpha)).clip(0, 255).astype(np.uint8)
a_path, b_path, out = sys.argv[1:4]
w, h, rate, fps, a_len = probe(a_path)
D = RISE + HOLD + PUSH
n = round(D * fps)
offset = a_len - D
a_frames, b_frames = frames(a_path, offset), frames(b_path, 0)
pad = round(w * 0.15 / SCREEN)
body, corner = phone_body()
# A up to the phone, the transition frames, then B from where the push lands; the sound crossfades over D
ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-i", a_path, "-i", b_path,
"-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", "-r", rate, "-i", "-",
"-filter_complex",
f"[0:v]trim=end={offset:.4f},setpts=PTS-STARTPTS,fps={rate},format=yuv420p,setsar=1[a];"
f"[1:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={rate},"
f"trim=start={D},setpts=PTS-STARTPTS,format=yuv420p,setsar=1[b];"
f"[2:v]format=yuv420p,setsar=1[c];"
f"[a][c][b]concat=n=3:v=1:a=0[v];[0:a][1:a]acrossfade=d={D}[au]",
"-map", "[v]", "-map", "[au]", "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
"-c:a", "aac", out], stdin=subprocess.PIPE)
for i in range(n):
ff.stdin.write(screen_frame(i).tobytes())
ff.stdin.close()
if ff.wait():
sys.exit("ffmpeg failed")





