How to add a dolly zoom with Claude

The serum bottle and its acrylic block stay exactly the same size while the holographic backdrop behind them swells to 1.6 times its size over three seconds, as if the wall were rushing toward the camera.

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

    Add a dolly zoom to input.mp4
    Second the backdrop starts to grow: 1.0
    Second it reaches full size and holds: 4.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.

Add a dolly zoom to @clip1.mp4, put a sale badge on @clip2.mp4, give @clip3.mp4 a VHS look, then join all three with crossfades.

Settings you can change

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

SettingDefaultWhat it does
START1.0Second the backdrop starts to grow.
END4.0Second it reaches full size and holds.
ZOOM1.6How large the backdrop ends up; 1.6 is 60% closer.
BASE0.775Where the product stands, 0-1 down; the stand and floor below stay still with it.
CX0.5Product center, 0-1 across; the backdrop grows from here and BASE.
FEATHER0.03Height of the soft join between the still floor and the growing backdrop.

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
Add a dolly zoom to input.mp4 (540x960, a serum bottle standing on an acrylic block, whose base sits 77.5% down the frame and is centered across). Leave the video untouched before 1 second. From 1 to 4 seconds, scale the backdrop up from 1.0x to 1.6x with a smoothstep ease-in-out, growing from the point at 50% across and 77.5% down, then hold it at 1.6x to the end. On every frame from 1 second, cut the bottle out with rembg (u2net model), paint it out of a copy of the frame with OpenCV inpainting (mask dilated by 15 px) so the scaled backdrop has no second bottle in it, scale that copy, and put the bottle back on top at its original size and place. Keep everything below 77.5% down (the block and floor) unscaled too, blending into the scaled backdrop over the 3% of the frame height above that line. Use Python with numpy and OpenCV and ffmpeg to read and write the video (no text is drawn; if you add any, draw it with Pillow, not ffmpeg's drawtext), keep the size, 30fps and the audio, and save it as output.mp4.

Run it without Claude

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

python3 dolly-zoom.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Dolly zoom: the product and its stand hold their size while the backdrop behind them swells to 1.6x.

Usage: python3 dolly-zoom.py input.mp4 output.mp4
Needs ffmpeg, Pillow, numpy, OpenCV and rembg to cut the product out of every frame and fill the hole it leaves
(pip install pillow numpy opencv-python-headless "rembg[cpu]"; rembg downloads its u2net model on first use).
"""
import json
import subprocess
import sys

import cv2
import numpy as np
from PIL import Image
from rembg import new_session

START = 1.0          # second the backdrop starts to grow
END = 4.0            # second it reaches full size and holds there
ZOOM = 1.6           # how large the backdrop ends up (1.6 = 60% closer)
BASE = 0.775         # where the product stands, 0-1 down; the stand and floor below stay put with it
CX = 0.5             # product center, 0-1 across; the backdrop grows from (CX, BASE)
FEATHER = 0.03       # height of the soft join between the still floor and the growing backdrop, 0-1 of the frame


def zoom_at(t):
    """Backdrop scale at time t: 1 before START, easing in and out up to ZOOM at END."""
    p = min(max((t - START) / (END - START), 0), 1)
    return 1 + (ZOOM - 1) * p * p * (3 - 2 * p)


def backdrop(rgb, mask):
    """The frame with the product painted out, so the backdrop can grow without a second, bigger product in it."""
    hole = cv2.dilate((mask > 40).astype(np.uint8) * 255, np.ones((15, 15), np.uint8))
    small = cv2.inpaint(cv2.resize(rgb, (w // 4, h // 4)), cv2.resize(hole, (w // 4, h // 4)), 3, cv2.INPAINT_TELEA)
    filled = cv2.GaussianBlur(cv2.resize(small, (w, h), interpolation=cv2.INTER_CUBIC), (0, 0), 3)
    keep = (hole == 0)[:, :, None]
    return np.where(keep, rgb, filled)


def composite(rgb, t):
    """The backdrop scaled up around the product's base, then the product and the floor on top, unchanged."""
    z = zoom_at(t)
    if z == 1:
        return rgb
    mask = np.asarray(session.predict(Image.fromarray(rgb))[0])
    m = np.float32([[z, 0, (1 - z) * w * CX], [0, z, (1 - z) * h * BASE]])
    back = cv2.warpAffine(backdrop(rgb, mask), m, (w, h), flags=cv2.INTER_CUBIC)
    alpha = np.maximum(mask / 255, floor)[:, :, None]
    return (rgb * alpha + back * (1 - alpha)).round().astype(np.uint8)


def probe(path):
    info = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                                               "stream=width,height,r_frame_rate", "-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)


def render(src, out):
    """Read src frame by frame, composite each one and save to out, keeping the audio."""
    reader = subprocess.Popen(["ffmpeg", "-v", "error", "-i", src, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
                              stdout=subprocess.PIPE)
    writer = subprocess.Popen(["ffmpeg", "-y", "-v", "error",
                               "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", "-r", rate, "-i", "-",
                               "-i", src, "-map", "0:v", "-map", "1:a?", "-c:v", "libx264", "-crf", "18",
                               "-pix_fmt", "yuv420p", "-c:a", "copy", "-shortest", out], stdin=subprocess.PIPE)
    i = 0
    while raw := reader.stdout.read(w * h * 3):
        rgb = np.frombuffer(raw, np.uint8).reshape(h, w, 3)
        writer.stdin.write(composite(rgb, i / fps).tobytes())
        i += 1
    writer.stdin.close()
    if reader.wait() or writer.wait():
        sys.exit("ffmpeg failed")


src, out = sys.argv[1:3]
w, h, rate, fps = probe(src)
floor = np.clip((np.arange(h)[:, None] / h - BASE) / FEATHER + 1, 0, 1).repeat(w, axis=1)
session = new_session("u2net")
render(src, 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