Skip to content

Control Video Bandwidth

This demo controls the bandwidth of a live camera stream without rebuilding the pipeline. It changes three things while the stream is running:

  • videocrop reduces the visible frame area before encoding.
  • videorate plus a capsfilter lowers the output FPS.
  • x264enc properties control encoder bitrate and keyframe interval.

The example sends RTP/H.264 over UDP to 127.0.0.1:5600 and exposes a FastAPI server for runtime control.

Pipeline

The pipeline starts with a fixed camera mode, then applies crop, FPS, encoder, and RTP payload stages:

self.pipeline = Gst.parse_launch(
    "v4l2src device=/dev/video0 ! "
    "video/x-raw,format=YUY2,width=640,height=480,framerate=30/1 ! "
    "videocrop name=center_crop ! "
    "videorate name=rate drop-only=true ! "
    "capsfilter name=stream_caps ! "
    "videoconvert ! "
    "x264enc name=encoder "
    "bitrate=300 speed-preset=ultrafast tune=zerolatency "
    "key-int-max=30 vbv-buf-capacity=1000 "
    "bframes=0 byte-stream=true ! "
    "h264parse config-interval=1 ! "
    "rtph264pay pt=96 mtu=1400 config-interval=1 ! "
    "identity name=bandwidth_meter silent=true ! "
    "udpsink host=127.0.0.1 port=5600 sync=false async=false"
)

The named elements are saved after Gst.parse_launch():

1
2
3
4
self.crop = self.pipeline.get_by_name("center_crop")
self.capsfilter = self.pipeline.get_by_name("stream_caps")
self.encoder = self.pipeline.get_by_name("encoder")
self.bandwidth_meter = self.pipeline.get_by_name("bandwidth_meter")

Reduce Resolution With videocrop

videocrop does not ask the camera for a new mode. The camera still captures 640x480, and the pipeline crops the center of that frame before encoding. This is useful when the camera does not support the exact smaller resolution you want to stream.

For a target size, the controller calculates the crop borders:

1
2
3
4
5
6
def _center_crop_values(self, width: int, height: int):
    left = (CAMERA_WIDTH - width) // 2
    right = CAMERA_WIDTH - width - left
    top = (CAMERA_HEIGHT - height) // 2
    bottom = CAMERA_HEIGHT - height - top
    return {"left": left, "right": right, "top": top, "bottom": bottom}

Then it applies those values to videocrop:

1
2
3
4
self.crop.set_property("left", crop["left"])
self.crop.set_property("right", crop["right"])
self.crop.set_property("top", crop["top"])
self.crop.set_property("bottom", crop["bottom"])

A 320x240 output from a 640x480 camera frame crops 160 pixels from the left and right, and 120 pixels from the top and bottom.

Lower FPS With videorate

videorate adapts the buffer cadence. In this demo it is configured with drop-only=true, so lowering the FPS drops frames instead of duplicating frames to raise FPS.

The output rate is controlled by the capsfilter after videorate:

1
2
3
4
caps = Gst.Caps.from_string(
    f"video/x-raw,width={width},height={height},framerate={fps}/1"
)
self.capsfilter.set_property("caps", caps)

For example, setting fps=10 produces:

video/x-raw,width=640,height=480,framerate=10/1

Lower FPS reduces bandwidth because fewer frames reach the encoder.

Control Encoder Bandwidth

The encoder is the main bandwidth control point. x264enc uses bitrate in kilobits per second:

self.encoder.set_property("bitrate", bitrate_kbps)
self.encoder.set_property("key-int-max", key_int_max)

The demo starts with:

1
2
3
4
5
bitrate=300
key-int-max=30
vbv-buf-capacity=1000
tune=zerolatency
speed-preset=ultrafast

Use lower bitrate values for constrained links. Use a shorter keyframe interval when the receiver must recover quickly after stream changes, at the cost of more bandwidth.

After changing encoder settings, the controller asks the encoder to produce a fresh keyframe:

1
2
3
4
5
6
7
8
event = GstVideo.video_event_new_downstream_force_key_unit(
    Gst.CLOCK_TIME_NONE,
    Gst.CLOCK_TIME_NONE,
    0,
    True,
    0,
)
sinkpad.send_event(event)

GLib.MainContext()

FastAPI handles HTTP requests on the asyncio side. GStreamer owns the pipeline on a GLib thread. The controller uses GLib.MainContext() and a command queue so pipeline changes are applied from the GStreamer thread.

1
2
3
4
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)

Each API request submits a command:

future = stream_controller.submit(name, args)
return await asyncio.wait_for(asyncio.wrap_future(future), timeout=3)

The controller wakes the GLib context and handles queued commands:

1
2
3
4
5
6
7
8
def submit(self, name: str, args: dict | None = None) -> Future:
    future = Future()
    self.commands.put(Command(name=name, args=args or {}, future=future))
    self._invoke(self._process_commands)
    return future

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

This keeps the web server responsive while the pipeline state and element properties remain owned by the GStreamer side.

API

Start the app:

python3 bandwidth_app.py

Open the browser UI:

http://localhost:8002

Useful endpoints:

1
2
3
4
GET  /status
GET  /profiles
POST /profile
POST /stream

Apply the smaller low-bandwidth profile:

1
2
3
curl -X POST http://localhost:8002/profile \
  -H "Content-Type: application/json" \
  -d '{"profile":"center_320x240"}'

Apply custom stream settings:

1
2
3
curl -X POST http://localhost:8002/stream \
  -H "Content-Type: application/json" \
  -d '{"width":320,"height":240,"fps":10,"bitrate_kbps":100,"key_int_max":30}'

Receiver Pipeline

Run this receiver in another terminal:

1
2
3
4
5
6
7
gst-launch-1.0 -v \
  udpsrc port=5600 caps="application/x-rtp,media=video,encoding-name=H264,payload=96,clock-rate=90000" ! \
  rtph264depay ! \
  h264parse ! \
  avdec_h264 ! \
  videoconvert ! \
  autovideosink sync=false

Measure Bandwidth

The pipeline includes an identity element named bandwidth_meter. A pad probe counts outgoing RTP bytes over a rolling one-second window:

srcpad = self.bandwidth_meter.get_static_pad("src")
srcpad.add_probe(Gst.PadProbeType.BUFFER, self._on_bandwidth_buffer)

The /status response reports:

1
2
3
4
measured_bandwidth_bps
measured_bandwidth_kbps
measured_window_seconds
measured_total_bytes

You can also inspect the UDP traffic with iftop:

sudo iftop -i lo -f "udp port 5600"

Demo Code

bandwidth_control.py
import time
import queue
import threading
from collections import deque
from concurrent.futures import Future
from dataclasses import dataclass
from typing import Any

import gi

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

from gi.repository import GLib, Gst, GstVideo


Gst.init(None)


CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
DEFAULT_WIDTH = 640
DEFAULT_HEIGHT = 480
DEFAULT_FPS = 30
DEFAULT_BITRATE_KBPS = 300
DEFAULT_KEY_INT_MAX = 30
SUPPORTED_KEY_INT_MAX = (30, 60, 90)
DEFAULT_VBV_BUF_CAPACITY = 1000
BANDWIDTH_WINDOW_SECONDS = 1.0

CMD_INIT = "init"
CMD_SHUTDOWN = "shutdown"
CMD_STATUS = "status"
CMD_SET_STREAM = "set_stream"


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


class StreamController:
    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.crop = None
        self.capsfilter = None
        self.encoder = None
        self.bandwidth_meter = None
        self.started = False

        self.bandwidth_lock = threading.Lock()
        self.bandwidth_samples = deque()
        self.bandwidth_total_bytes = 0

        self.width = DEFAULT_WIDTH
        self.height = DEFAULT_HEIGHT
        self.fps = DEFAULT_FPS
        self.bitrate_kbps = DEFAULT_BITRATE_KBPS
        self.key_int_max = DEFAULT_KEY_INT_MAX

    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()
        self.commands.put(Command(name=name, args=args or {}, future=future))
        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:
                cmd.future.set_result(self._handle_command(cmd))
            except Exception as exc:
                cmd.future.set_exception(exc)

        return False

    def _handle_command(self, cmd: Command):
        if cmd.name == CMD_INIT:
            return self._init_pipeline()
        if cmd.name == CMD_SHUTDOWN:
            return self._shutdown_pipeline()
        if cmd.name == CMD_STATUS:
            return {"ok": True, **self._status_fields()}
        if cmd.name == CMD_SET_STREAM:
            return self._set_stream(**cmd.args)

        raise RuntimeError(f"Unknown command: {cmd.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 ! "
            "videocrop name=center_crop ! "
            "videorate name=rate drop-only=true ! "
            "capsfilter name=stream_caps ! "
            "videoconvert ! "
            "x264enc name=encoder "
            "bitrate=300 speed-preset=ultrafast tune=zerolatency "
            "key-int-max=30 vbv-buf-capacity=1000 "
            "bframes=0 byte-stream=true ! "
            "h264parse config-interval=1 ! "
            "rtph264pay pt=96 mtu=1400 config-interval=1 ! "
            "identity name=bandwidth_meter silent=true ! "
            "udpsink host=127.0.0.1 port=5600 sync=false async=false"
        )

        self.crop = self.pipeline.get_by_name("center_crop")
        self.capsfilter = self.pipeline.get_by_name("stream_caps")
        self.encoder = self.pipeline.get_by_name("encoder")
        self.bandwidth_meter = self.pipeline.get_by_name("bandwidth_meter")

        if (
            self.crop is None
            or self.capsfilter is None
            or self.encoder is None
            or self.bandwidth_meter is None
        ):
            raise RuntimeError("Could not find required GStreamer elements")

        srcpad = self.bandwidth_meter.get_static_pad("src")
        if srcpad is None:
            raise RuntimeError("Could not find bandwidth meter src pad")
        srcpad.add_probe(Gst.PadProbeType.BUFFER, self._on_bandwidth_buffer)

        self._apply_stream_settings(
            self.width,
            self.height,
            self.fps,
            self.bitrate_kbps,
            self.key_int_max,
            force_keyframe=False,
        )

        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 bandwidth 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.crop = None
            self.capsfilter = None
            self.encoder = None
            self.bandwidth_meter = None

        with self.bandwidth_lock:
            self.bandwidth_samples.clear()
            self.bandwidth_total_bytes = 0

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

    def _set_stream(
        self,
        width: int,
        height: int,
        fps: int,
        bitrate_kbps: int,
        key_int_max: int,
    ):
        if not self.started:
            raise RuntimeError("Pipeline is not running")

        self._validate_stream_settings(width, height, fps, bitrate_kbps, key_int_max)
        self._apply_stream_settings(width, height, fps, bitrate_kbps, key_int_max)

        self.width = width
        self.height = height
        self.fps = fps
        self.bitrate_kbps = bitrate_kbps
        self.key_int_max = key_int_max

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

    def _validate_stream_settings(
        self,
        width: int,
        height: int,
        fps: int,
        bitrate_kbps: int,
        key_int_max: int,
    ):
        if width <= 0 or width > CAMERA_WIDTH:
            raise ValueError(f"width must be between 1 and {CAMERA_WIDTH}")
        if height <= 0 or height > CAMERA_HEIGHT:
            raise ValueError(f"height must be between 1 and {CAMERA_HEIGHT}")
        if fps <= 0 or fps > DEFAULT_FPS:
            raise ValueError(f"fps must be between 1 and {DEFAULT_FPS}")
        if bitrate_kbps <= 0:
            raise ValueError("bitrate_kbps must be greater than 0")
        if key_int_max not in SUPPORTED_KEY_INT_MAX:
            supported = ", ".join(map(str, SUPPORTED_KEY_INT_MAX))
            raise ValueError(f"key_int_max must be one of: {supported}")

    def _apply_stream_settings(
        self,
        width: int,
        height: int,
        fps: int,
        bitrate_kbps: int,
        key_int_max: int,
        force_keyframe: bool = True,
    ):
        crop = self._center_crop_values(width, height)

        self.crop.set_property("left", crop["left"])
        self.crop.set_property("right", crop["right"])
        self.crop.set_property("top", crop["top"])
        self.crop.set_property("bottom", crop["bottom"])

        caps = Gst.Caps.from_string(
            f"video/x-raw,width={width},height={height},framerate={fps}/1"
        )
        self.capsfilter.set_property("caps", caps)
        self.encoder.set_property("bitrate", bitrate_kbps)
        self.encoder.set_property("key-int-max", key_int_max)

        if force_keyframe:
            self._force_keyframe()

    def _force_keyframe(self):
        sinkpad = self.encoder.get_static_pad("sink")
        event = GstVideo.video_event_new_downstream_force_key_unit(
            Gst.CLOCK_TIME_NONE,
            Gst.CLOCK_TIME_NONE,
            0,
            True,
            0,
        )
        sinkpad.send_event(event)

    def _status_fields(self):
        crop = self._center_crop_values(self.width, self.height)
        return {
            "started": self.started,
            "camera_width": CAMERA_WIDTH,
            "camera_height": CAMERA_HEIGHT,
            "width": self.width,
            "height": self.height,
            "fps": self.fps,
            "bitrate_kbps": self.bitrate_kbps,
            "key_int_max": self.key_int_max,
            "vbv_buf_capacity": DEFAULT_VBV_BUF_CAPACITY,
            "crop": crop,
            "udp_host": "127.0.0.1",
            "udp_port": 5600,
            "has_pipeline": self.pipeline is not None,
            "has_crop": self.crop is not None,
            "has_capsfilter": self.capsfilter is not None,
            "has_encoder": self.encoder is not None,
            "has_bandwidth_meter": self.bandwidth_meter is not None,
            **self._bandwidth_fields(),
        }

    def _center_crop_values(self, width: int, height: int):
        left = (CAMERA_WIDTH - width) // 2
        right = CAMERA_WIDTH - width - left
        top = (CAMERA_HEIGHT - height) // 2
        bottom = CAMERA_HEIGHT - height - top
        return {"left": left, "right": right, "top": top, "bottom": bottom}

    def _on_bandwidth_buffer(self, pad, info):
        buffer = info.get_buffer()
        if buffer is None:
            return Gst.PadProbeReturn.OK

        now = time.monotonic()
        size = buffer.get_size()

        with self.bandwidth_lock:
            self.bandwidth_total_bytes += size
            self.bandwidth_samples.append((now, size))
            self._drop_old_bandwidth_samples(now)

        return Gst.PadProbeReturn.OK

    def _bandwidth_fields(self):
        now = time.monotonic()

        with self.bandwidth_lock:
            self._drop_old_bandwidth_samples(now)
            window_bytes = sum(size for _, size in self.bandwidth_samples)
            if self.bandwidth_samples:
                elapsed = max(now - self.bandwidth_samples[0][0], 0.001)
            else:
                elapsed = BANDWIDTH_WINDOW_SECONDS
            total_bytes = self.bandwidth_total_bytes

        bandwidth_bps = int((window_bytes * 8) / elapsed)

        return {
            "measured_bandwidth_bps": bandwidth_bps,
            "measured_bandwidth_kbps": round(bandwidth_bps / 1000, 1),
            "measured_window_seconds": BANDWIDTH_WINDOW_SECONDS,
            "measured_total_bytes": total_bytes,
        }

    def _drop_old_bandwidth_samples(self, now: float):
        cutoff = now - BANDWIDTH_WINDOW_SECONDS
        while self.bandwidth_samples and self.bandwidth_samples[0][0] < cutoff:
            self.bandwidth_samples.popleft()

    def _on_bus_message(self, bus, message):
        if message.type == Gst.MessageType.ERROR:
            err, debug = message.parse_error()
            print(f"GStreamer error: {err}")
            if debug:
                print(f"GStreamer debug: {debug}")
        elif message.type == Gst.MessageType.EOS:
            print("GStreamer EOS")
bandwidth_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, Field

try:
    from .bandwidth_control import (
        CAMERA_HEIGHT,
        CAMERA_WIDTH,
        CMD_SET_STREAM,
        CMD_STATUS,
        DEFAULT_FPS,
        StreamController,
    )
except ImportError:
    from bandwidth_control import (
        CAMERA_HEIGHT,
        CAMERA_WIDTH,
        CMD_SET_STREAM,
        CMD_STATUS,
        DEFAULT_FPS,
        StreamController,
    )


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

stream_controller = StreamController()


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


app = FastAPI(lifespan=lifespan)


class StreamRequest(BaseModel):
    width: int = Field(..., gt=0, le=CAMERA_WIDTH)
    height: int = Field(..., gt=0, le=CAMERA_HEIGHT)
    fps: int = Field(..., gt=0, le=DEFAULT_FPS)
    bitrate_kbps: int = Field(..., gt=0)
    key_int_max: int = Field(..., gt=0)


class ProfileRequest(BaseModel):
    profile: str


PROFILES = {
    "normal": {
        "width": 640,
        "height": 480,
        "fps": 30,
        "bitrate_kbps": 300,
        "key_int_max": 30,
    },
    "center_320x240": {
        "width": 320,
        "height": 240,
        "fps": 15,
        "bitrate_kbps": 100,
        "key_int_max": 30,
    },
}


async def await_stream_command(name: str, args: dict | None = None):
    try:
        future = stream_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 stream 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 / "bandwidth.html")


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


@app.get("/profiles")
async def profiles():
    return {"profiles": PROFILES}


@app.post("/stream")
async def set_stream(req: StreamRequest):
    return await await_stream_command(CMD_SET_STREAM, req.dict())


@app.post("/profile")
async def set_profile(req: ProfileRequest):
    if req.profile not in PROFILES:
        supported = ", ".join(PROFILES)
        raise HTTPException(
            status_code=400,
            detail=f"profile must be one of: {supported}",
        )

    return await await_stream_command(CMD_SET_STREAM, PROFILES[req.profile])


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


if __name__ == "__main__":
    main()
videoscale_demo.py

static/bandwidth.html
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Bandwidth Stream 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: 780px;
      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;
    }

    .row {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      align-items: end;
    }

    .button-group {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      margin-top: 10px;
    }

    h2 {
      margin: 0 0 12px;
      font-size: 18px;
      letter-spacing: 0;
    }

    label {
      display: grid;
      gap: 6px;
      min-width: 130px;
      color: #a0aec0;
      font-size: 14px;
    }

    input {
      width: 100%;
      box-sizing: border-box;
      padding: 8px 10px;
      border: 1px solid #4a5568;
      border-radius: 6px;
      background: #111827;
      color: #edf2f7;
      font-size: 14px;
    }

    button {
      min-width: 132px;
      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: 150px 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: 560px) {
      body {
        padding: 16px;
      }

      label,
      button {
        width: 100%;
      }

      dl {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <main>
    <h1>Bandwidth Stream Control</h1>

    <section>
      <h2>Crop</h2>
      <div class="button-group">
        <button data-profile="normal">Normal 640x480</button>
        <button data-profile="center_320x240">Center 320x240</button>
      </div>
    </section>

    <section>
      <h2>FPS</h2>
      <div class="button-group">
        <button data-fps="1">1 FPS</button>
        <button data-fps="5">5 FPS</button>
        <button data-fps="10">10 FPS</button>
        <button data-fps="15">15 FPS</button>
        <button data-fps="30">30 FPS</button>
      </div>
    </section>

    <section>
      <h2>Encoder</h2>
      <div class="button-group">
        <button data-bitrate="100">100 kbps</button>
        <button data-bitrate="200">200 kbps</button>
        <button data-bitrate="300">300 kbps</button>
        <button data-bitrate="500">500 kbps</button>
        <button data-bitrate="1000">1000 kbps</button>
      </div>
      <div class="button-group">
        <button data-key-int-max="30">Keyframe 30</button>
        <button data-key-int-max="60">Keyframe 60</button>
        <button data-key-int-max="90">Keyframe 90</button>
      </div>
    </section>

    <section>
      <dl>
        <dt>Pipeline</dt>
        <dd id="started">unknown</dd>
        <dt>Output</dt>
        <dd id="output">unknown</dd>
        <dt>Bitrate</dt>
        <dd id="bitrate-status">unknown</dd>
        <dt>Measured bandwidth</dt>
        <dd id="measured-bandwidth">unknown</dd>
        <dt>Keyframe interval</dt>
        <dd id="key-int-status">unknown</dd>
        <dt>Center crop</dt>
        <dd id="crop">unknown</dd>
        <dt>UDP</dt>
        <dd id="udp">unknown</dd>
        <dt>Elements</dt>
        <dd id="elements">unknown</dd>
      </dl>
    </section>

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

  <script>
    const profileButtons = Array.from(document.querySelectorAll("[data-profile]"));
    const fpsButtons = Array.from(document.querySelectorAll("[data-fps]"));
    const bitrateButtons = Array.from(document.querySelectorAll("[data-bitrate]"));
    const keyIntButtons = Array.from(document.querySelectorAll("[data-key-int-max]"));
    const startedEl = document.getElementById("started");
    const outputEl = document.getElementById("output");
    const bitrateStatusEl = document.getElementById("bitrate-status");
    const measuredBandwidthEl = document.getElementById("measured-bandwidth");
    const keyIntStatusEl = document.getElementById("key-int-status");
    const cropEl = document.getElementById("crop");
    const udpEl = document.getElementById("udp");
    const elementsEl = document.getElementById("elements");
    const messageEl = document.getElementById("message");
    let currentWidth = 640;
    let currentHeight = 480;
    let selectedFps = 30;
    let selectedBitrateKbps = 300;
    let selectedKeyIntMax = 30;

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

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

    function formatBandwidth(kbps) {
      if (kbps >= 1000) {
        return `${(kbps / 1000).toFixed(2)} Mbps`;
      }
      return `${kbps.toFixed(1)} kbps`;
    }

    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 currentProfile(status) {
      if (status.width === 640 && status.height === 480) {
        return "normal";
      }
      if (status.width === 320 && status.height === 240) {
        return "center_320x240";
      }
      return "";
    }

    function renderStatus(status) {
      startedEl.textContent = yesNo(status.started);
      outputEl.textContent = `${status.width}x${status.height} @ ${status.fps} FPS`;
      bitrateStatusEl.textContent = `${status.bitrate_kbps} kbps`;
      measuredBandwidthEl.textContent = formatBandwidth(status.measured_bandwidth_kbps);
      keyIntStatusEl.textContent = status.key_int_max;
      cropEl.textContent = `left ${status.crop.left}, right ${status.crop.right}, top ${status.crop.top}, bottom ${status.crop.bottom}`;
      udpEl.textContent = `${status.udp_host}:${status.udp_port}`;
      elementsEl.textContent = [
        status.has_pipeline ? "pipeline" : "",
        status.has_crop ? "crop" : "",
        status.has_capsfilter ? "capsfilter" : "",
        status.has_encoder ? "encoder" : "",
        status.has_bandwidth_meter ? "meter" : "",
      ].filter(Boolean).join(", ") || "missing";

      currentWidth = status.width;
      currentHeight = status.height;
      selectedFps = status.fps;
      selectedBitrateKbps = status.bitrate_kbps;
      selectedKeyIntMax = status.key_int_max;

      const activeProfile = currentProfile(status);
      for (const button of profileButtons) {
        button.disabled = !status.started;
        button.classList.toggle("active", button.dataset.profile === activeProfile);
      }
      for (const button of fpsButtons) {
        const fps = Number(button.dataset.fps);
        button.disabled = !status.started;
        button.classList.toggle("active", fps === status.fps);
      }
      for (const button of bitrateButtons) {
        const bitrateKbps = Number(button.dataset.bitrate);
        button.disabled = !status.started;
        button.classList.toggle("active", bitrateKbps === status.bitrate_kbps);
      }
      for (const button of keyIntButtons) {
        const keyIntMax = Number(button.dataset.keyIntMax);
        button.disabled = !status.started;
        button.classList.toggle("active", keyIntMax === status.key_int_max);
      }
    }

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

    async function applyStreamSettings(width, height, fps, bitrateKbps, keyIntMax) {
      const payload = {
        width,
        height,
        fps,
        bitrate_kbps: bitrateKbps,
        key_int_max: keyIntMax,
      };
      const status = await requestJson("/stream", {
        method: "POST",
        headers: {"content-type": "application/json"},
        body: JSON.stringify(payload),
      });
      renderStatus(status);
      return status;
    }

    for (const button of profileButtons) {
      button.addEventListener("click", async () => {
        try {
          const profile = button.dataset.profile;
          const width = profile === "normal" ? 640 : 320;
          const height = profile === "normal" ? 480 : 240;
          const status = await applyStreamSettings(
            width,
            height,
            selectedFps,
            selectedBitrateKbps,
            selectedKeyIntMax,
          );
          setMessage(`Applied ${button.textContent}`);
        } catch (err) {
          setMessage(err.message, true);
          await refreshStatus().catch(() => {});
        }
      });
    }

    for (const button of fpsButtons) {
      button.addEventListener("click", async () => {
        try {
          selectedFps = Number(button.dataset.fps);
          const status = await applyStreamSettings(
            currentWidth,
            currentHeight,
            selectedFps,
            selectedBitrateKbps,
            selectedKeyIntMax,
          );
          setMessage(`FPS set to ${status.fps}`);
        } catch (err) {
          setMessage(err.message, true);
          await refreshStatus().catch(() => {});
        }
      });
    }

    for (const button of bitrateButtons) {
      button.addEventListener("click", async () => {
        try {
          selectedBitrateKbps = Number(button.dataset.bitrate);
          const status = await applyStreamSettings(
            currentWidth,
            currentHeight,
            selectedFps,
            selectedBitrateKbps,
            selectedKeyIntMax,
          );
          setMessage(`Bitrate set to ${status.bitrate_kbps} kbps`);
        } catch (err) {
          setMessage(err.message, true);
          await refreshStatus().catch(() => {});
        }
      });
    }

    for (const button of keyIntButtons) {
      button.addEventListener("click", async () => {
        try {
          selectedKeyIntMax = Number(button.dataset.keyIntMax);
          const status = await applyStreamSettings(
            currentWidth,
            currentHeight,
            selectedFps,
            selectedBitrateKbps,
            selectedKeyIntMax,
          );
          setMessage(`Keyframe interval set to ${status.key_int_max}`);
        } 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>