Skip to content

FPS Control Async Flow

videorate is the GStreamer element used to normalize or change a stream's frame rate. Place it before a capsfilter, then set the capsfilter to the target rate, for example video/x-raw,framerate=15/1. videorate will drop frames when lowering FPS and duplicate frames when raising FPS so the downstream pipeline receives buffers at the requested cadence.

Simple pipeline that limits a test source to 10 FPS:

1
2
3
4
5
6
gst-launch-1.0 videotestsrc is-live=true ! \
  video/x-raw,framerate=30/1 ! \
  videorate ! \
  video/x-raw,framerate=10/1 ! \
  videoconvert ! \
  autovideosink

The main idea is that FastAPI and GStreamer run in different execution contexts. The API side receives HTTP requests asynchronously. The GStreamer side owns the pipeline and runs on its own GLib thread. They communicate with commands, futures, and a thread-safe queue.

flowchart LR
    subgraph API["FastAPI / asyncio side"]
        Req["HTTP request<br/>GET /status or POST /fps"]
        Route["FastAPI route<br/>status() / set_fps()"]
        Await["await_fps_command()<br/>wait for Future"]
        Resp["HTTP JSON response"]
    end

    subgraph Bridge["Thread-safe bridge"]
        Submit["fps_controller.submit()"]
        Queue["Command queue"]
        Future["Future result"]
    end

    subgraph Gst["GStreamer / GLib thread"]
        Loop["GLib MainLoop"]
        Process["_process_commands()"]
        Handle["_handle_command()"]
        Pipeline["GStreamer pipeline<br/>capsfilter changes FPS"]
    end

    Req --> Route --> Await --> Submit --> Queue
    Submit --> Future
    Queue -->|"invoke_full()"| Loop --> Process --> Handle --> Pipeline
    Pipeline -->|"set_result() / set_exception()"| Future
    Future --> Await --> Resp

What This Means

fps_app.py should stay responsive to HTTP requests. It does not touch the GStreamer pipeline directly. When a request arrives, it creates a command and waits asynchronously for the result.

fps_control.py owns the GStreamer objects. Its controller thread runs a GLib main loop, receives queued commands, and applies changes to the pipeline from that thread.

The queue and future are the bridge:

  • submit() puts a command into the queue.
  • GLib.MainContext.invoke_full() wakes the GStreamer thread.
  • _process_commands() handles the command.
  • The command result is written back to the Future.
  • FastAPI awaits that future and returns JSON to the client.

For POST /fps, the command eventually calls _set_fps(), validates the FPS, and updates the capsfilter caps to:

video/x-raw,framerate=<fps>/1

For GET /status, the command returns the current controller state.


Code

Pipeline
1
2
3
4
5
6
7
8
self.pipeline = Gst.parse_launch(
        "v4l2src device=/dev/video0 "
        "! video/x-raw,format=YUY2,width=640,height=480,framerate=30/1 "
        "! videorate name=rate drop-only=true "
        "! capsfilter name=fps_caps "
        "! videoconvert "
        "! fpsdisplaysink sync=false "
    )

The pipeline creates a named capsfilter:

"! videorate name=rate drop-only=true "
"! capsfilter name=fps_caps "

After Gst.parse_launch() builds the pipeline, the controller keeps a reference to that element:

self.capsfilter = self.pipeline.get_by_name("fps_caps")

When the API asks for a new FPS, _set_fps() first validates the requested value, then calls _apply_fps_caps():

1
2
3
4
5
6
7
8
def _set_fps(self, fps: int):
    fps = self._validate_fps(fps)
    if self.capsfilter is None:
        raise RuntimeError("Pipeline is not running")

    self._apply_fps_caps(fps)
    self.fps = fps
    return {"ok": True, **self._status_fields()}

_apply_fps_caps() is the part that actually edits the caps. It creates a new Gst.Caps object from the requested frame rate and writes it to the capsfilter's caps property:

1
2
3
def _apply_fps_caps(self, fps: int):
    caps = Gst.Caps.from_string(f"video/x-raw,framerate={fps}/1")
    self.capsfilter.set_property("caps", caps)

Because videorate is directly before the capsfilter, it adapts the incoming stream to satisfy the new downstream caps. With drop-only=true, this example is intended for reducing FPS by dropping frames rather than creating extra frames.

Demo files:

  • fps_control.py contains the GStreamer controller. It creates the pipeline, owns the GLib main loop, validates FPS changes, and updates the capsfilter.
  • fps_app.py contains the FastAPI application. It exposes the status and FPS endpoints, serves the HTML page, and forwards commands to the controller.
  • static/fps.html contains the browser UI. It shows the current controller status and sends FPS update requests to the FastAPI server.
fps_control.py
"""
v4l2-ctl -d /dev/video0 --list-formats-ext
"""
import queue
import threading
from concurrent.futures import Future
from dataclasses import dataclass
from typing import Any

import gi

gi.require_version("Gst", "1.0")
gi.require_version("GLib", "2.0")

from gi.repository import Gst, GLib


Gst.init(None)


CMD_INIT = "init"
CMD_SHUTDOWN = "shutdown"
CMD_SET_FPS = "set_fps"
CMD_STATUS = "status"

SUPPORTED_FPS = (1, 5, 10, 20, 30)
DEFAULT_FPS = 30


@dataclass
class Command:
    name: str
    args: dict[str, Any]
    future: Future


class FpsController:
    def __init__(self):
        self.commands: queue.Queue[Command] = queue.Queue()

        self.context = GLib.MainContext()
        self.loop = GLib.MainLoop.new(self.context, False)
        self.thread = threading.Thread(target=self._thread_main, daemon=True)

        self.pipeline = None
        self.capsfilter = None
        self.fps = DEFAULT_FPS
        self.started = False

    def start(self):
        self.thread.start()
        return self.call_sync(CMD_INIT, timeout=5)

    def stop(self):
        try:
            self.call_sync(CMD_SHUTDOWN, timeout=5)
        finally:
            self._invoke(lambda: self.loop.quit() or False)
            self.thread.join(timeout=2)

    def submit(self, name: str, args: dict | None = None) -> Future:
        future = Future()
        cmd = Command(name=name, args=args or {}, future=future)
        self.commands.put(cmd)
        self._invoke(self._process_commands)
        return future

    def call_sync(self, name: str, args: dict | None = None, timeout: float = 2):
        future = self.submit(name, args)
        return future.result(timeout=timeout)

    def _invoke(self, callback):
        self.context.invoke_full(GLib.PRIORITY_DEFAULT, callback)

    def _thread_main(self):
        self.context.push_thread_default()
        try:
            self.loop.run()
        finally:
            self.context.pop_thread_default()

    def _process_commands(self):
        while True:
            try:
                cmd = self.commands.get_nowait()
            except queue.Empty:
                break

            try:
                result = self._handle_command(cmd)
                cmd.future.set_result(result)
            except Exception as exc:
                cmd.future.set_exception(exc)

        return False

    def _handle_command(self, cmd: Command):
        name = cmd.name
        args = cmd.args

        if name == CMD_INIT:
            return self._init_pipeline()
        if name == CMD_SHUTDOWN:
            return self._shutdown_pipeline()
        if name == CMD_SET_FPS:
            return self._set_fps(args["fps"])
        if name == CMD_STATUS:
            return self._status()

        raise RuntimeError(f"Unknown command: {name}")

    def _init_pipeline(self):
        if self.started:
            return {"ok": True, "already_started": True, **self._status_fields()}

        self.pipeline = Gst.parse_launch(
            "v4l2src device=/dev/video0 "
            "! video/x-raw,format=YUY2,width=640,height=480,framerate=30/1 "
            "! videorate name=rate drop-only=true "
            "! capsfilter name=fps_caps "
            "! videoconvert "
            "! fpsdisplaysink sync=false "
        )

        self.capsfilter = self.pipeline.get_by_name("fps_caps")
        if self.capsfilter is None:
            raise RuntimeError("Could not find fps capsfilter")

        self._apply_fps_caps(self.fps)

        bus = self.pipeline.get_bus()
        bus.add_signal_watch()
        bus.connect("message", self._on_bus_message)

        ret = self.pipeline.set_state(Gst.State.PLAYING)
        if ret == Gst.StateChangeReturn.FAILURE:
            raise RuntimeError("Failed to start FPS control pipeline")

        self.started = True
        return {"ok": True, **self._status_fields()}

    def _shutdown_pipeline(self):
        if self.pipeline is not None:
            self.pipeline.set_state(Gst.State.NULL)
            self.pipeline = None
            self.capsfilter = None

        self.started = False
        return {"ok": True, **self._status_fields()}

    def _set_fps(self, fps: int):
        fps = self._validate_fps(fps)
        if self.capsfilter is None:
            raise RuntimeError("Pipeline is not running")

        self._apply_fps_caps(fps)
        self.fps = fps
        return {"ok": True, **self._status_fields()}

    def _validate_fps(self, fps: int):
        if fps not in SUPPORTED_FPS:
            raise ValueError(f"FPS must be one of: {', '.join(map(str, SUPPORTED_FPS))}")
        return fps

    def _apply_fps_caps(self, fps: int):
        caps = Gst.Caps.from_string(f"video/x-raw,framerate={fps}/1")
        self.capsfilter.set_property("caps", caps)

    def _status(self):
        return {"ok": True, **self._status_fields()}

    def _status_fields(self):
        return {
            "started": self.started,
            "fps": self.fps,
            "supported_fps": list(SUPPORTED_FPS),
            "has_pipeline": self.pipeline is not None,
            "has_capsfilter": self.capsfilter is not None,
        }

    def _on_bus_message(self, bus, message):
        msg_type = message.type

        if msg_type == Gst.MessageType.ERROR:
            err, debug = message.parse_error()
            print(f"GStreamer error: {err}")
            if debug:
                print(f"GStreamer debug: {debug}")

        elif msg_type == Gst.MessageType.EOS:
            print("GStreamer EOS")
fps_app.py
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path

import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel

try:
    from .fps_control import (
        CMD_SET_FPS,
        CMD_STATUS,
        FpsController,
    )
except ImportError:
    from fps_control import (
        CMD_SET_FPS,
        CMD_STATUS,
        FpsController,
    )


STATIC_DIR = Path(__file__).with_name("static")

fps_controller = FpsController()


@asynccontextmanager
async def lifespan(app: FastAPI):
    fps_controller.start()
    try:
        yield
    finally:
        fps_controller.stop()


app = FastAPI(lifespan=lifespan)


class FpsRequest(BaseModel):
    fps: int


async def await_fps_command(name: str, args: dict | None = None):
    try:
        future = fps_controller.submit(name, args)
        return await asyncio.wait_for(
            asyncio.wrap_future(future),
            timeout=3,
        )
    except asyncio.TimeoutError:
        raise HTTPException(
            status_code=504,
            detail=f"GStreamer FPS command timed out: {name}",
        )
    except ValueError as exc:
        raise HTTPException(
            status_code=400,
            detail=str(exc),
        )
    except Exception as exc:
        raise HTTPException(
            status_code=500,
            detail=str(exc),
        )


@app.get("/")
async def index():
    return FileResponse(STATIC_DIR / "fps.html")


@app.get("/status")
async def status():
    return await await_fps_command(CMD_STATUS)


@app.post("/fps")
async def set_fps(req: FpsRequest):
    return await await_fps_command(
        CMD_SET_FPS,
        {"fps": req.fps},
    )


def main() -> None:
    uvicorn.run(app, host="0.0.0.0", port=8002)


if __name__ == "__main__":
    main()
static/fps.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>BT FPS Control</title>
  <style>
    :root {
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      color-scheme: light dark;
    }

    body {
      margin: 0;
      padding: 24px;
      background: #101418;
      color: #edf2f7;
    }

    main {
      max-width: 720px;
      margin: 0 auto;
    }

    h1 {
      margin: 0 0 20px;
      font-size: 28px;
      letter-spacing: 0;
    }

    section {
      margin-bottom: 16px;
      padding: 16px;
      border: 1px solid #2d3748;
      border-radius: 8px;
      background: #151b22;
    }

    .fps-buttons {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }

    button {
      min-width: 96px;
      padding: 9px 14px;
      border: 1px solid #3182ce;
      border-radius: 6px;
      background: #2b6cb0;
      color: #edf2f7;
      cursor: pointer;
      font-size: 14px;
    }

    button.active {
      border-color: #68d391;
      background: #2f855a;
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    dl {
      display: grid;
      grid-template-columns: 140px 1fr;
      gap: 8px 12px;
      margin: 0;
    }

    dt {
      color: #a0aec0;
    }

    dd {
      margin: 0;
      word-break: break-word;
    }

    #message {
      min-height: 22px;
      color: #90cdf4;
    }

    #message.error {
      color: #feb2b2;
    }

    @media (max-width: 520px) {
      body {
        padding: 16px;
      }

      dl {
        grid-template-columns: 1fr;
      }

      button {
        width: 100%;
      }
    }
  </style>
</head>
<body>
  <main>
    <h1>BT FPS Control</h1>

    <section class="fps-buttons">
      <button data-fps="1">1 FPS</button>
      <button data-fps="5">5 FPS</button>
      <button data-fps="10">10 FPS</button>
      <button data-fps="20">20 FPS</button>
      <button data-fps="30">30 FPS</button>
    </section>

    <section>
      <dl>
        <dt>Pipeline</dt>
        <dd id="started">unknown</dd>
        <dt>Current FPS</dt>
        <dd id="fps">unknown</dd>
        <dt>Pipeline object</dt>
        <dd id="pipeline">unknown</dd>
        <dt>Caps filter</dt>
        <dd id="capsfilter">unknown</dd>
      </dl>
    </section>

    <section id="message"></section>
  </main>

  <script>
    const buttons = Array.from(document.querySelectorAll("[data-fps]"));
    const startedEl = document.getElementById("started");
    const fpsEl = document.getElementById("fps");
    const pipelineEl = document.getElementById("pipeline");
    const capsfilterEl = document.getElementById("capsfilter");
    const messageEl = document.getElementById("message");

    function yesNo(value) {
      return value ? "yes" : "no";
    }

    function setMessage(text, isError = false) {
      messageEl.textContent = text || "";
      messageEl.className = isError ? "error" : "";
    }

    async function requestJson(url, options = {}) {
      const response = await fetch(url, options);
      const body = await response.json().catch(() => ({}));
      if (!response.ok) {
        throw new Error(body.detail || `HTTP ${response.status}`);
      }
      return body;
    }

    function renderStatus(status) {
      startedEl.textContent = yesNo(status.started);
      fpsEl.textContent = `${status.fps} FPS`;
      pipelineEl.textContent = status.has_pipeline ? "ready" : "missing";
      capsfilterEl.textContent = status.has_capsfilter ? "ready" : "missing";

      for (const button of buttons) {
        const fps = Number(button.dataset.fps);
        button.disabled = !status.started || !status.has_capsfilter;
        button.classList.toggle("active", fps === status.fps);
      }
    }

    async function refreshStatus() {
      const status = await requestJson("/status");
      renderStatus(status);
      return status;
    }

    for (const button of buttons) {
      button.addEventListener("click", async () => {
        try {
          const fps = Number(button.dataset.fps);
          const status = await requestJson("/fps", {
            method: "POST",
            headers: {"content-type": "application/json"},
            body: JSON.stringify({fps}),
          });
          renderStatus(status);
          setMessage(`FPS set to ${status.fps}`);
        } catch (err) {
          setMessage(err.message, true);
          await refreshStatus().catch(() => {});
        }
      });
    }

    refreshStatus().catch((err) => setMessage(err.message, true));
    setInterval(() => {
      refreshStatus().catch((err) => setMessage(err.message, true));
    }, 2000);
  </script>
</body>
</html>