Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/container-codecs.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Container (H.264/H.265)

# The image variant with a CEF build that plays H.264 and H.265 (docs/proprietary-codecs.md).
# A tag codecs-<version> (for example codecs-1.3.4) builds that commit and pushes
# A tag codecs-<version> (for example codecs-1.4.0) builds that commit and pushes
# <version>-codecs. Normal releases (v*.*.*) are not touched.
on:
push:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 1.4.0 (2026-11-06)

- **Interaction preview stays current on a slow path, also behind a proxy** (SPEC §7.2, §7.3). 1.3.4 limits what the kernel holds unsent, but a reverse proxy between the function and the browser reads everything at once and holds the previews in its own buffers: in the lab (2 Mbit/s behind a buffering proxy, 50 KB previews) they arrived 10–11 s old; at 500 kbit/s the delay grew to 14 s and the session was closed as stuck after 20 s, which ended its control.
- **Preview flow control:** a client that answers each preview frame with `{"type":"preview_ack"}` has at most 2 previews unanswered; a new picture skips it until an answer comes (counted in `preview_frames_dropped_total`) and then the newest picture goes out. Previews sent before the first answer count too, so what a proxy buffered then is drained first. A session is closed as `preview_not_taken` only when new pictures were skipped for 5 s without an answer (a static page closes no one). Clients that never send `preview_ack` work as before. The web UI and the widgets answer every frame. `preview_ack` is not counted against the input limit.
- **Kernel limit 32 KiB** instead of 64 KiB (`TCP_NOTSENT_LOWAT`): measured with 16, 32, 64 and 128 KiB at 2 Mbit/s and 40 ms; 16 and 32 KiB were equal and best, with no frame-rate loss at 6 and 16 Mbit/s.
- Lab, preview age at the client (median), 25 fps asked, 50 KB previews, 40 ms round trip: direct at 2 Mbit/s 2.8 s (1.3.3), 0.79 s (1.3.4), 0.36 s now; behind the proxy at 2 Mbit/s 10.1 s (1.3.4), 0.22 s now; behind the proxy at 500 kbit/s 14 s and closed (1.3.4), 1.1 s and control kept for 60 s now. Cost: on a 16 Mbit/s path with 40 ms round trip the preview runs at 17 instead of 22 fps (and is 54 instead of 80 ms old); on a LAN nothing changes.
- **Stuck-close log line:** `websocket_stuck_close` now also has the interaction `session` (0 on `/api/v1/events`) and `oldest_ms`, the age of the oldest waiting message. The counter `websocket_stuck_closes_total{reason}` is unchanged.
- Tests: unit tests for the window (per session, acks before the next send, a frame that was not sent, acks past the input limit, a backlog before the first ack, the cheap `preview_ack` check) and for a held-back preview counting towards the stuck close and an answer resetting it; `tests/unit/web/interact.test.mjs` (the UI answers every frame; `preview_ack` in the schema); `tests/integration/interact_test.py` (2 previews unanswered, 1 more per answer, closed and counted 5 s after the last).
- **`BROWSER_PREVIEW_QUALITY`** (default 75, 10–100, restart): the JPEG quality of the previews (`preview.jpg` and the interaction preview). Lower makes each picture smaller, so a slow link carries more of them.

## 1.3.4 (2026-11-05)

- **Interaction preview shows every new picture** (SPEC §7.3). The WebSocket preview waited only 20 ms for a new preview and otherwise sent the previous JPEG again: on a busy page (YouTube on test-all-gfx) about 2 of 9 frames per second were new, so the preview stood still for seconds. It now waits for the preview like `preview.jpg` (up to 100 ms), and a session never gets the same picture twice.
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.24 FATAL_ERROR)

project(mxl-browser-source
VERSION 1.3.4
VERSION 1.4.0
DESCRIPTION "MXL media function: a web page rendered offscreen with CEF"
LANGUAGES CXX C
)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The most used settings (all of them, with defaults and "restart required" marker
| `BROWSER_FRAME_CLOCK_PHASE_MS` | `0` | internal frame clock: its BeginFrames this many ms after each grain start |
| `BROWSER_REALTIME` | `true` | tick and converter threads `SCHED_FIFO` when the container allows it: give it `--ulimit rtprio=10` (Compose `ulimits: {rtprio: 10}`); `--cap-add SYS_NICE` alone does not help a non-root process (SPEC §2.2) |
| `BROWSER_CONVERT_THREADS` | `4` | threads that convert one frame (1–16) |
| `BROWSER_PREVIEW_QUALITY` | `75` | JPEG quality of the previews (10–100); lower makes each picture smaller, so a slow link carries more of them |
| `BROWSER_AUDIO_BUFFER_MAX_MS` | `300` | adaptive audio buffer: when Chromium delivers audio late, the 60 ms target (`BROWSER_AUDIO_BUFFER_MS`) rises by the silence played, up to this, and the video delay follows; it falls back at 1 ms/s after 10 s without an underrun. At or below `BROWSER_AUDIO_BUFFER_MS` the target is fixed (SPEC §6) |
| `BROWSER_OGRAF_DIR` | `<state>/ograf` | the OGraf graphics library (on the `/config` volume) |
| `BROWSER_OGRAF_UPLOAD_MAX_MB` | `256` | largest graphics ZIP (unpacked at most 4×) |
Expand All @@ -58,6 +59,8 @@ The page to show, its CSS and JavaScript, zoom, background and presets are the s

The preview on the Source page is the interaction window: back, forward and reload, and with "Interact" on, mouse (drag to select text, double and triple click), keys, text and IME go to the page and the preview runs at 25 fps instead of `BROWSER_PREVIEW_FPS`.

On a slow link the preview stays current instead of falling behind: the server always sends the newest picture and skips the ones the link cannot carry. Two things keep old pictures from piling up on the way. The kernel holds at most 32 KiB of a WebSocket unsent (`TCP_NOTSENT_LOWAT`), and the UI answers each picture (`preview_ack`), so at most 2 are on the way at once, also through a proxy. A client that stops reading or answering for 5 s is closed, which ends its control; the log line `websocket_stuck_close` names the client, the session and the reason, and `websocket_stuck_closes_total{reason}` counts it (`outbox_size`, `outbox_age`, `preview_not_taken`).

Operator screens can frame the interaction window as a widget: `GET /widgets` lists it (`interaction`, no parameters, `min_size` 640×400) and `GET /widget/interaction[?theme=dark|light|transparent]` is the page. It talks only to this instance's own origin (REST and both WebSockets on `WEB_PORT`), asks for `BROWSER_API_TOKEN` when one is set, and posts `widget-ready` and `widget-size` to its parent. Only the pages that `WIDGET_FRAME_ANCESTORS` names may frame it; on the platform set it to `'self' https://designer.<fqdn>`.

**Sign-in pages (Zscaler, Microsoft Entra ID).** Windowless CEF cannot show Chromium's WebAuthn dialog (passkeys, security keys, the QR code for a phone), so a page that asks for a passkey would hang. WebAuthn is therefore hidden from pages (`BROWSER_WEBAUTHN=false`), and Entra offers Authenticator push, a code or a password instead. Sign in once in the interaction window; with `BROWSER_PROFILE=persistent` the session survives restarts until the identity provider ends it. A tenant that allows passkeys only cannot sign in here: ask for another method, or for the graphics hosts to bypass the proxy sign-in.
Expand Down
12 changes: 8 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ Client → server (JSON Schema, draft 2020-12; the same schema as a file: `docs/
{"type": "object", "required": ["type"], "properties": {
"type": {"const": "preview"},
"fps": {"type": "integer", "minimum": 1, "maximum": 25, "description": "omitted: back to BROWSER_PREVIEW_FPS"},
"width": {"type": "integer", "minimum": 160, "maximum": 1920}}}
"width": {"type": "integer", "minimum": 160, "maximum": 1920}}},
{"type": "object", "required": ["type"], "description": "one per preview frame received, never more; after the first, the server keeps at most 2 previews unacknowledged", "properties": {
"type": {"const": "preview_ack"}}}
],
"$defs": {"modifiers": {"type": "array", "items": {"enum": ["shift", "ctrl", "alt", "meta", "capslock"]}, "uniqueItems": true}}
}
Expand All @@ -380,15 +382,16 @@ Server → client:
| `error` | `code`, `message` | rejected input, policy violations |
| binary | 8-byte little-endian grain index, then a JPEG | preview frames |

Limits: 200 input messages per second per session, 64 KiB per message; beyond that the server drops messages and counts them. Malformed messages get an `error` and are ignored.
Limits: 200 input messages per second per session, 64 KiB per message; beyond that the server drops messages and counts them (`preview_ack` does not count and is never dropped). Malformed messages get an `error` and are ignored.

### 7.3 Preview

The siblings preview with low-rate JPEG (multiviewer `/preview.jpg` at 5 fps and 480 px, test player thumbnail encoded on request). The browser source keeps that mechanism and adds a push path for interaction:

- `GET /api/v1/preview.jpg`: the newest frame, converted after the request arrives (each request gets a ticket and is answered with a preview made for it or a later one; 1.1.0 answered with the previous request's picture), encoded once per preview.
- Preview frames over the interaction WebSocket (each a preview made after it was due, waited for up to 100 ms like `preview.jpg`; a session never gets the same picture twice, 1.3.4) at `BROWSER_PREVIEW_FPS` (default 10, up to 25 while the session controls the page) and `BROWSER_PREVIEW_WIDTH` (default 960). A session asks for its rate with `preview` (a watcher can only lower it); the UI asks for 25 when its session gets control and for the default (`preview` without `fps`) when it loses it. The server also drops a session to at most `BROWSER_PREVIEW_FPS` when it loses control (toggle off, take-over, timeout), so only the controller can have more.
- A client that stops reading never slows the others. Preview frames are sent without waiting: when a client's socket is full, its frame is dropped (the next one is newer; `preview_frames_dropped_total`), and a frame that has started goes out whole before anything else. Text messages (state, acks, errors, events) keep their order and are never dropped: what does not fit waits in a per-client outbox that the connection's own thread drains, so no sender ever waits for a client (state, cursor and console broadcasts come from the CEF UI thread, where a wait could trip the hang timeout). A client whose outbox holds more than 4 MiB or whose oldest message has waited 5 s, or that takes no preview frame for 5 s, is closed, which ends its control; such a close is logged (`websocket_stuck_close` with the client and reason) and counted (`websocket_stuck_closes_total{reason}`). The kernel keeps at most 64 KiB of a WebSocket unsent (`TCP_NOTSENT_LOWAT`), so on a slow path previews are dropped early instead of waiting seconds in the send buffer. This holds for `/api/v1/events` too. Polling a JPEG adds a round trip per frame; a push keeps the click-to-picture time short. WebRTC is not used: full-rate monitoring is mxl-webrtc-monitor's job (route the video flow to it).
- A client that stops reading never slows the others. Preview frames are sent without waiting: when a client's socket is full, its frame is dropped (the next one is newer; `preview_frames_dropped_total`), and a frame that has started goes out whole before anything else. Text messages (state, acks, errors, events) keep their order and are never dropped: what does not fit waits in a per-client outbox that the connection's own thread drains, so no sender ever waits for a client (state, cursor and console broadcasts come from the CEF UI thread, where a wait could trip the hang timeout). A client whose outbox holds more than 4 MiB or whose oldest message has waited 5 s, or that takes no preview frame for 5 s, is closed, which ends its control; such a close is logged (`websocket_stuck_close` with the client, the interaction session (0 on `/api/v1/events`), the reason, the outbox bytes and the age of the oldest waiting message, `oldest_ms`) and counted (`websocket_stuck_closes_total{reason}`). The kernel keeps at most 32 KiB of a WebSocket unsent (`TCP_NOTSENT_LOWAT`; 64 KiB in 1.3.4), so on a slow path previews are dropped early instead of waiting seconds in the send buffer. This holds for `/api/v1/events` too.
- Preview flow control (after 1.3.4): the kernel limit does not help when a proxy sits on the path, because the proxy reads at once and holds the previews in its own buffers (lab, 2 Mbit/s behind a buffering proxy: 10 s old previews with 1.3.4). A client that answers each preview frame with `preview_ack` therefore has at most 2 previews unanswered; while it has 2, a new picture skips it (counted in `preview_frames_dropped_total`; an unchanged picture is not counted), so the newest picture goes out as soon as an answer comes back. Every preview counts from the first one on, also those sent before the first `preview_ack`: what a proxy buffered then is answered (drained) before new ones go out. An answer counts as reading: a session is closed as `preview_not_taken` only when new pictures were skipped for 5 s without an answer. A client that never sends `preview_ack` is not limited (as before). The web UI and the widgets answer every frame as it arrives. An extra `preview_ack` (for no frame) is ignored when no preview is unanswered; otherwise it is taken for a preview still on the way and lets one more through until the client has caught up (lab: an extra ack before the first frame, while a preview was already on the way, raised the age at 2 Mbit/s from 230 to 390 ms). Polling a JPEG adds a round trip per frame; a push keeps the click-to-picture time short. WebRTC is not used: full-rate monitoring is mxl-webrtc-monitor's job (route the video flow to it).
- The preview is made from the BGRA frame (downscaled box filter, composited over a checkerboard in the UI when transparent), so it costs nothing when no client watches.

## 8. NMOS
Expand Down Expand Up @@ -616,6 +619,7 @@ The file is one JSON document, written atomically by the UI and the API (`tmp` +
| `BROWSER_CONFIRM_DIALOGS` | `cancel` | no | `cancel`, `accept` |
| `BROWSER_URL_ALLOW` / `_DENY` | empty / §14.3 | no | URL policy lists |
| `BROWSER_PREVIEW_FPS` / `_WIDTH` | 10 / 960 | no | preview |
| `BROWSER_PREVIEW_QUALITY` | 75 | yes | JPEG quality of the previews (10–100); lower makes them smaller on a slow path |
| `BROWSER_INTERACT_TIMEOUT_S` | 120 | no | control ends after this idle time |
| `BROWSER_DEVTOOLS` | `false` | yes | DevTools proxy |
| `BROWSER_DEVTOOLS_PORT` | 9222 | yes | internal DevTools port (loopback) |
Expand Down Expand Up @@ -688,7 +692,7 @@ Prefix `mxl_browser_source_`. Durations in seconds with millisecond buckets.
| `interact_sessions` | gauge | – (controlling sessions, 0 or 1) |
| `interaction_events_total` | counter | `type`, `result` (`ok`, `rejected`, `dropped`) |
| `preview_frames_total` | counter | – (preview frames sent; until 1.3.1 also those a blocked client never took) |
| `preview_frames_dropped_total` | counter | – (preview frames dropped because the client's socket was full, §7.3) |
| `preview_frames_dropped_total` | counter | – (preview frames dropped because the client's socket was full or, with `preview_ack`, 2 previews were unanswered, §7.3) |
| `websocket_stuck_closes_total` | counter | `reason` (`outbox_size`, `outbox_age`, `preview_not_taken`; WebSockets closed because the client did not read, §7.3; 1.3.4) |
| `devtools_sessions` | gauge | – |
| `sender_enabled` | gauge | `sender` |
Expand Down
2 changes: 1 addition & 1 deletion deploy/mxl-browser-source-gpu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ spec:
type: RuntimeDefault
containers:
- name: browser-source
image: ghcr.io/leeo86/mxl-browser-source:1.3.4
image: ghcr.io/leeo86/mxl-browser-source:1.4.0
env:
- name: NMOS_HOST_ADDRESS
valueFrom:
Expand Down
2 changes: 1 addition & 1 deletion deploy/mxl-browser-source.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ spec:
type: RuntimeDefault
containers:
- name: browser-source
image: ghcr.io/leeo86/mxl-browser-source:1.3.4
image: ghcr.io/leeo86/mxl-browser-source:1.4.0
env:
- name: NMOS_HOST_ADDRESS
valueFrom:
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ services:
- ./registry.json:/home/registry.json:ro

browser-source:
image: ${MBS_IMAGE:-ghcr.io/leeo86/mxl-browser-source:1.3.4}
image: ${MBS_IMAGE:-ghcr.io/leeo86/mxl-browser-source:1.4.0}
network_mode: host
user: "1000:1000"
shm_size: 1gb
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.host.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# GPU: add -f docker/docker-compose.gpu.yaml.
services:
browser-source:
image: ${MBS_IMAGE:-ghcr.io/leeo86/mxl-browser-source:1.3.4}
image: ${MBS_IMAGE:-ghcr.io/leeo86/mxl-browser-source:1.4.0}
network_mode: host
user: "1000:1000"
shm_size: 1gb
Expand Down
Loading
Loading