How to add an AI chat reply animation with Claude

A chat box rises in at the bottom and types "best serum for dull skin?", the question flies up as a blue bubble, three dots bounce, and the AI's answer streams into a white card word by word.

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 an AI chat reply animation to input.mp4
    Question: best serum for dull skin?
    Answer: Try a daily vitamin C serum. It brightens dull skin and evens out tone in about two weeks.

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 an ai chat reply animation 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
QUESTIONbest serum for dull skin?The question typed into the chat box.
ANSWERTry a daily vitamin C serum. It brightens dull skin and evens out tone in about two weeks.The reply that streams in.
START0.3Second the chat box pops in.
TYPE_AT0.6Second the typing starts.
CPS15Characters typed per second.
SEND_AT2.5Second the question is sent and moves up as a bubble.
REPLY_AT3.3Second the answer starts streaming, after the typing dots.
WPS8Answer words per second.
BOX_Y0.87Chat box center, as a share of the frame height.

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 an AI chat reply animation to input.mp4. At 0.3 seconds a pure white (255, 255, 255) pill-shaped chat box rises in from below and fades in over 0.3 seconds: 88% of the frame width wide, 13% of the frame width tall, centered horizontally with its center 87% down the frame, with a soft dark drop shadow, gray (140, 144, 150) placeholder text "Ask anything" at its left and a blue (10, 132, 255) circular send button with a white up arrow at its right end. From 0.6 seconds "best serum for dull skin?" types into it at 15 characters per second in dark gray (32, 33, 36) regular Arial at 5% of the frame width, with a blue (10, 132, 255) blinking cursor after the last letter. At 2.5 seconds the box clears back to the placeholder and the question flies up over 0.3 seconds into a blue (10, 132, 255) rounded bubble with white text, right-aligned 6% from the right edge with its top 10% down the frame. From 2.8 to 3.3 seconds a small white bubble with three bouncing gray dots sits below it on the left, 6% from the left edge. At 3.3 seconds it becomes a white rounded card 80% of the frame width wide, and the answer "Try a daily vitamin C serum. It brightens dull skin and evens out tone in about two weeks." streams into it at 8 words per second in the same dark gray Arial, wrapped to the card, the card growing a line at a time. Everything stays to the end. 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 ai-chat-typing.py and run:

python3 ai-chat-typing.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""AI chat reply: a question is typed into a chat box, sent as a blue bubble, and the answer streams in word by word.

Usage: python3 ai-chat-typing.py input.mp4 output.mp4
Needs ffmpeg and Pillow (pip install pillow).
"""
import json
import math
import subprocess
import sys
from pathlib import Path

from PIL import Image, ImageDraw, ImageFilter, ImageFont

QUESTION = "best serum for dull skin?"
ANSWER = "Try a daily vitamin C serum. It brightens dull skin and evens out tone in about two weeks."
START = 0.3          # second the chat box pops in
TYPE_AT = 0.6        # second the typing starts
CPS = 15             # characters typed per second
SEND_AT = 2.5        # second the question is sent and moves up as a bubble
REPLY_AT = 3.3       # second the answer starts streaming (three typing dots before it)
WPS = 8              # answer words per second
MARGIN = 0.06        # side margin as a share of the frame width
TOP = 0.10           # top of the question bubble, as a share of the frame height
BOX_Y = 0.87         # chat box center, as a share of the frame height
TEXT_SIZE = 0.05     # text height as a share of the frame width
INK = (32, 33, 36)       # typed text and the answer
GREY = (140, 144, 150)   # placeholder and typing dots
BLUE = (10, 132, 255)    # question bubble, send button and cursor
FONTS = ["/System/Library/Fonts/Supplemental/Arial.ttf",          # macOS
         "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",       # Linux
         "C:/Windows/Fonts/arial.ttf"]                            # Windows
FONT_FILE = next((f for f in FONTS if Path(f).exists()), None) or sys.exit("No font found: add one to FONTS")


def card(cw, ch, radius, fill):
    """A rounded card with a soft drop shadow, on a transparent canvas with room for the shadow."""
    img = Image.new("RGBA", (cw + 2 * pad, ch + 2 * pad))
    shadow = Image.new("RGBA", img.size)
    ImageDraw.Draw(shadow).rounded_rectangle([pad, pad + 5, pad + cw, pad + ch + 5], radius=radius, fill=(0, 0, 0, 100))
    img.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(pad / 3)))
    ImageDraw.Draw(img).rounded_rectangle([pad, pad, pad + cw, pad + ch], radius=radius, fill=fill)
    return img


def wrap(text, width):
    lines = [""]
    for word in text.split():
        trial = (lines[-1] + " " + word).strip()
        if font.getlength(trial) <= width or not lines[-1]:
            lines[-1] = trial
        else:
            lines.append(word)
    return lines


def chat_box(t):
    """The input bar: typed text and a cursor until it's sent, a placeholder before and after."""
    img = card(box_w, box_h, box_h // 2, "white")
    draw = ImageDraw.Draw(img)
    x, cy = pad + box_h * 0.45, pad + box_h / 2
    typed = QUESTION[:max(0, int((t - TYPE_AT) * CPS))] if t < SEND_AT else ""
    draw.text((x, cy), typed or "Ask anything", font=font, anchor="lm", fill=INK if typed else GREY)
    if TYPE_AT <= t < SEND_AT and int(t * 2.5) % 2 == 0:
        cx = x + font.getlength(typed) + 3
        draw.rectangle([cx, cy - font.size * 0.6, cx + max(2, w // 270), cy + font.size * 0.6], fill=BLUE)
    r = box_h * 0.32  # send button: a blue circle with a white up arrow
    bx = pad + box_w - box_h * 0.5
    draw.ellipse([bx - r, cy - r, bx + r, cy + r], fill=BLUE)
    draw.line([bx, cy + r * 0.5, bx, cy - r * 0.5], fill="white", width=max(2, w // 180))
    draw.line([bx - r * 0.4, cy - r * 0.1, bx, cy - r * 0.55, bx + r * 0.4, cy - r * 0.1], fill="white", width=max(2, w // 180))
    return img


def question_bubble():
    bw = round(font.getlength(QUESTION) + 2 * line_h * 0.7)
    img = card(bw, round(line_h * 1.9), round(line_h * 0.8), BLUE)
    ImageDraw.Draw(img).text((pad + bw / 2, pad + line_h * 0.95), QUESTION, font=font, anchor="mm", fill="white")
    return img


def typing_dots(t):
    img = card(round(line_h * 3.2), round(line_h * 1.9), round(line_h * 0.8), "white")
    draw = ImageDraw.Draw(img)
    for i in range(3):
        bounce = max(0, math.sin((t * 6 - i * 0.8) * math.pi)) * line_h * 0.18
        cx, cy, r = pad + line_h * (0.9 + i * 0.7), pad + line_h * 0.95 - bounce, line_h * 0.16
        draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=GREY)
    return img


def answer_card(t):
    """The answer so far: whole words appear WPS per second, and the card grows a line at a time."""
    left = int((t - REPLY_AT) * WPS) + 1
    shown = []
    for line in answer_lines:
        words = line.split()[:max(0, left)]
        left -= len(line.split())
        if words:
            shown.append(" ".join(words))
    img = card(card_w, round(line_h * (len(shown) + 0.9)), round(line_h * 0.8), "white")
    draw = ImageDraw.Draw(img)
    for i, line in enumerate(shown):
        draw.text((pad + line_h * 0.7, pad + line_h * (0.95 + i)), line, font=font, anchor="lm", fill=INK)
    return img


def fade(img, k):
    img = img.copy()
    img.putalpha(img.getchannel("A").point(lambda a: round(a * k)))
    return img


def ease_out(p):
    p = min(max(p, 0), 1)
    return 1 - (1 - p) ** 3


def overlay(t):
    img = Image.new("RGBA", (w, h))
    if t < START:
        return img
    p = ease_out((t - START) / 0.3)  # the chat box rises in from below
    img.alpha_composite(fade(chat_box(t), p), (round(w * MARGIN) - pad, round(h * BOX_Y - box_h / 2 + (1 - p) * h * 0.05) - pad))
    if t >= SEND_AT:
        p = ease_out((t - SEND_AT) / 0.3)  # the question flies up from the chat box to the top right
        y = round(h * TOP + (1 - p) * h * (BOX_Y - TOP - 0.05))
        img.alpha_composite(fade(bubble_img, min(1, p * 2)), (w - round(w * MARGIN) - bubble_img.width + pad, y - pad))
    reply_y = round(h * TOP + bubble_img.height - 2 * pad + line_h * 0.6) - pad
    if SEND_AT + 0.3 <= t < REPLY_AT:
        img.alpha_composite(typing_dots(t), (round(w * MARGIN) - pad, reply_y))
    elif t >= REPLY_AT:
        img.alpha_composite(answer_card(t), (round(w * MARGIN) - pad, reply_y))
    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)
pad = round(w * 0.04)  # room around each card for its shadow
font = ImageFont.truetype(FONT_FILE, round(w * TEXT_SIZE))
line_h = round(font.size * 1.45)
box_w, box_h = round(w * (1 - 2 * MARGIN)), round(w * 0.13)
card_w = round(w * 0.8)
answer_lines = wrap(ANSWER, card_w - 1.4 * line_h)
bubble_img = question_bubble()
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