How to add a frozen text with Claude

Ice creeps across "ICE COLD" from left to right, leaving frosted blue letters with white cracks, little icicles underneath and star glints that flash in turn.

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 frozen text to input.mp4
    Text: ICE COLD

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 frozen text 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
TEXTICE COLDThe headline.
START0.5Second the ice starts creeping in from the left.
FREEZE1.0Seconds the ice takes to cover the whole headline.
TOP, BOTTOM(225, 245, 255), (70, 150, 220)Ice colors at the top and bottom of the letters.
OUTLINE(15, 50, 100)Dark blue rim that keeps the letters readable on light footage.
ICICLES9Icicles hanging off the bottom of the letters.
CRACKS14White cracks through the ice.
TWINKLE1.2Seconds between flashes of each sparkle.

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 frozen text to input.mp4: "ICE COLD" in Arial Black (or another heavy bold font) at an 81 pixel font size (15% of the frame width), centered horizontally 15% of the way down. Make the letters look like ice: a 4 pixel dark blue (15, 50, 100) outline; a fill that runs in a vertical gradient from (225, 245, 255) at the top of the outlined text to (70, 150, 220) at the bottom, mottled by about 8% with random noise blurred by 2 pixels so it looks frosted; and 14 jagged white cracks clipped to the letters (2 pixels wide, 4 segments of about 14 pixels each, each with a thin dark blue line 1 pixel down and right of it). Behind the letters, so their outline stays unbroken, hang 9 icicles spread evenly along the feet of the upright strokes (not under the round bottoms of C and O): light blue (130, 190, 235) downward-pointing triangles with no outline, 6 to 12 pixels wide, starting 6 pixels above the bottom of the outline and reaching 10 to 32 pixels below it. Nothing shows before 0.5 seconds. From 0.5 to 1.5 seconds the ice sweeps in from the left: a soft, ragged edge about 32 pixels wide (6% of the frame width) moves at a steady speed from just left of the text to just past its right edge, revealing the letters behind it, and the letters glow white right at the moving edge. From 1.5 seconds three four-pointed white star glints (up to 49 pixels across, 9% of the frame width, with a soft glow) flash at about (135, 137), (314, 161) and (412, 133) pixels, that is 18%/35%, 60%/70% and 83%/30% across and down the outlined text's box: each one's size follows the positive half of a sine wave with a 1.2 second period, the first peaking at 1.8 seconds and each of the others 0.4 seconds after the one before (1.8, 2.2 and 2.6 seconds, repeating every 1.2 seconds). Use Python with Pillow to draw it and ffmpeg to put it on the video (don't rely on ffmpeg's drawtext filter), keep the audio, and save it as output.mp4.

Run it without Claude

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

python3 frozen-text.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Frozen text: ice creeps across "ICE COLD" from left to right, leaving frosted blue letters with white cracks and glints.

Usage: python3 frozen-text.py input.mp4 output.mp4
Needs ffmpeg, Pillow and numpy (pip install pillow numpy).
"""
import json
import math
import subprocess
import sys
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont

TEXT = "ICE COLD"            # the headline
START = 0.5                  # second the ice starts creeping in from the left
FREEZE = 1.0                 # seconds the ice takes to cover the whole headline
Y = 0.15                     # text center, 0-1 down the frame
SIZE = 0.15                  # font size as a share of the frame width
TOP = (225, 245, 255)        # ice color at the top of the letters
BOTTOM = (70, 150, 220)      # ice color at the bottom of the letters
OUTLINE = (15, 50, 100)      # dark blue rim that keeps the letters readable on light footage
ICICLES = 9                  # icicles hanging off the bottom of the letters
ICICLE_LENGTH = 0.06         # longest icicle as a share of the frame width
ICICLE = (130, 190, 235)     # icicle color
CRACKS = 14                  # number of white cracks through the ice
GLINTS = [(0.18, 0.35), (0.60, 0.70), (0.83, 0.30)]  # sparkle spots, 0-1 across and down the text box
GLINT_SIZE = 0.09            # widest sparkle as a share of the frame width
TWINKLE = 1.2                # seconds between flashes of each sparkle; each one flashes 0.4s after the last
SEED = 5                     # change for a different frost and crack pattern
FONTS = ["/System/Library/Fonts/Supplemental/Arial Black.ttf",    # macOS
         "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
         "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",  # Linux
         "C:/Windows/Fonts/ariblk.ttf"]                           # Windows
FONT_FILE = next((f for f in FONTS if Path(f).exists()), None) or sys.exit("No bold font found: add one to FONTS")


def ice_letters():
    """The finished frozen headline on a transparent frame, plus its bounding box."""
    rng = np.random.default_rng(SEED)
    font = ImageFont.truetype(FONT_FILE, round(w * SIZE))
    shape = Image.new("L", (w, h))
    ImageDraw.Draw(shape).text((w / 2, h * Y), TEXT, font=font, anchor="mm", fill=255)
    rim = Image.new("L", (w, h))
    ImageDraw.Draw(rim).text((w / 2, h * Y), TEXT, font=font, anchor="mm", fill=255, stroke_width=4)
    x0, y0, x1, y1 = rim.getbbox()

    # ice body: a light-to-deep blue gradient, mottled with soft noise so it looks frosted
    ramp = np.linspace(0, 1, y1 - y0)[:, None, None]
    body = np.zeros((h, w, 3))
    body[y0:y1] = np.array(TOP) * (1 - ramp) + np.array(BOTTOM) * ramp
    body *= 1 + 0.08 * soft_noise(rng, 2)[..., None]
    ice = Image.fromarray(body.clip(0, 255).astype(np.uint8)).convert("RGBA")

    # cracks: jagged white lines with a dark edge, kept inside the letters
    cracks = Image.new("RGBA", (w, h))
    draw = ImageDraw.Draw(cracks)
    for _ in range(CRACKS):
        x, y = rng.uniform(x0, x1), rng.uniform(y0, y1)
        angle = rng.uniform(0, 2 * math.pi)
        points = [(x, y)]
        for _ in range(4):
            angle += rng.uniform(-0.8, 0.8)
            x, y = x + math.cos(angle) * w * 0.025, y + math.sin(angle) * w * 0.025
            points.append((x, y))
        draw.line([(px + 1, py + 1) for px, py in points], fill=OUTLINE + (150,), width=1)
        draw.line(points, fill=(255, 255, 255, 255), width=2)
    ice.alpha_composite(cracks)
    ice.putalpha(shape)

    # icicles: hanging from the feet of the upright strokes, tucked behind the letters so their outline stays clean
    letters = Image.new("RGBA", (w, h))
    draw = ImageDraw.Draw(letters)
    rows = np.asarray(shape) > 128
    under = np.nonzero(rows[y1 - 7] & rows[y1 - 4 - round(w * SIZE * 0.3)])[0]
    for x in (rng.choice(part) for part in np.array_split(under, ICICLES)):  # spread along the text
        half, length = rng.uniform(3, 6), rng.uniform(0.3, 1) * w * ICICLE_LENGTH
        draw.polygon([(x - half, y1 - 6), (x + half, y1 - 6), (x, y1 + length)], fill=ICICLE)

    rim_layer = Image.new("RGBA", (w, h), OUTLINE)
    rim_layer.putalpha(rim)
    letters.alpha_composite(rim_layer)
    letters.alpha_composite(ice)
    return letters, (x0, y0, x1, y1)


def soft_noise(rng, blur):
    """Blurred random noise scaled to mean 0 and spread 1, the same size as the frame."""
    n = np.asarray(Image.fromarray(rng.integers(0, 256, (h, w), dtype=np.uint8)).filter(ImageFilter.GaussianBlur(blur)), np.float32)
    return (n - n.mean()) / n.std()


def glint(size):
    """A four-pointed white star with a soft glow."""
    star = Image.new("L", (size, size))
    draw = ImageDraw.Draw(star)
    c, arm, waist = size / 2, size / 2, size / 14
    draw.polygon([(c, c - arm), (c + waist, c - waist), (c + arm, c), (c + waist, c + waist),
                  (c, c + arm), (c - waist, c + waist), (c - arm, c), (c - waist, c - waist)], fill=255)
    draw.ellipse([c - size / 7, c - size / 7, c + size / 7, c + size / 7], fill=255)
    img = Image.new("RGBA", (size, size), (255, 255, 255))
    img.putalpha(Image.fromarray(np.maximum(np.asarray(star), np.asarray(star.filter(ImageFilter.GaussianBlur(3))))))
    return img


def frost_mask(t):
    """How frozen each pixel is at t (0-1): a ragged edge that sweeps left to right."""
    x0, _, x1, _ = box
    front = x0 - edge_width + (x1 - x0 + 2 * edge_width) * (t - START) / FREEZE
    return np.clip((front - np.arange(w)[None, :] + ragged) / edge_width, 0, 1)


def overlay(t):
    if t < START:
        return blank
    img = letters.copy()
    if t < START + FREEZE:
        frozen = frost_mask(t)
        frost = np.asarray(letters.getchannel("A"), np.float32) * frozen * (1 - frozen) * 4  # whitest at the front
        white = Image.new("RGBA", (w, h), (255, 255, 255))
        white.putalpha(Image.fromarray(frost.astype(np.uint8)))
        img.alpha_composite(white)
        img.putalpha(Image.fromarray(np.minimum(np.asarray(letters.getchannel("A")), frozen * 255).astype(np.uint8)))
        return img
    x0, y0, x1, y1 = box
    for i, (gx, gy) in enumerate(GLINTS):
        twinkle = math.sin(2 * math.pi * (t - START - FREEZE - 0.4 * i) / TWINKLE)
        size = round(w * GLINT_SIZE * max(twinkle, 0))
        if size > 4:
            star = sparkle.resize((size, size), Image.LANCZOS)
            img.alpha_composite(star, (round(x0 + gx * (x1 - x0) - size / 2), round(y0 + gy * (y1 - y0) - size / 2)))
    return img


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


def render(src, out):
    """Draw overlay(t) on every frame of src and save to out, keeping the audio."""
    ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-i", src,
                           "-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-r", rate, "-i", "-",
                           "-filter_complex", "[0:v][1:v]overlay=shortest=1,format=yuv420p[v]",
                           "-map", "[v]", "-map", "0:a?", "-c:v", "libx264", "-crf", "18", "-c:a", "copy", out],
                          stdin=subprocess.PIPE)
    for i in range(math.ceil(duration * fps)):
        ff.stdin.write(overlay(i / fps).tobytes())
    ff.stdin.close()
    if ff.wait():
        sys.exit("ffmpeg failed")


src, out = sys.argv[1:3]
w, h, rate, fps, duration = probe(src)
letters, box = ice_letters()
edge_width = w * 0.06
ragged = soft_noise(np.random.default_rng(SEED), 4) * edge_width / 2
sparkle = glint(round(w * GLINT_SIZE))
blank = Image.new("RGBA", (w, h))
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