# Streams latency console

> Compare MoQT, WebRTC (WHEP), and HLS glass-to-glass on one timecoded source, measured off the painted pixels.

The latency console plays **one timecoded source over three transports at the
same time** and reports the glass-to-glass delay of each. It is the screen you
open when someone asks "how much faster is MoQT than HLS on this network,
right now, for this stream?"

The screen is a thin wrapper. The panels and the measurement logic come from a
framework-light (pure DOM) console module that ClutchCall shares with the
public demo console, so the console in the operator app and the console on the
demo page are the same implementation.

## What the console compares

Up to three panels, one per delivery path:

| Panel | Path | Configured by |
| ----- | ---- | ------------- |
| MoQT  | WebTransport (QUIC) to the relay, subscribing to one namespace | `relay` **and** `ns` |
| WHEP  | WebRTC playback from a WHEP endpoint | `whep` |
| HLS   | HLS playback via `hls.js` | `hls` |

Panels are independent. Configure one, two, or all three. A panel that has no
endpoint is simply not mounted — the MoQT panel in particular is skipped unless
**both** the relay URL and the namespace are present. If none of the four
fields is set, the console does not mount at all and the screen shows a hint
instead.

The comparison is only meaningful when all configured endpoints carry the
**same timecoded source** (the same encoder output, published to the relay and
to a mediamtx-style WHEP/HLS origin). Nothing in the console verifies that;
pointing the panels at different sources produces three unrelated numbers.

## Point it at a relay, WHEP and HLS endpoint

The four endpoints live in the URL query string, and the inline form at the top
of the screen writes them there:

```
/streams/latency-console?relay=https://relay.host:443/moq&ns=stream/tenant/lv_xxx&whep=http://host:8889/live/whep&hls=http://host:8888/live/index.m3u8
```

| Field | Query param | Expected value |
| ----- | ----------- | -------------- |
| Relay (QUIC/WT) | `relay` | WebTransport URL of the relay. `https://` only. |
| Namespace | `ns` | The MoQT namespace to subscribe to. |
| WHEP URL | `whep` | `http(s)` WHEP endpoint. |
| HLS URL | `hls` | `http(s)` playlist URL. |

Pressing **Load** validates the form, then rewrites `location.search` with the
non-empty fields, which reloads the screen. Because the endpoints come from the
URL, an operator can aim the console at **any** relay plus WHEP/HLS origin
without a backend change, and a working configuration is a shareable link.

Whenever the query endpoints change, the console module is destroyed and
re-mounted, and playback restarts on all panels together.

### Validation

Validation is reveal-on-submit: errors appear only after you press **Load**.
This exists because invalid values used to be written straight into
`location.search`, after which the console silently never mounted and the
screen looked broken.

The rules enforced:

- A namespace without a relay URL, or a relay URL without a namespace, is an
  error — the MoQT panel needs both.
- `relay` must be a URL with the `https` protocol, because it is a
  WebTransport URL.
- `whep` and `hls` must be valid `http` or `https` URLs.
- If all four fields are empty, the form reports that you need a relay +
  namespace, a WHEP URL, or an HLS URL before anything can load.

## Finding the relay URL and namespace for a live input

The form is not prefilled today — you paste both values. (Prefilling them from
a selected live input through the BFF is a known follow-up, not something this
screen does yet.)

- **Relay URL** is the WebTransport endpoint of the relay deployment serving
  the stream, on its QUIC `:443` plane, including the MoQT path — the
  placeholder shows the shape: `https://relay.host:443/moq`.
- **Namespace** is the namespace the live input publishes under. The
  placeholder shows the shape: `stream/tenant/lv_xxx` — the tenant, then the
  live input id.

Paste the same namespace the publisher uses. A namespace that nothing is
publishing to yields an empty MoQT panel while WHEP and HLS keep playing, which
is the usual cause of a "MoQT is broken" report.

## How glass-to-glass is measured

The source carries a **barcode burned into the video pixels** that encodes the
capture timecode. Each panel decodes and paints frames as normal, the console
reads the barcode back out of the painted pixels, and the delay is the
difference between the timecode it just read and now.

The consequence worth understanding: this measures the *pixels the viewer
actually sees*, not a protocol-level timestamp. Everything between the moment
the barcode was burned in and the moment the frame is painted is included —
packaging, the transport hop, relay or origin buffering, player jitter buffers
and playlist/segment buffering, and decode. Anything upstream of the burn-in —
sensor capture, the camera pipeline, and any processing before the timecode was
stamped — is not included, and is identical across the three panels anyway.

That is what makes the three numbers comparable: the same burn-in feeds all
three, so the differences between panels are attributable to the delivery path
and its player, not to the capture side.

## Reading the three panels

Each configured panel renders its own player plus its own live glass-to-glass
readout, so the three sit side by side on one screen against one source.

Read them as a *relative* result for the network and stream you are pointed at.
The absolute figure depends on the encoder, the origin, the buffering policy of
each player, and the path between that machine and the relay or origin — a
console run from a different network is a different measurement, not a
regression.

A panel that stays blank while the others play is a per-path problem: an
unpublished or misspelled namespace and a blocked QUIC path affect only MoQT;
a WHEP or HLS origin that is not serving the stream affects only that panel.

## QUIC-blocked networks and the WS fallback build

The MoQT panel uses the default client build, which is **WebTransport over
QUIC** against the relay's `:443` plane (`/sdk/moqt.js`). That is the product
path and it is what this console measures.

A **WebSocket-fallback MoQT build** also exists (`/sdk/moqt-ws.js`) for
networks where QUIC/UDP is blocked. It is not selectable from this screen — the
console always mounts the WebTransport build. So on a QUIC-blocked network the
MoQT panel will fail to connect here even though a fallback build would play;
that is a property of this console, not of the stream.

If the MoQT panel is the only one failing and the relay is otherwise healthy,
suspect UDP egress before you suspect the publisher.

## Where the console runs

The shared console module treats `hls.js` as an external dependency and, by
default, loads it from `/vendor/hls.mjs`. That default is correct for the
**standalone console build** served under `/console/` on the streams host,
which ships its own `vendor` directory.

Inside the operator SPA that path is not a real asset — the dev server would
try to resolve it as a source module and fail — so this screen hands the module
the URL of the `hls.js` the package already depends on. Both deployments end up
running the same panels and the same measurement; only the `hls.js` source
differs.

## Limitations

- **No prefill.** Relay, namespace, WHEP and HLS are typed or pasted by hand.
  There is no picker that resolves them from a selected live input.
- **No source verification.** The console assumes the endpoints carry the same
  timecoded, barcode-burned source. It cannot tell you that they do not.
- **Barcode required.** Without the burned-in timecode there is no
  glass-to-glass reading; the panels would still play, but the measurement has
  nothing to decode.
- **WebTransport only for MoQT.** The WS fallback build is not reachable from
  this screen.
- **`https` relay only.** A relay URL on any other scheme is rejected by the
  form.
- **Applying endpoints reloads the screen.** **Load** rewrites the query string,
  so all panels restart; it is not an incremental update of one panel.
- **Point-in-time, client-side.** The numbers are measured by the browser you
  are sitting at. Nothing is recorded or persisted by this screen.
