Building a Video Editing App in Python: How to Serve Videos with Flask and Video.js (Part 3)

How to serve videos in a Python video editing app with Flask & Video.js.
By Boris Delovski • Updated on Jul 6, 2025
blog image

In our previous article, we enhanced our video editing pipeline by implementing robust validation to ensure that transcriptions only omit existing words. We also synchronized text edits with their corresponding timestamps. While this was a significant improvement, our video editing app remains incomplete.

We now face a limitation with Streamlit, the framework used for our app. Even though Streamlit supports video streaming, it only allows users to stream a single segment of a video, which falls short of our requirements. We need the ability to extract multiple segments from the original video based on text edits, concatenate them, and display only those selected segments in the video player. Since Streamlit lacks this functionality, we must seek an alternative solution.

To overcome this limitation, we will integrate Video.js to display the current version of the video. This will allow us to support both the original and edited segments. Additionally, we will use Flask to serve the processed videos to the Video.js player.

How to Build a Flask Application

Flask is a minimalist, lightweight web framework for Python that enables developers to create web applications quickly without excessive boilerplate code. Rather than bundling every feature from the start, such as an integrated ORM or built‐in form validation, Flask offers a streamlined core. It handles essential tasks like URL routing, HTTP request/response management, and template rendering.

Developers can extend Flask’s functionality as needed through extensions, allowing them to integrate only the required tools for a specific project. This flexibility makes Flask both beginner-friendly and scalable for larger applications. As a result, it is widely used for building a range of applications, from simple websites and RESTful APIs to more complex web platforms. Its adaptability and lightweight nature have made it a go-to framework for developers and major companies like Pinterest and LinkedIn, enabling projects to start small and expand seamlessly.

Flask operates on the Werkzeug WSGI toolkit, which facilitates communication between the application and the web server. It also leverages the Jinja2 template engine to render dynamic HTML. These foundational components ensure that Flask remains both lightweight and highly adaptable.

Our code for the Flask application looks like this:

import os
import re
from flask import Flask, request, Response, abort

app = Flask(__name__)

@app.route("/videos/<path:filename>")
def stream_video(filename):
    """
    Stream video files with HTTP byte-range support so that
    browsers can seek/pause/refresh without error.
    """
    # Make sure we only serve files from the 'videos' directory
    video_path = os.path.join("videos", filename)

    if not os.path.exists(video_path):
        return abort(404, "Video not found.")

    # Check Range header for partial requests
    range_header = request.headers.get('Range', None)
    if not range_header:
        # No Range header -> serve the entire file
        return serve_complete_file(video_path)

    # Handle partial (byte-range) requests
    file_size = os.path.getsize(video_path)
    byte_start, byte_end = parse_range_header(range_header, file_size)
    chunk_size = (byte_end - byte_start) + 1

    with open(video_path, 'rb') as f:
        f.seek(byte_start)
        data = f.read(chunk_size)

    # HTTP 206 Partial Content
    response = Response(data, 206, mimetype="video/mp4")
    response.headers.add("Content-Range", f"bytes {byte_start}-{byte_end}/{file_size}")
    response.headers.add("Accept-Ranges", "bytes")
    response.headers.add("Content-Length", str(chunk_size))
    return response

def parse_range_header(range_header, file_size):
    """
    Given a 'Range' header (e.g. 'bytes=100-200'), return
    the start and end byte positions.
    """
    match = re.search(r"bytes=(\d+)-(\d*)", range_header)
    if not match:
        # If it's not a valid range header, serve entire file
        return 0, file_size - 1

    byte_start = int(match.group(1))
    byte_end_str = match.group(2).strip()
    if byte_end_str:
        byte_end = int(byte_end_str)
    else:
        byte_end = file_size - 1

    # Clamp values
    byte_end = min(byte_end, file_size - 1)
    return byte_start, byte_end

def serve_complete_file(file_path):
    """
    Serve the entire file when there's no Range header.
    """
    with open(file_path, 'rb') as f:
        data = f.read()
    response = Response(data, 200, mimetype="video/mp4")
    response.headers.add("Content-Length", str(os.path.getsize(file_path)))
    return response

if __name__ == "__main__":
    # Run Flask on port 8000; adjust as needed
    app.run(host="127.0.0.1", port=8000, debug=True)

Let’s break down this code in detail, explaining what each part does and how it works.

How to Set Up the Flask Application

First, we need to import all the necessary modules and set up the Flask application:

import os
import re
from flask import Flask, request, Response, abort

app = Flask(__name__)

We will use regular expressions in our code and handle files, so we need to import os and re. Additionally, we will import Flask, request, Response, and abort from the Flask framework to manage HTTP requests and responses. To set up the core of our web service, we will create an instance of the Flask application by running the following:

app = Flask(__name__)

Article continues below

How to Define the Video Streaming Endpoint

Next, we need to define how the video streaming endpoint will function:

@app.route("/videos/<path:filename>")
def stream_video(filename):
    """
    Stream video files with HTTP byte-range support so that
    browsers can seek/pause/refresh without error.
    """
    # Make sure we only serve files from the 'videos' directory
    video_path = os.path.join("videos", filename)

    if not os.path.exists(video_path):
        return abort(404, "Video not found.")

    # Check Range header for partial requests
    range_header = request.headers.get('Range', None)
    if not range_header:
        # No Range header -> serve the entire file
        return serve_complete_file(video_path)

    # Handle partial (byte-range) requests
    file_size = os.path.getsize(video_path)
    byte_start, byte_end = parse_range_header(range_header, file_size)
    chunk_size = (byte_end - byte_start) + 1

    with open(video_path, 'rb') as f:
        f.seek(byte_start)
        data = f.read(chunk_size)

    # HTTP 206 Partial Content
    response = Response(data, 206, mimetype="video/mp4")
    response.headers.add("Content-Range", f"bytes {byte_start}-{byte_end}/{file_size}")
    response.headers.add("Accept-Ranges", "bytes")
    response.headers.add("Content-Length", str(chunk_size))
    return response

The first step is to create an endpoint that supports dynamic file paths. We can achieve this using the following decorator:

@app.route("/videos/<path:filename>")</path:filename>

This configuration enables the server to handle different video files located in the videos folder. This folder acts as the repository for the file you are currently working on. Later, when we build the Streamlit app, I will show you how to ensure that any video file we work on is automatically moved to this directory. This will happen even if the file was originally imported from another location. That will be covered in more detail later.

Next, we will write code that combines the videos directory with the provided filename using os.path.join(). By doing so we ensure that only files within this directory are served. Immediately after, the code will check whether the file exists. If it does not, it will call abort(404, "Video not found.") to return a 404 error.

# Construct the full path to the video file.
    video_path = os.path.join("videos", filename)
# If the file does not exist, return a 404 error.
    if not os.path.exists(video_path):
        return abort(404, "Video not found.")

Following that, we need to ensure that our application can handle HTTP requests to serve only specific ranges of the video file. This will enable us to later send selected video segments, concatenated together, to the Video.js player we will build.  The way the code works is relatively simple. First, the code checks for the presence of the Range header in the incoming request. This header informs the server which portion of the file is requested by the client.

# Retrieve the 'Range' header from the request (if present).
    range_header = request.headers.get('Range', None)
    if not range_header:
        # No Range header provided; serve the complete video file.
        return serve_complete_file(video_path)

The server first checks for a Range header. If no Range header is present, the serve_complete_file function is called to return the entire file. This function has not been defined yet, but it will be later. If the Range header exists, the file size is determined, and the parse_range_header function extracts the start and end byte positions. Once again, this function has not been defined yet either, but we will cover it shortly.

After determining which function to use to extract the necessary part of the video, the server opens the file in binary mode. It then seeks to the specified starting byte and reads the required chunk. Next, a Response object is created with a status code of 206 Partial Content. It includes the appropriate headers (Content-Range, Accept-Ranges, Content-Length) to inform the client about the served segment.

# Open the video file in binary mode, seek to the start of the requested range, and read the specified chunk.
    with open(video_path, 'rb') as f:
        f.seek(byte_start)
        data = f.read(chunk_size)

    # Build the response with a 206 Partial Content status.
    response = Response(data, 206, mimetype="video/mp4")
    response.headers.add("Content-Range", f"bytes {byte_start}-{byte_end}/{file_size}")
    response.headers.add("Accept-Ranges", "bytes")
    response.headers.add("Content-Length", str(chunk_size))
    return response

How to Extract Certain Parts of the Video

Now that we have defined the logic behind how our video streaming endpoint, it is time to define the first of two helper functions: the parse_range_header function.

def parse_range_header(range_header, file_size):
    """
    Parse the HTTP 'Range' header to determine the requested byte range.

    The Range header should be in the format 'bytes=start-end'. This function uses a regular expression
    to extract the start and (optional) end byte positions. If the end byte is not provided, it defaults
    to the end of the file. It also ensures that the requested end does not exceed the file size.

    Parameters:
      range_header (str): The value of the Range header (e.g., "bytes=100-200").
      file_size (int): The total size of the file in bytes, used to validate and clamp the range.

    Returns:
      tuple[int, int]: A tuple containing the starting byte and ending byte indices.
                        If the header is malformed, it defaults to (0, file_size - 1) to serve the entire file.
    """
    # Use a regex to match the range pattern.
    match = re.search(r"bytes=(\d+)-(\d*)", range_header)
    if not match:
        # If no valid match is found, return the full file range.
        return 0, file_size - 1

    # Extract the start byte and convert it to an integer.
    byte_start = int(match.group(1))
    # Extract the end byte as a string and remove any whitespace.
    byte_end_str = match.group(2).strip()
    if byte_end_str:
        # If an end byte is provided, convert it to an integer.
        byte_end = int(byte_end_str)
    else:
        # If no end is specified, set the end to the last byte of the file.
        byte_end = file_size - 1

    # Ensure that the byte_end does not exceed the total file size.
    byte_end = min(byte_end, file_size - 1)
    return byte_start, byte_end

This helper function extracts the requested byte range from the HTTP Range header. The expected format is "bytes=start-end". It uses a regular expression to match this pattern and extracts the starting byte and the (optional) ending byte. If the ending byte is not provided, the function defaults it to the last byte of the file (file_size - 1). This ensures that video sections running until the end are correctly handled. Finally, we add validation to ensure that the ending byte does not exceed the total file size. This way we prevent out-of-range errors.

How to Serve the Entire Video

When we first load our video into the video editing app, we need to ensure that the entire original video file can be served to the Video.js player. The same applies when using the undo functionality to revert completely to the original content. To achieve this, we will create a helper function called serve_complete_file.

def serve_complete_file(file_path):
    """
    Serve the complete video file without any byte-range filtering.

    This function is used when the client does not specify a Range header.
    It reads the entire file and returns it in the response with a 200 OK status.

    Parameters:
      file_path (str): The full path to the video file.

    Returns:
      Response: A Flask Response object containing the full file data with the proper Content-Length header.
    """
    # Open the file in binary mode and read its entire contents.
    with open(file_path, 'rb') as f:
        data = f.read()
    # Build the response with a 200 OK status.
    response = Response(data, 200, mimetype="video/mp4")
    response.headers.add("Content-Length", str(os.path.getsize(file_path)))
    return response

This function is called when no Range header is present in the request, indicating that the client wants the full video file. In this scenario, the entire file is read in binary mode, and a Response object is created with a 200 OK status. The Content-Length header is set to the file size to ensure the client knows how much data to expect.

How to Run the Flask Application

The final block of code checks whether the script is being run directly rather than imported as a module. If it is, the Flask development server starts.

if __name__ == "__main__":
    # Run the Flask application on localhost at port 8000.
    # The debug mode is enabled for development purposes, which provides detailed error messages and auto-reloading.
    app.run(host="127.0.0.1", port=8000, debug=True)

The Flask application is configured to run on localhost (127.0.0.1) using port 8000 with debugging enabled. This setup allows the application to auto-reload and display detailed error messages, which can be helpful for troubleshooting during development. While you might consider disabling these detailed messages once development is complete, doing so isn’t always necessary.

Now that we have completed our Flask application, let’s create a function to generate our video player using Video.js.

How to Create the Video.js Player

Video.js is a free, open-source HTML5 video player developed with JavaScript and CSS, offering a reliable and customizable playback experience. It automatically falls back to older technologies like Flash in legacy browsers when needed. With robust customization capabilities, including plugin integration, control adjustments, and video event management, it is well-suited for frameworks like React and CMS platforms like WordPress. Moreover, it can be incorporated into apps built with Streamlit. This is the approach we will be taking. 

To be more precise, we will create a helper function that can be invoked within our Streamlit app to construct a video player. Here's what the code will look like:

def generate_videojs_player(video_url, video_name, playback_ranges=None):
    """
    Generate the HTML for a Video.js player with optional playback ranges.

    Args:
        video_url (str): URL path where the video is hosted.
        video_name (str): Name of the video file.
        playback_ranges (list, optional): List of dicts with "start" and "end" times.

    Returns:
        str: HTML content for embedding a Video.js player in a webpage.
    """
    if not playback_ranges:
        playback_ranges_js = []
    else:
        playback_ranges_js = ",".join(
            f"{{ start: {r[0]}, end: {r[1]} }}" for r in playback_ranges
        )

    return f"""
    <!DOCTYPE html>
    <html>
    <head>
      <link href="https://vjs.zencdn.net/8.0.4/video-js.css" rel="stylesheet">
      <style>
        .video-js .vjs-progress-control {{
          display: block !important;
        }}
      </style>
    </head>
    <body>
      <video
        id="video"
        class="video-js"
        controls
        preload="auto"
        width="640"
        height="360"
        data-setup="{{}}"
      >
        <source src="{video_url}/{video_name}" type="video/mp4">
        Your browser does not support the video tag.
      </video>

      <script src="https://vjs.zencdn.net/8.0.4/video.min.js"></script>
      <script>
        document.addEventListener('DOMContentLoaded', () => {{
          const player = videojs('video');
          const playbackRanges = [{playback_ranges_js}];

          if (playbackRanges.length > 0) {{
            let currentRangeIndex = 0;

            const resetToFirstSegment = () => {{
              currentRangeIndex = 0;
              player.currentTime(playbackRanges[0].start);
            }};

            player.on('timeupdate', () => {{
              const currentRange = playbackRanges[currentRangeIndex];
              if (currentRange && player.currentTime() >= currentRange.end) {{
                currentRangeIndex++;
                if (currentRangeIndex < playbackRanges.length) {{
                  player.currentTime(playbackRanges[currentRangeIndex].start);
                }} else {{
                  player.pause();
                  resetToFirstSegment();
                }}
              }}
            }});

            player.on('loadedmetadata', resetToFirstSegment);

            player.on('play', () => {{
              if (player.currentTime() === 0 || currentRangeIndex >= playbackRanges.length) {{
                resetToFirstSegment();
              }}
            }});

            player.on('ended', () => {{
              resetToFirstSegment();
              player.pause();
            }});
          }}
        }});
      </script>
    </body>
    </html>
    """

The function generates a complete HTML page as a string. This page includes a Video.js player configured to play a specified video. Additionally, it can restrict playback to certain segments, known as playback ranges, which is crucial for previewing changes made to the original video through transcription-based editing. 

The function has three parameters:

  • video_url: The base URL or path where the video file is hosted.
  • video_name: The filename of the video.
  • playback_ranges: An optional list where each element represents a segment defined by its start and end times. 

The video will be hosted by the Flask application we built earlier. In the Streamlit app, we can reference it by its name. The playback ranges will be generated when we begin editing the transcription of the original video, but more on that later.

The first block of code converts the optional Python list of playback segments into a string that represents a JavaScript array of objects. 

    if not playback_ranges:
        playback_ranges_js = []
    else:
        playback_ranges_js = ",".join(
            f"{{ start: {r[0]}, end: {r[1]} }}" for r in playback_ranges
        )

If playback_ranges is not provided (or is empty), the code sets playback_ranges_js to an empty list ([]). When converted to a string inside the HTML, it becomes "[]".

On the other hand, if a list of playback ranges is provided, the code iterates over each range r (expected to be a two-element iterable). For each range, it formats a JavaScript object literal with keys start and end. These formatted objects are then joined with commas to form a valid JavaScript array snippet, which is later embedded into the HTML.

When constructing the HTML structure, there is much more work to be done. Our function must return a multi-line f-string that forms an entire HTML document. Let’s break the HTML code down into a few sections.

First, we need to create the HTML head section. This can be achieved using the following lines of code:

<head>
      <link href="https://vjs.zencdn.net/8.0.4/video-js.css" rel="stylesheet">
      <style>
        .video-js .vjs-progress-control {
          display: block !important;
        }
      </style>
    </head>

There are two key components here. First, we include a link to the Video.js stylesheet hosted on a CDN, ensuring the player is styled according to Video.js standards. Second, we add a small inline CSS snippet to ensure the progress control (the video's seek bar) appears correctly. The use of !important guarantees that our defined style overrides any default settings that might otherwise hide it.

Next, we need to define the HTML body and video element.

    <body>
      <video
        id="video"
        class="video-js"
        controls
        preload="auto"
        width="640"
        height="360"
        data-setup="{}"
      >
        <source src="{video_url}/{video_name}" type="video/mp4">
        Your browser does not support the video tag.
      </video>

The video source here will be constructed by combining the video_url and video_name values. If the browser does not support the video element, the user will see the text “Your browser does not support the video tag.” The key attributes in this section of the code include:

  • id="video":  Uniquely identifies the video element. 
  • class="video-js":  Applies Video.js-specific styling and behavior. 
  • controls:  Displays built-in player controls.
  • preload="auto":  Instructs the browser to preload the video.
  • width and height:  Set the dimensions of the player.
  • data-setup="{}":  A Video.js-specific attribute used for initializing the player. An empty object indicates a default setup. 

After the video element, the final section of the HTML loads the Video.js JavaScript library and includes an inline script to initialize the player and manage playback ranges.

<script src="https://vjs.zencdn.net/8.0.4/video.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', () => {
    const player = videojs('video');
    const playbackRanges = [{playback_ranges_js}];

    if (playbackRanges.length > 0) {
      let currentRangeIndex = 0;

      const resetToFirstSegment = () => {
        currentRangeIndex = 0;
        player.currentTime(playbackRanges[0].start);
      };

      player.on('timeupdate', () => {
        const currentRange = playbackRanges[currentRangeIndex];
        if (currentRange && player.currentTime() >= currentRange.end) {
          currentRangeIndex++;
          if (currentRangeIndex < playbackRanges.length) {
            player.currentTime(playbackRanges[currentRangeIndex].start);
          } else {
            player.pause();
            resetToFirstSegment();
          }
        }
      });

      player.on('loadedmetadata', resetToFirstSegment);

      player.on('play', () => {
        if (player.currentTime() === 0 || currentRangeIndex >= playbackRanges.length) {
          resetToFirstSegment();
        }
      });

      player.on('ended', () => {
        resetToFirstSegment();
        player.pause();
      });
    }
  });
</script>

The external script tag loads Video.js from a CDN, providing all the necessary functionality and API support for the player. Meanwhile, the inline script waits until the DOM is fully loaded before initializing the Video.js player on the <video> element with the id "video"

The script manages playback ranges by creating a playbackRanges array from the previously formatted string. It uses the variable currentRangeIndex to track the current playback segment. It also defines a helper function called resetToFirstSegment to reset playback to the first segment. Additionally, several event listeners are implemented to enforce playback within the specified segments:

  • timeupdate: Checks the current time against the end of the current segment. If needed, it jumps to the next segment or pauses and resets playback.
  • loadedmetadata: Resets playback to the first segment once the video metadata is fully loaded.
  • play: Ensures that playback always starts from the correct segment.
  • ended: Resets and pauses the video when it naturally reaches the end.

In this article, we developed a Flask application to efficiently serve videos using HTTP byte-range requests, enabling smooth streaming and segment-based playback. Additionally, we integrated Video.js, which provides an interactive and flexible video player for previewing different video segments based on our edits. In the next article, we will bring all these components together into a unified application using Streamlit.

Boris Delovski

Data Science Trainer

Boris Delovski

Boris is a data science trainer and consultant who is passionate about sharing his knowledge with others.

Before Edlitera, Boris applied his skills in several industries, including neuroimaging and metallurgy, using data science and deep learning to analyze images.