---
title: "How to add scrolling lyrics with Claude (tested prompt and script)"
description: "The whole lyric sheet sits in dim gray over blurred, darkened footage and glides up one line at a time, with the line being sung lit bright white just above the middle. Install one skill and ask Claude Code, or run the Python script we tested yourself."
url: https://www.recharm.com/effects/scrolling-lyrics
---

# How to add scrolling lyrics with Claude

The whole lyric sheet sits in dim gray over blurred, darkened footage and glides up one line at a time, with the line being sung lit bright white just above the middle.

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

## 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

   ```
   Add scrolling lyrics to input.mp4 using the word timings in words.json
   Words on one lyric line: 2
   Text height as a share of the frame width: 0.12
   ```

## 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 scrolling lyrics 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.

| Setting      | Default | What it does                                                    |
| ------------ | ------- | --------------------------------------------------------------- |
| `LINE_WORDS` | `2`     | Words on one lyric line.                                        |
| `SIZE`       | `0.12`  | Text height as a share of the frame width.                      |
| `X`          | `0.08`  | Left edge of the lines, as a share of the frame width.          |
| `Y`          | `0.42`  | Where the sung line's center sits, from 0 (top) to 1 (bottom).  |
| `PITCH`      | `1.35`  | Space between line centers, in text heights.                    |
| `SCROLL`     | `0.4`   | Seconds the sheet takes to scroll up to the next line.          |
| `LIT`        | `0.25`  | Seconds a line takes to brighten or dim.                        |
| `DIM_TEXT`   | `0.4`   | Opacity of the lines not being sung.                            |
| `HOLD`       | `0.8`   | Seconds the last line stays bright after its last word.         |
| `DIM`        | `0.6`   | How much the footage darkens behind the lyrics.                 |
| `BLUR`       | `10`    | Blur of the footage behind the lyrics (Gaussian sigma, pixels). |

## Without the skill

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

```
Add a scrolling lyrics view to input.mp4 (540x960), using the word timings in words.json (a list of {"word", "start", "end"} in seconds). Group the words into lines of 2 words; a line is sung from its first word's start to its last word's end. For the whole clip, blur the footage (Gaussian, sigma 10 pixels) and darken it with a 60% black layer. Show every line at once, left-aligned 43 pixels from the left edge, in white Arial Black (or another heavy bold font) 65 pixels tall, line centers 88 pixels apart. Lines not being sung are white at 40% opacity. The sheet starts with the first line's center 42% down the frame (y = 403); when each later line starts, the sheet scrolls up by one line (88 pixels) over 0.4 seconds with a smoothstep ease, so the sung line always ends up centered at y = 403. A line brightens to fully opaque white over 0.25 seconds from its start and dims back to 40% over the 0.25 seconds before the next line starts; the last line dims over the 0.25 seconds before 0.8 seconds after its last word, after which every line is dim. Draw the text with Python and Pillow (don't use ffmpeg's drawtext filter), put it on the video with ffmpeg, keep the audio and the 6 second length at 30fps, and save it as output.mp4.
```

## Run it without Claude

This is the exact script we tested. Save it as `scrolling-lyrics.py` and run:

```
python3 scrolling-lyrics.py input.mp4 words.json output.mp4
```

```
#!/usr/bin/env python3
"""Scrolling lyrics: the whole lyric sheet sits over blurred footage and scrolls up line by line, the sung line bright white and the rest dim.

Usage: python3 scrolling-lyrics.py input.mp4 words.json output.mp4
words.json is a list of {"word", "start", "end"} in seconds (from an LRC file, or faster-whisper with word_timestamps).
Needs ffmpeg and Pillow (pip install pillow).
"""
import json
import math
import subprocess
import sys
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

LINE_WORDS = 2          # words on one lyric line
SIZE = 0.12             # text height as a share of the frame width
X = 0.08                # left edge of the lines, as a share of the frame width
Y = 0.42                # where the sung line's center sits (0 = top, 1 = bottom)
PITCH = 1.35            # space between line centers, in text heights
SCROLL = 0.4            # seconds the sheet takes to scroll up to the next line
LIT = 0.25              # seconds a line takes to brighten or dim
DIM_TEXT = 0.4          # opacity of the lines not being sung
HOLD = 0.8              # seconds the last line stays bright after its last word
DIM = 0.6               # how much the footage darkens behind the lyrics
BLUR = 10               # blur of the footage behind the lyrics (Gaussian sigma, pixels)
FONTS = ["/System/Library/Fonts/Supplemental/Arial Black.ttf",    # macOS
         "/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 lyric_lines(path):
    """(start, end, text) for each line of LINE_WORDS words."""
    words = json.loads(Path(path).read_text())
    groups = [words[i:i + LINE_WORDS] for i in range(0, len(words), LINE_WORDS)]
    return [(g[0]["start"], g[-1]["end"], " ".join(x["word"] for x in g)) for g in groups]

def line_image(text):
    font = ImageFont.truetype(FONT_FILE, round(w * SIZE))
    img = Image.new("RGBA", (w, round(w * SIZE * 1.6)))
    ImageDraw.Draw(img).text((round(w * X), img.height / 2), text, font=font, anchor="lm", fill="white")
    return img

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

def overlay(t):
    img = Image.new("RGBA", (w, h), (0, 0, 0, round(255 * DIM)))
    pos = sum(ease((t - start) / SCROLL) for start, _, _ in lines[1:])    # how many lines have scrolled past
    for i, (start, end, art) in enumerate(lines):
        until = lines[i + 1][0] if i + 1 < len(lines) else end + HOLD
        lit = min(ease((t - start) / LIT), ease((until - t) / LIT))
        layer = art.copy()
        layer.putalpha(art.getchannel("A").point(lambda a: a * (DIM_TEXT + (1 - DIM_TEXT) * lit)))
        y = h * Y + (i - pos) * w * SIZE * PITCH
        img.alpha_composite(layer, (0, round(y - art.height / 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):
    """Blur src, draw overlay(t) on every frame 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", f"[0:v]gblur=sigma={BLUR}[bg];[bg][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, words, out = sys.argv[1:4]
w, h, rate, fps, duration = probe(src)
lines = [(start, end, line_image(text)) for start, end, text in lyric_lines(words)]
render(src, out)
```

## More music-synced visuals

- [AI voice orb **AI voice orb**](https://www.recharm.com/effects/ai-voice-orb)
- [Audio-reactive blob **Audio-reactive blob**](https://www.recharm.com/effects/audio-reactive-blob)
- [Audio-reactive particles **Audio-reactive particles**](https://www.recharm.com/effects/audio-reactive-particles)
- [Audio-reactive text **Audio-reactive text**](https://www.recharm.com/effects/audio-reactive-text)
- [Audio waveform **Audio waveform**](https://www.recharm.com/effects/audio-waveform)

## 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)
