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
fittonone, 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 to0over 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).
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:
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.mp4Run 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
duration(url)URL-encodes the video’s address and callsGET /edit/v1/probe/{url}. It returnsmetadata.format.duration: 20.72 seconds for the sample clip.bar(color, opacity, rounded)returns an SVG asset: a 1080 × 16 px rectangle. Withrounded=True, it rounds both ends, then draws a square over the left end, so only the leading edge is round.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 theEDGEof the frame.- The main block checks for the API key, probes the length, then submits the edit to
/edit/v1/renderand 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 video | Expected width (share × 1080 px) | Measured width | Difference |
|---|---|---|---|
| 10% | 108 px | 108 px | 0 px |
| 25% | 270 px | 271 px | +1 px |
| 50% | 540 px | 540 px | 0 px |
| 75% | 810 px | 811 px | +1 px |
| 90% | 972 px | 974 px | +2 px |
| 99% | 1,069 px | 1,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:
FILLandTRACKtake any CSS color, andTRACK_OPACITYsets 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.