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 an ink transition Length of the transition in seconds (the clips overlap by this much): 1.5 Each blot's center (0-1 across, 0-1 down) and when it starts, as a share of the transition: (0.28, 0.22, 0.00), (0.75, 0.52, 0.15), (0.30, 0.82, 0.30)
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 an ink 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 |
|---|---|---|
D | 1.5 | Length of the transition in seconds (the clips overlap by this much). |
BLOTS | (0.28, 0.22, 0.00), (0.75, 0.52, 0.15), (0.30, 0.82, 0.30) | Each blot's center (0-1 across, 0-1 down) and when it starts, as a share of the transition. |
LOBES | 0.30 | How far the blot outlines bulge in and out (0 makes circles). |
RAGGED | 0.025 | Fine ragged noise on the edges, as a share of the frame width. |
RIM | 0.14 | Width of the ink band ahead of clip B, as a share of the frame width. |
INK | (20, 14, 38) | Ink color, a deep indigo. |
FPS | 30 | Frame rate of the output. |
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 ink transition that starts 1.5 seconds before A ends, so the result is 10.5 seconds. Three ink blots grow over A, centered at (28% across, 22% down), (75% across, 52% down) and (30% across, 82% down); they start at 0, 0.15 and 0.30 of the way through the transition and each finishes growing at the end of it, with a smoothstep ease (slow, fast, slow). Give each blot a wavy outline: its radius at angle a is r * (1 + 0.3 * f(a)), where f is a sum of sines with 3, 5, 7 and 11 lobes (random phases), scaled to -1..1, and roughen all edges by up to 2.5% of the frame width with smooth noise. Paint the blots in solid ink (20, 14, 38), and show B inside each blot except for a band of ink 14% of the frame width wide around its edge, so the ink leads and B follows. Size the blots so B covers the whole frame exactly when the transition ends; before it show only A, after it only B. Anti-alias the edges over 2 pixels. Fit B to A's size (scale to cover, center crop) at 30fps. Do the per-pixel compositing in Python with numpy and Pillow, piping frames to and from ffmpeg (no ffmpeg drawtext). Crossfade the audio over the same 1.5 seconds. Encode H.264 yuv420p and save it as output.mp4.Run it without Claude
This is the exact script we tested. Save it as ink-transition.py and run:
python3 ink-transition.py clipA.mp4 clipB.mp4 output.mp4Show the script
#!/usr/bin/env python3
"""Ink transition: three indigo ink blots with ragged edges bloom over clip A, and clip B opens up inside them.
Usage: python3 ink-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 subprocess
import sys
import numpy as np
from PIL import Image
D = 1.5 # transition length in seconds (the clips overlap by this much)
BLOTS = [(0.28, 0.22, 0.00), # each blot: center (0-1 across, 0-1 down) and when it starts (share of D)
(0.75, 0.52, 0.15),
(0.30, 0.82, 0.30)]
LOBES = 0.30 # how far the blot outlines bulge in and out (0 = circles)
RAGGED = 0.025 # fine ragged noise on the edges, as a share of the frame width
RIM = 0.14 # width of the ink band ahead of clip B, as a share of the frame width
INK = (20, 14, 38) # ink color, a deep indigo
FPS = 30
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 a float 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 True:
chunk = ff.stdout.read(w * h * 3)
if not chunk:
break
yield np.frombuffer(chunk, np.uint8).reshape(h, w, 3).astype(np.float32)
def blot_shapes(rng):
"""For each blot, every pixel's distance to its center (in pixels), shrunk where the outline bulges out.
A blot of size r then covers the pixels whose value is below r."""
y, x = np.mgrid[0:h, 0:w].astype(np.float32)
grid = Image.fromarray(rng.random((h // 24 + 2, w // 24 + 2)).astype(np.float32))
ragged = (np.asarray(grid.resize((w, h), Image.BICUBIC), np.float32) - 0.5) * 2 * RAGGED * w
shapes = []
for cx, cy, _ in BLOTS:
dx, dy = x - cx * w, y - cy * h
angle = np.arctan2(dy, dx)
wave = sum(rng.uniform(0.4, 1) * np.sin(k * angle + rng.uniform(0, 2 * np.pi)) for k in (3, 5, 7, 11))
shapes.append(np.hypot(dx, dy) / (1 + LOBES * wave / np.abs(wave).max()) + ragged)
return shapes
def ink(a, b, p):
"""Frame at progress p (0-1): ink covers A where a blot has spread, and B shows inside the ink band."""
covered = revealed = np.zeros((h, w), np.float32)
for (_, _, delay), shape in zip(BLOTS, shapes):
grow = np.clip((p - delay) / (1 - delay), 0, 1)
r = reach * grow * grow * (3 - 2 * grow) # starts slowly, speeds up, then settles
covered = np.maximum(covered, np.clip((r - shape) / 2, 0, 1))
revealed = np.maximum(revealed, np.clip((r - RIM * w - shape) / 2, 0, 1))
covered, revealed = covered[..., None], revealed[..., None]
return a * (1 - covered) + np.array(INK, np.float32) * (covered - revealed) + b * revealed
a_path, b_path, out = sys.argv[1:4]
w, h, a_len = probe(a_path)
shapes = blot_shapes(np.random.default_rng(7))
reach = np.min(shapes, axis=0).max() + RIM * w + 4 # final blot size: just enough for B to fill the frame
start = round((a_len - D) * FPS) # first frame of the transition
steps = round(D * FPS)
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 = ink(a, next(b_frames), (i - start + 1) / steps)
ff.stdin.write(np.clip(a, 0, 255).astype(np.uint8).tobytes())
for b in b_frames:
ff.stdin.write(b.astype(np.uint8).tobytes())
ff.stdin.close()
if ff.wait():
sys.exit("ffmpeg failed")





