Introduction
Text crawls, also known as tickers or scrollers, are those horizontal streams of text that glide across the bottom of a screen. Most often seen during news broadcasts, sports coverage, or emergency alerts, they’re designed to deliver real-time information without interrupting the main visual content. Whether you’re seeing a stock market update or breaking news headlines, you’re watching a crawl in action.
American television audiences began seeing crawls in the late 1970s and early 1980s, when local TV news started experimenting with live graphics. Early systems were hardware-driven and often involved custom analog or digital circuitry to handle text overlays. These devices were expensive and limited in their ability to update or stylize the text. Over time, dedicated character generators and broadcast systems gave way to more flexible digital solutions powered by software. Today, even small studios and independent creators can run professional-looking crawls using commodity hardware and open-source tools.
In this article, we’ll walk through a practical approach for building a high-performance text crawl overlay using Python. We’ll use OpenCV to grab live video input and Pygame to composite a scrolling text overlay, all in real time. This approach isn't perfect, but it demonstrates how OpenCV can interface with a camera or video input while layering in custom-rendered graphics.
In a future post, we may explore moving this system to OpenGL or a similar GPU-accelerated rendering framework. That would allow for richer animation effects, such as smooth fades, motion blur, or shader-driven color variations. For now, our focus is on building a working foundation that can run on most systems with minimal setup.
Crawl Features in vMix, OBS, and Streamyard
For those working in live video production, vMix is a name that comes up often. It is a professional software suite used for switching camera inputs, adding graphics overlays, recording, and streaming. It is popular with content creators, churches, independent broadcasters, and even television studios. Its strength lies in its versatility, with support for a wide range of video formats, effects, and automation features.
vMix includes a built-in text crawl feature, and it works well for many static use cases. You can enter a line of text and have it scroll across the bottom of the screen during a live broadcast. The downside is that this text is usually fixed once the crawl begins. To change it, you typically need to stop the effect, edit the text, and start the crawl again. That is not ideal during a live show.
To address this, vMix offers advanced options such as linking a crawl to an external database or spreadsheet. This allows the system to pull in real-time data, including breaking news, live scores, or social media updates. However, many users avoid this path. Setting up a database, writing the data loader, and making sure updates are synchronized adds technical complexity that can be overwhelming. Many creators would rather focus on their content than on wiring up backend systems.
Streamyard and OBS both offer ways to display scrolling text, but with limitations. Streamyard, being a browser-based production tool, includes a basic ticker feature you can enable in the Branding panel. It scrolls a single line of text across the bottom of the screen, but there is no support for real-time updates or styling beyond simple font and color changes. OBS, on the other hand, allows more flexibility through its text sources. By applying a scroll filter to a text source, you can create a horizontal crawl, and even point the text source to a file for live updates. However, OBS lacks native support for complex layouts, separators, or dynamic effects without additional scripting or plugins. In both cases, you are working with simplified tools that handle basic needs well, but fall short when you want to introduce rich, layered graphics or custom logic.
The Python-based approach presented here offers a simpler alternative. It watches a plain text file for changes, which allows you to update crawl content live without restarting the effect or integrating a database. It may not replace a full vMix setup, but it offers a scriptable, lightweight solution that is easy to control and customize.
How our Python Crawl Script Works
While plenty of commercial tools offer basic crawl features, building your own gives you full control. For example, you may be able to parse a data feed (such as one in json) and incorporate directly into your crawl. The script we’re using is designed to demonstrate a live crawl overlay using nothing more than Python, OpenCV, and Pygame. It’s lean, fast, and flexible enough to adapt to different production needs. Here’s a breakdown of what the script does behind the scenes:
Loads Configuration and Fonts
Sets up font size, scroll speed, overlay height, target frame rate, background opacity, and file paths. Initializes the font using a system-installed TTF file.Initializes the Video Source
Uses OpenCV to open a camera or video input device. Attempts to match the requested resolution and confirms that video frames are being received.Prepares Pygame for Rendering
Initializes Pygame and creates a display surface sized to match the incoming video feed. This is where the live video and overlay will be composited.Monitors the Crawl Text File for Changes
Periodically checks a plain text file on disk (crawl.txt). If the content has changed, it re-renders the crawl surface with updated text.Splits Text by Separator Tags
Breaks the crawl text into chunks using [[SEP]] as a delimiter. This allows insertion of a logo or visual divider between text blocks.Optionally Inserts a PNG Separator Logo
If a separator PNG exists, it is resized to match the font height and placed between each text chunk. The final surface becomes a seamless horizontal row of text and images.Creates a Seamless Scrolling Surface
Renders all the chunks onto a surface that is twice as wide as needed. This allows for a wraparound effect, so the scroll appears continuous.Draws the Background and Crawl Overlay
Adds a semi-transparent black background bar behind the crawl to improve legibility. Then blits the crawl surface onto the live video.Scrolls the Crawl Horizontally
Moves the crawl surface leftward based on the scroll speed and frame delta time. When the scroll reaches the end, it loops back to the beginning.Maintains Real-Time Video Display
Each frame, it grabs a fresh image from the video source and composites it with the overlay using Pygame before showing the result onscreen.Listens for Quit Events
Keeps running until the user closes the window. On quit, it gracefully shuts down the video capture and Pygame systems.
This setup gives you full control over both visuals and behavior. You can adjust the speed, styling, and content without touching any complicated menus or external software. It also leaves room for future improvements like dynamic effects, support for multiple languages, or API-driven updates. For anyone looking to break free from the constraints of pre-built tools, this script offers a solid foundation that stays flexible and easy to adapt.
Real-World Use Cases for Dynamic Crawls
This crawl overlay is more than just a proof of concept. It can play a useful role in live production workflows. Many broadcast tools like vMix allow you to capture the output of another application’s window or even an entire monitor. With that in mind, you could run this Python-based overlay on a separate system or secondary display, and feed it directly into your production setup. Because it runs independently of your main video source, the crawl can be updated on the fly without affecting the rest of the content. You do not need to render anything in advance or stop your stream to make edits. A quick change to the text file is all it takes to put fresh content on screen. This script could be modified to update contents from an external data provider (such as one for news, stocks, weather).
Outside of live broadcasting, this same tool can be adapted for digital signage. Picture a restaurant with televisions playing a silent sports feed or a video loop. You could reserve a strip of screen space along the bottom for promotions, upcoming events, or rotating announcements. Because the crawl is live-rendered, you can make changes throughout the day with no need to restart the video or reload assets. This kind of setup works well in retail spaces, trade show booths, or lobbies where timely, lightweight messaging can enhance the experience without the cost of commercial signage platforms.
The Code
You can clone from our ongoing Github utility repository. This content is in directory “video-overlay-crawl”.
Most configuration can be handled in the #Config block.
# Config
font_path = "C:/Windows/Fonts/arial.ttf"
font_size = 28
scroll_speed = 70 # pixels per second
crawl_height = 40
target_fps = 30
bg_opacity = 180
crawl_file = "crawl.txt"
sep_file = "separator.png"
Additionally, look in the main() block for the capture initialization (resolution).
cap, vid_w, vid_h = init_video_source(0, 1280, 720)
For testing, I limited the video output to 720p so I could see my debug window easily (I use a single screen setup). However, this could easily be set to 1920x1080 for most full screen resolutions.
import os
import cv2
import pygame
import numpy as np
import time
import sys
# Config
font_path = "C:/Windows/Fonts/arial.ttf"
font_size = 28
scroll_speed = 70 # pixels per second
crawl_height = 40
target_fps = 30
bg_opacity = 180
crawl_file = "crawl.txt"
sep_file = "separator.png"
def init_video_source(index=0, target_w=1280, target_h=720):
"""
Initialize the video source (camera or video file) with specified width and height.
:param index:
:param target_w:
:param target_h:
:return:
"""
cap = cv2.VideoCapture(index)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
for _ in range(5):
ret, frame = cap.read()
if ret and frame is not None:
break
else:
raise RuntimeError("Could not read from video source.")
h, w = frame.shape[:2]
print(f"Video source initialized at {w}x{h} (requested {target_w}x{target_h})")
return cap, w, h
def render_text_image(text, font_path, font_size=36, text_color=(255, 255, 255),
padding=20, separator_path=None, min_width=0):
"""
Render a text string into a Pygame surface, handling line breaks and optional separators.
:param text:
:param font_path:
:param font_size:
:param text_color:
:param padding:
:param separator_path:
:param min_width:
:return:
"""
pygame.font.init()
font = pygame.font.Font(font_path, font_size)
entries = [t.strip() for t in text.split("[[SEP]]") if t.strip()]
rendered_chunks = []
sep_surface = None
if separator_path and os.path.exists(separator_path):
sep_surface = pygame.image.load(separator_path).convert_alpha()
sep_h = font.get_height()
scale_ratio = sep_h / sep_surface.get_height()
sep_w = int(sep_surface.get_width() * scale_ratio)
sep_surface = pygame.transform.smoothscale(sep_surface, (sep_w, sep_h))
for i, entry in enumerate(entries):
text_surf = font.render(entry, True, text_color)
rendered_chunks.append(text_surf)
if sep_surface and i < len(entries) - 1:
rendered_chunks.append(sep_surface)
# Calculate total content width
content_width = sum(chunk.get_width() + padding for chunk in rendered_chunks)
height = font.get_height()
# Enforce minimum width
width = max(content_width, min_width)
# Make a surface twice as wide to support looping
surface = pygame.Surface((width * 2, height), pygame.SRCALPHA)
# Draw once
x = 0
for chunk in rendered_chunks:
surface.blit(chunk, (x, 0))
x += chunk.get_width() + padding
# Duplicate to the right for seamless scroll
loop_buffer = pygame.Surface((width, height), pygame.SRCALPHA)
loop_buffer.blit(surface, (0, 0), (0, 0, width, height))
surface.blit(loop_buffer, (width, 0))
return surface
def draw_crawl(screen, crawl_surface, x, y, bg_opacity):
"""
Draw the crawl surface onto the screen at specified position with a background.
:param screen:
:param crawl_surface:
:param x:
:param y:
:param bg_opacity:
:return:
"""
crawl_rect = crawl_surface.get_rect(topleft=(x, y))
bg_rect = pygame.Rect(0, y, screen.get_width(), crawl_rect.height)
bg_surface = pygame.Surface((bg_rect.width, bg_rect.height), pygame.SRCALPHA)
bg_surface.fill((0, 0, 0, bg_opacity))
screen.blit(bg_surface, bg_rect.topleft)
screen.blit(crawl_surface, (x, y))
if x + crawl_surface.get_width() < screen.get_width():
screen.blit(crawl_surface, (x + crawl_surface.get_width(), y))
def check_and_update_crawl(font_path, font_size, crawl_text_path, last_text, min_width):
"""
Check if the crawl text file has changed and update the rendered surface if it has.
:param font_path:
:param font_size:
:param crawl_text_path:
:param last_text:
:param min_width:
:return:
"""
try:
with open(crawl_text_path, "r", encoding="utf-8") as f:
text = f.read().strip().replace('\r\n', '\n').replace('\r', '\n')
except Exception:
print("[DEBUG] Failed to read crawl text file.")
return None, last_text
if text == last_text:
return None, last_text
print("[DEBUG] New crawl text detected.")
surface = render_text_image(text, font_path, font_size, min_width=min_width, separator_path=sep_file)
return surface, text
def main():
"""
Main function to initialize Pygame, set up video capture, and run the crawl overlay.
:return:
"""
pygame.init()
pygame.font.init()
cap, vid_w, vid_h = init_video_source(0, 1280, 720)
screen = pygame.display.set_mode((vid_w, vid_h))
clock = pygame.time.Clock()
last_crawl_text = ""
crawl_surface, last_crawl_text = check_and_update_crawl(
font_path, font_size, crawl_file, last_crawl_text, vid_w
)
if crawl_surface is None:
raise RuntimeError("Failed to load initial crawl text.")
crawl_w = crawl_surface.get_width()
scroll_x = vid_w
y_offset = vid_h - crawl_height
last_time = time.time()
while True:
ret, frame = cap.read()
if not ret:
print("Video source error.")
break
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
surf = pygame.image.frombuffer(frame_rgb.tobytes(), frame_rgb.shape[1::-1], "RGB")
screen.blit(surf, (0, 0))
now = time.time()
delta = now - last_time
last_time = now
updated_surface, last_crawl_text = check_and_update_crawl(
font_path, font_size, crawl_file, last_crawl_text, vid_w
)
if updated_surface:
new_crawl_w = updated_surface.get_width()
if new_crawl_w != crawl_w:
scroll_x = vid_w
crawl_surface = updated_surface
crawl_w = new_crawl_w
scroll_x -= scroll_speed * delta
if scroll_x < -crawl_w:
scroll_x = 0
draw_crawl(screen, crawl_surface, scroll_x, y_offset, bg_opacity)
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.QUIT:
cap.release()
pygame.quit()
sys.exit()
clock.tick(target_fps)
if __name__ == "__main__":
main()
Conclusion
Creating your own dynamic crawl overlay with Python gives you control that many off-the-shelf tools cannot match. You are not limited by rigid interfaces or locked into a particular workflow. Whether you want a lightweight solution for streaming, a way to push live updates into a broadcast, or just a better digital signage option, this script provides a strong foundation. It runs on common hardware, uses well-known libraries, and stays easy to modify for your specific needs.
While commercial platforms like vMix, OBS, and Streamyard offer crawl features, they often fall short when it comes to flexibility or real-time control. With this script, you gain the ability to manage your content from a simple text file, insert branding between segments, and adjust everything in real time. It may be a modest tool, but it fills a real gap for creators and producers who like to build their own solutions and keep things efficient.
If you decide to extend it further, you will find plenty of room to experiment. Add fade effects, drop shadows, or even multilingual support. Once you get the basics running, you have the power to go wherever your production goals take you.