---
title: "How to add an earth zoom transition with Claude (tested prompt and script)"
description: "Clip A shrinks into a patch of ground as the view pulls back to a whole planet hanging in starry space, the globe turns, and the view dives down into clip B at a different spot on it. Install one skill and ask Claude Code, or run the Python script we tested yourself."
url: https://www.recharm.com/effects/earth-zoom-transition
---

# How to add an earth zoom transition with Claude

Clip A shrinks into a patch of ground as the view pulls back to a whole planet hanging in starry space, the globe turns, and the view dives down into clip B at a different spot on it.

Made by the script on this page, from our own test clips.

## Get this effect

1. ### Switch to Code in your [Claude Desktop App](https://claude.com/download)

2. ### Install the skill (once)

   ```
   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 an earth zoom transition
   Length of the transition in seconds: 2.4
   Seconds into the transition the pull-back ends and the planet stops turning; the dive into B takes the rest: 1.0, 1.4
   ```

## 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 earth zoom 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`                 | `2.4`                | Length of the transition in seconds.                                                                         |
| `OUT_END, TURN_END` | `1.0, 1.4`           | Seconds into the transition the pull-back ends and the planet stops turning; the dive into B takes the rest. |
| `PLANET`            | `0.42`               | Planet radius at its smallest, as a share of the frame width.                                                |
| `CLIP_AT_PLANET`    | `0.02`               | Clip size when the whole planet shows. Smaller is a deeper zoom.                                             |
| `PLACE_A, PLACE_B`  | `(8, -30), (18, 45)` | Latitude and longitude of each clip on the planet.                                                           |
| `SEED`              | `7`                  | Changes the shape of the continents.                                                                         |

## Without the skill

Don’t want to install the skill? Paste this prompt into Claude Code and Claude writes the script itself.

```
Join clipA.mp4 and clipB.mp4 (both 540x960) with a 2.4 second Earth zoom transition, using Python with numpy and Pillow for the frames and ffmpeg to decode and encode. The output is 9.6 seconds at 30fps: A untouched until 3.6s, the transition from 3.6s to 6.0s (A's last 2.4 seconds and B's first 2.4 seconds play inside it), then the rest of B untouched. Draw a planet procedurally (no map images): an orthographic sphere centered in the frame, its surface colored from 3D fractal value noise (land greens and browns above sea level, blues below, white polar caps, soft white clouds), lit from the top left, with a blue (90, 150, 245) atmosphere glow round the rim and a near-black (4, 4, 5) background with faint scattered stars. Place clip A at latitude 8, longitude -30 and clip B at latitude 18, longitude 45 on the planet. The camera always looks at a point on the planet with the place's clip lying flat on the ground there, scaled to k of its full size, with edges feathered over 12% of its smaller side once k drops below 1. From 0 to 1.0s into the transition, look at A's place and zoom out with k = 0.02^smoothstep(u / 1.0), where u is seconds into the transition and smoothstep(p) = 3p^2 - 2p^3, so at k = 0.02 the planet's radius is 42% of the frame width (227 px) and A is a tiny patch at its center. From 1.0s to 1.4s, turn the planet so the view swings from A's place to B's place (smoothstep), at k = 0.02. From 1.4s to 2.4s, dive into B's place with k = 0.02^(1 - smoothstep((u - 1.4) / 1.0)), landing on B full frame at 2.4s. Add finer noise octaves as you zoom in so the ground keeps detail. Crossfade the audio over the same 2.4 seconds, keep the size, and save it as output.mp4.
```

## Run it without Claude

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

```
python3 earth-zoom-transition.py clipA.mp4 clipB.mp4 output.mp4
```

```
#!/usr/bin/env python3
"""Earth zoom transition: clip A pulls back off the ground to a whole planet in space, the planet turns, and the view dives down into clip B somewhere else.

Usage: python3 earth-zoom-transition.py clipA.mp4 clipB.mp4 output.mp4  (both clips need an audio track)
Needs ffmpeg, numpy and Pillow (pip install numpy pillow). The planet is drawn from noise, so no map images are needed.
"""
import json
import subprocess
import sys

import numpy as np
from PIL import Image

D = 2.4                  # transition length in seconds
OUT_END = 1.0            # seconds A takes to pull back to the whole planet
TURN_END = 1.4           # second (into the transition) the planet stops turning and the dive into B starts
PLANET = 0.42            # planet radius at its smallest, as a share of the frame width
CLIP_AT_PLANET = 0.02    # clip size at that moment, as a share of its full size (0.02 = 50x zoom)
PLACE_A = (8, -30)       # latitude, longitude of clip A on the planet (both on land for SEED)
PLACE_B = (18, 45)       # latitude, longitude of clip B
SEED = 7                 # changes the planet's continents
FPS = 30                 # output frame rate
LAND_HEIGHTS = [0, 0.04, 0.1, 0.17, 0.24]   # land color by height above the sea, from coast to rock
LAND_COLORS = np.array([[95, 125, 70], [60, 105, 50], [115, 115, 65], [135, 110, 80], [160, 155, 145]])
SEA_DEPTHS = [-0.2, -0.04, 0]                # sea color by depth, from deep to shallow
SEA_COLORS = np.array([[8, 30, 80], [14, 55, 110], [30, 105, 140]])

def probe(path):
    info = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                                               "stream=width,height", "-of", "json", path]))
    return info["streams"][0]["width"], info["streams"][0]["height"]

def frames(path):
    """Decode a clip as RGB frames at FPS, fitted to w x h."""
    raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", path, "-vf",
                                   f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps={FPS}",
                                   "-f", "rawvideo", "-pix_fmt", "rgb24", "-"])
    return np.frombuffer(raw, np.uint8).reshape(-1, h, w, 3)

def noise(p):
    """Smooth random values (0-1) at 3D points p, from a lattice of random numbers blended between corners."""
    i = np.floor(p).astype(np.int64)
    f = p - i
    f = f * f * (3 - 2 * f)
    out = 0
    for dx in (0, 1):
        for dy in (0, 1):
            for dz in (0, 1):
                v = LATTICE[(PERM[(PERM[(i[..., 0] + dx) & 255] + i[..., 1] + dy) & 255] + i[..., 2] + dz) & 255]
                out = out + v * np.where(dx, f[..., 0], 1 - f[..., 0]) * np.where(dy, f[..., 1], 1 - f[..., 1]) \
                    * np.where(dz, f[..., 2], 1 - f[..., 2])
    return out

def fbm(p, base, octaves):
    """Noise at ever finer scales added together; octaves can be fractional, so detail fades in as you zoom."""
    total = np.zeros(p.shape[:-1])
    for o in range(int(np.ceil(octaves))):
        weight = min(1.0, octaves - o) * 0.5 ** (o + 1)
        total += weight * (noise(p * base * 2 ** o + 17.3 * o) - 0.5)
    return total

def direction(lat, lon):
    la, lo = np.radians(lat), np.radians(lon)
    return np.array([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)])

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

def camera(u):
    """Where the view looks (a point on the planet) and how big clip-sized ground looks (1 = full frame) at u seconds in."""
    low = np.log(CLIP_AT_PLANET)
    if u < OUT_END:
        return PLACE_A_DIR, np.exp(low * smooth(u / OUT_END))
    if u < TURN_END:
        s = smooth((u - OUT_END) / (TURN_END - OUT_END))
        look = (1 - s) * PLACE_A_DIR + s * PLACE_B_DIR
        return look / np.linalg.norm(look), CLIP_AT_PLANET
    return PLACE_B_DIR, np.exp(low * (1 - smooth((u - TURN_END) / (D - TURN_END))))

def planet(look, k):
    """The planet, lit from the top left, with an atmosphere glow and stars behind, at half size (it is soft anyway)."""
    R = k * PLANET * w / CLIP_AT_PLANET / 2      # planet radius in half-size pixels
    x = (xs - hw / 2) / R
    y = (hh / 2 - ys) / R
    r2 = x * x + y * y
    east = np.cross([0, 0, 1], look)
    east /= np.linalg.norm(east)
    north = np.cross(look, east)
    img = np.broadcast_to(STARS, (hh, hw, 3)).copy()
    disk = r2 < 1
    if k > 0.6:                                   # the clip still covers the middle: only the ring around it shows
        disk &= (np.abs(x) > k * w / 4 / R * 0.9) | (np.abs(y) > k * h / 4 / R * 0.9)
    z = np.sqrt(np.clip(1 - r2[disk], 0, 1))
    p = x[disk, None] * east + y[disk, None] * north + z[:, None] * look
    detail = np.log2(max(R, 2) / 3)               # octaves down to about 3 pixels
    height = fbm(p, 1.6, detail)
    land = np.stack([np.interp(height, LAND_HEIGHTS, LAND_COLORS[:, c]) for c in range(3)], -1)
    sea = np.stack([np.interp(height, SEA_DEPTHS, SEA_COLORS[:, c]) for c in range(3)], -1)
    ground = np.where((height > 0)[:, None], land, sea)
    ground = np.where((np.abs(p[:, 2]) + 0.4 * height > 0.92)[:, None], 220, ground)
    cloud = np.clip((fbm(p + 40, 3, min(6, detail)) - 0.08) * 7, 0, 0.8)[:, None]
    ground = ground * (1 - cloud) + 228 * cloud
    light = 0.3 + 0.8 * np.clip(-0.45 * x[disk] + 0.5 * y[disk] + 0.74 * z, 0, 1)[:, None]
    haze = (1 - z[:, None]) ** 2.5
    img[disk] = ground * light * (1 - haze) + np.array([110, 170, 240]) * haze
    rim = np.clip(np.sqrt(r2) - 1, 0, None)
    glow = (np.exp(-rim * 18) * (r2 >= 1))[..., None]
    img = img * (1 - glow) + np.array([90, 150, 245]) * glow
    return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).resize((w, h), Image.BICUBIC)

def place_clip(img, clip, look, k, place):
    """Lay the clip on the ground at its place, k of its full size, with soft edges once it is smaller than the frame."""
    if np.dot(look, place) < 0.3:
        return
    east = np.cross([0, 0, 1], look)
    east /= np.linalg.norm(east)
    north = np.cross(look, east)
    R = k * PLANET * w / CLIP_AT_PLANET
    cx, cy = w / 2 + np.dot(place, east) * R, h / 2 - np.dot(place, north) * R
    cw, ch = max(2, round(w * k)), max(2, round(h * k))
    small = Image.fromarray(clip).resize((cw, ch), Image.LANCZOS)
    feather = min(cw, ch) * 0.12 * smooth((1 - k) / 0.4)
    gx = np.minimum(np.arange(cw) + 0.5, cw - np.arange(cw) - 0.5)
    gy = np.minimum(np.arange(ch) + 0.5, ch - np.arange(ch) - 0.5)
    f = max(feather, 1e-6)
    edge = np.clip(gy[:, None] / f, 0, 1) * np.clip(gx[None, :] / f, 0, 1)
    mask = Image.fromarray((edge * edge * (3 - 2 * edge) * 255).astype(np.uint8))
    img.paste(small, (round(cx - cw / 2), round(cy - ch / 2)), mask)

A, B, out = sys.argv[1:4]
w, h = probe(A)
hw, hh = w // 2, h // 2
ys, xs = np.mgrid[0:hh, 0:hw].astype(np.float64)
rng = np.random.default_rng(SEED)
PERM = rng.permutation(256)
LATTICE = rng.random(256)
STARS = np.where(rng.random((hh, hw, 1)) < 0.004, rng.uniform(60, 200, (hh, hw, 1)), 4.0) * [1, 1, 1.2]
PLACE_A_DIR, PLACE_B_DIR = direction(*PLACE_A), direction(*PLACE_B)

a, b = frames(A), frames(B)
start = len(a) - round(D * FPS)
enc = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}",
                        "-r", str(FPS), "-i", "-", "-i", A, "-i", B, "-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)
for i in range(start + len(b)):
    if i < start:
        frame = a[i]
    elif i >= len(a):
        frame = b[i - start]
    else:
        u = (i - start) / FPS
        look, k = camera(u)
        img = planet(look, k)
        place_clip(img, a[i], look, k, PLACE_A_DIR)
        place_clip(img, b[i - start], look, k, PLACE_B_DIR)
        frame = np.asarray(img)
    enc.stdin.write(np.ascontiguousarray(frame).tobytes())
enc.stdin.close()
if enc.wait():
    sys.exit("ffmpeg failed")
```

## More transitions

- [Barn door transition **Barn door transition**](https://www.recharm.com/effects/barn-door)
- [Blinds transition **Blinds transition**](https://www.recharm.com/effects/blinds-transition)
- [Block dissolve **Block dissolve**](https://www.recharm.com/effects/block-dissolve)
- [Blur transition **Blur transition**](https://www.recharm.com/effects/blur-transition)
- [Bounce transition **Bounce transition**](https://www.recharm.com/effects/bounce-transition)

## 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](https://www.recharm.com/get-started)
