How to add a heat haze with Claude

From 1 second the air starts to shimmer: thin wavy ripples climb up the frame, making the edges of the bottle and the acrylic block wobble most near the bottom while the top of the shot stays steady.

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 heat haze to input.mp4
    Second the haze starts: 1.0
    Seconds it takes to build up to full strength; it stays after: 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.

Add a heat haze 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 haze starts.
EASE0.5Seconds it takes to build up to full strength; it stays after.
AMOUNT11How far the picture bends sideways at the bottom of the frame, in pixels.
CALM_TOP0.15Top share of the frame left still; the haze grows from there to the bottom.
CELL(36, 14)Size of the ripples (width, height) in pixels.
RISE90Pixels per second the ripples climb up the frame.
SEED7Random seed for the ripple pattern.

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 heat haze to input.mp4. Leave the first 1.0 seconds untouched, then ease the haze in over 0.5 seconds (smoothstep, k = u²(3 − 2u) with u = (t − 1.0) / 0.5 clamped to 0-1) and keep it at full strength to the end. Build two smooth noise layers, each the frame width wide and twice the frame height tall: random values from -1 to 1 on a coarse grid with one value every 36 pixels across and every 14 pixels down, scaled up to full size with cubic interpolation (use a fixed random seed). At time t (s = t − 1.0) read layer A shifted up by 90 × s pixels and layer B shifted up by 153 × s pixels, wrapping around, so the ripples climb at two speeds. The haze weight down the frame is 0 in the top 15% and grows to 1 at the bottom edge as ((y/height − 0.15) / 0.85)^0.8. Each output pixel samples the input at x + dx, y + dy, with dx = k × 11 × weight × (0.65 × A + 0.35 × B) and dy = k × 11 × 0.3 × weight × (A − B), using bilinear sampling (OpenCV remap) and mirrored edges. Use Python with numpy and OpenCV for the pixels and ffmpeg to decode and encode, 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 heat-haze.py and run:

python3 heat-haze.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Heat haze: from 1 second the air shimmers, wobbly ripples rising up the frame and bending the picture most near the bottom.

Usage: python3 heat-haze.py input.mp4 output.mp4
Needs ffmpeg, numpy and OpenCV (pip install numpy opencv-python-headless).
"""
import json
import subprocess
import sys

import cv2
import numpy as np

START = 1.0       # second the haze starts
EASE = 0.5        # seconds it takes to build up to full strength (it stays after)
FPS = 30          # output frame rate
AMOUNT = 11       # how far the picture bends sideways at the bottom of the frame, in pixels
CALM_TOP = 0.15   # top share of the frame left still; the haze grows from there to the bottom
CELL = (36, 14)   # size of the ripples (width, height) in pixels: short, wide cells make wavy sideways bands
RISE = 90         # pixels per second the ripples climb up the frame
SEED = 7          # change for a different (but repeatable) ripple pattern


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


def frames(path):
    """Decode a clip as RGB frames at FPS."""
    ff = subprocess.Popen(["ffmpeg", "-v", "error", "-i", path, "-vf", f"fps={FPS}",
                           "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
    while True:
        data = ff.stdout.read(w * h * 3)
        if len(data) < w * h * 3:
            return
        yield np.frombuffer(data, np.uint8).reshape(h, w, 3)


def noise(rng, tall):
    """Smooth random values from -1 to 1, w wide and `tall` high: a coarse random grid scaled up with cubic smoothing."""
    grid = rng.uniform(-1, 1, (tall // CELL[1] + 2, w // CELL[0] + 2)).astype(np.float32)
    big = cv2.resize(grid, (w + 2 * CELL[0], tall + 2 * CELL[1]), interpolation=cv2.INTER_CUBIC)
    return big[CELL[1]:CELL[1] + tall, CELL[0]:CELL[0] + w]


def haze(frame, t, k):
    """Bend one frame at strength k (0-1): two ripple layers climb at different speeds so the shimmer boils
    rather than slides, and each pixel is pushed sideways (and a little up and down) by their sum."""
    s = t - START
    rows = np.arange(h)
    a = layer_a[(rows + round(RISE * s)) % len(layer_a)]
    b = layer_b[(rows + round(RISE * 1.7 * s)) % len(layer_b)]
    dx = k * AMOUNT * weight * (0.65 * a + 0.35 * b)
    dy = k * AMOUNT * 0.3 * weight * (a - b)
    return cv2.remap(frame, grid_x + dx, grid_y + dy, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)


src, out = sys.argv[1:3]
w, h, duration = probe(src)
rng = np.random.default_rng(SEED)
layer_a, layer_b = noise(rng, 2 * h), noise(rng, 2 * h)
grid_x, grid_y = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32))
weight = (np.clip((grid_y / h - CALM_TOP) / (1 - CALM_TOP), 0, 1) ** 0.8).astype(np.float32)

enc = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}",
                        "-r", str(FPS), "-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)
for i, frame in enumerate(frames(src)):
    t = i / FPS
    u = min(max((t - START) / EASE, 0), 1)
    k = u * u * (3 - 2 * u)
    enc.stdin.write((haze(frame, t, k) if k > 0 else frame).tobytes())
enc.stdin.close()
if enc.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