How to add a camera shutter transition with Claude

Six dark gray aperture blades twist inward over clip A, leaving a shrinking hexagon until the lens is shut, then twist back open to reveal clip B.

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 camera shutter transition
    Seconds the blades take to shut over clip A: 0.35
    Seconds the shutter stays closed between the clips: 0.1

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 camera shutter transition.

Settings you can change

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

SettingDefaultWhat it does
CLOSE0.35Seconds the blades take to shut over clip A.
SHUT0.1Seconds the shutter stays closed between the clips.
OPEN0.35Seconds the blades take to open onto clip B.
BLADES6Number of blades; the opening is a polygon with this many sides.
TWIST60Degrees the blades turn between fully open and shut.
BLADE_COLORS(52, 53, 58), (40, 41, 46)Alternating blade shades.
SEAM(88, 90, 98)Color of the lines where the blades overlap.
RIM(150, 152, 160)Color of the edge round the opening.

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 0.8 second camera shutter (aperture) transition starting 0.8 seconds before A ends (so the result is 11.2 seconds for two 6 second clips). Build the transition frames with Python, numpy and Pillow. The picture shows through a regular hexagon centered in the frame whose corners sit at radius r = o * R from the center, where R = (half the frame diagonal + 20) / cos(30 degrees) so the fully open hexagon clears the corners, and whose corners are at angles turn + i*60 degrees with turn = 60 * (1 - o) degrees, so the blades twist as they close. Everything outside the hexagon is covered by six opaque blades: blade i fills the area between hexagon edge i and the two neighboring edges' lines carried outward past their corners (a pinwheel), shaded alternately RGB (52, 53, 58) and (40, 41, 46), with a 2 pixel (88, 90, 98) line along each of those outward lines and a 2 pixel (150, 152, 160) outline round the hexagon. Draw the blades at twice the frame size and scale them down for smooth edges. Over the first 0.35 seconds the blades close over clip A (o = 1 - s(p), p from 0 to 1, with smoothstep s(p) = p*p*(3-2p)); for the next 0.1 seconds the shutter stays fully closed; over the last 0.35 seconds they open onto clip B (o = s(p)), with B playing from its first frame at the start of the transition. Don't use ffmpeg's drawtext. Use ffmpeg for the rest: match B's size and frame rate to A (30fps), crossfade the audio over the same 0.8 seconds, keep the audio and save it as output.mp4 (H.264, yuv420p).

Run it without Claude

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

python3 camera-shutter-transition.py clipA.mp4 clipB.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Camera shutter: six gray aperture blades twist shut over clip A, then twist open onto clip B.

Usage: python3 camera-shutter-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

import numpy as np
from PIL import Image, ImageDraw

CLOSE = 0.35                 # seconds the blades take to shut over clip A
SHUT = 0.1                   # seconds the shutter stays closed
OPEN = 0.35                  # seconds the blades take to open onto clip B
BLADES = 6                   # number of blades (the opening is a polygon with this many sides)
TWIST = 60                   # degrees the blades turn between fully open and shut
BLADE_COLORS = [(52, 53, 58), (40, 41, 46)]   # alternating blade shades
SEAM = (88, 90, 98)          # lines where the blades overlap
RIM = (150, 152, 160)        # bright edge round the opening
FPS = 30

a_path, b_path, out_path = sys.argv[1:4]
info = json.loads(subprocess.run(
    ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height:format=duration",
     "-of", "json", a_path], capture_output=True, check=True).stdout)
w, h = info["streams"][0]["width"], info["streams"][0]["height"]
D = CLOSE + SHUT + OPEN
offset = float(info["format"]["duration"]) - D
n = round(D * FPS)
# fully open, the polygon's inner circle clears the frame's corners
widest = (math.hypot(w, h) / 2 + 20) / math.cos(math.pi / BLADES)


def frames(path, start):
    """The n frames from `start`, fitted to A's size, as RGB arrays."""
    raw = subprocess.run(
        ["ffmpeg", "-v", "error", "-ss", str(start), "-i", path, "-frames:v", str(n),
         "-vf", f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={FPS}",
         "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
    return np.frombuffer(raw, np.uint8).reshape(-1, h, w, 3)


def smoothstep(p):
    return p * p * (3 - 2 * p)


def openness(t):
    """1 = shutter fully open, 0 = shut."""
    if t < CLOSE:
        return 1 - smoothstep(t / CLOSE)
    if t < CLOSE + SHUT:
        return 0.0
    return smoothstep((t - CLOSE - SHUT) / OPEN)


def blades(o):
    """The blades as an RGBA layer, drawn at twice the size and scaled down for smooth edges."""
    s = 2
    img = Image.new("RGBA", (w * s, h * s))
    draw = ImageDraw.Draw(img)
    cx, cy, r = w * s / 2, h * s / 2, o * widest * s
    turn = math.radians(TWIST * (1 - o))
    corners = [(cx + r * math.cos(turn + 2 * math.pi * i / BLADES), cy + r * math.sin(turn + 2 * math.pi * i / BLADES))
               for i in range(BLADES)]
    far = 4 * max(w, h) * s
    # each blade fills the space between its edge of the opening and the two neighboring edges carried outward
    seams = []
    for i in range(BLADES):
        (x0, y0), (x1, y1) = corners[i], corners[(i + 1) % BLADES]
        ang = turn + 2 * math.pi * (i + 0.5) / BLADES + math.pi / 2         # direction of this edge
        prev = ang - 2 * math.pi / BLADES                                   # direction of the edge before
        tip = (x1 + far * math.cos(ang), y1 + far * math.sin(ang))
        tail = (x0 + far * math.cos(prev), y0 + far * math.sin(prev))
        draw.polygon([(x0, y0), (x1, y1), tip, tail], fill=BLADE_COLORS[i % 2])
        seams.append([(x1, y1), tip])
    for seam in seams:
        draw.line(seam, fill=SEAM, width=2 * s)
    if r > 1:
        draw.line(corners + corners[:1], fill=RIM, width=2 * s, joint="curve")
    return img.resize((w, h), Image.LANCZOS)


def shutter_frame(picture, o):
    layer = np.asarray(blades(o), np.float32)
    alpha = layer[:, :, 3:] / 255
    return (picture * (1 - alpha) + layer[:, :, :3] * alpha).astype(np.uint8).tobytes()


tail, head = frames(a_path, offset), frames(b_path, 0)
middle = b"".join(shutter_frame(tail[i] if i / FPS < CLOSE + SHUT else head[i], openness(i / FPS)) for i in range(n))

# A up to the shutter, then the shutter frames from stdin, then the rest of B; the sound crossfades underneath
subprocess.run(
    ["ffmpeg", "-y", "-v", "error", "-i", a_path, "-i", b_path,
     "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", "-r", str(FPS), "-i", "-",
     "-filter_complex",
     f"[0:v]fps={FPS},trim=end_frame={round(offset * FPS)},setpts=PTS-STARTPTS,format=yuv420p,setsar=1[a];"
     f"[2:v]format=yuv420p,setsar=1[m];"
     f"[1:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={FPS},"
     f"trim=start_frame={n},setpts=PTS-STARTPTS,format=yuv420p,setsar=1[b];"
     f"[a][m][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_path],
    input=middle, check=True)

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