Learn · Developers

How to add an animated progress bar to a video in Python

Contents

A progress bar across the top of a short video shows viewers how much is left. It is a common touch on TikTok, Reels and Shorts, and it has to fill in step with the video, ending exactly on the last frame.

This guide builds that bar inside the video itself, not the kind of progress bar a Python library prints in a terminal. A Python script reads the video’s length, draws the bar as SVG, animates it with one tween and renders the result with Shotstack’s video editing API. We measured the bar on the rendered frames.

TL;DR

  • Read the video’s length from the Probe endpoint, so the animation matches the clip.
  • Draw the bar and its track as SVG rectangles: a white bar with a rounded leading end over a 40% black track, so it shows on any footage. The shape asset is deprecated.
  • Set fit to none, or the thin SVG is scaled up to cover the frame.
  • Start the bar one frame width to the left (offset.x = -1) and tween it linearly to 0 over the video’s length.
  • In our render, the bar’s width stayed within 2 px of the elapsed share of the video, about one frame of travel.

How the bar fills

The bar is a full-width rectangle, 1080 px wide and 16 px tall. At the start of the video it sits one full frame width to the left, off screen. One linear tween slides it to its resting place over the length of the video, so the visible part grows from the left edge.

Because the tween’s length must equal the video’s length, the script asks for that length first. The Probe endpoint reads it from the hosted file, so you don’t have to download the video or hardcode a number.

Here is the production render. The sample is a 20.72 second vertical clip from one of Shotstack’s Studio templates, and the bar runs along the top edge:

Add a progress bar to a video with Python

The script uses only the Python standard library, so there is nothing to install. Set SHOTSTACK_API_KEY to the API key from your Shotstack dashboard, then run it with Python 3.

The bar’s look is set by the constants at the top: EDGE (top), BAR (height, 16 px), FILL (white), TRACK (black) and TRACK_OPACITY (0.4).

Python
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

API = "https://api.shotstack.io/edit/v1"
VIDEO = "https://templates.shotstack.io/summer-holiday-recap-review-story-template-highlights/55c3321e-2487-46b7-9db6-3bbad528c043/source.mp4"
WIDTH, HEIGHT = 1080, 1920  # output size: vertical 9:16
EDGE = "top"  # "top" or "bottom" edge of the frame
BAR = 16  # bar height in pixels
FILL = "#FFFFFF"  # bar color
TRACK, TRACK_OPACITY = "#000000", 0.4  # the empty track behind the bar


def call(path, body=None):
    data = json.dumps(body).encode() if body else None
    headers = {
        "x-api-key": os.environ["SHOTSTACK_API_KEY"],
        "Content-Type": "application/json",
    }
    request = urllib.request.Request(f"{API}/{path}", data, headers)
    try:
        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]}")


def duration(url):
    """Length of the video in seconds, read by the Probe endpoint."""
    probe = call("probe/" + urllib.parse.quote(url, safe=""))
    return float(probe["metadata"]["format"]["duration"])


def bar(color, opacity=1.0, rounded=False):
    """A full-width SVG rectangle. rounded=True rounds the right end only."""
    shape = f'<rect width="{WIDTH}" height="{BAR}"/>'
    if rounded:  # round both ends, then square off the left end again
        shape = f'<rect width="{WIDTH}" height="{BAR}" rx="{BAR / 2}"/><rect width="{BAR}" height="{BAR}"/>'
    svg = (
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {WIDTH} {BAR}" width="{WIDTH}" '
        f'height="{BAR}" fill="{color}" fill-opacity="{opacity}">{shape}</svg>'
    )
    return {"type": "svg", "src": svg}


def clip(asset, length, **settings):
    return {"asset": asset, "start": 0, "length": length, **settings}


def build_edit(length):
    fill = {"from": -1, "to": 0, "start": 0, "length": length, "interpolation": "linear"}
    tracks = [
        {
            "clips": [
                clip(
                    bar(FILL, rounded=True), length, fit="none", position=EDGE, offset={"x": [fill]}
                )
            ]
        },
        {"clips": [clip(bar(TRACK, TRACK_OPACITY), length, fit="none", position=EDGE)]},
        {"clips": [clip({"type": "video", "src": VIDEO}, length)]},
    ]
    return {
        "timeline": {"tracks": tracks},
        "output": {"format": "mp4", "size": {"width": WIDTH, "height": HEIGHT}},
    }


if __name__ == "__main__":
    if not os.environ.get("SHOTSTACK_API_KEY"):
        sys.exit("Set SHOTSTACK_API_KEY first.")
    length = duration(VIDEO)
    edit = build_edit(length)
    if "--dry-run" in sys.argv:
        print(json.dumps(edit["timeline"]["tracks"][0], indent=2))
        sys.exit()
    print(f"Video length: {length}s")
    render_id = call("render", edit)["id"]
    print("Render queued:", render_id)
    while True:
        time.sleep(5)
        status = call(f"render/{render_id}")
        if status["status"] == "done":
            print(status["url"])
            break
        if status["status"] == "failed":
            sys.exit(f"Render failed: {status.get('error')}")

Run python progress_bar.py. It prints the video’s length, the render ID and the URL of the finished video:

Text
Video length: 20.72s
Render queued: 08a3cc02-4ee9-4083-b0a7-e8307b68c43c
https://shotstack-api-v1-output.s3-ap-southeast-2.amazonaws.com/35tqpmb0ya/08a3cc02-4ee9-4083-b0a7-e8307b68c43c.mp4

Run python progress_bar.py --dry-run to print the bar’s track as JSON without rendering. It still calls the Probe endpoint for the length, so it needs the API key too.

How the script works

  1. duration(url) URL-encodes the video’s address and calls GET /edit/v1/probe/{url}. It returns metadata.format.duration: 20.72 seconds for the sample clip.
  2. bar(color, opacity, rounded) returns an SVG asset: a 1080 × 16 px rectangle. With rounded=True, it rounds both ends, then draws a square over the left end, so only the leading edge is round.
  3. build_edit(length) builds the fill tween and stacks three tracks: the moving white bar on top, the dark track under it, and the video at the bottom. Both bar clips sit on the EDGE of the frame.
  4. The main block checks for the API key, probes the length, then submits the edit to /edit/v1/render and polls every 5 seconds until the video is ready.

Three details that make the bar work

Use the SVG asset, not the shape asset

The shape asset is deprecated, so the script draws both rectangles with the SVG asset. Its src is the raw markup, starting with <svg. A URL or a data:image/svg+xml URI isn’t accepted, and the outer element needs xmlns, viewBox, width and height.

Set fit to none

By default, a clip’s asset is scaled to fill the frame. In our first test render, a thin SVG bar was scaled up and covered most of the frame.

With fit set to none on both bar clips, the SVG keeps its own size. In the final render, the bar ran from row 0 to row 16: exactly 16 px.

Put it on the edge, with a track behind it

Video tools that add progress bars put them on the top or bottom edge of the frame. The bottom of a vertical video is crowded: captions, the account name and the apps’ buttons sit there, as our vertical video safe zones guide shows. So the script uses the top edge.

A white bar on its own can vanish against a bright sky. The 40% black track under it darkens the strip the bar runs along, so the bar stands out on any footage.

Both clips use position: top, which anchors them to the top of the frame. Our guide to positioning clips explains anchors in more detail.

What the rendered frames show

We extracted frames from the production render (1080 × 1920, 25 fps, 20.72 seconds) and measured the white part of the bar. At 25 fps the bar moves about 2 px per frame.

Point in the videoExpected width (share × 1080 px)Measured widthDifference
10%108 px108 px0 px
25%270 px271 px+1 px
50%540 px540 px0 px
75%810 px811 px+1 px
90%972 px974 px+2 px
99%1,069 px1,070 px+1 px

Every reading was within 2 px of the expected width, about one frame of travel. The bar stayed 16 px tall throughout.

Change the look

  • Thicker or thinner: change BAR. The SVG, the track and the fill all use it.
  • Colors: FILL and TRACK take any CSS color, and TRACK_OPACITY sets how much of the video shows through the empty track.
  • Bottom edge: set EDGE = "bottom". Check the result against the safe zones first, because the apps draw their captions and buttons near the bottom.
  • Gradients or a percentage label: use the html5 asset instead of SVG. Its docs give one sizing rule above all: size the clip to the content, such as 1080 × 16, not to the full canvas, then place it with offset.

Add a progress bar to every video with Shotstack

You now have a script that reads a video’s length, draws a progress bar as SVG and animates it with one tween. The bar finishes on the video’s last frame. Point VIDEO at your own clip, or pass the URL in as a parameter and loop over a batch, and every video gets a bar that matches its length.

Create a free Shotstack account to get your API key and render your first progress bar. To build the rest of the video around it, see our guide to automating short-form videos.

Frequently asked questions (FAQs)

Can I move the bar in from the edge?

Yes, add an offset with a y value to both bar clips. Offsets are fractions of the frame, and a negative y moves a clip down. On a 1920 px frame, y: -0.15 puts a top bar 288 px down, below the top buttons in our safe zones guide.

What if the video is made of several clips?

Use the length of the whole timeline instead of one file’s. Add up the clips’ lengths, or take the end of the last clip, and pass that to build_edit(). The bar then fills across the finished video, not the first clip.

Does it work for horizontal videos?

Yes, set WIDTH and HEIGHT to 1920 and 1080. The SVG is drawn at the new width, and the tween still runs from -1 to 0, because offsets are fractions of the frame, not pixels.

Keep reading

Start free

  • Unlimited developer sandbox
  • No credit card required