diff --git a/.github/workflows/join.yml b/.github/workflows/join.yml
new file mode 100644
index 0000000..4e21a04
--- /dev/null
+++ b/.github/workflows/join.yml
@@ -0,0 +1,64 @@
+name: Join page
+
+# Deploys the join page (js/join) to GitHub Pages from main, at the domain
+# `--join-page` defaults to. Check builds it on every ready PR.
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "js/join/**"
+ - "package.json"
+ - "bun.lock"
+ - "flake.lock"
+ - ".github/workflows/join.yml"
+ workflow_dispatch:
+
+# One deploy at a time, and never cancel one halfway.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build
+ if: github.repository_owner == 'moq-dev'
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/nix
+
+ - name: Build
+ run: |
+ nix develop --command bun install --frozen-lockfile
+ nix develop --command bun run --cwd js/join build
+
+ - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
+ with:
+ path: js/join/dist
+
+ deploy:
+ name: Deploy
+ needs: build
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 10
+ # The only job with write access. `github-pages` deploys only from main.
+ environment:
+ name: github-pages
+ url: ${{ steps.deploy.outputs.page_url }}
+ permissions:
+ pages: write
+ id-token: write
+
+ steps:
+ - id: deploy
+ uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
diff --git a/.gitignore b/.gitignore
index 26da5a3..8fd68f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,7 @@ result
# direnv
.direnv
+
+# The join page's dependencies and build.
+node_modules
+dist
diff --git a/Cargo.lock b/Cargo.lock
index 9395c63..d603dd6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3196,6 +3196,7 @@ name = "moq-auth"
version = "0.2.1"
dependencies = [
"aws-lc-rs",
+ "axum",
"base64 0.23.1",
"data-encoding",
"jsonwebtoken",
@@ -3398,6 +3399,17 @@ dependencies = [
"x509-parser",
]
+[[package]]
+name = "moq-room"
+version = "0.2.11"
+dependencies = [
+ "kio",
+ "moq-auth",
+ "moq-json",
+ "moq-net",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "moq-scene"
version = "0.1.0"
@@ -3467,11 +3479,13 @@ dependencies = [
"moq-mux",
"moq-net",
"moq-relay",
+ "moq-room",
"moq-scene",
"moq-stats",
"moq-tokio",
"moq-video",
"pollster",
+ "qrcodegen",
"tempfile",
"tokio",
"tracing",
@@ -4925,6 +4939,12 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "qrcodegen"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
+
[[package]]
name = "quick-error"
version = "2.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 420e5bf..b4cb309 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,12 +25,13 @@ allow_attributes_without_reason = "warn"
[workspace.dependencies]
hang = { path = "moq/rs/hang" }
kio = { path = "moq/rs/kio" }
+moq-auth = { path = "moq/rs/moq-auth", default-features = false }
moq-audio = { path = "moq/rs/moq-audio", default-features = false }
-moq-auth = { path = "moq/rs/moq-auth" }
moq-json = { path = "moq/rs/moq-json" }
moq-mux = { path = "moq/rs/moq-mux" }
moq-net = { path = "moq/rs/moq-net" }
moq-relay = { path = "moq/rs/moq-relay", default-features = false }
+moq-room = { path = "moq/rs/moq-room" }
moq-scene = { path = "rs/moq-scene" }
moq-stats = { path = "moq/rs/moq-stats" }
moq-tokio = { path = "moq/rs/moq-tokio", default-features = false }
diff --git a/README.md b/README.md
index 633c0da..67ff8fa 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,7 @@ Every panel is a tab you can drag, split, or stack.
| Items | Place, show, hide, and order what the preview scene or the top layer draws, back to front. Select an item to filter or copy it. |
| Sources | Add a source by hand, see whether each and its own track are live and where it is shown, clone one, and remove one. Select a source to filter it. |
| Devices | List this machine's cameras, displays, windows, and audio inputs, and add one as a source. |
+| Guests | Invite a guest, show their join link as a QR code, see who joined, and take a guest out of every scene. |
| Filters | Crop the selected source or item, add, order, and set its filters, set an item's blend mode, and copy or paste filters. |
| Audio | Set each audio source's gain and mute, and watch its meter and the program's. |
| Output | See the broadcast path, whether the program is encoding, the canvas, and the encoder's work. |
@@ -134,6 +135,63 @@ best video and audio its catalog lists, switches when the catalog changes, and
comes back on its own when the publisher restarts. Audio from every source is mixed into the program whether
or not its scene is live; mute a source to leave it out.
+## Guests
+
+Guests and phones join a room the studio owns on its relay, from a web page,
+and their camera and screen appear as broadcast sources. The room follows
+[moq-room](https://github.com/moq-dev/moq/tree/main/rs/moq-room): a guest
+named `alice` publishes `alice/camera.hang` and `alice/screen.hang` under it.
+Guests need a relay that verifies tokens, so run your own with
+[`moq auth serve`](https://doc.moq.dev/bin/cli) behind it, and a key that can
+sign only for the room:
+
+```bash
+moq auth generate --algorithm ES256 --out room.jwk --public-dir keys \
+ --root show/guests --publish '**' --subscribe '**'
+moq-studio show.toml --connect 'https://relay.example.com/show?jwt=...' \
+ --room guests --room-key room.jwk --join-page https://join.example.com/
+```
+
+- `--room` is a path under the `--connect` URL's, here `show/guests` on the
+ relay. The studio's own token must let it subscribe there. A guest's browser
+ dials the same relay, so `--connect` must be `https://`, or `http://` only to
+ this machine or the local network.
+- `--room-key` signs each guest's token. The studio refuses a key whose scope
+ reaches outside the room, so a stolen key cannot sign for the rest of the
+ relay. Put its public half in the directory `moq auth serve --key-dir` reads.
+- `--join-page` is where the join page (`js/join`, `bun run --cwd js/join
+ build`) is hosted. Browsers open the camera only on an `https://` page or
+ `localhost`, so the studio refuses any other. It defaults to `https://join.studio.moq.dev/`, which this
+ repository deploys from `main`, but that domain is not live yet, so for now
+ host the page yourself and pass its URL.
+- `--guest-expiry` (default `4h`) is how long a link works. The relay
+ disconnects a guest when their link expires, so send a new one before then.
+- With `--failover`, a link still names only the `--connect` relay: the join
+ page dials one relay, and a guest's token is rooted at the room there, so
+ guests drop while that relay is down. The studio follows the room through
+ both relays, like any broadcast source, so a guest the failover routes at
+ the same path, as a relay in the same cluster does, keeps reaching it.
+ Anyone the failover lets publish in the room joins as a guest, so guard the
+ room there as on the `--connect` relay.
+
+**Invite** in the Guests panel makes a link for one guest name and shows it as
+a QR code; **Copy link** puts it on the clipboard instead. The guest's token is
+in the link's fragment, which the browser never sends to a server, and lets
+them publish only under their own name, so they cannot appear as someone else.
+Treat the link like a password: anyone holding it can join as that guest until
+it expires. A guest's broadcasts become sources named after them (`alice`,
+`alice screen`) when they first appear, muted and in no scene: place them from
+the Items panel and unmute them in Audio to put them on air. That includes a
+guest invited before a restart whose link still works. A guest's source is
+saved to the scene file but is not your edit: it stays out of History, so undo
+never removes it and a guest joining never loses what you could redo. A guest
+who reconnects after you removed their source comes back under a new name
+(`alice 2`), since undoing the removal brings back the old one.
+**Remove from scenes** is your edit, so it undoes: it takes a guest, any clone
+of their sources, and any mask reading them out of every scene and the top
+layer and mutes them; a signed token cannot be taken back, so they stay
+connected until it expires.
+
## Scene file
The collection is TOML, so it diffs and reviews like code. It names devices,
@@ -374,9 +432,11 @@ just check # Lint everything, including the quest tree
just test # Run the tests
just ci # Both, as CI runs them
just fix # Auto-fix formatting
+just join # Serve the join page locally
```
-`rs/moq-scene` is the UI-free compositor and `rs/moq-studio` the app. See
+`rs/moq-scene` is the UI-free compositor, `rs/moq-studio` the app, and
+`js/join` the join page, built on `@moq/room` and `@moq/publish`. See
[AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md), and the
[quest tree](quest/README.md) for planned work.
diff --git a/biome.jsonc b/biome.jsonc
new file mode 100644
index 0000000..0ae743c
--- /dev/null
+++ b/biome.jsonc
@@ -0,0 +1,51 @@
+// moq's settings, so the join page reads like moq's own packages.
+{
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ // Only the JavaScript. `!!moq` keeps the submodule out of the scan, whose
+ // own root config biome would otherwise refuse.
+ "includes": ["js/**", "package.json", "biome.jsonc", "!!moq"]
+ },
+ "formatter": {
+ "lineWidth": 120,
+ "indentStyle": "tab",
+ "indentWidth": 4,
+ "lineEnding": "lf"
+ },
+ "linter": {
+ "rules": {
+ "suspicious": {
+ "noImportCycles": {
+ "level": "error",
+ "options": {
+ "ignoreTypes": true
+ }
+ }
+ },
+ "nursery": {
+ "noFloatingPromises": "error",
+ "noMisusedPromises": "error"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "yaml": {
+ "formatter": {
+ "enabled": false
+ }
+ },
+ "markdown": {
+ "formatter": {
+ "enabled": false
+ }
+ }
+}
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..c6b4854
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,242 @@
+{
+ "lockfileVersion": 2,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "studio",
+ "devDependencies": {
+ "@biomejs/biome": "^2.6.4",
+ },
+ },
+ "js/join": {
+ "name": "@moq/studio-join",
+ "version": "0.0.0",
+ "dependencies": {
+ "@moq/net": "^0.4.7",
+ "@moq/publish": "^0.5.4",
+ "@moq/room": "^0.2.1",
+ "@moq/signals": "^0.2.6",
+ },
+ "devDependencies": {
+ "@types/bun": "^1.4.4",
+ "typescript": "7.0.2",
+ "vite": "^8.3.5",
+ },
+ },
+ },
+ "packages": {
+ "@biomejs/biome": ["@biomejs/biome@2.6.4", "", { "optionalDependencies": { "@biomejs/cli-android-arm64": "2.6.4", "@biomejs/cli-android-x64": "2.6.4", "@biomejs/cli-darwin-arm64": "2.6.4", "@biomejs/cli-darwin-x64": "2.6.4", "@biomejs/cli-linux-arm64": "2.6.4", "@biomejs/cli-linux-arm64-musl": "2.6.4", "@biomejs/cli-linux-x64": "2.6.4", "@biomejs/cli-linux-x64-musl": "2.6.4", "@biomejs/cli-win32-arm64": "2.6.4", "@biomejs/cli-win32-x64": "2.6.4" }, "bin": { "biome": "bin/biome" } }, "sha512-8sKKwaNW4ot22awZ3lBjwWLHnUFXgXnrBPYS2gNlBAXRbDC++7kqjK2H7dR/FgCDtPvO1xfKpgEhJkGxAKYkxA=="],
+
+ "@biomejs/cli-android-arm64": ["@biomejs/cli-android-arm64@2.6.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jwrwigP4tBcHxzuV8izioX/BOUhJL/4e2rNSOE5nCjBkVC018LQAQUGiH+CNkFYvLtQ1dcZo5/xXERmaAGc7JQ=="],
+
+ "@biomejs/cli-android-x64": ["@biomejs/cli-android-x64@2.6.4", "", { "os": "android", "cpu": "x64" }, "sha512-HdGInPJ4nZFwHxzvEtcp2nmSBtUyJrurmtIK43upFckuS3+ZqVM05HtsxQWnqGsuy/pxckIAjyKWlEKRlBXUyA=="],
+
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0VvcUz46wbg59Q4TZ59KkIjDJrxEyu4Eoe79+F4SChxoWe+W7AEyIp6F5r72C9+p6MsB6dIovnZt1D30lGM3XA=="],
+
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.6.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fxmvkF81srlS3DiMyfm+aG3J0eG+6/BjaxRyjy57KbLSafnq0PO+hHfVWmtH2ty0S7nm6yDXBRwySfFeaRJKRQ=="],
+
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+bK1Y/evmIUiJr7auertuMbiaYVicJhqA1bZyL47lCMWugv8l0gFQCqvR/gN1vZMHYm+HwoTKgeMxdNFCoQTg=="],
+
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-31ZaH/MHqPX+O+evRwRrCHQ8W5/9adY+/eMytGGWZnRx1T41QsWIF09+/WujynLBhWZUxNGS5CtsWLwgOFTAyg=="],
+
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tbFZMdL6dP/ws4edXxYG9WhvaVa1ZDzL4G5DzahnWW7Psib9H+VTdyU4XXVxpJk5j/nYZroSbvSNyYlpW3yCIw=="],
+
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-bomSa2XdQAGGj6Cwdm4M13qE+lN9+AOlW8KtI4hdyVyRRF/Z71qPAi95T4wtdZK0QpBbkTW8hgbZZCwPi4xzIw=="],
+
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.6.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-eCsqt8Ma3eVap+oyJCDnJZH60SIU464B66n3y38EUTJdM1T/HfZol1FJC9vb+sHToX0ix8XzqPcwJnOzyAJz0g=="],
+
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-pYsMy5AThiHFOZAsngg8qN4PurM6nInKBhVX7sbj3eNpRQqo1Hb0MgCwPL7VqVMrmcEEMj9Ks3qbkallR65e8g=="],
+
+ "@kixelated/libavjs-webcodecs-polyfill": ["@kixelated/libavjs-webcodecs-polyfill@0.5.6", "", { "dependencies": { "@libav.js/types": "^6.7.7", "@ungap/global-this": "^0.4.4" } }, "sha512-A6FBsEAT/t0cDDaxo3h5XabiHX2RtycQbWV+MNKUghmctrXU1ygl05i3TTtnSF/eLmCLLO8xBMrXO+eGnpLWFw=="],
+
+ "@libav.js/types": ["@libav.js/types@6.10.9", "", {}, "sha512-AdngxD/VO8OI6/w7wlJC77kuFcsSvCUuo2RuC6gGeSW2DEvbzgBo2JtFLV6UWZp/tRNZhKm6jS0Tv8gwCKBHLg=="],
+
+ "@libav.js/variant-opus-af": ["@libav.js/variant-opus-af@6.10.9", "", {}, "sha512-yULJUCQIHDtwDPbT38t9pP/7EGgqwynVcOau46hZjtCe8TWMp67DTi2X0Emr2M4Ci3nzxNPf25IzO+IXSmHDBg=="],
+
+ "@moq/flate": ["@moq/flate@0.1.4", "", { "dependencies": { "pako": "^3.0.2" } }, "sha512-o+rQuNeHpNC81oLX38DV4HwmZOTJqjvkluNh3Z/qOJ+kpqDAB8rOS9ge1qSk6q399XC9gwBzbWE0RIyWUfWwpQ=="],
+
+ "@moq/hang": ["@moq/hang@0.5.4", "", { "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.10.9", "@moq/json": "^0.4.3", "@moq/loc": "^0.2.4", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6", "@svta/cml-iso-bmff": "^1.0.7", "@svta/cml-utils": "^1.6.2", "@zod/mini": "^4.6.5", "zod": "^4.6.5" } }, "sha512-oHRqFBF1P5O1TVpFmxqWRM6Nkltnu8jzvybk/nIBSyMbUYeFWbbiWaWNDxXu3jeXX7ohm01oh5kkrwT/+bQ1uw=="],
+
+ "@moq/json": ["@moq/json@0.4.3", "", { "dependencies": { "@moq/flate": "^0.1.3", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6" }, "peerDependencies": { "@zod/mini": "^4.5.0", "zod": "^4.5.0" } }, "sha512-HTfLRnuvA5sWvLzXjsfAfrdAUJiLG8FyiWUGmWR598kPq+S2SNJ3gK1uoUWeb/+z/fgEY/kdrg1BHv+TmAz/3g=="],
+
+ "@moq/loc": ["@moq/loc@0.2.4", "", { "dependencies": { "@moq/net": "^0.4.2" } }, "sha512-cwe0y+j7mfIuC+DOrfdLB1k5YU2+/xIfhnTv8VEiKLM5ZyfOZHblK3UwnrKqChcKkGFt3FkedRWFLMDIt9GCuA=="],
+
+ "@moq/msf": ["@moq/msf@0.3.1", "", { "dependencies": { "@moq/net": "^0.4.2", "@zod/mini": "^4.6.5", "zod": "^4.6.5" } }, "sha512-ZM9aZEtK/3dudeM2t5N9lujIpqEeDDOi6uHQp63mYOsBLlEUFEKff6y6WLicJJxGT9BM3h0mxh1Sv9YBT6jceQ=="],
+
+ "@moq/net": ["@moq/net@0.4.7", "", { "dependencies": { "@moq/pattern": "^0.2.1", "@moq/qmux": "^0.3.3", "@moq/signals": "^0.2.6", "async-mutex": "^0.5.0", "bowser": "^2.14.1" }, "peerDependencies": { "@zod/mini": "^4.5.0", "zod": "^4.5.0" } }, "sha512-klFav+4XZjR1GsgRJr6jNt1BuBNGc01+AivI8a1f/pBeKNcqL7t9k9xn5RP78Bprnf4H7BUgTi14vvexPCuTNA=="],
+
+ "@moq/pattern": ["@moq/pattern@0.2.1", "", {}, "sha512-NXlegs0abkYoIUkcxyJjnUHC1CGw8VkooAbuNKLK5AF0hCAyUWw1QNliHaLBty4K76qfrrvJK6cf7j31Z0KF0A=="],
+
+ "@moq/publish": ["@moq/publish@0.5.4", "", { "dependencies": { "@moq/hang": "^0.5.4", "@moq/json": "^0.4.3", "@moq/net": "^0.4.7", "@moq/signals": "^0.2.6", "mediabunny": "^1.58.1" } }, "sha512-uOMShdgkpXZ4WNjD8IDPlbrXl6UB69IeT3OrcDzX45nAJxuzNKJsOMwh7XPxa2/2Kr6fmV71XxUGb1ap74GHjg=="],
+
+ "@moq/qmux": ["@moq/qmux@0.3.4", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.1" } }, "sha512-6XxjqsK1bi2bdIZ7+g8gkHh3v6NOEArqb6IMtHokZI+wP4Y7OB45zHrxUe7v42pcf08RnfqfTkphxHgZfVLyPw=="],
+
+ "@moq/room": ["@moq/room@0.2.1", "", { "dependencies": { "@moq/hang": "^0.5.1", "@moq/json": "^0.4.1", "@moq/net": "^0.4.1", "@moq/publish": "^0.5.1", "@moq/signals": "^0.2.5", "@moq/watch": "^0.6.1" } }, "sha512-QEGqpcCF0x1zZFg1FYxny/NfMc7bFCY2lqAgjfD+NgKIf7xgbi/YGziUwynkhciTBZUdfHDD1FoJNlWxhdgmVA=="],
+
+ "@moq/signals": ["@moq/signals@0.2.6", "", { "peerDependencies": { "@types/react": "^19.2.17", "react": "^19.3.0", "solid-js": "^1.9.15" }, "optionalPeers": ["@types/react", "react", "solid-js"] }, "sha512-8L5y9cJSOVYGrHCZ20kcZsrmoxVXzl2dBQhsRj8H4zbbul70SmZcdeLG+O3JBP5W3tjo8EmLR+DOmh1/p7QQYw=="],
+
+ "@moq/studio-join": ["@moq/studio-join@workspace:js/join"],
+
+ "@moq/watch": ["@moq/watch@0.6.6", "", { "dependencies": { "@moq/hang": "^0.5.4", "@moq/json": "^0.4.3", "@moq/msf": "^0.3.1", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6", "media-captions": "0.0.18" } }, "sha512-wtUoblpIS/UTgrW/IeQ6dlsZJDVo5nAbzU8oJWDU4K6JSiKyD1b08p19LPloeRJ/UPpA17r1PM4l35IOuoMXdg=="],
+
+ "@moq/web-socket-stream": ["@moq/web-socket-stream@0.1.1", "", {}, "sha512-VKi/GE/CLqmbEZlgNbBLbAFk2pQTxT+KUD2DjCSoxqZxbS3KBe5BBZ0m3fila4fEGyll2JT5MmgEyVr4zkZEVA=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="],
+
+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="],
+
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+
+ "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.7", "", { "peerDependencies": { "@svta/cml-utils": "^1.6.2" } }, "sha512-pYM60+pcGd8G8USeVKOe4X25FdmKd4ihTLQWRszxSE/E0I/SFugqeexdlXyBiJbOhkzc8PFceIWv+RvrB9+NDw=="],
+
+ "@svta/cml-utils": ["@svta/cml-utils@1.7.0", "", {}, "sha512-NNa9v5CxrmULRAu5zx1ENHVwF4xKfclEdm+OmaIZDwoTNLVpf7JGgwbxkYsbRQYPCZrQTTLlR4sVjwgS6gcmug=="],
+
+ "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
+
+ "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.12", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="],
+
+ "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
+
+ "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
+
+ "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
+
+ "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
+
+ "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
+
+ "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
+
+ "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
+
+ "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
+
+ "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
+
+ "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
+
+ "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
+
+ "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
+
+ "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
+
+ "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
+
+ "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
+
+ "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
+
+ "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
+
+ "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
+
+ "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
+
+ "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
+
+ "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
+
+ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
+
+ "@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="],
+
+ "@zod/mini": ["@zod/mini@4.6.7", "", { "peerDependencies": { "zod": "^4.6.0" } }, "sha512-oLORzy4OERpr+gaITCsrCK9uDWd9lOCLG00fRZEJidVITLS5SQtifmzP6fBSFH9YxkXFoT6/o4bRZDq2tTHrog=="],
+
+ "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
+
+ "bowser": ["bowser@2.15.0", "", {}, "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ=="],
+
+ "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="],
+
+ "mediabunny": ["mediabunny@1.65.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-TNnDNOmD5Fme4rwMGWuNv485a9iQwd9lgw88PI9zQ0FMnzEUoo6EJpG7ZhdJ2iwZFWo8AzNkP1KPq5IAHaktKw=="],
+
+ "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
+
+ "pako": ["pako@3.0.3", "", {}, "sha512-tmrebBBYI5FOw8QICGwJNqE8fotC8R+gwcxOvoFNv+GKehOI6Lc6YBolNcQNnC4v96l1wt7rh102ifD4kVeZAQ=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
+
+ "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
+
+ "rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="],
+
+ "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
+
+ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
+
+ "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
+
+ "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
+
+ "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
+
+ "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+ }
+}
diff --git a/flake.nix b/flake.nix
index 048ecb6..d268027 100644
--- a/flake.nix
+++ b/flake.nix
@@ -117,6 +117,9 @@
cargo-shear
quest-cli
+ # The join page: install, lint, type-check, test, and build.
+ bun
+
# `just check` lints.
shellcheck
shfmt
diff --git a/js/join/index.html b/js/join/index.html
new file mode 100644
index 0000000..c7cbadf
--- /dev/null
+++ b/js/join/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+ Join the studio
+
+
+
+
+
+ Join the studio
+
+
+
+ You are joining as . The host sees your camera, microphone, and any screen you share.
+
+
+
+
+ Connecting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/join/package.json b/js/join/package.json
new file mode 100644
index 0000000..d5ad0d6
--- /dev/null
+++ b/js/join/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@moq/studio-join",
+ "private": true,
+ "type": "module",
+ "version": "0.0.0",
+ "description": "The page a guest opens to join a MoQ Studio room",
+ "license": "(MIT OR Apache-2.0)",
+ "repository": "github:moq-dev/studio",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "check": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "dependencies": {
+ "@moq/net": "^0.4.7",
+ "@moq/publish": "^0.5.4",
+ "@moq/room": "^0.2.1",
+ "@moq/signals": "^0.2.6"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.4.4",
+ "typescript": "7.0.2",
+ "vite": "^8.3.5"
+ }
+}
diff --git a/js/join/src/index.css b/js/join/src/index.css
new file mode 100644
index 0000000..0908644
--- /dev/null
+++ b/js/join/src/index.css
@@ -0,0 +1,73 @@
+:root {
+ color-scheme: dark;
+ font-family: system-ui, sans-serif;
+ background: #111;
+ color: #eee;
+}
+
+main {
+ max-width: 40rem;
+ margin: 0 auto;
+ padding: 1rem;
+}
+
+button {
+ font: inherit;
+ padding: 0.5rem 1rem;
+ border: 1px solid #444;
+ border-radius: 0.375rem;
+ background: #222;
+ color: inherit;
+ cursor: pointer;
+}
+
+button[aria-pressed="true"] {
+ background: #1f6f4a;
+ border-color: #2a9d68;
+}
+
+#join {
+ background: #1f6f4a;
+}
+
+canvas {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: #000;
+ border-radius: 0.375rem;
+}
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.dot {
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ background: #c93;
+}
+
+.dot.connected {
+ background: #3c8;
+}
+
+.dot.disconnected,
+.error {
+ color: #f77;
+ background: #c33;
+}
+
+.error {
+ background: none;
+}
diff --git a/js/join/src/index.ts b/js/join/src/index.ts
new file mode 100644
index 0000000..f45ac4d
--- /dev/null
+++ b/js/join/src/index.ts
@@ -0,0 +1,146 @@
+/**
+ * The join page: a guest opens the studio's link and publishes their camera,
+ * microphone, and screen into the studio's room, following moq-room's
+ * `{name}/camera.hang` and `{name}/screen.hang`.
+ *
+ * @module
+ */
+
+import * as Net from "@moq/net";
+import * as Publish from "@moq/publish";
+import { Local } from "@moq/room";
+import { Effect } from "@moq/signals";
+import { type Link, parse } from "./link.ts";
+
+/** Where the fragment is kept once it leaves the address bar, so a reload rejoins. */
+const STORED = "moq-studio-join";
+
+function element(id: string): T {
+ const found = document.getElementById(id);
+ if (!found) throw new Error(`missing #${id}`);
+ return found as T;
+}
+
+function fail(err: unknown): void {
+ const error = element("error");
+ error.textContent = err instanceof Error ? err.message : String(err);
+ error.hidden = false;
+}
+
+/**
+ * `use` on the page's session storage, or `undefined` where the browser denies
+ * it, which only costs rejoining after a reload.
+ */
+function stored(use: (storage: Storage) => T): T | undefined {
+ try {
+ return use(sessionStorage);
+ } catch {
+ return undefined;
+ }
+}
+
+/** The link this page was opened with, moved out of the address bar. */
+function read(): Link {
+ const fragment = location.hash.slice(1) || stored((storage) => storage.getItem(STORED));
+ if (!fragment) throw new Error("open the join link the host sent you");
+ const link = parse(fragment);
+ // Out of the address bar, history, and anything that shows them, such as a screen share.
+ stored((storage) => storage.setItem(STORED, fragment));
+ history.replaceState(null, "", location.pathname + location.search);
+ return link;
+}
+
+/** Publish into the room until the guest leaves. Returns how to leave. */
+function join(link: Link): () => void {
+ const signals = new Effect();
+ const connection = new Net.Connection({ url: link.url, enabled: true });
+ signals.cleanup(() => connection.close());
+
+ const local = new Local({
+ connection,
+ identity: Net.Path.from(link.name),
+ enabled: true,
+ cameraEnabled: true,
+ microphoneEnabled: true,
+ user: { id: link.name, name: link.name },
+ });
+ signals.cleanup(() => local.close());
+
+ const preview = new Publish.Preview.Renderer({
+ canvas: element("preview"),
+ frames: local.cameraCapture.out.frames,
+ display: local.cameraCapture.out.display,
+ flip: true,
+ });
+ signals.cleanup(() => preview.close());
+
+ // A refused permission or a missing device leaves the toggle on with nothing sent, so say so.
+ signals.run((effect) => {
+ const failures = [
+ ["camera", effect.get(local.webcam.out.error)],
+ ["microphone", effect.get(local.microphone.out.error)],
+ ] as const;
+ const messages = failures.flatMap(([device, error]) => (error ? [`No ${device}: ${error.message}`] : []));
+ // The screen picker gives no reason when it is closed or refused.
+ if (effect.get(local.screenEnabled) && !effect.get(local.share.out.source)) {
+ messages.push("No screen shared: pick one in the browser's prompt, or turn Share screen off.");
+ }
+ const capture = element("capture");
+ capture.textContent = messages.join(" ");
+ capture.hidden = messages.length === 0;
+ });
+
+ signals.run((effect) => {
+ const status = effect.get(connection.status);
+ element("status").textContent = status.charAt(0).toUpperCase() + status.slice(1);
+ element("status-dot").className = `dot ${status}`;
+ const error = effect.get(connection.error);
+ if (error) fail(error);
+ });
+
+ const toggles = [
+ ["camera", local.cameraEnabled],
+ ["microphone", local.microphoneEnabled],
+ ["screen", local.screenEnabled],
+ ] as const;
+ for (const [id, enabled] of toggles) {
+ const button = element(id);
+ signals.event(button, "click", () => enabled.set(!enabled.peek()));
+ signals.run((effect) => {
+ button.setAttribute("aria-pressed", String(effect.get(enabled)));
+ });
+ }
+
+ return () => signals.close();
+}
+
+function main(): void {
+ let link: Link;
+ try {
+ link = read();
+ } catch (err) {
+ fail(err);
+ return;
+ }
+
+ element("name").textContent = link.name;
+ element("ready").hidden = false;
+
+ // Capture starts on a click, which some phones require before they open the camera.
+ let leave: (() => void) | undefined;
+ element("join").addEventListener("click", () => {
+ element("ready").hidden = true;
+ element("error").hidden = true;
+ element("joined").hidden = false;
+ leave = join(link);
+ });
+ element("leave").addEventListener("click", () => {
+ leave?.();
+ leave = undefined;
+ stored((storage) => storage.removeItem(STORED));
+ element("joined").hidden = true;
+ element("ready").hidden = false;
+ });
+}
+
+main();
diff --git a/js/join/src/link.test.ts b/js/join/src/link.test.ts
new file mode 100644
index 0000000..2fb01fc
--- /dev/null
+++ b/js/join/src/link.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "bun:test";
+import { parse } from "./link.ts";
+
+const fragment = (params: Record) => new URLSearchParams(params).toString();
+
+test("a link dials the room with its token in the query", () => {
+ const link = parse(fragment({ url: "https://relay.example:4443/show/guests", name: "alice", jwt: "a.b.c" }));
+ expect(link.name).toBe("alice");
+ expect(link.url.origin).toBe("https://relay.example:4443");
+ expect(link.url.pathname).toBe("/show/guests");
+ expect(link.url.searchParams.get("jwt")).toBe("a.b.c");
+});
+
+test("a name with spaces survives the fragment's encoding", () => {
+ const link = parse(fragment({ url: "https://relay.example/room", name: "Alice Smith", jwt: "t" }));
+ expect(link.name).toBe("Alice Smith");
+});
+
+test("an incomplete or malformed link is refused", () => {
+ const valid = { url: "https://relay.example/room", name: "alice", jwt: "t" };
+ for (const key of ["url", "name", "jwt"] as const) {
+ const { [key]: _, ...missing } = valid;
+ expect(() => parse(fragment(missing))).toThrow("incomplete");
+ }
+ expect(() => parse(fragment({ ...valid, url: "not a url" }))).toThrow("invalid relay");
+ expect(() => parse(fragment({ ...valid, url: "moqt://relay.example/room" }))).toThrow("cannot dial");
+ for (const name of ["a/b", "a\\b", " alice"]) {
+ expect(() => parse(fragment({ ...valid, name }))).toThrow("invalid guest");
+ }
+});
diff --git a/js/join/src/link.ts b/js/join/src/link.ts
new file mode 100644
index 0000000..455cd40
--- /dev/null
+++ b/js/join/src/link.ts
@@ -0,0 +1,40 @@
+/**
+ * The join link's fragment: the room's relay URL, the guest's name, and the
+ * token the studio signed for that name. A fragment never reaches a server, so
+ * the token stays out of every request log on the way here.
+ *
+ * @module
+ */
+
+/** A guest's way into the studio's room. */
+export interface Link {
+ /** The relay at the room, with the token in its query, as `@moq/net` dials it. */
+ url: URL;
+ /** The guest's identity in the room: one path segment. */
+ name: string;
+}
+
+/** Parse a join link's fragment, without its `#`. Throws on a link the studio did not make. */
+export function parse(fragment: string): Link {
+ const params = new URLSearchParams(fragment);
+ const relay = params.get("url");
+ const name = params.get("name");
+ const jwt = params.get("jwt");
+ if (!relay || !name || !jwt) throw new Error("this join link is incomplete; ask the host for a new one");
+
+ let url: URL;
+ try {
+ url = new URL(relay);
+ } catch {
+ throw new Error(`this join link names an invalid relay: ${relay}`);
+ }
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
+ throw new Error(`a browser cannot dial ${url.protocol}// relays`);
+ }
+ if (name.includes("/") || name.includes("\\") || name.trim() !== name) {
+ throw new Error(`this join link names an invalid guest: ${name}`);
+ }
+
+ url.searchParams.set("jwt", jwt);
+ return { url, name };
+}
diff --git a/js/join/tsconfig.json b/js/join/tsconfig.json
new file mode 100644
index 0000000..461eb10
--- /dev/null
+++ b/js/join/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "lib": ["esnext", "dom", "dom.iterable"],
+ "target": "esnext",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "types": ["bun"],
+
+ "allowUnreachableCode": false,
+ "allowUnusedLabels": false,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "strict": true,
+ "useUnknownInCatchVariables": true,
+ "erasableSyntaxOnly": true,
+ "skipLibCheck": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/js/join/vite.config.ts b/js/join/vite.config.ts
new file mode 100644
index 0000000..4654c3e
--- /dev/null
+++ b/js/join/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ // Relative asset URLs, so the built page works from any path it is hosted under.
+ base: "./",
+ build: {
+ target: "esnext",
+ // @moq/publish's capture and encode pipeline is most of the page, in one chunk.
+ chunkSizeWarningLimit: 1024,
+ },
+});
diff --git a/justfile b/justfile
index a40fff9..dd34f2e 100644
--- a/justfile
+++ b/justfile
@@ -7,7 +7,7 @@ features := "moq-scene/capture,moq-studio/capture,moq-studio/openh264"
default:
@just --list
-# Lint the repository tooling, the quest tree, and the workspace.
+# Lint the repository tooling, the quest tree, the join page, and the workspace.
check:
sh/check-moq-pin.sh
quest check
@@ -16,6 +16,10 @@ check:
shfmt -d .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh
taplo format --check
nixfmt --check flake.nix
+ bun install --frozen-lockfile
+ bun biome check .
+ bun run check
+ bun run --cwd js/join build
cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo clippy --locked --workspace --all-targets --features {{ features }} -- -D warnings
@@ -24,6 +28,7 @@ check:
[positional-arguments]
test *args:
bash .claude/hooks/direnv.test.sh
+ bun run test
cargo nextest run --locked --workspace --features {{ features }} "$@"
# Run what CI's Check runs: the lints, then the tests.
@@ -36,6 +41,7 @@ fix:
shfmt -w .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh
taplo format
nixfmt flake.nix
+ bun biome check --write .
cargo fmt --all
# Composite scenes and publish a program through wgpu on Mesa's software Vulkan
@@ -53,3 +59,8 @@ render-sw:
# Move the moq submodule to moq's latest main (or a given commit) and refresh Cargo.lock.
upgrade-moq rev="origin/main":
sh/upgrade-moq.sh {{ rev }}
+
+# Serve the join page locally while working on it.
+join:
+ bun install --frozen-lockfile
+ bun run --cwd js/join dev
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f2ae849
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "studio",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "workspaces": [
+ "js/join"
+ ],
+ "scripts": {
+ "check": "bun run --filter='*' check",
+ "test": "bun run --filter='*' test",
+ "fix": "bun biome check --write ."
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.6.4"
+ }
+}
diff --git a/quest/m0/README.md b/quest/m0/README.md
index 37e1902..6b91606 100644
--- a/quest/m0/README.md
+++ b/quest/m0/README.md
@@ -96,7 +96,7 @@ repository.
- [ML video effects](/quest/m0/effects.md) - background removal and other ML effects, baked into their source's track
- [Destinations](/quest/m0/destinations.md) - RTMP, RTMPS, and WHIP push with per-destination codecs and shared encoders, on a UI-free egress library a hosted service can reuse
- [Multiple canvases](/quest/m0/multi-canvas.md) - landscape and portrait canvases composite at once from shared sources
-- [Guests](/quest/m0/guests.md) - remote guests and phone cameras join through a moq-room link or QR code and appear as sources
+- [The join page is live](/quest/m0/join-page-live.md) - `join.studio.moq.dev` serves the join page `--join-page` defaults to
- [Guest return feed](/quest/m0/guest-return.md) - guests see the program and hear a mix-minus
- [Guest token refresh](/quest/m0/guest-refresh.md) - a show outlasts the guest link's expiry without new links
- [Audio mixes](/quest/m0/mixes.md) - per-output mixes, meters, monitoring, and sync offsets
diff --git a/quest/m0/broadcast-sync.md b/quest/m0/broadcast-sync.md
index dba0ab0..437d640 100644
--- a/quest/m0/broadcast-sync.md
+++ b/quest/m0/broadcast-sync.md
@@ -38,4 +38,3 @@ video waits for audio instead. Update `README.md`.
## Related
- [Audio jitter target](https://github.com/moq-dev/moq/blob/main/quest/m1/audio-jitter-target/README.md) - the delay estimate this reuses
-- [Guests](/quest/m0/guests.md) - guests are broadcast sources and inherit this
diff --git a/quest/m0/guests.md b/quest/m0/guests.md
deleted file mode 100644
index 01a714a..0000000
--- a/quest/m0/guests.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# [M] Studio guests
-
-## Goal
-
-The studio owns a moq-room prefix and shows a join link and QR code. A guest
-or a phone opens a join page this repository serves (built on @moq/publish), and each participant's camera
-and screen appear in the studio as sources. The host sees who joined and can
-remove a participant from the scene.
-
-## Plan
-
-- **Rooms:** reuse moq-room's conventions (`{identity}/camera.hang`,
- `{identity}/screen.hang`). Do not invent a guest protocol.
-- **Tokens:**
- - Minting a moq-auth token needs a signing key or an issuer API. For a
- self-hosted relay, the studio accepts only a key whose moq-auth `Scope`
- is rooted at its room prefix, and refuses an unscoped relay-wide key. A
- stolen workstation key then cannot sign for other tenants. A hosted
- issuer brings its own API.
- - Each guest token is short-lived and minted with `moq_room::claims(room,
- identity)`, so it publishes only under that guest's own `/`.
- A guest cannot impersonate another participant.
-- **Join link:** carry the token in the URL fragment, so it never reaches a
- server log.
-- **Relay required:** guests join through a relay. Peer-to-peer WHIP ingress
- inside the studio was considered and dropped, because it brings NAT
- traversal and TURN for no capability the relay lacks.
diff --git a/quest/m0/join-page-live.md b/quest/m0/join-page-live.md
new file mode 100644
index 0000000..a578ca1
--- /dev/null
+++ b/quest/m0/join-page-live.md
@@ -0,0 +1,18 @@
+# [XS] The join page is live
+
+## Goal
+
+`https://join.studio.moq.dev/` serves the join page (`js/join`), so
+`--join-page` works without being passed. The maintainer:
+
+- enables GitHub Pages for moq-dev/studio with GitHub Actions as its source,
+ and limits the `github-pages` environment's deployment branches to `main`
+- protects `main` so only reviewed PRs reach it, since whatever lands there
+ deploys to the page every guest's token passes through
+- points `join.studio.moq.dev` at Pages in DNS, sets it as the custom domain,
+ and enforces HTTPS
+- runs the Join page workflow (`.github/workflows/join.yml`) once
+
+Check: `curl -sI https://join.studio.moq.dev/` answers 200 over a valid
+certificate, and the page loads. Then drop the README's note that the
+default is not live yet, and delete this quest.
diff --git a/rs/moq-scene/src/edit.rs b/rs/moq-scene/src/edit.rs
index d6ebbf2..e7bd3c6 100644
--- a/rs/moq-scene/src/edit.rs
+++ b/rs/moq-scene/src/edit.rs
@@ -111,6 +111,14 @@ impl Change {
}
}
+ /// The name of the source, audio, or video entry the change makes.
+ fn entry(&self) -> Option<&str> {
+ match self {
+ Self::Source { name, .. } | Self::Audio { name, .. } | Self::Video { name, .. } => Some(name),
+ Self::Scene { .. } | Self::Item { .. } | Self::Transition { .. } => None,
+ }
+ }
+
/// Whether the change leaves its part as it was.
fn is_noop(&self) -> bool {
match self {
@@ -666,6 +674,63 @@ impl History {
Ok(())
}
+ /// Make `edit` to `collection` without recording it, for a change the
+ /// user did not make, such as a guest joining. It is not undone, and every
+ /// edit here still undoes and redoes around it.
+ ///
+ /// So it must commute with each of them: it may only add source, audio,
+ /// and video entries, under names no edit here changes, and nothing that
+ /// names another source, such as a clone or a mask, which an edit could
+ /// remove. Anything else is refused.
+ pub fn apply_outside(&self, collection: &mut Collection, edit: &Edit) -> Result<(), Error> {
+ for change in &edit.changes {
+ let name = match change {
+ Change::Source {
+ name,
+ before: None,
+ after: Some(source),
+ } if source.opens() => name,
+ Change::Audio {
+ name,
+ before: None,
+ after: Some(_),
+ } => name,
+ Change::Video {
+ name,
+ before: None,
+ after: Some(video),
+ } if video.filters.iter().all(|filter| filter.source().is_none()) => name,
+ _ => {
+ return Err(Error::Invalid(format!(
+ "`{}` changes more than adding a source's own entries",
+ edit.label
+ )));
+ }
+ };
+ if self.touches(name) {
+ return Err(conflict(format!("the history edits `{name}`")));
+ }
+ }
+ collection.apply(edit)
+ }
+
+ /// Whether any edit here, done or undone, changes the source, audio, or
+ /// video entry called `name`.
+ pub fn touches(&self, name: &str) -> bool {
+ let mut changes = self.entries().flat_map(|edit| &edit.changes);
+ changes.any(|change| change.entry() == Some(name))
+ }
+
+ /// `base`, or `base 2`, `base 3`, and so on: the first name no source of
+ /// `collection` has and no edit here changes, for
+ /// [`apply_outside`](Self::apply_outside) to add.
+ pub fn unused_name(&self, collection: &Collection, base: &str) -> String {
+ std::iter::once(base.to_string())
+ .chain((2..).map(|n| format!("{base} {n}")))
+ .find(|name| !collection.sources.contains_key(name) && !self.touches(name))
+ .expect("an unused name")
+ }
+
/// Serialize to the history file format, in the scene file's version,
/// since entries hold its sources, scenes, and items.
pub fn to_toml(&self) -> Result {
@@ -987,6 +1052,82 @@ mod tests {
assert!(!edited.sources.contains_key("blue"));
}
+ /// A change from outside, such as a guest joining, drops nothing to redo,
+ /// and undo and redo pass over it, here or after a reload.
+ #[test]
+ fn an_outside_edit_keeps_the_history_around_it() {
+ let mut collection = collection();
+ let mut history = History::new();
+ drag(&mut collection, &mut history, 1.0, None);
+ drag(&mut collection, &mut history, 2.0, None);
+ history.undo(&mut collection).unwrap();
+
+ let mut draft = collection.draft("Add guest");
+ draft.set_source("guest", Some(color([0, 255, 0, 255]))).unwrap();
+ draft.set_audio("guest", Some(Audio::default())).unwrap();
+ history
+ .apply_outside(&mut collection, &draft.finish().unwrap())
+ .unwrap();
+ assert_eq!((history.position(), history.entries().len()), (1, 2));
+
+ history.redo(&mut collection).unwrap();
+ assert_eq!(collection.scenes[0].items[0].rect.x, 2.0);
+ history.seek(&mut collection, 0).unwrap();
+ assert_eq!(collection.scenes[0].items[0].rect.x, 0.0);
+ assert!(collection.sources.contains_key("guest"));
+
+ // The scene file now holds the guest, and the whole history still replays.
+ let (mut loaded, dropped) = History::from_toml(&history.to_toml().unwrap(), &collection).unwrap();
+ assert_eq!(dropped, 0);
+ loaded.seek(&mut collection, 2).unwrap();
+ assert_eq!(collection.scenes[0].items[0].rect.x, 2.0);
+ assert!(collection.sources.contains_key("guest"));
+ }
+
+ /// An outside change that an edit's undo or redo would collide with, or
+ /// that moves what an edit names by index, is refused.
+ #[test]
+ fn refuses_an_outside_edit_the_history_could_not_pass_over() {
+ let mut collection = collection();
+ let mut history = History::new();
+ let mut draft = collection.draft("Add blue");
+ draft.set_source("blue", Some(color([0, 0, 255, 255]))).unwrap();
+ commit(&mut collection, &mut history, draft, None);
+ history.undo(&mut collection).unwrap();
+ let before = collection.clone();
+
+ let mut taken = collection.draft("Add guest");
+ taken.set_source("blue", Some(color([0, 0, 255, 255]))).unwrap();
+ let mut clone = collection.draft("Add guest");
+ clone
+ .set_source("clone", Some(Source::Clone { source: "red".into() }))
+ .unwrap();
+ let mut placed = collection.draft("Add guest");
+ placed.set_source("guest", Some(color([0, 255, 0, 255]))).unwrap();
+ let item = Item::new("guest", Rect::fill(&collection.canvas));
+ placed.insert_item(&main(), 0, item).unwrap();
+ let mut masked = collection.draft("Add guest");
+ masked.set_source("guest", Some(color([0, 255, 0, 255]))).unwrap();
+ let mask = Filter::Mask {
+ source: "red".into(),
+ mode: Default::default(),
+ };
+ let video = Video {
+ filters: vec![mask],
+ ..Default::default()
+ };
+ masked.set_video("guest", Some(video)).unwrap();
+ let mut removed = collection.draft("Remove red");
+ removed.remove_source("red").unwrap();
+ for draft in [taken, clone, placed, masked, removed] {
+ let edit = draft.finish().unwrap();
+ assert!(history.apply_outside(&mut collection, &edit).is_err(), "{edit:?}");
+ }
+ assert_eq!(collection, before);
+ assert_eq!(history.unused_name(&collection, "blue"), "blue 2");
+ assert_eq!(history.unused_name(&collection, "red"), "red 2");
+ }
+
#[test]
fn refuses_a_file_that_is_not_a_history() {
let collection = collection();
diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml
index 52bd1ef..ac9c56a 100644
--- a/rs/moq-studio/Cargo.toml
+++ b/rs/moq-studio/Cargo.toml
@@ -45,14 +45,20 @@ egui_dock = "0.21"
hang = { workspace = true }
kio = { workspace = true, features = ["async", "runtime"] }
moq-audio = { workspace = true, features = ["bus"] }
+# Signs guest tokens with the room key.
+moq-auth = { workspace = true }
moq-json = { workspace = true }
moq-mux = { workspace = true }
moq-net = { workspace = true }
+# The guest room: its path convention, roster, and token claims.
+moq-room = { workspace = true }
moq-scene = { workspace = true }
moq-stats = { workspace = true }
moq-tokio = { workspace = true, default-features = false, features = ["aws-lc-rs", "net"] }
moq-video = { workspace = true }
pollster = { workspace = true }
+# The join link's QR code. No dependencies of its own.
+qrcodegen = "1.8"
tempfile = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
@@ -60,7 +66,8 @@ url = { workspace = true }
usage-rs = { workspace = true, features = ["completions", "config"] }
[dev-dependencies]
-moq-auth = { workspace = true }
+# The auth server a test relay verifies guest tokens with.
+moq-auth = { workspace = true, features = ["serve"] }
moq-relay = { workspace = true, features = ["test-support"] }
# A paused clock, so health tests sample without waiting.
tokio = { workspace = true, features = ["test-util"] }
diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs
index 32b0fa7..be655ba 100644
--- a/rs/moq-studio/src/engine.rs
+++ b/rs/moq-studio/src/engine.rs
@@ -103,6 +103,16 @@ impl State {
Ok(())
}
+ /// Apply `draft`, a change the user did not make, such as a guest joining,
+ /// without recording it: it is not undone, and drops nothing that could be
+ /// redone. See [`History::apply_outside`] for what it may change.
+ pub fn apply_outside(&mut self, draft: Draft) -> anyhow::Result<()> {
+ let edit = draft.finish()?;
+ self.history.apply_outside(&mut self.collection, &edit)?;
+ self.edited();
+ Ok(())
+ }
+
/// Undo the newest edit, returning its label, or `None` with none left.
pub fn undo(&mut self) -> anyhow::Result