How to add a portal transition with Claude

A glowing orange ring opens on the bottle with clip B showing inside it, then the camera pushes through the ring until clip B fills the screen.

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 portal transition
    Length of the transition in seconds: 1.2
    Where in clip A the portal opens, as 0-1 across and down; OpenCV tracks it from there: (0.5, 0.5)

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 portal transition.

Settings you can change

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

SettingDefaultWhat it does
D1.2Length of the transition in seconds.
ANCHOR(0.5, 0.5)Where in clip A the portal opens, as 0-1 across and down; OpenCV tracks it from there.
OPEN_SIZE0.22Radius the ring opens to before the push, as a share of the frame width.
OPEN_TIME0.35Share of the transition spent opening the ring.
PUSH1.8How far the camera pushes into clip A by the end.
B_START_ZOOM1.5How close clip B starts inside the portal before settling to full frame.
CORE, GLOW(255, 238, 200), (255, 120, 20)Colors of the ring's hot core and the glow around it.
SPIN10Number of bright streaks spinning around the ring.

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.2 second portal transition over the last 1.2 seconds of A (output 10.8 seconds long). Fit B to A's size and frame rate. At A's 4.8 second mark a glowing ring opens on the center of the frame and is pinned to the scene with OpenCV optical flow (track corners within 20% of the frame width of the point, move it by their median flow). Over the first 35% of the transition the ring grows to a radius of 22% of the frame width; inside the ring show B, scaled 1.5x around the ring's center. Over the remaining 65%, the ring's center eases to the frame center, its radius grows (squared ease) to 1.15x the distance to the farthest corner, A zooms in around the ring's center up to 1.8x, and B eases from 1.5x to 1.0x so it fills the frame exactly when the transition ends. Draw the ring with numpy, painted over the picture (blend toward the color, don't add): with a ring width of 0.8% of the frame width plus 3% of the radius, a soft orange (255, 120, 20) glow with a Gaussian falloff of 3x the ring width either side of the circle's edge, then a hot core (255, 238, 200) with a falloff of 0.6x the ring width, both brightened and dimmed by 0.7 + 0.3 * cos(10 * angle - 14 * seconds) so 10 bright streaks spin around it. Fade the ring in over the first 8% and out over the last 25% of the push. Crossfade the audio over the same 1.2 seconds. Use Python with OpenCV and numpy and ffmpeg to encode, keep the audio, and save it as output.mp4.

Run it without Claude

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

python3 portal-transition.py clipA.mp4 clipB.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Portal transition: a glowing orange ring opens on the subject of clip A and the camera pushes through it into clip B.

Usage: python3 portal-transition.py clipA.mp4 clipB.mp4 output.mp4  (both clips need an audio track)
Needs ffmpeg, OpenCV and numpy (pip install opencv-python-headless numpy). OpenCV tracks the spot the
portal opens on, so the ring stays pinned to the scene while clip A's camera moves.
"""
import json
import subprocess
import sys

import cv2
import numpy as np

D = 1.2                       # transition length in seconds (the last D seconds of A overlap the first of B)
ANCHOR = (0.5, 0.5)           # where the portal opens in A's first transition frame (0-1 across and down)
OPEN_SIZE = 0.22              # radius the ring opens to before the push, as a share of the frame width
OPEN_TIME = 0.35              # share of the transition spent opening the ring
PUSH = 1.8                    # how far the camera pushes into A (1.8 = 80% closer by the end)
B_START_ZOOM = 1.5            # B starts this much closer inside the portal and settles to 1.0
CORE = (255, 238, 200)        # color of the ring's hot core
GLOW = (255, 120, 20)         # color of the glow around it
SPIN = 10                     # bright streaks around the ring, which spin as it grows


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):
    """Yield the clip's frames as RGB arrays, fitted to A's size and frame rate."""
    ff = subprocess.Popen(["ffmpeg", "-v", "error", "-i", path, "-vf",
                           f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={rate}",
                           "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
    while buf := ff.stdout.read(w * h * 3):
        yield np.frombuffer(buf, np.uint8).reshape(h, w, 3)
    ff.wait()


def smooth(p):
    p = min(max(p, 0.0), 1.0)
    return p * p * (3 - 2 * p)


def track(prev, cur, point):
    """Move point by the median optical flow of the corners around it in clip A."""
    g0, g1 = cv2.cvtColor(prev, cv2.COLOR_RGB2GRAY), cv2.cvtColor(cur, cv2.COLOR_RGB2GRAY)
    box = np.zeros_like(g0)
    cv2.circle(box, (round(point[0]), round(point[1])), round(w * 0.2), 255, -1)
    pts = cv2.goodFeaturesToTrack(g0, 60, 0.01, 6, mask=box)
    if pts is None:
        return point
    new, ok, _ = cv2.calcOpticalFlowPyrLK(g0, g1, pts, None, winSize=(21, 21), maxLevel=3)
    moved = (new - pts)[ok.ravel() == 1]
    return point + np.median(moved.reshape(-1, 2), axis=0) if len(moved) else point


def portal(a, b, p, anchor, t):
    """One transition frame, p going 0 -> 1: the ring opens on the anchor, then the camera pushes through it."""
    push = max(p - OPEN_TIME, 0) / (1 - OPEN_TIME)
    e = smooth(push)
    cx, cy = anchor * (1 - e) + np.array([w / 2, h / 2]) * e          # the portal drifts to the frame center
    far = max(np.hypot(x, y) for x in (cx, w - cx) for y in (cy, h - cy))
    r = w * OPEN_SIZE * smooth(p / OPEN_TIME) + (far * 1.15 - w * OPEN_SIZE) * push ** 2

    s = 1 + (PUSH - 1) * push ** 2                                    # push into A around the portal
    a = cv2.warpAffine(a, np.float32([[s, 0, cx - s * cx], [0, s, cy - s * cy]]), (w, h), borderMode=cv2.BORDER_REFLECT)
    sb = B_START_ZOOM + (1 - B_START_ZOOM) * e                        # B, centered in the portal, settles to full frame
    b = cv2.warpAffine(b, np.float32([[sb, 0, cx - sb * w / 2], [0, sb, cy - sb * h / 2]]), (w, h), borderMode=cv2.BORDER_REFLECT)

    dist = np.hypot(xs - cx, ys - cy)
    inside = np.clip(r - dist + 0.5, 0, 1)[..., None]
    width = w * 0.008 + r * 0.03
    spin = 0.7 + 0.3 * np.cos(SPIN * np.arctan2(ys - cy, xs - cx) - 14 * t)
    fade = smooth(p / 0.08) * (1 - smooth((push - 0.75) / 0.25))      # ring fades in, and out as it leaves the frame
    glow = (np.exp(-((dist - r) / (width * 3)) ** 2) * (0.55 + 0.45 * spin) * fade)[..., None]
    core = (np.exp(-((dist - r) / (width * 0.6)) ** 2) * spin * fade)[..., None]
    out = a * (1 - inside) + b * inside
    out = out * (1 - glow) + np.float32(GLOW) * glow                  # paint the glow, then the core over it
    out = out * (1 - core) + np.float32(CORE) * core
    return np.clip(out, 0, 255).astype(np.uint8)


def render(a_path, b_path, out):
    ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}",
                           "-r", rate, "-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)
    first = round((duration - D) * fps)
    clip_b = frames(b_path)
    anchor, prev = np.array([w * ANCHOR[0], h * ANCHOR[1]]), None
    for i, a in enumerate(frames(a_path)):
        if i >= first:
            if prev is not None:
                anchor = track(prev, a, anchor)
            prev = a
            p = (i - first) / round(D * fps)
            a = portal(a.astype(np.float32), next(clip_b).astype(np.float32), p, anchor, i / fps)
        ff.stdin.write(a.tobytes())
    for b in clip_b:
        ff.stdin.write(b.tobytes())
    ff.stdin.close()
    if ff.wait():
        sys.exit("ffmpeg failed")


a_path, b_path, out = sys.argv[1:4]
w, h, rate, fps, duration = probe(a_path)
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
render(a_path, b_path, out)

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