How to add a match cut with Claude

The bottle eases into the exact size and spot it fills in the next shot, so at the cut only the backdrop and the block under it change.

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 match cut
    Seconds of clip A the cut may leave from: (2.0, 4.0)
    Seconds of clip B the cut may land on: (0.3, 2.0)

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 match cut.

Settings you can change

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

SettingDefaultWhat it does
A_WINDOW(2.0, 4.0)Seconds of clip A the cut may leave from.
B_WINDOW(0.3, 2.0)Seconds of clip B the cut may land on.
STEP0.1Search step in seconds; smaller finds a closer match but takes longer.
ALIGN0.5Seconds clip A takes to slide and zoom onto B's framing before the cut.

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
Make a match cut from clipA.mp4 to clipB.mp4 (both 540x960, 30fps) with Python, OpenCV and ffmpeg. Search clipA's frames from 2.0 to 4.0 seconds and clipB's frames from 0.3 to 2.0 seconds in 0.1 second steps (every 3rd frame). For each pair, match ORB features (cv2.ORB_create(2000), brute-force Hamming matcher with cross-check), fit a scale + rotation + shift taking B's points onto A's with cv2.estimateAffinePartial2D (RANSAC threshold 3), skip pairs where moving A that way would leave empty edges, and keep the pair whose moved A frame has the lowest mean absolute difference from the B frame (on these clips that is A's frame at 2.4 seconds and B's at 1.0 seconds). Play A untouched until 0.5 seconds before that frame, then ease it (smoothstep) from its own framing to the fitted alignment, so on A's last frame the bottle sits exactly where it is in B's frame. Then hard cut to B, untouched, from the matched frame to its end. Cut the audio at the same points with 0.02 second fades. The result is 540x960, 30fps, H.264 yuv420p and about 7.43 seconds long. Keep the audio and save it as output.mp4.

Run it without Claude

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

python3 match-cut.py clipA.mp4 clipB.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Match cut: OpenCV finds where the product sits the same way in both shots, lines clip A up on clip B and cuts.

Usage: python3 match-cut.py clipA.mp4 clipB.mp4 output.mp4  (same size and frame rate, both with audio)
Needs ffmpeg, OpenCV and numpy (pip install opencv-python-headless numpy).
"""
import subprocess
import sys

import cv2
import numpy as np

A_WINDOW = (2.0, 4.0)   # seconds of clip A the cut may leave from
B_WINDOW = (0.3, 2.0)   # seconds of clip B the cut may land on
STEP = 0.1              # search step in seconds
ALIGN = 0.5             # seconds clip A takes to slide and zoom onto B's framing before the cut

orb = cv2.ORB_create(2000)
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)


def read_frames(path):
    video = cv2.VideoCapture(path)
    frames = []
    while True:
        ok, img = video.read()
        if not ok:
            return frames, video.get(cv2.CAP_PROP_FPS)
        frames.append(img)


def features(img):
    return orb.detectAndCompute(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), None)


def alignment(fa, fb):
    """Scale, rotation and shift taking points in B's frame to the same feature in A's (None if no fit)."""
    (ka, da), (kb, db) = fa, fb
    pairs = matcher.match(db, da)
    if len(pairs) < 10:
        return None
    pb = np.float32([kb[p.queryIdx].pt for p in pairs])
    pa = np.float32([ka[p.trainIdx].pt for p in pairs])
    m, _ = cv2.estimateAffinePartial2D(pb, pa, ransacReprojThreshold=3)
    return m


def covers_frame(m):
    """True if A, moved by m, still fills the whole frame (no empty edges)."""
    corners = np.float32([[0, 0, 1], [w, 0, 1], [0, h, 1], [w, h, 1]]) @ m.T
    return corners.min() >= 0 and (corners[:, 0] <= w).all() and (corners[:, 1] <= h).all()


def moved(img, m):
    """A's frame resampled so that its point m @ p lands on output pixel p."""
    return cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_CUBIC | cv2.WARP_INVERSE_MAP)


def best_cut():
    """The pair of frames (one from each clip) that look most alike once lined up."""
    best = None
    ib_range = range(round(B_WINDOW[0] * fps), round(B_WINDOW[1] * fps) + 1, round(STEP * fps))
    fb = {ib: features(b[ib]) for ib in ib_range}
    for ia in range(round(A_WINDOW[0] * fps), round(A_WINDOW[1] * fps) + 1, round(STEP * fps)):
        fa = features(a[ia])
        for ib in ib_range:
            m = alignment(fa, fb[ib])
            if m is None or not covers_frame(m):
                continue
            diff = cv2.absdiff(moved(a[ia], m), b[ib]).mean()
            if best is None or diff < best[0]:
                best = (diff, ia, ib, m)
    if best is None:
        sys.exit("No matching frames found: widen A_WINDOW or B_WINDOW")
    return best[1:]


src_a, src_b, out = sys.argv[1:4]
a, fps = read_frames(src_a)
b, _ = read_frames(src_b)
h, w = a[0].shape[:2]
ia, ib, m = best_cut()
cut_a, cut_b = (ia + 1) / fps, ib / fps
print(f"cutting from A at {cut_a:.2f}s to B at {cut_b:.2f}s")

writer = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{w}x{h}",
                           "-r", str(fps), "-i", "-", "-i", src_a, "-i", src_b, "-filter_complex",
                           f"[1:a]atrim=0:{cut_a},afade=out:st={cut_a - 0.02}:d=0.02[aa];"
                           f"[2:a]atrim={cut_b},asetpts=PTS-STARTPTS,afade=in:d=0.02[ab];[aa][ab]concat=v=0:a=1[au]",
                           "-map", "0:v", "-map", "[au]", "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
                           "-c:a", "aac", out], stdin=subprocess.PIPE)
start = ia - round(ALIGN * fps)
for i in range(ia + 1):
    p = min(max((i - start) / (ia - start), 0), 1)
    p = p * p * (3 - 2 * p)                      # ease in and out
    step = np.float32([[1, 0, 0], [0, 1, 0]]) * (1 - p) + m * p
    writer.stdin.write((moved(a[i], step) if p > 0 else a[i]).tobytes())
for img in b[ib:]:
    writer.stdin.write(img.tobytes())
writer.stdin.close()
if writer.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