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: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ target
config.toml
certs
docs
# The server embeds the built bundle from static/.
sdk
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,27 @@ jobs:
# The load run and the tests that need ffmpeg are ignored, so this is the
# unit suite plus every integration suite.
- run: cargo test

sdk:
runs-on: ubuntu-latest

defaults:
run:
working-directory: sdk

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: sdk/package-lock.json

- run: npm ci

- run: npm run check

# The server serves this file, so it has to match the sources it was
# built from.
- run: git diff --exit-code ../static/stream-viewer.js
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# [Unreleased]

## Added

- A viewer SDK for browsers, `@kimsun721/stream-viewer`: joining, playback, simulcast layers, perf reports, reconnecting, the relay and leaf sides of P2P relay, a debug log, and a player built on it. See [sdk/README.md](sdk/README.md). It does not answer the upload probe yet, so the server does not promote a browser to relay.
- `/stream-viewer.js` serves the same SDK as one file.

## Changed

- `/watch` runs on the SDK.

# [0.1.1]

## Added
Expand Down
5 changes: 4 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ HTTPS `:8443`, 클라이언트가 직접 호출합니다.
| 메서드 | 경로 | 인증 | 설명 |
| --- | --- | --- | --- |
| `GET` | `/watch?room={id}` | 없음 | 최소한의 시청 페이지 |
| `GET` | `/stream-viewer.js` | 없음 | 한 파일로 묶은 뷰어 SDK. `/watch`가 이걸로 동작 |
| `POST` | `/offer` | 없음 | 시청자 입장. SDP offer를 보내고 SDP answer를 받음 |
| `POST` | `/whip` | 스트림 키 | 송출. `application/sdp`로 주고받음 |
| `DELETE` | `/whip/sessions/{id}` | 스트림 키 | 송출 세션 종료 |

`/whip`은 OBS로 검증했습니다. 브라우저를 포함한 다른 WHIP 클라이언트도 송출할 수 있지만, 키프레임을 주기적으로 보내는 것은 송출자의 책임입니다. 시청자의 레이어 전환은 새 레이어의 키프레임에서 일어나고, 서버는 그 키프레임을 한 번만 요청합니다.

직접 만드는 페이지에서 시청자 프로토콜을 손으로 구현할 필요는 없습니다. npm의 `@kimsun721/stream-viewer`인 [뷰어 SDK](sdk/README.md)가 입장, 재생, 레이어 전환, 재접속, P2P 중계를 맡고, 플레이어도 함께 제공합니다.

## 방 상태

```
Expand Down Expand Up @@ -170,7 +173,7 @@ LOAD_METRICS_PORT=9464 cargo test --release --test load -- --ignored --nocapture

- [Decisions](docs/decisions/): 설계가 지금 모양인 이유를 남긴 ADR
- [Architecture](docs/architecture/overview.md): 컴포넌트, 스레드, 데이터 흐름
- [Features](docs/feature/): simulcast, P2P relay, 뷰어 SDK 설계 문서
- [Features](docs/feature/): simulcast, P2P relay 설계 문서
- [Tests](docs/test/README.md): 테스트 구성, 부하 테스트, 기록된 베이스라인

## 로드맵
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ HTTPS `:8443`, called by clients directly.
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/watch?room={id}` | none | A minimal viewer page |
| `GET` | `/stream-viewer.js` | none | The viewer SDK as one file, which `/watch` runs on |
| `POST` | `/offer` | none | Viewer joins. SDP offer in, SDP answer out |
| `POST` | `/whip` | stream key | Publish. `application/sdp` in and out |
| `DELETE` | `/whip/sessions/{id}` | stream key | End the publishing session |

`/whip` is tested with OBS. Another WHIP client, a browser included, can publish as well, but sending keyframes on an interval is its job: a viewer changes layer on a keyframe of the new layer, and the server asks for that keyframe once.

A page of your own does not speak the viewer protocol by hand: the [viewer SDK](sdk/README.md), `@kimsun721/stream-viewer` on npm, joins, plays, follows the layers, reconnects and relays, and comes with a player.

## Room State

```
Expand Down Expand Up @@ -170,7 +173,7 @@ LOAD_METRICS_PORT=9464 cargo test --release --test load -- --ignored --nocapture

- [Decisions](docs/decisions/): ADRs recording why the design is what it is
- [Architecture](docs/architecture/overview.md): components, threads and data flow
- [Features](docs/feature/): design docs for simulcast, the P2P relay and the viewer SDK
- [Features](docs/feature/): design docs for simulcast and the P2P relay
- [Tests](docs/test/README.md): suites, the load run, and recorded baselines

## Roadmap
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ A WebRTC SFU for low-latency live streaming. One process; persistence and horizo
| Media loops | Each owns a share of the rooms, drives str0m for their clients and forwards RTP |
| HTTP API | Room CRUD and JSON metrics for an external backend |
| Metrics port | Prometheus exposition, without a key. Off unless configured |
| HTTPS API | SDP offers from viewers, WHIP from publishers, and a minimal viewer page at `/watch` |
| HTTPS API | SDP offers from viewers, WHIP from publishers, a minimal viewer page at `/watch`, and the viewer SDK it runs on |
| Viewer SDK | `sdk/`, published to npm. The browser side of the viewer protocol, and a player built on it |
| DataChannel | Per-client side band: renegotiation, simulcast layer requests, P2P relay signaling |

The external backend is treated as a black box that speaks HTTP. This server makes no assumptions about its stack.
Expand Down
5 changes: 3 additions & 2 deletions docs/feature/p2p-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@ Before choosing the DataChannel, check that str0m's SCTP takes a few Mbps. If it

**Rejected: the server as a stand-in leaf.** The server would open a peer link to the candidate as a leaf and attach the real one only after it passed. That makes the server a WebRTC client inside a media loop, decrypts the whole stream for the trial, and still measures only the path to the server.

**When.** Once relays run in browsers. Today only the str0m test clients answer the relay protocol, and `watch.html` does not speak it.
**When.** Browsers can relay through the viewer SDK, which leaves the probe unanswered until this lands. Today only the str0m test clients answer it.

## Notes

- DC cannot carry RTP. Media flows over a separate `RTCPeerConnection` between A and B.
- A relay forwards only what it receives, so its leaf watches the relay's layer.
- One relay carries one leaf.
- No TURN. A pair that cannot connect directly times out and stays on the SFU.
- A browser re-encodes a track it forwards to another peer link, which costs the relay CPU and quality and changes the bitrate it sends. Passing encoded frames through needs the Encoded Transform API. The test clients forward RTP as is, so this has not come up.
- A browser re-encodes a track it forwards to another peer link, which costs the relay CPU and quality and changes the bitrate it sends. The viewer SDK holds the encoder near the bitrate the relay receives, with a start and a floor, because a browser starts low and the rate is judged after `min_p2p_connection_age_secs`. Passing encoded frames through needs the Encoded Transform API and a keyframe from the publisher at link up, which the server has no message for. The test clients forward RTP as is.
- A relay that changes layer while paired changes what its encoder is held to, and its rate lags behind. Seen with a headless Chrome publisher, whose layers come and go: the server demoted pairs while the relay moved between `h` and `m`.
33 changes: 0 additions & 33 deletions docs/feature/viewer-sdk.md

This file was deleted.

4 changes: 4 additions & 0 deletions docs/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ exercises the same paths a browser would.
```text
cargo test unit and integration
cargo test --test viewer -- --include-ignored adds the test that needs ffmpeg installed
cd sdk && npm run check the viewer SDK: types, unit tests, build
cd sdk && npm run e2e the SDK in headless Chrome against a release build
```

The load run is separate and is described below.
Expand All @@ -22,6 +24,8 @@ The load run is separate and is described below.
| `tests/control_api.rs` | API key, stream key, content type, duplicate streamer, key reissue, missing rooms, the metrics endpoints |
| `tests/relay.rs` | Relay promotion, the upload cutoff, leaf gating, the peer link timeout |
| `tests/load.rs` | The load baseline. Ignored by default |
| `sdk/e2e/watch.mjs` | One viewer on `/watch`: playback, layer picks, a room going idle and back, a room that is not live |
| `sdk/e2e/relay.mjs` | Two viewers paired as relay and leaf: the handover both ways, the relay's reports, a relay leaving. The test answers the upload probe the SDK leaves alone |

Each load run worth keeping is recorded under [`baseline/`](baseline/), from
[`_template.md`](baseline/_template.md), so a later run is compared against an
Expand Down
2 changes: 2 additions & 0 deletions sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
92 changes: 92 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# @kimsun721/stream-viewer

The browser side of [stream](https://github.com/kimsun721/stream), a WebRTC SFU for live streaming. It joins a room, plays it, follows the simulcast layers the server offers, reports link quality, and relays to another viewer when the server pairs it with one, so a page does not reimplement the viewer protocol.

```sh
npm install @kimsun721/stream-viewer
```

## A ready-made player

```js
import { createPlayer } from "@kimsun721/stream-viewer/player";

const player = createPlayer(document.getElementById("player"), {
room: "RM_0_...",
server: "https://stream.example.com:8443",
});

// later
player.destroy();
```

The player draws the video, a stats readout, layer buttons, a mute toggle and a debug log. Its colors are custom properties on `.sv-player` (`--sv-bg`, `--sv-panel`, `--sv-ink`, `--sv-accent` and the rest), so a page restyles it without touching the rules.

## The core, with your own UI

```js
import { Viewer } from "@kimsun721/stream-viewer";

const viewer = new Viewer({ room: "RM_0_...", server: "https://stream.example.com:8443" });
viewer.attach(document.querySelector("video"));

viewer.on("state", ({ state, reason }) => console.log(state, reason));
viewer.on("layers", (layers) => console.log(layers.layers, layers.current, layers.mode));
viewer.on("stats", (stats) => console.log(stats.width, stats.mbps));

await viewer.connect();
viewer.setLayer("l");
viewer.setAutoLayer();
viewer.close();
```

| Option | Default | |
| --- | --- | --- |
| `room` | | The room id |
| `server` | the page's origin | Base URL of the server's client API, the HTTPS port |
| `reconnect` | `true` | Join again after a dropped connection, and keep asking while the room is not live |
| `maxRetryDelayMs` | `10000` | Ceiling for the wait between attempts, which doubles from one second |
| `statsIntervalMs` | `1000` | How often stats are read and link quality reported |
| `debug` | `false` | Emit `log` events, also switched with `setDebug` |
| `rtcConfiguration` | | Passed to `RTCPeerConnection`, relay links included |
| `relay` | `true` | Forward the stream to another viewer when the server makes this one a relay |

| Event | Payload |
| --- | --- |
| `state` | `idle`, `connecting`, `connected`, `reconnecting`, `not-live`, `failed` or `closed`, with a `reason` |
| `layers` | The video track's layers, the one being sent, a pending target and the mode |
| `layerchange` | A switch, with how long it took when it answers a request |
| `stats` | Picture size, frame rate, bitrate, round trip, freezes, loss. From the relay link while a leaf watches through it |
| `role` | `relay`, `leaf` or `null`, and whether the peer link is up |
| `log` | Debug mode: switches, freezes, RTP timestamp jumps, connection changes |
| `error` | An error, retried or not |

`connect()` resolves once connected. It rejects with `JoinError` for a refused offer that is not worth retrying, and when `close()` is called first.

## Notes

- A layer change takes effect on the next keyframe of the new layer. The server asks the publisher for one once, so a publisher has to send keyframes on an interval of its own, as OBS does.
- Browsers start video muted to allow autoplay. The player has a button for sound; with the core, unmute the element from a user gesture.
- The server may pair two viewers: the relay forwards what it receives to the leaf over a peer link of their own, and the server stops sending to the leaf. Either side falls back to the server's stream when the link drops or the server demotes the pair. A page needs no code for it, and the player says nothing of it outside debug mode.
- A switch between the server's stream and a relay's keeps the old picture up until the new source has decoded a frame. A leaf whose relay goes silent for a second goes back to the server without waiting for the connection state.
- A browser encodes a forwarded track again. The relay holds its encoder near the bitrate it receives, since the server demotes a relay that sends well under it.
- The SDK does not answer the server's upload probe, which the server is to replace with a measurement of its own. Until then the server promotes no browser to relay, and the pairing above does not happen.
- A leaf watches the relay's layer. Picking a layer has no effect while it does.
- The relay link has no TURN of its own: pass ICE servers in `rtcConfiguration`, or a pair that cannot connect directly stays on the server.
- The server serves the same code as one file at `/stream-viewer.js`, which its own `/watch` page uses.

## Developing

```sh
npm ci
npm run check # typecheck, unit tests, build
```

`npm run build` also rewrites `../static/stream-viewer.js`. Commit it with the sources; CI fails when it drifts.

```sh
cargo build --release # from the repository root
npm run e2e # or: npm run e2e -- relay
```

`e2e/` starts that build with a config of its own, publishes Chrome's fake camera over WHIP and watches it from `/watch` in headless Chrome. It takes Chrome from its usual install path, or `CHROME_PATH`, and the server from `STREAM_BIN`.
21 changes: 21 additions & 0 deletions sdk/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { build } from "esbuild";

const common = { bundle: true, format: "esm", target: "es2022", logLevel: "warning" };

// The package: two entry points sharing one chunk, so a page importing both
// gets one Viewer class.
await build({
...common,
entryPoints: { index: "src/index.ts", player: "src/player.ts" },
outdir: "dist",
splitting: true,
});

// The server's copy, one file. Checked in, and CI fails if it drifts from the
// sources.
await build({
...common,
entryPoints: ["src/bundle.ts"],
outfile: "../static/stream-viewer.js",
banner: { js: "// Built from sdk/ by `npm run build`. Do not edit." },
});
Loading
Loading