Learn · Developers

How to make rolling credits for a video with Python

Contents

Rolling credits are the list of names that scrolls up the screen at the end of a film. Short films, documentaries, event recaps and game trailers all end with one.

Building the roll by hand is slow. Every name is typed into a title tool, and one late change means lining the columns up again. The scroll also has to move at the right speed, or the text stutters on its way up.

This article gives you a Python script that turns a CSV of names into rolling credits. It renders with Shotstack’s video editing API. We measured the scroll on every frame of the rendered video.

TL;DR

  • Keep the credits in a CSV with three columns: section, role and name.
  • The script lays the names out as one HTML page: roles on the left, names on the right, a heading for each section.
  • One HTML5 clip scrolls the page from below the frame to above it.
  • The roll moves a whole number of pixels on every frame. The script works out the clip length from that.
  • In our render, the roll moved exactly 4 pixels on each of the 670 frames we measured.

What makes rolling credits look smooth?

A video shows the roll in a new position on every frame. The roll looks smooth when it moves the same distance each time. Video trainer Larry Jordan explains why in his guide to credit rolls: “we can move up 4 lines per frame, but we can’t move 3.5 or 7.3, because video can’t display partial pixels.”

So you don’t choose how long the credits last. You choose a speed in whole pixels per frame, and the length follows from the number of lines.

Speed at 25 fpsPixels a secondTime for a line to cross a 1080-pixel frame
2 pixels a frame5021.6 s
3 pixels a frame7514.4 s
4 pixels a frame10010.8 s
5 pixels a frame1258.6 s

The example uses 4 pixels a frame. Its 22 lines take 30 seconds to roll through.

The credits file

The script reads credits.csv. Each row is one credit, in the order it appears on screen. These are sample names for a made-up short film:

csv
section,role,name
Cast,Mara,Elena Whitlock
Cast,Tobias,Marcus Adeyemi
Cast,The Ferryman,Henrik Solberg
Cast,Ines,Priya Raman
Cast,Young Mara,Lottie Brennan
Crew,Directed by,Sofia Lindqvist
Crew,Written by,Daniel Okoro
Crew,Produced by,Hannah Voss
Crew,Director of photography,Kenji Watanabe
Crew,Edited by,Amira Haddad
Crew,Production design,Lucas Ferreira
Crew,Costume design,Niamh Gallagher
Crew,Original music,Theo Marchetti
Crew,Sound design,Yara Castillo
Crew,Colorist,Jonas Ekström
Special thanks,,The people of Port Ellery
Special thanks,,Harbor Lane Bakery
Special thanks,,Rosa and Emil Lindqvist

A new value in the section column starts a new heading. A row with no role, like the three under “Special thanks”, is centered on its own line.

Make rolling credits with Python

The script uses only the Python standard library. Save it next to credits.csv. Set SHOTSTACK_API_KEY to the API key from your Shotstack dashboard, then run it with Python 3.

Python
import csv
import html
import json
import math
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.shotstack.io/edit/v1/render"
CSV_FILE = "credits.csv"  # columns: section, role, name
TITLE = "The Long Way Home"
WIDTH, HEIGHT, FPS = 1920, 1080, 25
STEP = 4  # pixels the roll moves on every frame: keep it a whole number
HEIGHTS = {"title": 260, "head": 170, "row": 64}  # pixels each kind of line takes

CSS = """html,body{margin:0;width:var(--w);height:var(--h);overflow:hidden;
background:transparent;color:#fff;font-family:Georgia,serif}
#roll{position:absolute;left:0;top:var(--h);width:var(--w)}
.title{height:var(--title);line-height:var(--title);text-align:center;font-size:104px}
.head{height:var(--head);padding-top:90px;box-sizing:border-box;text-align:center;
font:26px/calc(var(--head) - 90px) Arial,sans-serif;letter-spacing:10px;
text-transform:uppercase;color:#9a9a9a}
.row{display:flex;height:var(--row);line-height:var(--row);font-size:40px;
white-space:nowrap}
.role{width:calc(var(--w) / 2 - 40px);text-align:right;font-family:Arial,sans-serif;
font-size:24px;letter-spacing:3px;text-transform:uppercase;color:#9a9a9a}
.name{margin-left:80px}
.solo{width:var(--w);text-align:center}"""


def load(path):
    """Read the CSV into lines: (kind, role, name)."""
    lines, section = [("title", "", TITLE)], None
    with open(path, newline="", encoding="utf-8") as f:
        for number, row in enumerate(csv.DictReader(f), start=2):
            if not (row.get("name") or "").strip():
                sys.exit(f"{path} line {number}: name is empty")
            if row["section"] != section:
                section = row["section"]
                lines.append(("head", "", section))
            lines.append(("row", row["role"].strip(), row["name"].strip()))
    return lines


def page(lines):
    """Return the HTML for the roll and its height in pixels."""
    parts = []
    for kind, role, name in lines:
        name = html.escape(name)
        if kind != "row":
            parts.append(f'<div class="{kind}">{name}</div>')
        elif role:
            role = html.escape(role)
            parts.append(
                f'<div class="row"><div class="role">{role}</div>'
                f'<div class="name">{name}</div></div>'
            )
        else:
            parts.append(f'<div class="row"><div class="solo">{name}</div></div>')
    height = sum(HEIGHTS[kind] for kind, _, _ in lines)
    return f'<div id="roll">{"".join(parts)}</div>', height


def build_edit(lines):
    markup, height = page(lines)
    frames = math.ceil((HEIGHT + height) / STEP)  # until the last line has left
    length = frames / FPS
    js = f"gsap.to('#roll', {{y: -{frames * STEP}, duration: {length}, ease: 'none'}});"
    sizes = {"w": WIDTH, "h": HEIGHT, **HEIGHTS}
    root = ":root{" + "".join(f"--{k}:{v}px;" for k, v in sizes.items()) + "}"
    asset = {"type": "html5", "html": markup, "css": root + CSS, "js": js}
    clip = {"asset": asset, "start": 0, "length": length}
    return {
        "timeline": {"background": "#000000", "tracks": [{"clips": [clip]}]},
        "output": {
            "format": "mp4",
            "size": {"width": WIDTH, "height": HEIGHT},
            "fps": FPS,
        },
    }


def call(url, body=None):
    data = json.dumps(body).encode() if body else None
    headers = {
        "x-api-key": os.environ["SHOTSTACK_API_KEY"],
        "Content-Type": "application/json",
    }
    try:
        request = urllib.request.Request(url, data, headers)
        with urllib.request.urlopen(request) as response:
            return json.load(response)["response"]
    except urllib.error.HTTPError as error:
        sys.exit(f"HTTP {error.code}: {error.read().decode()[:300]}")


if __name__ == "__main__":
    lines = load(CSV_FILE)
    edit = build_edit(lines)
    length = edit["timeline"]["tracks"][0]["clips"][0]["length"]
    if "--dry-run" in sys.argv:
        print(json.dumps(edit, indent=2))
        sys.exit()
    if not os.environ.get("SHOTSTACK_API_KEY"):
        sys.exit("Set SHOTSTACK_API_KEY first.")
    print(f"{len(lines)} lines, {length}s at {STEP * FPS} pixels a second")
    render_id = call(API, edit)["id"]
    print("Render queued:", render_id)
    while True:
        time.sleep(5)
        status = call(f"{API}/{render_id}")
        if status["status"] == "done":
            print(status["url"])
            break
        if status["status"] == "failed":
            sys.exit(f"Render failed: {status.get('error')}")

Run python rolling_credits.py. It prints the number of lines and the length of the roll, then the URL of the rendered video:

Text
22 lines, 30.04s at 100 pixels a second
Render queued: 61c2ff4c-f43b-4c8b-9c7e-746f2e3b481c
https://shotstack-api-v1-output.s3-ap-southeast-2.amazonaws.com/35tqpmb0ya/61c2ff4c-f43b-4c8b-9c7e-746f2e3b481c.mp4

This is the rendered video. The title rises from the bottom of the frame, then the cast, the crew and the thanks follow at the same speed:

Run python rolling_credits.py --dry-run to print the edit without rendering. This is the clip it builds, with the long html and css strings cut short:

JSON
{
  "asset": {
    "type": "html5",
    "html": "<div id=\"roll\"><div class=\"title\">The Long Way Home</div><di...",
    "css": ":root{--w:1920px;--h:1080px;--title:260px;--head:170...",
    "js": "gsap.to('#roll', {y: -3004, duration: 30.04, ease: 'none'});"
  },
  "start": 0,
  "length": 30.04
}

How the script works

  • load() reads the CSV into a list of lines. It adds the title first and a heading each time the section changes.
  • page() turns the lines into HTML. Each kind of line has a fixed height in HEIGHTS, so the script knows the height of the whole roll without opening a browser.
  • The page starts just below the frame. One GSAP tween moves it up at a constant speed until the last line has left the top.
  • build_edit() rounds the distance up to a whole number of steps. The clip length is that number of frames, so the roll moves exactly STEP pixels on each one.
  • The sizes go into the stylesheet as CSS variables. Change HEIGHTS in Python and the page follows.

The clip uses the Edit API’s HTML5 asset, which renders a small web page inside a video clip. Our guide to converting an HTML animation to MP4 covers how that asset captures frames.

What the rendered frames show

To check the scroll, we read every frame of the rendered video and measured how far the names had moved since the frame before. We then rendered the same credits a second time with the length rounded to 26 seconds, which works out to 4.62 pixels a frame.

RenderFrames measuredMovement per frame
The script: 30.04 s, 4 pixels a frame6704 pixels on every frame
Rounded length: 26 s, 4.62 pixels a frame5804 pixels on 220 frames, 5 pixels on 360
  • With a whole number of pixels, every frame moved the same distance. The roll is 751 frames long, and the rest had no names in the measured area.
  • With the rounded length, the roll switched between 4 and 5 pixel steps. That uneven movement is what the eye reads as stutter.
  • The text was equally sharp in both renders. The difference is only in the movement.
  • The result was the same when the credits clip started 2 seconds into the timeline and played over footage: 4 pixels on all 545 frames measured.

Tune the roll

  • Speed. STEP is the speed in pixels per frame. Lower it for a slower, longer roll. STEP * FPS gives the speed in pixels a second.
  • Frame rate. Set FPS to the frame rate of the film the credits belong to. Our guide to choosing a video frame rate lists the common ones.
  • Line heights. HEIGHTS sets the space each title, heading and credit takes. A long role or name stays on one line, so shorten it or lower the font size in CSS if it runs off the frame.
  • Fonts. The page uses Georgia and Arial, which are system families. To use your own font, embed it in the stylesheet as the fonts section of the HTML5 guide describes.
  • Music. Add an audio clip on a second track with the same length as the credits clip.

Roll the credits on your next video with Shotstack

You now have a script that turns a list of names into rolling credits. It lays out the roles and names, picks a clip length that keeps the scroll even, and renders the roll with the Edit API. When a name changes, edit the CSV and run it again.

Create a free Shotstack account to get your API key and render your first credit roll.

Frequently asked questions (FAQs)

Can the credits roll over footage instead of black?

Yes. The page has a transparent background, so the clip shows whatever is on the track below it. Add a video or image clip on a track under the credits, and lower its opacity (we used 0.35) so the names stay easy to read.

How do I add the credits to the end of my video?

Put both in one edit: your film as a video clip that starts at 0, and the credits clip with its start set to the film’s length. The roll’s speed doesn’t change when the clip starts later. Our guide to merging videos with the Edit API shows how clips line up on a timeline.

How long will my rolling credits be?

Divide the distance by the speed. The distance is the frame height plus the height of the roll, and the speed is STEP * FPS pixels a second. In the example, each extra credit adds 64 pixels, which is 0.64 seconds at 100 pixels a second.

Keep reading

Start free

  • Unlimited developer sandbox
  • No credit card required