open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js
Transaction Science beb4679dc6 Merge #493 (wai/messaging-calls @01f3aac1): wai.live.message and wai.live.call, conversations and calls on live rooms
Merged last, as asked. Conflicts, all adjacent additions, both sides kept:
- joule-moq-room: Cargo.toml's wai features (live_world, remote_desktop and
  live_messaging); lib.rs's modules (messaging, msgstore, remote), Config's
  remote, messaging and call, Service's remote, messaging and calls, the epoch
  through remote::epoch with the conversation and calls opened beside it, and
  settings()'s refusals (#482's world room, #493's conversation and call); the
  README's world, conversation and call sections.
- joule-moq-web: app.js asks a remote room's grant as #489 does and takes a
  given grant as #493 does (a remote page's fallback to viewer only when it
  asked); run.sh runs room.e2e.mjs (now with --remote) and messaging.e2e.mjs,
  each its own --only scenarios; the usage names every scenario.
- workflows: joule-moq-room.yml's and joule-moq-web.yml's accounts;
  wai-conformance.yml's WAI_FEATURES gains live_messaging (#477's union) and
  the first step's test build names live_messaging beside the train's targets.
- wai-live-web's wai features (stream_termination, live_world, live_messaging),
  wai-rs lib.rs's modules, registry.rs's capability list, REGISTRY.md's rows;
  CHANGELOG, #493's entry on top.
- capabilities.json and v2.ttl regenerated (registry export, then --check).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:23:34 -04:00

2288 lines
116 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The room client: a participant publishes its camera and microphone through the relay
// and subscribes to everyone else; the room's clock (a room key) publishes the lifecycle.
//
// Two ways in:
// - a room service (joule-moq-room): the page makes its own Ed25519 key, asks the
// service for a grant for it (a publisher's or a viewer's), and presents the grant with
// a proof by that key (WAI live-rooms §10.5); the service's clock authors the lifecycle.
// The service's base URL is the `service` parameter, or `data-service` on #room-app;
// the page enters when `enter("publisher" | "viewer")` is called (the #join and #watch
// buttons). The relay is the WebSocket at `/moq` on the page's own origin unless `ws`
// names another, and WebTransport is not tried;
// - a test room (`fixture`): every key from the fixture, and a page with `role=clock`
// authors the lifecycle, driven by the test.
//
// Query parameters:
// service=<url> the room service, as above
// fixture=<url> the test room (descriptor, grants, composition descriptor, keys)
// who=<name> this participant, by name; `role=clock` for the room's clock
// wt=<url> the relay's WebTransport URL (https://127.0.0.1:<port>/moq)
// ws=<url> its WebSocket URL (ws://127.0.0.1:<port>/moq)
// hashes=<hex,...> serverCertificateHashes for a relay with a development certificate
// transport=<t> `websocket` forces the WebSocket binding; otherwise WebTransport is
// tried first and the binding is the fallback
// fetch=1 a late joiner: earlier groups of every media track by FETCH
// (the "Seek back" control, #seek, fetches a participant's last seconds from the relay's
// DVR window, verifies every object, plays the video from its first key frame in a view of
// its own, and returns to live; `roomTest.seekBack(seconds)` does the same)
// audioFrameMs=<n> the Opus frame duration, 20 by default
// issuer_directory=<url> where Privacy Pass issuers' directories are (RFC 9578 §4): an
// issuer's at <url>/<issuer name>/.well-known/private-token-issuer-directory;
// by default at https://<issuer name>/.well-known/…
// age_threshold=<n> the threshold of the token to obtain, in a room that admits by age; by
// default the least the room's challenges offer at or above its age
// rate=<n>, rate_scheme=<uri>, rate_value=<v> a publisher rates its own video from its
// first group: n years under the scheme, with that value (age-assurance §3.6)
// debug=binding the state of the frame each tile shows (WAI browser-sink §10): its
// binding, the receipts it verified under, its route and energy entry
// unseen_grace_ms=<n>, idle_ms=<n>, autoplay_grace_ms=<n> the termination policy of each
// stream this page receives (WAI stream-termination §4.3, ./termination.js):
// a video stream hidden or out of view this long is terminated with a signed
// record, its subscription ended and its audio kept; 10000, 0 (never) and 0
// by default
// autoplay=off the viewer's autoplay setting (§4.4), recorded in each stream's log: media
// is subscribed only after the viewer's first activation; `autoplay` in this
// page's storage keeps it across visits
//
// The endpoints among them (`service`, `ws`, `wt`, `fixture`, `issuer_directory`), and the URI
// a relay's GOAWAY moves the session to, are taken only on the page's own origin
// (`https://<host>`, or `wss://<host>` for a relay); a page served from the loopback interface
// (a fixture page, the end-to-end harness) takes loopback addresses, and `hashes` only there
// (./endpoint.js). A link that names another origin is refused and shown, and the page
// connects to nothing: otherwise a visitor who joined from it would send their camera and
// microphone there.
//
// A room that admits by age (WAI age-assurance §5.6; `GET /room`'s `age`): the service answers
// a viewer's grant request `401` with Privacy Pass challenges (RFC 9577); the page obtains a
// token from the issuer by blind issuance (RFC 9578 §6, ./privacy-pass.js) and asks again with
// it, and the grant states the threshold it was admitted at. A publisher carries the room's
// rating on every track it publishes (§5.7). A track the relay holds above the page's
// threshold is refused `below_threshold`, and the page says so (§5.5).
//
// An end-to-end encrypted room (WAI confidentiality): the room service names the room's
// confidentiality record (`GET /room`'s `e2ee`). The page checks it, loads the build of the
// codec with the room's MLS member (`joule_moq_web_e2ee`), leaves a KeyPackage, and follows
// the room's log (./e2ee.js). It seals each encoded frame before it is signed and published,
// and opens every object it receives through the receiving checks in their order before
// any decoder sees it; an object that does not open is counted with its reason and never
// decoded. Such a room has no seeking back: the relay keeps nothing of it.
//
// A room with co-performance (WAI co-performance): the room service names its policy and its
// audience's grants (`GET /room`'s `co`). The page shows the turn schedule and who holds the
// floor, each participant's intents as upcoming lines, the selection window open now with an
// input box for a viewer whose grant allows it, each selection with its credits, and the
// clock's fidelity statement (./co.js). It states its media's timestamps on the room clock, so
// that an object's due instant is its capture instant there, and sends media only while it
// holds the floor. A lead asks the room's clock for schedules, handovers and windows, its
// grant presented with a proof over the service's challenge (live-rooms §10.5.3).
// ai=scripted a demo AI participant: the page enters as a publisher whose grant marks
// it an AI participant, publishes a drawn picture and a tone instead of a
// camera and a microphone, and announces a line ahead of each it says, as
// a caption a machine made; `script=off` leaves the lines to a test
// A watch room (WAI co-watch): the room service names its policy and its source (`GET /room`'s
// `watch`). The page fetches the source, checks its bytes by the content hash the policy
// names, enters the room's co-watch log by a proof of its key over the service's challenge,
// applies each control at its instant, keeps the source at the controlled position by the
// catch-up rule, states what it presents twice a slot, and shows the room's sync (./cowatch.js).
// The page's events in such a room are its co-watch statements: it sends no caption or chat.
// A remote desktop room (WAI remote-desktop): the room service names its host and its rules
// (`GET /room`'s `remote`). The page publishes no media in it: #join asks for a controller's grant
// (the service's allow rule decides; refused, the page asks for a viewer's) and #watch for a
// viewer's, each for the key the page made. The page draws the host's screen, every frame
// verified against its receipt first, and sends its pointer, keys, composed and pasted text as
// input statements under its grant in effect, once the clock has recorded it joining (./remote.js);
// a grant the host exchanges for it, to give or take back control, is read again from the service.
//
// Everything a test reads is on `window.roomTest`.
import initWai, * as wai from "../pkg/wai_live_web.js";
import { E2ee } from "./e2ee.js";
import { CoWatch, ACTION } from "./cowatch.js";
import { RemoteViewer, keyFromSeed, readEvent } from "./remote.js";
import { connect } from "../moq/transport.js";
import { MoqSession, RequestRefused, withTimeout } from "../moq/session.js";
import * as media from "../moq/media.js";
import { Compositor } from "./compose.js";
import * as pp from "./privacy-pass.js";
import { Co, wireControls } from "./co.js";
import { SideTable, buildReport, askUserAgent, chainToken, stateOf, renderStates, metadataProbe, installFramePatches } from "./binding.js";
import { checkEndpoint, isLoopback, kindOf } from "./endpoint.js";
import * as term from "./termination.js";
// The codec's wasm: the plain build, or, for an end-to-end encrypted room, the build with
// the member of the room's MLS group, loaded for such a room alone.
let moq = null;
async function loadMoq(sealed) {
if (moq) return moq;
const m = await import(sealed ? "../pkg/joule_moq_web_e2ee.js" : "../pkg/joule_moq_web.js");
await m.default();
moq = m;
return m;
}
const q = new URLSearchParams(location.search);
// What each endpoint the query string may name is, for the page's words.
const ENDPOINT_NAMES = { service: "room service", ws: "relay", wt: "relay", fixture: "test room", issuer_directory: "Privacy Pass issuer directory" };
// The endpoint the query string names as `name`, held to ./endpoint.js; `fallback` when it
// names none. One it names and the page refuses is recorded in `state.refused` and shown, and
// gives null: the caller then connects to nothing.
function queryEndpoint(name, kind, fallback = null) {
const v = q.get(name);
if (v == null || v === "") return fallback;
const c = checkEndpoint(v, kind, location.href);
if (c.ok) return c.url;
state.refused.push({ name, value: v, reason: c.reason });
log(`refused the ${name} the link names: ${v} (${c.reason})`);
status(`This link names a ${ENDPOINT_NAMES[name] || name} that is not this page's own (${v}: ${c.reason}). The page does not use it, and connects to nothing.`);
return null;
}
// The browser sink (WAI browser-sink): each subscribed track is routed before it is decoded
// (§4), every frame a video decoder outputs is bound to the object it came from in a side
// table (§7, §9.2), and with `debug=binding` the page shows the state of the frame each
// tile last drew (§10).
const DEBUG_BINDING = q.get("debug") === "binding";
const bindings = new SideTable();
installFramePatches();
const $ = (id) => document.getElementById(id);
const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const unhex = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16)));
const enc = new TextEncoder();
const dec = new TextDecoder();
const logLines = [];
function log(m) {
const line = `${(performance.now() / 1000).toFixed(3)} ${m}`;
logLines.push(line);
if (logLines.length > 2000) logLines.shift();
const el = $("log");
if (el) { el.textContent += line + "\n"; el.scrollTop = el.scrollHeight; }
console.log(line);
}
// Track Aliases this session's PUBLISHes give (§9.8): the publisher's for its own data.
const ALIAS = { catalog: 1, events: 2, audio: 10, audioReceipts: 11, video: 20, videoReceipts: 21 };
const RECEIPT_ALIAS = { audio: 1, video: 2 }; // the catalog's wai_receipt_alias
// Publisher priorities (MOQT §5.1.1, lower first): audio before video, the receipts
// tracks at their media track's, events before both, the catalog first.
const PRIORITY = { catalog: 0, events: 16, audio: 64, video: 96 };
// Subscriber priorities (§5.1.2) a participant gives its requests.
const SUB_PRIORITY = { catalog: 0, events: 8, audio: 32, video: 64, screen: 48 };
const VIDEO_TIMEOUT_MS = 2000; // OBJECT_DELIVERY_TIMEOUT on each video object
const AUDIO_TIMEOUT_MS = 1000;
const RECEIPT_WAIT_MS = 6000; // an object waits this long for its receipts
const RECOVER_AFTER_MS = 1000; // then, past this, its group of the receipts track is fetched
const MISSING_AFTER_MS = 4000; // receipts with no object after this: accounted by inclusion
const state = {
phase: "loading",
errors: [],
// Endpoints the query string named and the page refused (./endpoint.js).
refused: [],
transport: null,
published: { audio: 0, video: 0, events: 0, bytes: 0 },
publishCodec: {},
subs: {}, // principal -> per-track tallies
timeline: [],
captions: [],
goaways: [],
sessions: 0,
mode: null,
ended: null,
// Seeking back into the relay's DVR window: "live", "seeking" (fetching and verifying)
// or "playing", and each run's counts.
dvr: { mode: "live", runs: [] },
// An end-to-end encrypted room: the record's suite, and what this page sealed.
e2ee: null,
// A room that admits by age: its age, the threshold this page was admitted at, the token
// it presented (base64url; spent), how the service answered, and the tracks withheld.
age: null,
// The browser sink: each track's routing entry and the codec strings put to the user
// agent; what the user agent keeps of an unregistered metadata member.
binding: { routes: {}, asked: [], metadata: null },
// Stream termination (WAI stream-termination): the panel this page declared, whether its
// refresh rate was measured, the policy, and every stream log it signed.
termination: { panel: null, refresh_measured: false, policy: null, autoplay: "on", streams: [] },
};
window.roomTest = { state, logLines };
function fail(e) {
const m = (e && e.stack) || String(e);
state.errors.push(m);
log(`error: ${m}`);
status(`Something went wrong: ${(e && e.message) || e}`);
}
// What the page tells its reader, where it has a place for it.
function status(text) {
const el = $("status");
if (el) el.textContent = text;
}
window.addEventListener("error", (e) => fail(e.error || e.message));
window.addEventListener("unhandledrejection", (e) => fail(e.reason));
// -------------------------------------------------------------------------- the events track
// One principal's events track (live-events §8.1): an MSF event timeline whose groups
// are the room-clock minutes. Object 0 of a group is its join set (§8.2): here every
// earlier record of the chain (any other event MAY be repeated, and a late subscriber
// then holds the whole chain); each later object is an array of one record.
class EventsTrack {
constructor(seed, roomId, t0, clock) {
this.clock = clock;
this.seed = seed;
this.roomId = roomId;
this.t0 = t0;
this.seq = 0;
this.prev = new Uint8Array(32);
this.lastT = 0;
this.records = [];
this.group = null;
this.stream = null;
this.writer = null;
this.nextObject = 0;
this.session = null;
}
now() {
const t = Math.max(this.lastT, this.clock());
this.lastT = t;
return t;
}
// A record signed as this chain's next and sent, but not kept: the chain goes on as if it
// had never been signed. For a test that the relay withholds what it refuses.
async detached(kind, payload) {
const t = this.now();
const rec = wai.eventSign(this.seed, this.roomId, BigInt(this.seq), this.prev, BigInt(t), kind, payload);
await this.send(rec, t);
return JSON.parse(wai.eventRead(rec));
}
async attach(session) {
this.session = session;
this.group = null; // a new session opens a new stream for the current group
}
async emit(kind, payload) {
const t = this.now();
const rec = wai.eventSign(this.seed, this.roomId, BigInt(this.seq), this.prev, BigInt(t), kind, payload);
const v = JSON.parse(wai.eventRead(rec));
this.prev = unhex(v.digest);
this.seq++;
await this.send(rec, t);
this.records.push(rec);
return { record: rec, view: v };
}
async send(rec, t) {
const s = this.session;
if (!s) return;
const g = Number(wai.moqGroup(BigInt(t)));
const msf = (r) => JSON.parse(wai.msfRecord(r, BigInt(this.t0)));
if (this.group !== g) {
if (this.stream) await this.stream.close();
this.group = g;
this.writer = new moq.SubgroupWriter(BigInt(ALIAS.events), BigInt(g), PRIORITY.events, false);
this.stream = await s.openStream(0);
await this.stream.write(this.writer.header());
await this.stream.write(this.writer.object(0n, enc.encode(JSON.stringify(this.records.map(msf)))));
this.nextObject = 1;
}
await this.stream.write(this.writer.object(BigInt(this.nextObject++), enc.encode(JSON.stringify([msf(rec)]))));
state.published.events++;
}
}
// --------------------------------------------------------------------------------- room
let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack, service, e2ee, co, cowatch, remote;
// Where Privacy Pass issuers' directories are, when the link names it (`issuer_directory`).
let issuerDirectory = null;
// The service's clock less this page's, in µs, from `GET /room`'s `now_us`: room-clock instants
// this page states are the service's.
let skew = 0;
const subscribed = new Map(); // principal -> subscription state
const signers = {};
const published = []; // the PUBLISH requests held open
function roomNow() {
return Math.max(0, Math.round(Date.now() * 1000 + skew - t0));
}
// A principal as the page names it: the clock, this page, a fixture's participant, or a
// publisher by its grant's `order` attribute.
function nameOf(P) {
if (fx.clock && P === fx.clock.principal) return fx.participants.length ? "clock" : "room";
if (me && P === me.principal) return fx.participants.length ? me.name : "you";
const p = fx.participants.find((x) => x.principal === P);
if (p) return p.name;
const g = (state.grants || []).find((x) => x.principal === P);
const order = g && (g.attrs.find((a) => a[0] === "order") || [])[1];
if (order != null) return `${isAi(P) ? "AI participant" : "participant"} ${Number(order) + 1}`;
if (g && g.attrs.some((a) => a[0] === "audience_input")) return `audience member ${P.slice(0, 4)}`;
return P.slice(0, 8);
}
// Whether `P`'s grant marks it an AI participant (live-rooms §4.4, `ai`).
function isAi(P) {
const g = (state.grants || []).find((x) => x.principal === P);
return !!g && g.attrs.some((a) => a[0] === "ai");
}
// The room's grants as the page holds them, read.
function readGrants() {
const gv = JSON.parse(room.grants());
if (gv.outcome !== "read") throw new Error(`the room's grants: ${gv.reason}`);
state.grants = gv.grants;
}
async function boot() {
await loadMoq(false);
await initWai();
const fixture = queryEndpoint("fixture", "http");
const wt = queryEndpoint("wt", "http");
const ws = queryEndpoint("ws", "ws");
if (state.refused.length || !fixture) {
state.phase = "refused";
if (!fixture && !state.refused.length) status("This page needs a room service or a test room.");
return;
}
fx = await (await fetch(fixture)).json();
const role = q.get("role") || "participant";
me = role === "clock" ? { name: "clock", ...fx.clock } : fx.participants.find((p) => p.name === q.get("who"));
if (!me) throw new Error(`no participant "${q.get("who")}" in the fixture`);
state.mode = "fixture";
transportOpts = {
webtransport: wt,
websocket: ws,
// Certificate hashes let a session trust a development certificate: a loopback page's alone.
hashes: isLoopback(location.hostname) ? (q.get("hashes") || "").split(",").filter(Boolean).map(unhex) : [],
force: q.get("transport") || "",
};
window.roomTest.role = role;
await begin(role);
}
// The room service's answer, or why there is none. `headers` are added to a request.
async function api(path, body, headers = {}) {
const r = await fetch(`${service}${path}`, body ? { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), cache: "no-store" } : { cache: "no-store" });
let j = null;
try { j = await r.json(); } catch { /* not JSON */ }
return { status: r.status, body: j, headers: r.headers };
}
// A Privacy Pass token for one of a room's challenges (WAI age-assurance §5.6): the least
// threshold at or above the room's age the challenges offer (or `age_threshold`), each
// challenge checked as RFC 9577 §2.1.3 has it, the token obtained from its issuer by RFC 9578
// §6. The challenges are the `401`'s `WWW-Authenticate` fields; the keys' thresholds are the
// room's policy's.
async function obtainToken(r, age) {
const offered = pp.parseChallenges(r.headers.get("www-authenticate"));
const policy = JSON.parse(age.policy);
const thresholdOf = (key) => {
const h = Array.from(key, (x) => x.toString(16).padStart(2, "0")).join("");
for (const i of policy.issuers) for (const k of i.keys) if (k.key === h) return k.threshold;
return null;
};
const want = q.get("age_threshold") ? Number(q.get("age_threshold")) : null;
const usable = offered
.map((o) => ({ ...o, c: pp.readChallenge(o.challenge), threshold: o.tokenKey && thresholdOf(o.tokenKey) }))
.filter((o) => pp.acceptable(o.c, location.host) && o.threshold != null && (want != null ? o.threshold === want : o.threshold >= age.age))
.sort((a, b) => a.threshold - b.threshold);
if (!usable.length) throw new Error(`the room admits at ${age.age} or above, and none of its ${offered.length} challenges is one this page can answer`);
const o = usable[0];
status(`This room is rated ${age.age}. Asking ${o.c.issuerName} for a token of ${o.threshold} or over…`);
const token = await pp.issue({ challenge: o.challenge, tokenKey: o.tokenKey, directory: pp.directoryOf(o.c.issuerName, issuerDirectory) });
return { token, threshold: o.threshold, issuer: o.c.issuerName };
}
const ROLE_OF = { publisher: "participant", viewer: "viewer" };
// Enter the room the service keeps: as a publisher (camera and microphone) or a viewer.
// `given`: a grant issued already, with the key it is for (`{ seed, answer }`, the answer in the
// form of `POST /grant`'s), as a call page hands over the grant its call came with (./call.js).
async function enter(role, given = null) {
if (state.mode) throw new Error("this page has entered already");
// The relay and the issuer directory a link may name, held to the page's own origin before
// anything is asked of the camera, the room service or a relay.
const ws = queryEndpoint("ws", "ws", `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/moq`);
issuerDirectory = queryEndpoint("issuer_directory", "http");
if (state.refused.length) {
state.phase = "refused";
return state.phase;
}
state.mode = "service";
state.phase = "entering";
const asked = Date.now();
let r = await api("/room");
if (r.status !== 200) throw new Error(`the room service answered ${r.status}`);
// The service's clock less this page's, where it differs by more than the request's own
// uncertainty (half its round trip): a page on the service's clock keeps its own.
if (r.body.now_us) {
const back = Date.now();
const est = Math.round(r.body.now_us - ((asked + back) / 2) * 1000);
if (Math.abs(est) > ((back - asked) / 2) * 1000 + 20_000) skew = est;
}
const sealed = !!r.body.e2ee;
// A remote desktop room: a controller's grant or a viewer's, and no camera.
const remoteRoom = !!r.body.remote;
const ai = role === "publisher" && !remoteRoom && q.get("ai") === "scripted" ? await scriptedModel() : null;
await loadMoq(sealed);
await initWai();
let stream = null;
if (role === "publisher" && !remoteRoom) {
// The camera and microphone first: a room place is asked for only once they are on.
const d = JSON.parse(wai.roomDescriptor(unhex(r.body.descriptor)));
if (ai) {
stream = syntheticStream(d);
} else {
status("Asking for your camera and microphone…");
stream = await navigator.mediaDevices.getUserMedia(constraintsOf(d));
}
}
// The key this page signs with: made here, and never sent anywhere.
const seed = given ? unhex(given.seed) : crypto.getRandomValues(new Uint8Array(32));
const principal = hex(moq.publicKeyOf(seed));
status("Asking the room for a grant…");
const age = r.body.age;
if (age) state.age = { age: age.age, ratings: age.ratings.length, admitted: null, issuer: null, token: null, answer: null, withheld: [] };
// An AI participant asks for a grant that says so (live-rooms §4.4, `ai`). In a remote desktop
// room the page asks to control, or to watch. A grant given with the page (a call's) is taken
// as it is.
const request = ai ? { principal, role, ai } : remoteRoom ? { principal, role: role === "publisher" ? "controller" : "viewer" } : { principal, role };
r = given ? { status: 200, body: given.answer } : await api("/grant", request);
if (!given && remoteRoom && r.status === 403 && request.role === "controller") {
log(`the room does not let this page control (${r.body && r.body.error}): it watches`);
request.role = "viewer";
r = await api("/grant", request);
}
if (r.status === 401 && age) {
// The room admits by age: a token, then the request again with it (RFC 9577 §2.2.2).
const t = await obtainToken(r, age);
state.age.token = pp.b64url(t.token);
state.age.issuer = t.issuer;
r = await api("/grant", request, { authorization: pp.authorization(t.token) });
if (r.status === 200) state.age.admitted = t.threshold;
} else if (age && r.status === 200) {
state.age.admitted = 0;
}
if (age) state.age.answer = r.status === 200 ? "admitted" : (r.body && r.body.error) || String(r.status);
if ((r.status === 403 || r.status === 401) && age) {
if (stream) for (const t of stream.getTracks()) t.stop();
state.mode = null;
state.phase = "refused";
status(`The room is rated ${age.age}, and did not admit this page: ${r.body && r.body.error}.`);
return state.phase;
}
if (r.status === 409 && role === "publisher" && !remoteRoom) {
for (const t of stream.getTracks()) t.stop();
state.mode = null;
state.phase = "full";
status(r.body && r.body.error === "full" ? `The room has its ${r.body.max_publishers} publishers. You can watch, or try again later.` : "The room is busy. You can watch, or try again later.");
return state.phase;
}
if (r.status === 429) throw new Error(`too many grants from this address; try again in ${r.body.retry_after_s} s`);
if (r.status !== 200) throw new Error(`the room service answered ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`);
const v = r.body.room;
const d = JSON.parse(wai.roomDescriptor(unhex(v.descriptor)));
if (sealed !== !!v.e2ee) throw new Error("the room the grant is for is not end-to-end encrypted as the room was");
// A room with co-performance lists its audience's grants beside its publishers': a page
// verifies their inputs with them.
fx = { room: v.room, t0_us: v.t0_us, descriptor: v.descriptor, compose: v.compose, grants: v.co ? v.grants.concat(v.co.audience) : v.grants, clock: { principal: d.clock }, participants: [], co: v.co, remote: v.remote };
me = { name: "you", seed: hex(seed), principal, grant: r.body.grant, grant_digest: r.body.digest, role: remoteRoom ? r.body.role : role };
// The room's rating, which every track this page publishes carries (age-assurance §5.7).
me.ratings = (v.age && v.age.ratings) || [];
state.grant = { role, exp: r.body.exp, digest: r.body.digest };
if (sealed) {
// The room's confidentiality record (confidentiality §3.3), checked against the room
// the grant is for: its id, its authority and its recording mode.
const c = JSON.parse(moq.e2eeCheckRecord(unhex(v.e2ee.record), unhex(v.room), unhex(d.authority), d.recording_mode));
if (!c.ok) throw new Error(`the room's confidentiality record is refused: ${c.reason}`);
state.e2ee = { suite: c.suite, record: v.e2ee.record.slice(0, 16), tampered: [] };
e2ee = new E2ee({
moq, wai, api, log, room: v.room, clock: d.clock, seed, suite: c.suite, role,
grantOf: (digest) => (state.grants || []).find((g) => g.digest === digest),
refreshGrants: () => refreshGrants(),
onRecord: (bytes) => { records = records.then(() => onRecord(bytes)).catch(fail); },
onChange: onKeys,
});
if ($("dvr")) $("dvr").hidden = true;
status("Leaving a key package with the room…");
if (role === "publisher") await e2ee.keyPackage();
}
transportOpts = { webtransport: null, websocket: ws, hashes: [], force: "websocket" };
window.roomTest.role = remoteRoom ? "viewer" : ROLE_OF[role];
// A watch room's page enters its co-watch log first: the clock's `join` for it is then on the
// clock's events track, which the page subscribes to next, and which a room of viewers alone
// has no other record on yet.
if (v.watch) await startCoWatch(v, r.body, seed, principal);
// A remote desktop room's page publishes nothing but its events: it enters as a viewer does.
await begin(remoteRoom ? "viewer" : ROLE_OF[role], stream);
pollRoom();
if (cowatch) runCoWatch();
if (e2ee) {
e2ee.follow(() => state.phase !== "left").catch(fail);
status(role === "publisher" ? "You are in the room. It is end-to-end encrypted: your media is sealed before it leaves this page, and sent once the room's group has keyed you." : "Watching. This room is end-to-end encrypted, and a viewer is not a member of its group: it opens nothing.");
renderE2ee();
}
return state.phase;
}
function constraintsOf(d) {
const vp = d.profiles.find((p) => p.media === 2);
const ap = d.profiles.find((p) => p.media === 1);
const vl = vp.layers[0];
return {
audio: { channelCount: ap.channels, sampleRate: ap.sample_rate, echoCancellation: (ap.processing & 1) !== 0, noiseSuppression: (ap.processing & 2) !== 0, autoGainControl: (ap.processing & 4) !== 0 },
video: { width: vl.width, height: vl.height, frameRate: vl.fps },
};
}
// The room, the composition and the session, once the page knows who it is.
async function begin(role, stream) {
roomId = unhex(fx.room);
t0 = fx.t0_us;
const who = $("who");
if (who) who.textContent = `${me.name} · ${me.principal.slice(0, 12)}`;
room = new wai.Room(unhex(fx.descriptor), JSON.stringify(fx.grants));
readGrants();
state.descriptor = JSON.parse(wai.roomDescriptor(unhex(fx.descriptor)));
eventsTrack = new EventsTrack(unhex(me.seed), roomId, t0, roomNow);
if (fx.co && fx.co.policy) startCo();
// A remote desktop room (WAI remote-desktop) has no composition: its host's screen is drawn
// on a canvas of its own.
if (role !== "clock" && fx.compose) {
compositor = new Compositor(wai, $("canvas"), unhex(fx.compose), { log, bindingOf: (vf) => SideTable.of(vf) });
if (DEBUG_BINDING) setInterval(() => { try { renderStates($("binding-debug"), bindingRows()); } catch (e) { fail(e); } }, 500);
await compositor.startAudio(new URL("./worklet.js", import.meta.url));
await startTermination();
setInterval(() => { try { compositor.draw(); } catch (e) { fail(e); } }, Math.round(1000 / compositor.desc.params.fps));
}
if (role !== "clock") setInterval(sweepMissing, 1000);
status("Connecting to the relay…");
await openSession(transportOpts);
if (fx.remote && state.mode === "service") await startRemote();
if (role === "clock") {
window.roomTest.clockEmit = clockEmit;
state.phase = "ready";
} else if (role === "viewer") {
await subscribeEvents(fx.clock.principal);
state.phase = "watching";
status("Watching. Everything you receive is verified before it is shown.");
} else {
await startPublishing(stream);
await subscribeEvents(fx.clock.principal);
state.phase = "publishing";
status("You are in the room. Everything you receive is verified before it is shown.");
}
log(`ready as ${role} ${me.name}`);
}
async function openSession(opts) {
const t = await connect({ ...opts, log });
// A participant presents its room grant with a proof by its key (live-rooms §10.5.2);
// the clock, a room key with no grant, presents its own token.
const id = me.grant ? { grant: { record: unhex(me.grant), seed: unhex(me.seed) } } : { token: unhex(me.token) };
const s = await MoqSession.connect(t, moq, { ...id, implementation: "joule-moq-web/room", log, properties: !!e2ee });
state.transport = { kind: t.kind, protocol: t.protocol };
state.sessions++;
s.onGoaway = (g) => onGoaway(s, g);
session = s;
window.roomTest.session = s;
s.closed.then((i) => onClosed(s, i));
// The events track is published on every session.
const ns = [fx.room, me.principal, "events"];
published.push(await s.publish(ns, "0", ALIAS.events, { publisher_priority: PRIORITY.events }));
await eventsTrack.attach(s);
return s;
}
// The session ended: by this page, by a move after GOAWAY, or by the relay (its session
// limit, the grant's expiry, a refusal).
function onClosed(s, info) {
if (s !== session || state.goaways.some((g) => !g.moved && !g.refused)) return;
if (state.goaways.some((g) => g.refused)) return; // ended by this page, and said so
state.ended = { code: info && info.code, reason: info && info.reason };
if (state.phase === "left") return;
state.phase = "ended";
log(`the session ended: ${JSON.stringify(state.ended)}`);
if (state.mode === "service") status(`The session ended${info && info.reason ? `: ${info.reason}` : ""}. Reload the page to enter again.`);
}
// Leave: the session closes, and the relay ends this page's tracks, which the room's clock
// records as the publisher leaving.
// ------------------------------------------------------------------------------- co-watch
// Whether `principal` may control `action` (co-watch §5.2): the direct power, or the action's
// bit of its grant's `watch_control`.
function mayControl(principal, action) {
const g = (state.grants || []).find((x) => x.principal === principal);
if (!g) return false;
if (g.powers & 2) return true;
const a = g.attrs.find((x) => x[0] === "watch_control");
return !!a && /^[1-9][0-9]?$/.test(a[1]) && Number(a[1]) <= 15 && ((Number(a[1]) >> (action - 1)) & 1) === 1;
}
async function startCoWatch(v, granted, seed, principal) {
status("Fetching what the room watches…");
const base = new URL(service, location.href);
cowatch = new CoWatch({
api, wai, room: v.room, t0: v.t0_us, seed, principal, grant: granted.grant,
media: $("cowatch-media"), policies: [v.watch.policy], objects: v.watch.source.objects,
sourceUrl: `${service}${v.watch.source.path}`,
mayControl: (p, a) => mayControl(p, a),
// The proof names what this page addresses: the service under its prefix (live-rooms §10.5.1).
target: `//${location.host}${base.pathname.replace(/\/$/, "")}/watch/join`,
log, onChange: renderCoWatch,
});
if ($("cowatch")) $("cowatch").hidden = false;
if ($("caption-form")) $("caption-form").hidden = true;
await cowatch.estimateClock();
await cowatch.loadSource();
await cowatch.join();
}
function runCoWatch() {
const principal = me.principal;
const alive = () => state.phase !== "left";
cowatch.follow(alive).catch(fail);
cowatch.run(alive).catch(fail);
for (const [id, a] of [["cw-play", ACTION.play], ["cw-pause", ACTION.pause], ["cw-back", ACTION.seek], ["cw-fwd", ACTION.seek], ["cw-rate", ACTION.rate]]) {
if ($(id)) $(id).disabled = !mayControl(principal, a);
}
status("Watching together. The room keeps every viewer at one position, and measures how close.");
}
function watchControl(name, arg) {
if (!cowatch) throw new Error("not a watch room");
return cowatch.control(ACTION[name], arg);
}
function renderCoWatch() {
const el = $("cw-sync");
if (!el || !cowatch) return;
const s = cowatch.snapshot();
const ms = (us) => `${us >= 0 ? "+" : "−"}${Math.abs(us / 1000).toFixed(0)} ms`;
const mine = s.error_us == null ? "" : `You: ${ms(s.error_us)} from the room`;
let room = "Room: no window measured yet";
if (s.last_window) {
const n = s.last_window.viewers.length;
const unknown = s.last_window.viewers.filter((x) => x.verdict === "unknown").length;
const out = s.last_window.viewers.filter((x) => x.verdict === "out_of_sync").length;
const bound = `${s.last_window.bound_us / 1000} ms`;
room = out ? `Room: ${out} of ${n} out of sync (bound ${bound})` : unknown ? `Room: ${n - unknown} of ${n} in sync within ${bound}, ${unknown} unknown` : `Room: all ${n} in sync within ${bound}`;
}
el.textContent = [mine, room].filter(Boolean).join(" · ");
}
async function leave() {
state.phase = "left";
for (const sub of subscribed.values()) for (const [m, st] of Object.entries(sub.tracks)) endStream(sub, m, st, "user", false);
if (state.capture && state.capture.stream) for (const t of state.capture.stream.getTracks()) t.stop();
if (session) session.close(0, "left");
status("You left the room. Reload the page to enter again.");
}
// A new room may start while this page watches an empty one: the page says so.
function pollRoom() {
setInterval(async () => {
if (state.phase !== "watching" && state.phase !== "publishing") return;
try {
const r = await api("/room");
if (r.status === 200 && r.body.room !== fx.room) {
state.roomMoved = r.body.room;
if (state.phase === "watching") status("A new room has started. Reload the page to watch it.");
}
} catch { /* the next poll */ }
}, 20000);
}
// ------------------------------------------------------------------------- co-performance
const b64u = (b) => btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64u = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
let coChallenge = null;
// A request to the room service's clock (`POST <service>/co/...`), this page's grant presented
// with a proof over the service's last challenge (live-rooms §10.5.3); a first request, or one
// whose challenge was used, is answered 401 with a new one and sent again.
async function coApi(path, body) {
const u = new URL(`${service}${path}`, location.href);
for (let i = 0; ; i++) {
const headers = { "content-type": "application/json", authorization: `Bearer ${b64u(unhex(me.grant))}` };
if (coChallenge) {
headers["wai-room-proof"] = b64u(moq.grantProofHttp(unhex(me.grant), unhex(me.seed), coChallenge, "POST", `//${u.host}${u.pathname}`));
coChallenge = null;
}
const r = await fetch(u, { method: "POST", headers, body: JSON.stringify(body), cache: "no-store" });
const c = r.headers.get("wai-room-challenge");
if (c) coChallenge = fromB64u(c);
let j = null;
try { j = await r.json(); } catch { /* not JSON */ }
if (r.status !== 401 || i >= 1) return { status: r.status, body: j };
}
}
// The model digest this page's grant names, when it marks it an AI participant.
function myAi() {
const g = (state.grants || []).find((x) => x.principal === me.principal);
const a = g && g.attrs.find((x) => x[0] === "ai");
return a ? a[1] : null;
}
async function emitOwn(kind, payload) {
const r = await eventsTrack.emit(kind, payload);
await (records = records.then(() => onRecord(r.record)));
return r.view;
}
// A line this page says: a caption, a machine's with its model when this page is an AI
// participant (live-rooms §4.4), a person's otherwise.
function say(text) {
const m = myAi();
return emitOwn(0x0a, wai.captionPayload(Math.max(1000, text.length * 60), m ? 2 : 1, m || undefined, document.documentElement.lang || "en", text));
}
function startCo() {
co = new Co({
wai, log, $, nameOf, isAi, roomNow, say,
me: () => me,
grantOf: (P) => (state.grants || []).find((g) => g.principal === P),
isPublisher: (P) => (state.grants || []).some((g) => g.principal === P && g.publish),
present: () => ((state.room && state.room.present) || []).map((p) => p.principal),
emit: emitOwn,
emitDetached: (kind, payload) => eventsTrack.detached(kind, payload),
api: coApi,
policyBytes: unhex(fx.co.policy),
policyHex: fx.co.policy,
});
window.roomTest.co = co;
wireControls(co, $, fail);
co.render();
setInterval(() => { try { co.render(); } catch (e) { fail(e); } }, 250);
if (q.get("ai") === "scripted" && q.get("script") !== "off" && me.role === "publisher") scriptLines().catch(fail);
}
// The demo AI participant's lines: a scripted source. It announces each line two seconds
// ahead, then says it; every third it says otherwise, so that its fidelity is not all kept.
const LINES = ["the bridge comes next", "back to the top, softer", "a long note to end the phrase", "the call, then its answer"];
async function scriptLines() {
for (let n = 0; ; n++) {
await new Promise((r) => setTimeout(r, 7000));
if (state.phase !== "publishing") { if (state.phase === "left" || state.phase === "ended") return; continue; }
const text = LINES[n % LINES.length];
await co.announce({ text, inMs: 2000, say: n % 3 === 2 ? `${text}, or not` : text });
}
}
// The model a scripted AI participant names: SHA-256 of its name.
async function scriptedModel() {
const d = await crypto.subtle.digest("SHA-256", enc.encode("wai demo: a scripted participant"));
return hex(new Uint8Array(d));
}
// A drawn picture and a tone in place of a camera and a microphone, at the room's profiles.
function syntheticStream(d) {
const vl = d.profiles.find((p) => p.media === 2).layers[0];
const ap = d.profiles.find((p) => p.media === 1);
const canvas = document.createElement("canvas");
canvas.width = vl.width;
canvas.height = vl.height;
const g = canvas.getContext("2d");
let n = 0;
setInterval(() => {
n++;
g.fillStyle = "#1a2a3a";
g.fillRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < 8; i++) {
g.fillStyle = `hsl(${(i * 45 + n * 3) % 360} 60% 55%)`;
const h = (Math.sin((n + i * 7) / 6) * 0.4 + 0.5) * canvas.height;
g.fillRect(i * (canvas.width / 8) + 4, canvas.height - h, canvas.width / 8 - 8, h);
}
g.fillStyle = "#ffffff";
g.font = "16px system-ui, sans-serif";
g.fillText("AI participant (scripted)", 12, 24);
}, Math.round(1000 / vl.fps));
const vs = canvas.captureStream(vl.fps);
const ac = new AudioContext({ sampleRate: ap.sample_rate });
const osc = ac.createOscillator();
osc.frequency.value = 220;
const gain = ac.createGain();
gain.gain.value = 0.08;
const dest = ac.createMediaStreamDestination();
dest.channelCount = ap.channels;
dest.channelCountMode = "explicit";
osc.connect(gain).connect(dest);
osc.start();
return new MediaStream([vs.getVideoTracks()[0], dest.stream.getAudioTracks()[0]]);
}
// The clock's log of objects (co-performance §9.1), every page of it.
async function coObjects() {
const out = [];
for (let from = 0; ; ) {
const r = await api(`/co/objects?from=${from}`);
if (r.status !== 200) throw new Error(`the clock's log answered ${r.status}`);
out.push(...r.body.objects);
if (r.body.end) return out;
from = r.body.next;
}
}
// ---------------------------------------------------------------------- remote desktop
// A remote desktop room (WAI remote-desktop) the room service keeps: the host's screen on its own
// canvas, this page's pointer, keys, composed text and pasted text sent as input statements under
// its grant in effect, the floor asked for and given back, and a latency record of each input the
// host applied, presented at the refresh that showed its frame. The key is the one this page
// made, and the grant the service issued for it.
async function startRemote() {
// A room whose host has not come yet: the page waits for it.
for (let i = 0; !fx.remote.host; i++) {
if (i === 0) status("Waiting for the host to share their screen…");
await new Promise((ok) => setTimeout(ok, 1000));
const r = await api("/room");
if (r.status === 200 && r.body.room === fx.room && r.body.remote) fx.remote = r.body.remote;
else if (r.status === 200 && r.body.room !== fx.room) throw new Error("a new room has started; reload the page to enter it");
}
const key = await keyFromSeed(unhex(me.seed));
remote = new RemoteViewer({
room: fx.room, me: me.principal, host: fx.remote.host, clock: fx.clock.principal, grant: me.grant, key, roomNow, log,
unixNow: () => Math.floor((Date.now() * 1000 + skew) / 1e6),
grantOf: async (digest) => {
if (!state.grants.some((g) => g.digest === digest)) await refreshGrants();
const i = state.grants.findIndex((g) => g.digest === digest);
return i >= 0 ? fx.grants[i] : null;
},
emit: async (k, p) => (await eventsTrack.emit(k, p)).view, canvas: $("remote-canvas"), panel: $("remote-state"),
});
await remote.start();
window.roomTest.remote = remote;
$("remote").hidden = false;
const controls = () => {
$("remote-request").hidden = !remote.control;
$("remote-release").hidden = !remote.control;
};
controls();
setInterval(controls, 500);
$("remote-request").onclick = () => remote.requestFloor().catch(fail);
$("remote-release").onclick = () => remote.releaseFloor().catch(fail);
await subscribeEvents(fx.remote.host);
}
// ------------------------------------------------------------------------------- the clock
async function clockEmit(desc) {
const lp = wai.lifecyclePayload(JSON.stringify(desc));
const r = await eventsTrack.emit(lp[0], lp.subarray(1));
return r.view;
}
// ------------------------------------------------------------------------------ publishing
async function pickVideoCodec(profile) {
const l = profile.layers[0];
for (const codec of profile.codecs) {
const cfg = { codec, width: l.width, height: l.height, bitrate: l.max_kbps * 1000, framerate: l.fps, latencyMode: "realtime" };
try {
const s = await VideoEncoder.isConfigSupported(cfg);
if (s.supported) return cfg;
} catch { /* not this one */ }
}
throw new Error(`no video codec of the profile is supported: ${profile.codecs.join(", ")}`);
}
async function startPublishing(given) {
const d = state.descriptor;
const vp = d.profiles.find((p) => p.media === 2);
const ap = d.profiles.find((p) => p.media === 1);
const vcfg = await pickVideoCodec(vp);
const frameMs = Number(q.get("audioFrameMs") || 20);
const acfg = {
codec: "opus",
sampleRate: ap.sample_rate,
numberOfChannels: ap.channels,
bitrate: 32000,
opus: { frameDuration: frameMs * 1000, usedtx: (ap.processing & 8) !== 0, complexity: 5 },
};
const as = await AudioEncoder.isConfigSupported(acfg);
if (!as.supported) throw new Error("this browser's AudioEncoder does not take Opus at 48 kHz");
state.publishCodec = { video: vcfg.codec, audio: "opus", audioFrameMs: frameMs };
const vl = vp.layers[0];
const catalog = {
version: 1,
tracks: [
{
namespace: `${fx.room}-${me.principal}-video`, name: "0", packaging: "wai", isLive: true, role: "video",
codec: vcfg.codec, width: vl.width, height: vl.height, framerate: vl.fps, bitrate: vl.max_kbps * 1000, renderGroup: 1,
wai_capabilities: [media.CAPABILITY_OF(vcfg.codec)], wai_receipts: true, wai_receipt_alias: RECEIPT_ALIAS.video, wai_receipts_track: "0_receipts",
},
{ namespace: `${fx.room}-${me.principal}-video`, name: "0_receipts", packaging: "wai-receipts", isLive: true },
{
namespace: `${fx.room}-${me.principal}-audio`, name: "0", packaging: "wai", isLive: true, role: "audio",
codec: "opus", samplerate: ap.sample_rate, channelConfig: String(ap.channels), bitrate: 32000, renderGroup: 1,
wai_capabilities: ["wai.audio.opus"], wai_receipts: true, wai_receipt_alias: RECEIPT_ALIAS.audio, wai_receipts_track: "0_receipts",
},
{ namespace: `${fx.room}-${me.principal}-audio`, name: "0_receipts", packaging: "wai-receipts", isLive: true },
{ namespace: `${fx.room}-${me.principal}-events`, name: "0", packaging: "eventtimeline", eventType: "urn:wai:event:1", mimetype: "application/json", isLive: true },
],
};
state.catalog = catalog;
signers.video = new moq.TrackSigner(unhex(me.seed), BigInt(RECEIPT_ALIAS.video), BigInt(ALIAS.video), BigInt(ALIAS.videoReceipts), PRIORITY.video, me.name, BigInt(VIDEO_TIMEOUT_MS));
signers.audio = new moq.TrackSigner(unhex(me.seed), BigInt(RECEIPT_ALIAS.audio), BigInt(ALIAS.audio), BigInt(ALIAS.audioReceipts), PRIORITY.audio, me.name, BigInt(AUDIO_TIMEOUT_MS));
await publishTracks(session);
const stream = given || await navigator.mediaDevices.getUserMedia(constraintsOf(d));
state.capture = { video: stream.getVideoTracks()[0].getSettings(), audio: stream.getAudioTracks()[0].getSettings() };
Object.defineProperty(state.capture, "stream", { value: stream, enumerable: false });
let vgroup = 0, agroup = 0, vframes = 0;
// In a room with co-performance: each medium's offset from its capture clock to the room
// clock, so that a timestamp states the capture instant on the room clock (its due instant,
// co-performance §9.1); and whether the last frame was held back off the floor, so that the
// next one sent is a key frame.
// A video frame keeps its timestamp through the encoder, so its capture instant is its
// timestamp and the offset. An audio encoder numbers its output by the samples it has taken,
// so a stretch held back off the floor would be lost from its timestamps: each output's
// capture instant is read from the inputs it took, in order, instead (`aq`).
const offset = {};
const stamp = (m, ts) => (co && offset[m] != null ? ts + offset[m] : ts);
const aq = [];
let aqTaken = 0;
const audioDue = (samples, fallback) => {
if (!co || !aq.length) return fallback;
const due = aq[0].room + Math.round((aqTaken / ap.sample_rate) * 1e6);
aqTaken += samples;
while (aq.length && aqTaken >= aq[0].frames) { aqTaken -= aq[0].frames; aq.shift(); }
return due;
};
let heldBack = false;
const venc = new VideoEncoder({
output: (chunk) => {
// In an end-to-end encrypted room nothing is sent before the room's group has keyed
// this page, and no group is numbered for it: a track's groups run from 0 with no gap,
// so a late joiner's FETCH from group 0 asks for nothing the relay never had.
if (unkeyed("video")) return;
const g = vgroup++;
const b = new Uint8Array(chunk.byteLength);
chunk.copyTo(b);
const payload = vcfg.codec.startsWith("vp09") ? media.ivfOne(b, vl.width, vl.height, chunk.timestamp) : media.av1One(b, vl.width, vl.height, vl.fps, 1);
const env = wai.pack(media.manifestFor(media.CAPABILITY_OF(vcfg.codec), { width: vl.width, height: vl.height, timestamp_us: stamp("video", chunk.timestamp), key: chunk.type === "key" }), payload);
sendGroup("video", g, env);
},
error: fail,
});
venc.configure(vcfg);
const aenc = new AudioEncoder({
output: (chunk) => {
if (unkeyed("audio")) return;
const g = agroup++;
const b = new Uint8Array(chunk.byteLength);
chunk.copyTo(b);
const samples = Math.round(((chunk.duration || frameMs * 1000) / 1e6) * ap.sample_rate);
const late = co ? co.takeLate() : 0;
const due = audioDue(samples, chunk.timestamp);
const env = wai.pack(media.manifestFor("wai.audio.opus", { sample_rate: ap.sample_rate, channels: ap.channels, timestamp_us: Math.max(0, due - late), samples }), media.oggOpusOne(b, { channels: ap.channels, inputRate: ap.sample_rate, samples }));
sendGroup("audio", g, env);
},
error: fail,
});
aenc.configure(acfg);
// Frames at the profile's layer size: a capture that is another size is scaled first.
const scaler = new OffscreenCanvas(vl.width, vl.height);
const sctx = scaler.getContext("2d");
pump(new MediaStreamTrackProcessor({ track: stream.getVideoTracks()[0] }).readable, (frame) => {
if (co && offset.video == null) offset.video = roomNow() - frame.timestamp;
// Off the floor (co-performance §6.4): nothing is encoded, and the first frame back is a
// key frame.
if (co && !co.sends(roomNow())) { heldBack = true; frame.close(); return; }
// A key frame every two seconds, so a subscriber that joins can start within two.
const key = vframes % (vl.fps * 2) === 0 || heldBack;
heldBack = false;
if (venc.encodeQueueSize < 4) {
vframes++;
let f = frame;
if (frame.displayWidth !== vl.width || frame.displayHeight !== vl.height) {
sctx.drawImage(frame, 0, 0, vl.width, vl.height);
f = new VideoFrame(scaler, { timestamp: frame.timestamp });
}
venc.encode(f, { keyFrame: key });
// The participant's own tile shows what it sends (a self view, no audio of its own).
if (compositor) compositor.setVideo(me.principal, f.clone());
if (f !== frame) f.close();
}
frame.close();
});
pump(new MediaStreamTrackProcessor({ track: stream.getAudioTracks()[0] }).readable, (ad) => {
if (ad.sampleRate !== acfg.sampleRate || ad.numberOfChannels !== acfg.numberOfChannels) {
if (!state.audioMismatch) log(`the microphone gives ${ad.numberOfChannels} channel(s) at ${ad.sampleRate} Hz; the profile asks ${acfg.numberOfChannels} at ${acfg.sampleRate}`);
state.audioMismatch = { sampleRate: ad.sampleRate, channels: ad.numberOfChannels };
ad.close();
return;
}
if (co && !co.sends(roomNow())) { ad.close(); return; }
// Its first sample's instant on the room clock: it ends now.
if (co) aq.push({ room: roomNow() - Math.round((ad.numberOfFrames / ad.sampleRate) * 1e6), frames: ad.numberOfFrames });
aenc.encode(ad);
ad.close();
});
}
// The rating claims a track this page publishes carries (WAI age-assurance §5.7): the room's,
// and, for its video with `rate`, its own (§3.6: a principal rates its own tracks).
function ratingsFor(ns, name) {
const out = [...(me.ratings || [])];
if (q.get("rate") && ns[2] === "video" && name === "0") {
out.push(moq.ratingClaim(unhex(me.seed), JSON.stringify(ns), name, 0n, q.get("rate_scheme") || "", q.get("rate_value") || "", BigInt(q.get("rate")), new Date().toISOString().replace(/\.\d+Z$/, "Z")));
}
return out;
}
async function publishTracks(s) {
const P = me.principal;
const pub = async (ns, name, alias, props) => published.push(Object.assign(await s.publish(ns, name, alias, { ...props, ratings: ratingsFor(ns, name) }), { ns, name }));
await pub([fx.room, P], "catalog", ALIAS.catalog, { publisher_priority: PRIORITY.catalog });
for (const m of ["audio", "video"]) {
await pub([fx.room, P, m], "0_receipts", ALIAS[`${m}Receipts`], { publisher_priority: PRIORITY[m] });
await pub([fx.room, P, m], "0", ALIAS[m], { publisher_priority: PRIORITY[m], object_delivery_timeout: m === "video" ? VIDEO_TIMEOUT_MS : AUDIO_TIMEOUT_MS });
signers[m].setAliases(BigInt(ALIAS[m]), BigInt(ALIAS[`${m}Receipts`]));
}
// The catalog: group 0, one object (MSF §5); a session that moves sends it again.
const w = new moq.SubgroupWriter(BigInt(ALIAS.catalog), 0n, PRIORITY.catalog, true);
const b = enc.encode(JSON.stringify(state.catalog));
const obj = w.object(0n, b);
const h = w.header();
const all = new Uint8Array(h.length + obj.length);
all.set(h, 0);
all.set(obj, h.length);
await s.sendStream(all, 1000);
w.free();
}
async function pump(readable, each) {
const r = readable.getReader();
for (;;) {
const { value, done } = await r.read();
if (done) return;
try { each(value); } catch (e) { fail(e); }
}
}
// Whether an encoded frame of `m` goes unsent: in an end-to-end encrypted room, before the
// page holds an epoch (§4.9: it seals under its current epoch).
function unkeyed(m) {
if (!e2ee || e2ee.joined) return false;
e2ee.stats.unkeyed[m]++;
return true;
}
// Group g of a media track: its receipts first (no later than the group's first object,
// moq-streaming-format §4.1), then the object, each on a stream of its own.
async function sendGroup(m, g, envelope) {
const s = session;
if (state.stoppedPublishing) return;
try {
const now = new Date().toISOString();
let sg;
if (e2ee) {
// Sealed before it is signed (confidentiality §4.9, §8.1): the receipt is over the
// sealed bytes. Until the room's group has keyed this page, nothing is sent.
const sl = e2ee.seal([fx.room, me.principal, m], g, PRIORITY[m], envelope, m === "video", m === "video" ? VIDEO_TIMEOUT_MS : AUDIO_TIMEOUT_MS);
if (!sl) { e2ee.stats.unkeyed[m]++; return; }
if (m === "video" && state.e2ee.tamperNext) {
// A test plays a relay that changes a byte: signed as it travels, so the relay's
// door passes it, and every member refuses it bad_tag.
state.e2ee.tamperNext = false;
sl.payload[Math.min(5, sl.payload.length - 1)] ^= 1;
state.e2ee.tampered.push(g);
}
if (m === "video") {
// What this page sealed, by group, the last few hundred: what a test compares with
// what a subscriber outside the group received.
e2eeSent.set(g, { group: g, epoch: sl.epoch, plain_len: envelope.length, plain_head: hex(envelope.slice(0, 64)), sealed_len: sl.payload.length, sealed_head: hex(sl.payload.slice(0, 64)) });
if (e2eeSent.size > 400) e2eeSent.delete(e2eeSent.keys().next().value);
}
e2ee.stats.sealed[m]++;
sg = signers[m].signSealed(BigInt(g), sl.payload, sl.props, now, now);
} else {
sg = signers[m].sign(BigInt(g), envelope, now, now);
}
const r = sg.receiptsStream();
const md = sg.mediaStream();
sg.free();
// sendOrder: higher goes first; audio before video, receipts before their object.
const base = m === "audio" ? 2_000_000 : 1_000_000;
await s.sendStream(r, base + 1);
await s.sendStream(md, base);
state.published[m]++;
state.published.bytes += md.length + r.length;
} catch (e) {
if (s.goaway || s.closedInfo) return; // the session is moving; the next group goes on the new one
fail(e);
}
}
// ------------------------------------------------------------------------------ subscribing
function tally(principal) {
if (!state.subs[principal]) {
const t = () => ({ received: 0, verified: 0, refused: 0, no_receipt: 0, receipts: 0, receipts_refused: 0, decoded: 0, decode_errors: 0, reset: 0, dropped_accounted: 0, dropped_unaccounted: 0, fetched: 0, fetched_verified: 0, fetched_decoded: 0, first_live_group: null, first_fetched_group: null, last_fetched_group: null, chain: {} });
state.subs[principal] = { audio: t(), video: t(), screen: t(), catalog: false, events: 0 };
}
return state.subs[principal];
}
const eventsSubscribed = new Set();
async function subscribeEvents(principal) {
if (eventsSubscribed.has(principal)) return;
eventsSubscribed.add(principal);
const ns = [fx.room, principal, "events"];
const onRecords = (payload) => {
let arr;
try { arr = JSON.parse(dec.decode(payload)); } catch { return log(`events of ${principal.slice(0, 8)}: an object that is not JSON`); }
for (const rec of arr) {
let bytes;
try { bytes = wai.msfRecordRead(JSON.stringify(rec), BigInt(t0)); } catch (e) { log(`an event record refused: ${e.message || e}`); continue; }
records = records.then(() => onRecord(bytes)).catch(fail);
}
};
const handler = {
stream: () => ({ object: (j, p) => onRecords(p) }),
fill: () => ({ object: (j, p) => onRecords(p) }),
};
try {
await session.subscribe(ns, "0", {
subscriber_priority: SUB_PRIORITY.events, group_order: 1, forward: true, rendezvous_timeout: 10000,
location_filter: { type: "next" }, fill: { location_filter: { type: "relative", start_group: 1 } },
}, handler);
} catch (e) {
if (!withheld(e, principal, "events")) throw e;
}
}
// Records are taken one at a time, in the order they arrive: a join under a grant the page
// does not hold waits while the page asks the service for the room's grants again.
let records = Promise.resolve();
async function onRecord(bytes) {
const v = JSON.parse(room.offer(bytes));
if (!v.ok) return log(`an event refused: ${v.reason}`);
if (!v.new) return;
if (co) co.onRecord(v, bytes);
if (remote) remote.onRecord(bytes);
if (service && v.kind_name === "join" && v.payload && v.payload.detail && !state.grants.some((g) => g.digest === v.payload.detail.grant)) {
await refreshGrants();
}
// A grant exchanged (live-rooms §4.6): the room's grants read again when it names a new one.
if (service && v.kind_name === "grant_exchange") {
const exchanged = hex(readEvent(bytes).payload.subarray(64, 96));
if (!state.grants.some((g) => g.digest === exchanged)) await refreshGrants();
}
const who = nameOf(v.principal);
const entry = { t: v.t, who, kind: v.kind_name, payload: v.payload, digest: v.digest };
state.timeline.push(entry);
renderTimeline(entry);
if (v.kind_name === "caption") {
state.captions.push({ t: v.t, who, text: v.payload.text, origin: v.payload.origin_label, model: v.payload.model, lang: v.payload.lang });
renderCaption();
}
if (v.kind >= 0x10) onLifecycle(v.t);
}
async function refreshGrants() {
const r = await api("/room");
if (r.status !== 200 || r.body.room !== fx.room) return log("the room's grants could not be read again");
fx.grants = r.body.co ? r.body.grants.concat(r.body.co.audience) : r.body.grants;
if (r.body.co) fx.co = r.body.co;
room.setGrants(JSON.stringify(fx.grants));
readGrants();
log(`the room's grants read again: ${fx.grants.length}`);
}
// The room is laid out at the later of this page's instant and the lifecycle event's: an event
// the clock stamped a moment ahead of this page's clock still takes effect.
let lastLifecycleT = 0;
function onLifecycle(t = 0) {
lastLifecycleT = Math.max(lastLifecycleT, t);
const st = JSON.parse(room.state());
state.room = st;
if (compositor) compositor.relayout(room, Math.max(roomNow(), lastLifecycleT));
for (const own of st.own) {
if (own.principal === me.principal || subscribed.has(own.principal)) continue;
subscribeParticipant(own.principal).catch(fail);
}
// In a room with co-performance, every present principal's events: an audience member's
// inputs too, which a selection is made from. In a remote desktop room, every present
// principal's events too: a viewer shows who is typing, from statements whose keys and text are
// sealed to the host (remote-desktop §5.5).
if (co || (fx && fx.remote)) {
for (const p of st.present) {
if (p.principal === me.principal || eventsSubscribed.has(p.principal)) continue;
subscribeEvents(p.principal).catch(fail);
}
}
}
async function subscribeParticipant(P) {
if (subscribed.has(P)) return;
const sub = { principal: P, tracks: {}, catalog: null };
subscribed.set(P, sub);
tally(P);
await subscribeEvents(P);
let resolveCatalog;
const got = new Promise((r) => { resolveCatalog = r; });
const onCatalog = (p) => {
try { const c = JSON.parse(dec.decode(p)); if (!sub.catalog) { sub.catalog = c; tally(P).catalog = true; resolveCatalog(c); } } catch (e) { fail(e); }
};
try {
await session.subscribe([fx.room, P], "catalog", {
subscriber_priority: SUB_PRIORITY.catalog, group_order: 1, forward: true, rendezvous_timeout: 10000,
location_filter: { type: "next" }, fill: { location_filter: { type: "relative", start_group: 1 } },
}, { stream: () => ({ object: (j, p) => onCatalog(p) }), fill: () => ({ object: (j, p) => onCatalog(p) }) });
} catch (e) {
if (withheld(e, P, "catalog")) return;
throw e;
}
const catalog = await withTimeout(got, 10000, `the catalog of ${P.slice(0, 8)}`);
for (const tr of catalog.tracks.filter((x) => x.packaging === "wai")) {
await subscribeMedia(sub, tr);
}
}
// A request the relay refused `below_threshold` (WAI age-assurance §5.5): the track is rated
// above the threshold this page was admitted at. Recorded and shown, not an error.
function withheld(e, P, m) {
if (!(e instanceof RequestRefused) || !/below_threshold/.test(e.message)) return false;
const t = tally(P);
t.withheld = [...(t.withheld || []), m];
if (!state.age) state.age = { age: null, admitted: 0, withheld: [] };
state.age.withheld.push({ principal: P, track: m });
log(`${m} of ${P.slice(0, 8)}: rated above this page's threshold, withheld by the relay (below_threshold)`);
status(`Some of the room is rated above the age you were admitted at, and is withheld: ${m} of ${nameOf(P)}.`);
return true;
}
// A host's screen is a track of the room's `screen` kind (live-rooms §6.1), named by its namespace.
function mediaOf(tr) {
if (typeof tr.namespace === "string" && tr.namespace.endsWith("-screen")) return "screen";
return tr.role === "audio" ? "audio" : "video";
}
async function subscribeMedia(sub, tr) {
const P = sub.principal;
const m = mediaOf(tr);
await autoplayAllowed();
const T = tally(P)[m];
const ns = [fx.room, P, m];
const verifier = new moq.TrackVerifier(unhex(P), BigInt(tr.wai_receipt_alias));
const st = { verifier, pending: new Map(), seen: new Set(), receiptsAt: new Map(), decoder: null, waitingKey: true, tr, m, chainOf: new Map(), verifiedBy: new Map() };
sub.tracks[m] = st;
st.route = await routeTrack(P, tr, m);
st.decoder = makeDecoder(P, tr, T, st);
const onReceipts = (group, payload) => {
const r = JSON.parse(verifier.receipts(BigInt(group), payload, BigInt(Date.now())));
if (!r.ok) { T.receipts_refused++; log(`receipts of ${m} group ${group} of ${P.slice(0, 8)} refused: ${r.reason}`); return; }
T.receipts++;
T.chain[r.chain] = (T.chain[r.chain] || 0) + 1;
// Each group's admission to its chain, for the state of a frame decoded from it (§10).
st.chainOf.set(group, r.chain);
for (const e of r.released || []) st.chainOf.set(e.group, e.outcome);
if (st.chainOf.size > 2048) st.chainOf.delete(st.chainOf.keys().next().value);
if (!st.seen.has(group)) st.receiptsAt.set(group, performance.now());
const held = st.pending.get(group);
if (held) { st.pending.delete(group); onObject(group, held.payload, held.fetched, held.meta); }
};
// `meta`: the object's Object ID, Publisher Priority and property list, which an end-to-
// end encrypted room's objects are opened with.
const onObject = (group, payload, fetched, meta) => {
const r = JSON.parse(verifier.object(BigInt(group), 0n, payload));
if (!r.ok && r.reason === "no_receipt") {
if (!st.pending.has(group)) { st.pending.set(group, { payload, at: performance.now(), fetched, meta }); T.no_receipt++; }
return;
}
st.receiptsAt.delete(group);
if (!r.ok) { T.refused++; log(`${m} group ${group} of ${P.slice(0, 8)} refused: ${r.reason}`); return; }
if (fetched) T.fetched_verified++; else T.verified++;
// What a frame decoded from this object names (browser-sink §7.2): its content hash and
// its group receipt's link hash, as the verifier established them.
st.verifiedBy.set(r.content_hash, r.group_receipt);
if (st.verifiedBy.size > 1024) st.verifiedBy.delete(st.verifiedBy.keys().next().value);
deliver(P, st, T, group, payload, fetched, { ...(meta || {}), bind: { digest: r.content_hash, receipt: r.group_receipt, group } });
};
st.onObject = onObject;
st.onReceipts = onReceipts;
st.ns = ns;
const rh = {
stream: (h) => ({ object: (j, p) => onReceipts(h.group_id, p) }),
fill: () => ({ object: (j, p) => onReceipts(j.group_id, p) }),
};
const mh = {
stream: (h) => ({
object: (j, p, k, props) => {
if (st.seen.has(h.group_id)) return;
st.seen.add(h.group_id);
T.received++;
if (T.first_live_group == null) T.first_live_group = h.group_id;
if (T.last_live_group == null || h.group_id > T.last_live_group) T.last_live_group = h.group_id;
onObject(h.group_id, p, false, { object: j.object_id, priority: h.publisher_priority, props });
},
reset: () => { T.reset++; },
}),
fill: () => ({ object: () => {} }),
// The publisher ended the track: the stream ended (stream-termination §4.3, `ended`).
done: () => { if (st.sink && !st.sink.terminated) endStream(sub, m, st, "ended"); },
};
// The receipts track first and with no delivery timeout (moq-streaming-format §4.1),
// at the media track's subscriber priority.
st.subs = [await session.subscribe(ns, tr.wai_receipts_track, { subscriber_priority: SUB_PRIORITY[m], group_order: 1, forward: true, rendezvous_timeout: 10000, location_filter: { type: "next" } }, rh)];
try {
st.subs.push(await session.subscribe(ns, tr.name, { subscriber_priority: SUB_PRIORITY[m], group_order: 1, forward: true, rendezvous_timeout: 10000, location_filter: { type: "next" } }, mh));
} catch (e) {
if (withheld(e, P, m)) return;
throw e;
}
openSinkLog(sub, st, ns, tr, m);
log(`subscribed to ${m} of ${P.slice(0, 8)} (${tr.codec}, receipt alias ${tr.wai_receipt_alias})`);
if (q.get("fetch") === "1") lateJoin(sub, st, T, ns, tr).catch(fail);
}
// A late joiner (MOQT §3.2): every earlier group of the track and of its receipts track,
// by FETCH, from group 0 to the Largest Object; each object verified against its receipt
// like any other, and the video decoded from its first key frame.
async function lateJoin(sub, st, T, ns, tr) {
const params = { group_order: 1, subscriber_priority: SUB_PRIORITY[st.m] + 1, location_filter: { type: "absolute", start: [0, 0] } };
// A FETCH is refused before any object is sent, so one tried again counts nothing twice:
// a relay asked for groups still arriving may pass the request to the publisher, which
// takes no requests; a moment later its cache holds them.
const fetchAgain = async (name, onObject) => {
for (let i = 0; ; i++) {
try { return await session.fetch(ns, name, params, onObject); } catch (e) {
if (i >= 3 || !(e instanceof RequestRefused)) throw e;
log(`FETCH of ${st.m} ${name} of ${sub.principal.slice(0, 8)}: ${e.message}; again`);
T.fetch_retries = (T.fetch_retries || 0) + 1;
await new Promise((r) => setTimeout(r, 750));
}
}
};
const rec = [];
const r1 = await fetchAgain(tr.wai_receipts_track, (j, p) => rec.push([j.group_id, p]));
for (const [g, p] of rec) {
const r = JSON.parse(st.verifier.receipts(BigInt(g), p, BigInt(Date.now())));
if (r.ok) { T.receipts++; T.chain[r.chain] = (T.chain[r.chain] || 0) + 1; } else T.receipts_refused++;
}
const objs = [];
const r2 = await fetchAgain(tr.name, (j, p, props) => objs.push([j.group_id, p, { object: j.object_id, priority: j.publisher_priority, props }]));
for (const [g, p, meta] of objs) {
// In an end-to-end encrypted room a group already received live was opened once: its
// copy by FETCH is the same location, which the receiving checks refuse as a replay
// (confidentiality §6 step 8). It is not presented again.
if (e2ee && st.seen.has(g)) { T.fetched_live = (T.fetched_live || 0) + 1; continue; }
T.fetched++;
if (T.first_fetched_group == null) T.first_fetched_group = g;
T.last_fetched_group = g;
st.seen.add(g);
st.onObject(g, p, true, meta);
}
log(`FETCH of ${st.m} of ${sub.principal.slice(0, 8)}: ${rec.length} receipts (${r1.how}), ${objs.length} objects (${r2.how})`);
}
// An object verified against its receipt, on its way to a decoder. In an end-to-end
// encrypted room it is opened first, through the receiving checks in their order
// (confidentiality §6); one that does not open never reaches a decoder (§10). One sealed in
// an epoch this page has not reached, or sent before this page's Welcome, waits (§5.8).
const E2EE_HOLD_MS = 15000;
const e2eeSent = new Map();
const E2EE_HOLD_MAX = 600;
function deliver(P, st, T, group, payload, fetched, meta) {
if (!e2ee) return decodeEnvelope(P, st, T, group, payload, fetched, meta && meta.bind);
const E = T.e2ee || (T.e2ee = { opened: 0, refused: {}, held: 0, held_expired: 0, by_epoch: {}, fetched_opened: 0, fetched_refused: {}, fetched_epochs: {}, samples: [] });
if (st.intercept) {
const f = st.intercept;
st.intercept = null;
return f({ group, payload, meta, fetched });
}
const v = e2ee.open(st.ns, st.tr.name, group, meta.object || 0, meta.priority, meta.props, payload);
if (E.samples.length < 8 && st.m === "video" && !fetched && !v.ok) E.samples.push({ group, sealed_len: payload.length, sealed_head: hex(payload.slice(0, 64)), outcome: v.reason || `pending ${v.pending}` });
if (v.ok) {
if (fetched) E.fetched_opened++; else E.opened++;
E.by_epoch[v.epoch] = (E.by_epoch[v.epoch] || 0) + 1;
if (fetched) E.fetched_epochs[v.epoch] = (E.fetched_epochs[v.epoch] || 0) + 1;
return decodeEnvelope(P, st, T, group, v.payload, fetched, meta && meta.bind);
}
if (v.pending != null || (v.reason === "epoch_not_joined" && e2ee.awaitingWelcome)) {
st.sealedHeld = st.sealedHeld || [];
if (st.sealedHeld.length >= E2EE_HOLD_MAX) { E.held_expired++; return; }
st.sealedHeld.push({ group, payload, fetched, meta, at: performance.now() });
E.held++;
return;
}
const into = fetched ? E.fetched_refused : E.refused;
into[v.reason] = (into[v.reason] || 0) + 1;
if ((into[v.reason] || 0) <= 3) log(`${st.m} group ${group} of ${P.slice(0, 8)} does not open: ${v.reason}`);
}
// The room's group moved: what waited is opened again, in its order; the view says so.
function onKeys() {
for (const sub of subscribed.values()) {
for (const st of Object.values(sub.tracks)) {
if (!st.sealedHeld || !st.sealedHeld.length) continue;
const T = tally(sub.principal)[st.m];
const held = st.sealedHeld.sort((a, b) => a.group - b.group);
st.sealedHeld = [];
const now = performance.now();
for (const h of held) {
if (now - h.at > E2EE_HOLD_MS) { T.e2ee.held_expired++; continue; }
deliver(sub.principal, st, T, h.group, h.payload, h.fetched, h.meta);
}
}
}
renderE2ee();
}
function renderE2ee() {
const el = $("e2ee");
if (!el || !e2ee) return;
el.hidden = false;
const s = e2ee.state();
const roster = s.roster ? Object.entries(s.roster).map(([leaf, p]) => `${p === me.principal ? "you" : nameOf(p)} (leaf ${leaf})`) : [];
const where = e2ee.stats.removed != null ? `removed at epoch ${e2ee.stats.removed}` : s.epoch != null ? `epoch ${s.epoch}` : e2ee.role === "publisher" ? "waiting for the room's group" : "not a member";
$("e2ee-state").textContent = `${where}${roster.length ? ` · ${roster.length} member${roster.length === 1 ? "" : "s"}: ${roster.join(", ")}` : ""}`;
el.dataset.epoch = s.epoch != null ? String(s.epoch) : "";
}
// ------------------------------------------------------------------ stream termination
// WAI stream-termination: a stream log for each track this page receives (./termination.js),
// signed under a key the page makes for its session; a video stream nobody can see is ended.
const TERM_POLICY = {
unseen_grace_ms: Number(q.get("unseen_grace_ms") || 10000),
idle_ms: Number(q.get("idle_ms") || 0),
autoplay_grace_ms: Number(q.get("autoplay_grace_ms") || 0),
};
const UNSEEN_REASONS = ["hidden", "occluded", "display_off"];
let termSeed = null;
const attention = { occluded: false, activated: false, lastActivation: 0, waiters: [] };
const nowUs = () => Math.round((performance.timeOrigin + performance.now()) * 1000);
// The viewer's autoplay setting (§4.4): the `autoplay` parameter, else this page's storage.
function autoplaySetting() {
let v = q.get("autoplay");
if (!v) { try { v = localStorage.getItem("autoplay"); } catch { v = null; } }
return v === "off" ? "off" : "on";
}
// Under the setting off, media waits for the viewer's first activation: a stream is never
// started by autoplay against it (§4.4).
function autoplayAllowed() {
if (state.termination.autoplay !== "off" || attention.activated || (navigator.userActivation && navigator.userActivation.hasBeenActive)) return Promise.resolve();
status("Autoplay is off: press a key or click to start the room's media.");
return new Promise((r) => attention.waiters.push(r));
}
function eachSink(f) {
for (const sub of subscribed.values()) for (const st of Object.values(sub.tracks)) if (st.sink && !st.sink.terminated) f(st.sink);
}
// The session key, the panel (capability-constraints §4.1) and the observations (§4.2).
async function startTermination() {
termSeed = hex(crypto.getRandomValues(new Uint8Array(32)));
state.termination.policy = TERM_POLICY;
state.termination.autoplay = autoplaySetting();
const refresh = await term.measureRefresh(window);
state.termination.refresh_measured = refresh != null;
if (refresh == null) log("the refresh rate could not be measured (no animation frames): 60 Hz declared");
state.termination.panel = term.readDeclaration(window, refresh || 60000);
document.addEventListener("visibilitychange", () => {
const v = document.visibilityState === "hidden" ? term.UNSEEN : term.SEEN;
const t = nowUs();
eachSink((x) => x.observe(term.SIGNAL.visibility, v, t));
log(`visibility: ${document.visibilityState}`);
});
const canvas = $("canvas");
if (canvas && typeof IntersectionObserver === "function") {
new IntersectionObserver((entries) => {
attention.occluded = !entries[entries.length - 1].isIntersecting;
const t = nowUs();
eachSink((x) => x.observe(term.SIGNAL.occlusion, attention.occluded ? term.UNSEEN : term.SEEN, t));
}).observe(canvas);
}
for (const type of term.ACTIVATION_EVENTS) {
window.addEventListener(type, (e) => {
if (!term.isActivation(e)) return;
attention.activated = true;
for (const w of attention.waiters.splice(0)) w();
const t = nowUs();
attention.lastActivation = t;
eachSink((x) => x.observe(term.SIGNAL.interaction, 1, t));
}, { capture: true, passive: true });
}
// A panel that changes (a window moved to another display, its range switched) is declared
// again, and applies to every video stream from then (§3.3).
const redeclare = () => {
const d = term.readDeclaration(window, state.termination.panel.refresh_milli);
if (term.sameDeclaration(d, state.termination.panel)) return;
state.termination.panel = d;
const t = nowUs();
eachSink((x) => x.display(d, t));
log(`the panel changed: ${d.long}×${d.short}, ${d.bit_depth}-bit${d.hdr ? ", HDR" : ""}`);
};
window.addEventListener("resize", redeclare);
// A screen can change with no resize of the viewport: read again every second.
setInterval(() => { try { redeclare(); } catch (e) { fail(e); } }, 1000);
for (const mq of ["(video-dynamic-range: high)", "(dynamic-range: high)"]) {
try { matchMedia(mq).addEventListener("change", redeclare); } catch { /* no such feature */ }
}
setInterval(() => { try { terminationTick(); } catch (e) { fail(e); } }, 250);
}
function uaAutoplay(m) {
if (typeof navigator.getAutoplayPolicy !== "function") return null;
try { return navigator.getAutoplayPolicy(m === "audio" ? "audiocontext" : "mediaelement"); } catch { return null; }
}
// The open record of a stream just subscribed (§3.2), and the state at it (§4.2). The ladder is
// the participant's video tracks in its catalog; the rendition, this track's place among them.
function openSinkLog(sub, st, ns, tr, m) {
if (!termSeed) return;
const videoTracks = ((sub.catalog && sub.catalog.tracks) || []).filter((x) => x.packaging === "wai" && mediaOf(x) === "video");
const ua = uaAutoplay(m);
const open = {
stream: wai.trackDigest(JSON.stringify(ns), tr.name),
media: m,
start: attention.activated || (navigator.userActivation && navigator.userActivation.hasBeenActive) ? "user" : "autoplay",
autoplay: state.termination.autoplay,
ua_policy: ["allowed", "allowed-muted", "disallowed"].includes(ua) ? ua : null,
...TERM_POLICY,
display: m === "video" ? state.termination.panel : null,
ladder: m === "video" ? term.ladderOf(videoTracks) : null,
};
st.rendition = m === "video" ? Math.max(0, videoTracks.findIndex((x) => x.name === tr.name)) : null;
const t = nowUs();
st.sink = new term.SinkStream(wai, termSeed, open, t, { log });
if (document.visibilityState === "hidden") st.sink.observe(term.SIGNAL.visibility, term.UNSEEN, t);
if (attention.occluded) st.sink.observe(term.SIGNAL.occlusion, term.UNSEEN, t);
state.termination.streams.push({ principal: sub.principal, media: m, sink: st.sink });
}
// An object verified on its way to a decoder, gathered into its stream's segment (§4.5);
// false once the stream is terminated, and the object is then discarded (§4.3).
function receipted(st, T, bind, envelope, mediaUs, durUs) {
if (!st.sink || !bind) return true;
if (st.sink.received({ hash: bind.digest, bytes: envelope.length, mediaUs, durUs, rendition: st.rendition }, nowUs())) return true;
T.discarded_after_termination = (T.discarded_after_termination || 0) + 1;
return false;
}
// §4.3: a stream whose reason is due is terminated; a video stream ended as unseen is
// subscribed again, under a new log, once the page is seen, and one ended as idle or
// unattended once the viewer interacts.
function terminationTick() {
const t = nowUs();
const seen = document.visibilityState !== "hidden" && !attention.occluded;
for (const sub of subscribed.values()) {
for (const [m, st] of Object.entries(sub.tracks)) {
if (!st.sink) continue;
if (!st.sink.terminated) {
const reason = st.sink.due(t);
if (reason) endStream(sub, m, st, reason);
} else if (!st.reopening && state.phase !== "left" && ((seen && UNSEEN_REASONS.includes(st.sink.terminatedAs))
|| (["idle", "autoplay_unattended"].includes(st.sink.terminatedAs) && attention.lastActivation > st.sink.terminatedAt))) {
st.reopening = true;
log(`${m} of ${sub.principal.slice(0, 8)}: ${st.sink.terminatedAs === "idle" || st.sink.terminatedAs === "autoplay_unattended" ? "the viewer is back" : "seen again"}; subscribed under a new log`);
subscribeMedia(sub, st.tr).catch(fail);
}
}
}
}
// The termination signed, and the subscription ended (`unsubscribe` false where the session
// itself is going).
function endStream(sub, m, st, reason, unsubscribe = true) {
if (!st.sink || st.sink.terminated) return;
const r = st.sink.terminate(reason, nowUs());
const T = tally(sub.principal)[m];
T.terminated = (T.terminated || 0) + 1;
if (unsubscribe && session) for (const x of st.subs || []) session.unsubscribe(x);
log(`${m} of ${sub.principal.slice(0, 8)} terminated (${reason}) at media ${r.media} µs, after ${st.sink.segments} segment receipts`);
}
function terminationSnapshot() {
const tm = state.termination;
return {
panel: tm.panel, refresh_measured: tm.refresh_measured, policy: tm.policy, autoplay: tm.autoplay,
visibility: document.visibilityState, occluded: attention.occluded,
streams: tm.streams.map((x) => { const y = x.sink.summary(); delete y.records; return { principal: x.principal, ...y, records: x.sink.records.length }; }),
};
}
// Every stream log this page signed, with its records and the page's own verdict on it
// (stream-termination §6, through wai-live-web).
// The corpus live-conformance/termination at `url`, checked through this page's wasm build: the
// failures (none when every match and log gets its stated outcome).
window.roomTest.terminationCorpus = async (url) => JSON.parse(wai.terminationCorpusVerify(await (await fetch(url, { cache: "no-store" })).text()));
window.roomTest.terminationLogs = () => state.termination.streams.map((x) => ({ principal: x.principal, ...x.sink.summary(), verdict: x.sink.verdict() }));
// §4: a track's route, from the user agent's answers for its codec string. This page loads
// no wasm decoder for a room's tracks, so a track routed anywhere but the user agent's
// decoder is not decoded here.
async function routeTrack(P, tr, m) {
const capability = (tr.wai_capabilities && tr.wai_capabilities[0]) || media.CAPABILITY_OF(tr.codec);
const params = m === "audio"
? { "wai:codec": tr.codec, "wai:sample_rate": Number(tr.samplerate || 48000), "wai:channel_count": Number(tr.channelConfig || 1) }
: { "wai:codec": tr.codec, "wai:frame_width": Number(tr.width), "wai:frame_height": Number(tr.height) };
const object = { name: `${P.slice(0, 8)}/${m}`, capability, codec: tr.codec, need: tr.wai_determinism || "none", params };
const r = await buildReport(wai, [object], { ask: (o) => askAndCount(o) });
const entry = r.table[0] || { route: "none", reason: r.reason, tier: "none", energy: null };
state.binding.routes[object.name] = { capability, codec: tr.codec, ...entry };
if (entry.route !== "ua") log(`${m} of ${P.slice(0, 8)} (${capability}, ${tr.codec}) routed ${entry.route}: ${entry.reason || JSON.stringify(entry.legs)}`);
return entry;
}
function askAndCount(o) {
state.binding.asked.push(o.codec);
return askUserAgent(o, log);
}
// §10: each tile's frame, its binding as drawn, and its state.
function bindingRows() {
const rows = [];
if (!compositor) return rows;
for (const [P, d] of compositor.drawn) {
const sub = subscribed.get(P);
const st = sub && sub.tracks.video;
const chain = chainToken(st && d.group != null ? st.chainOf.get(d.group) : null);
const s = stateOf(wai, d.binding ? { binding: d.binding } : null, chain, "not_applicable");
const route = st && st.route;
rows.push({
// An unbound frame (this page's own camera, say) has no group, so no chain to show.
// A chain this page anchored, or filled below its anchor, says from which group it holds
// the chain (browser-sink §10).
principal: P, name: nameOf(P), ...s, provenance: "not_applicable", group: d.group,
chain: !d.binding ? "—" : chain === "anchor" ? `anchor at group ${d.group}` : chain === "history" ? `history, group ${d.group}, below the anchor` : chain,
route: route ? route.route : "none", energy: route ? route.energy : null,
// Whether the digest is one this page's verifier accepted, under that receipt.
receipt_verified: !!(st && s.digest && st.verifiedBy.get(s.digest) === s.receipt),
});
}
return rows;
}
function makeDecoder(P, tr, T, st) {
if (tr.role === "audio") {
const d = new AudioDecoder({
output: (ad) => { T.decoded++; if (compositor) compositor.addAudio(P, ad); else ad.close(); },
error: (e) => { T.decode_errors++; log(`audio decoder of ${P.slice(0, 8)}: ${e.message || e}`); },
});
d.configure({ codec: "opus", sampleRate: tr.samplerate, numberOfChannels: Number(tr.channelConfig || 1) });
return d;
}
const d = new VideoDecoder({
output: (vf) => {
T.decoded++;
// The binding entered for this frame's chunk, attached to the frame (§9.2 step 2).
if (bindings.output(d, vf)) T.bound = (T.bound || 0) + 1; else T.unbound = (T.unbound || 0) + 1;
// A host's screen is drawn by the remote desktop viewer (WAI remote-desktop), not tiled.
if (remote && st.m === "screen") remote.frame(vf);
else if (compositor) compositor.setVideo(P, vf); else vf.close();
},
error: (e) => { T.decode_errors++; bindings.reset(d); log(`video decoder of ${P.slice(0, 8)}: ${e.message || e}`); st.waitingKey = true; },
});
d.configure({ codec: tr.codec, codedWidth: tr.width, codedHeight: tr.height, optimizeForLatency: true });
return d;
}
function decodeEnvelope(P, st, T, group, envelope, fetched, bind) {
let e;
try { e = wai.unpack(envelope); } catch (err) { T.decode_errors++; T.envelope_errors = (T.envelope_errors || 0) + 1; return log(`an envelope that does not read: ${err.message || err}`); }
const manifest = JSON.parse(e.manifest);
const cap = e.capability;
const payload = e.payload();
e.free();
if (!st.tr.wai_capabilities.includes(cap)) {
// A capability the catalog did not declare is a protocol error (§4), not a fallback.
T.decode_errors++;
return log(`a capability the catalog does not declare: ${cap}`);
}
// Decoded only through its route (browser-sink §4.3): this page decodes on the user
// agent's decoders alone, and an object routed elsewhere is not decoded here.
if (!st.route || st.route.route !== "ua") { T.not_routed = (T.not_routed || 0) + 1; return; }
// A codec that met an error is closed for good (WebCodecs): a new one is made, and the
// video waits for its next key frame.
if (st.decoder.state === "closed") {
st.decoder = makeDecoder(P, st.tr, T, st);
st.waitingKey = true;
T.decoder_restarts = (T.decoder_restarts || 0) + 1;
log(`${st.m} decoder of ${P.slice(0, 8)}: made again after an error`);
}
const d = st.decoder;
if (d.state !== "configured") return;
try {
if (cap === "wai.audio.opus") {
const packets = media.oggOpusPackets(payload);
// Fetched audio is earlier than the live stream: decoded on a decoder of its own and
// counted, never mixed.
if (fetched) return decodeFetchedAudio(st, T, packets, manifest);
if (!receipted(st, T, bind, envelope, manifest.target.timestamp_us, packets.length * AUDIO_FRAME_MS * 1000)) return;
for (const pk of packets) {
d.decode(new EncodedAudioChunk({ type: "key", timestamp: manifest.target.timestamp_us, data: pk }));
}
return;
}
const units = cap === "wai.video.vp9" ? media.ivfFrames(payload).map((f) => f.data) : cap === "wai.video.avc" || cap === "wai.video.hevc" ? [payload] : media.av1Units(payload);
const key = !!manifest.target.key;
// A fetched group older than the live stream is decoded on its own decoder.
if (fetched) return decodeFetched(P, st, T, units, key, manifest);
if (st.waitingKey && !key) return;
// Each group rides a stream of its own and waits for its receipts, so under load a
// group can be released after a later one: decoded then, it would corrupt the
// decoder's references. It is dropped, and counted.
if (st.lastVideoGroup != null && group <= st.lastVideoGroup) {
T.late_dropped = (T.late_dropped || 0) + 1;
return;
}
if (!receipted(st, T, bind, envelope, manifest.target.timestamp_us, Math.round(1e6 / Number(st.tr.framerate || 30)))) return;
st.lastVideoGroup = group;
st.waitingKey = false;
// Every object this decoder is given was verified first, so each frame it outputs may
// be bound (§7.2); the tier depends on the frame's format, read when it is output.
const route = st.route;
const entry = bind ? { principal: P, group, binding: (vf) => wai.makeBinding(bind.digest, bind.receipt, wai.frameTier(route.route, route.tier, vf.format == null, false)) } : null;
if (remote && st.m === "screen" && bind) remote.chunk(group, manifest.target.timestamp_us, bind.digest);
for (const u of units) {
bindings.chunk(d, manifest.target.timestamp_us, entry);
d.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: manifest.target.timestamp_us, data: u }));
}
} catch (err) {
T.decode_errors++;
log(`decode of ${st.m} group ${group} of ${P.slice(0, 8)}: ${err.message || err}`);
}
}
function decodeFetchedAudio(st, T, packets, manifest) {
if (!st.fetchDecoder) {
st.fetchDecoder = new AudioDecoder({ output: (ad) => { T.fetched_decoded++; ad.close(); }, error: (e) => { T.decode_errors++; log(`fetched audio decoder: ${e.message || e}`); } });
st.fetchDecoder.configure({ codec: "opus", sampleRate: st.tr.samplerate, numberOfChannels: Number(st.tr.channelConfig || 1) });
}
for (const pk of packets) st.fetchDecoder.decode(new EncodedAudioChunk({ type: "key", timestamp: manifest.target.timestamp_us, data: pk }));
}
// The earlier groups a late joiner fetched, decoded in order on a decoder of their own from
// the first key frame, so the live decoder's state is not disturbed.
function decodeFetched(P, st, T, units, key, manifest) {
if (!st.fetchDecoder) {
if (!key) return;
st.fetchDecoder = new VideoDecoder({ output: (vf) => { T.fetched_decoded++; vf.close(); }, error: (e) => { T.decode_errors++; log(`fetched video decoder: ${e.message || e}`); } });
st.fetchDecoder.configure({ codec: st.tr.codec, codedWidth: st.tr.width, codedHeight: st.tr.height });
}
for (const u of units) st.fetchDecoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: manifest.target.timestamp_us, data: u }));
}
// Objects that waited past RECEIPT_WAIT_MS for their receipts, and receipts whose object
// never came: the latter accounted for by inclusion (jwp-receipts §4.4).
function sweepMissing() {
const now = performance.now();
for (const sub of subscribed.values()) {
for (const st of Object.values(sub.tracks)) {
const T = tally(sub.principal)[st.m];
for (const [g, p] of st.pending) {
if (now - p.at > RECEIPT_WAIT_MS) { st.pending.delete(g); T.refused++; log(`${st.m} group ${g}: its receipts did not arrive`); continue; }
// A receipts-track object the subscription did not deliver (one that reached the
// relay just before the subscription did) is recovered by FETCH of its group
// (moq-streaming-format §4.1).
if (now - p.at > RECOVER_AFTER_MS && !p.recovering) {
p.recovering = true;
T.receipt_fetches = (T.receipt_fetches || 0) + 1;
session.fetch(st.ns, st.tr.wai_receipts_track, { group_order: 1, location_filter: { type: "absolute_group_end", start: [g, 0], end_group_delta: 0 } }, (j, payload) => st.onReceipts(j.group_id, payload))
.then(() => { if (!st.pending.has(g)) T.receipts_recovered = (T.receipts_recovered || 0) + 1; })
.catch((e) => log(`${st.m} group ${g}: the FETCH of its receipts: ${e.message || e}`));
}
}
for (const [g, at] of st.receiptsAt) {
if (st.seen.has(g)) { st.receiptsAt.delete(g); continue; }
// Receipts of a group before the media subscription's first are of no object this
// subscription asked for (the receipts track was subscribed first): not a drop.
if (T.first_live_group == null || g < T.first_live_group) { if (now - at > MISSING_AFTER_MS) st.receiptsAt.delete(g); continue; }
if (now - at < MISSING_AFTER_MS) continue;
st.receiptsAt.delete(g);
const r = JSON.parse(st.verifier.inclusion(BigInt(g), 0n));
if (r.ok) T.dropped_accounted++; else T.dropped_unaccounted++;
}
}
}
}
// ---------------------------------------------------------------------------- seeking back
// Seeking back (WAI live-recording §8, live-rooms §10.6): a participant's last seconds, by
// FETCH below the live edge of its tracks and of their receipts tracks, which the relay
// answers from its DVR window. Every object is verified against its receipt exactly as a
// live one is (the same verifier, which admits the earlier groups as history), the video
// is played from its first key frame in a view of its own at the pace it was captured, the
// audio decoded and counted, and the page then returns to live. What the relay no longer
// holds, or withholds, comes as an End of Unknown Range, counted and never played.
const AUDIO_FRAME_MS = Number(q.get("audioFrameMs") || 20);
let dvrStop = null;
function renderDvr() {
const d = state.dvr;
const run = d.runs[d.runs.length - 1];
const canvas = $("dvr-canvas");
if (canvas) canvas.hidden = d.mode !== "playing";
if ($("live")) $("live").hidden = d.mode === "live";
if ($("seek")) $("seek").disabled = d.mode !== "live" || !subscribedVideo();
const el = $("dvr-status");
if (!el) return;
if (!run) { el.textContent = ""; return; }
const v = run.video, a = run.audio;
const what = `${run.who}, ${run.seconds} s back: video ${v.verified} of ${v.fetched} verified, audio ${a.verified} of ${a.fetched}${v.unknown + a.unknown ? `; ${v.unknown + a.unknown} range(s) the relay no longer holds` : ""}`;
el.textContent = d.mode === "seeking" ? `Fetching ${what}` : d.mode === "playing" ? `Playing ${what}; ${v.played} frames shown` : `Back to live after ${what}; ${v.played} frames shown`;
}
function subscribedVideo() {
return [...subscribed.values()].find((s) => s.tracks.video && tally(s.principal).video.last_live_group != null);
}
// FETCH groups `from` to `to` of a track and of its receipts track, the receipts first,
// each verified; returns the verified objects in group order.
async function fetchVerified(st, m, from, to, c) {
const params = { group_order: 1, subscriber_priority: SUB_PRIORITY[m] + 2, location_filter: { type: "absolute_group_end", start: [from, 0], end_group_delta: to - from } };
const rec = [];
const r1 = await session.fetch(st.ns, st.tr.wai_receipts_track, params, (j, p) => rec.push([j.group_id, p]));
for (const [g, p] of rec) {
const r = JSON.parse(st.verifier.receipts(BigInt(g), p, BigInt(Date.now())));
if (!r.ok) c.receipts_refused++;
}
const objs = [];
const r2 = await session.fetch(st.ns, st.tr.name, params, (j, p) => objs.push([j.group_id, j.object_id, p]));
c.unknown += r1.ranges.length + r2.ranges.length;
const ok = [];
for (const [g, o, p] of objs) {
c.fetched++;
const r = JSON.parse(st.verifier.object(BigInt(g), BigInt(o), p));
if (r.ok) { c.verified++; ok.push([g, p]); } else { c.refused++; log(`seek back: ${m} group ${g} refused: ${r.reason}`); }
}
return ok;
}
async function seekBack(seconds = 10) {
if (e2ee) throw new Error("an end-to-end encrypted room has no seeking back: the relay keeps nothing of it");
if (state.dvr.mode !== "live") return state.dvr.runs[state.dvr.runs.length - 1];
const sub = subscribedVideo();
if (!sub) throw new Error("no participant's video to seek back into");
const counts = () => ({ from: null, to: null, fetched: 0, verified: 0, refused: 0, receipts_refused: 0, unknown: 0, decoded: 0, played: 0 });
const run = { principal: sub.principal, who: nameOf(sub.principal), seconds, video: counts(), audio: counts(), started_at: performance.now(), playing_at: null, live_at: null, error: null };
state.dvr.runs.push(run);
state.dvr.mode = "seeking";
renderDvr();
try {
const got = {};
for (const m of ["video", "audio"]) {
const st = sub.tracks[m];
const T = tally(sub.principal)[m];
if (!st || T.last_live_group == null) continue;
const perSecond = m === "video" ? Number(st.tr.framerate || 30) : 1000 / AUDIO_FRAME_MS;
const to = T.last_live_group;
const from = Math.max(0, to - Math.round(seconds * perSecond));
run[m].from = from;
run[m].to = to;
got[m] = await fetchVerified(st, m, from, to, run[m]);
}
log(`seek back ${seconds} s into ${run.who}: video groups ${run.video.from}..${run.video.to}, ${run.video.verified} of ${run.video.fetched} verified; audio ${run.audio.verified} of ${run.audio.fetched}; ${run.video.unknown + run.audio.unknown} unknown range(s)`);
state.dvr.mode = "playing";
run.playing_at = performance.now();
renderDvr();
await playBack(sub, got, run);
} catch (e) {
run.error = String(e.message || e);
log(`seek back: ${run.error}`);
} finally {
backToLive();
}
return run;
}
// The fetched video, decoded and drawn at the pace of its timestamps from its first key
// frame; the audio decoded alongside and counted.
async function playBack(sub, got, run) {
const canvas = $("dvr-canvas");
const ctx = canvas && canvas.getContext("2d");
const vst = sub.tracks.video;
const vdec = new VideoDecoder({
output: (vf) => {
run.video.decoded++;
if (ctx && state.dvr.mode === "playing") { ctx.drawImage(vf, 0, 0, canvas.width, canvas.height); run.video.played++; renderDvr(); }
vf.close();
},
error: (e) => log(`seek back: video decoder: ${e.message || e}`),
});
vdec.configure({ codec: vst.tr.codec, codedWidth: vst.tr.width, codedHeight: vst.tr.height });
const ast = sub.tracks.audio;
let adec = null;
if (ast && got.audio) {
adec = new AudioDecoder({ output: (ad) => { run.audio.decoded++; ad.close(); }, error: (e) => log(`seek back: audio decoder: ${e.message || e}`) });
adec.configure({ codec: "opus", sampleRate: ast.tr.samplerate, numberOfChannels: Number(ast.tr.channelConfig || 1) });
for (const [, env] of got.audio) {
try {
const e = wai.unpack(env);
const manifest = JSON.parse(e.manifest);
const payload = e.payload();
e.free();
for (const pk of media.oggOpusPackets(payload)) adec.decode(new EncodedAudioChunk({ type: "key", timestamp: manifest.target.timestamp_us, data: pk }));
} catch (err) { log(`seek back: audio: ${err.message || err}`); }
}
}
const frames = [];
for (const [, env] of got.video || []) {
try {
const e = wai.unpack(env);
const manifest = JSON.parse(e.manifest);
const cap = e.capability;
const payload = e.payload();
e.free();
const units = cap === "wai.video.vp9" ? media.ivfFrames(payload).map((f) => f.data) : cap === "wai.video.avc" || cap === "wai.video.hevc" ? [payload] : media.av1Units(payload);
frames.push({ key: !!manifest.target.key, ts: manifest.target.timestamp_us, units });
} catch (err) { log(`seek back: video: ${err.message || err}`); }
}
const first = frames.findIndex((f) => f.key);
let stopped = false;
dvrStop = () => { stopped = true; };
if (first >= 0) {
const t0 = performance.now();
const ts0 = frames[first].ts;
for (const f of frames.slice(first)) {
if (stopped) break;
const due = t0 + (f.ts - ts0) / 1000;
const wait = due - performance.now();
if (wait > 0) await new Promise((r) => setTimeout(r, Math.min(wait, 1000)));
for (const u of f.units) vdec.decode(new EncodedVideoChunk({ type: f.key ? "key" : "delta", timestamp: f.ts, data: u }));
}
}
try { await vdec.flush(); } catch { /* closed */ }
if (adec) { try { await adec.flush(); } catch { /* closed */ } adec.close(); }
vdec.close();
}
function backToLive() {
if (dvrStop) { dvrStop(); dvrStop = null; }
const run = state.dvr.runs[state.dvr.runs.length - 1];
if (run && run.live_at == null) run.live_at = performance.now();
state.dvr.mode = "live";
renderDvr();
}
window.roomTest.seekBack = (seconds) => seekBack(seconds);
if ($("seek")) {
$("seek").addEventListener("click", () => {
const s = Math.max(1, Math.min(900, Number(($("seek-s") || {}).value || 10)));
seekBack(s).catch(fail);
});
}
if ($("live")) $("live").addEventListener("click", () => { if (dvrStop) dvrStop(); });
setInterval(() => { if (state.dvr.mode === "live") renderDvr(); }, 1000);
// ------------------------------------------------------------------------------- GOAWAY
// GOAWAY (MOQT §9.2): no new request on the old session; a new session to the URI it
// names (or the same one), the tracks published again and every subscription made again
// there, then the old session closed by this end, before the relay's timeout.
async function onGoaway(old, g) {
state.goaways.push({ uri: g.uri, timeout_ms: g.timeout_ms, at: performance.now() });
try {
const opts = { ...transportOpts };
if (g.uri) {
// The session moves only within this page's own origin (or, on a loopback page, to a
// loopback address): a GOAWAY naming another is refused, and the session ends.
const c = checkEndpoint(g.uri, kindOf(g.uri), location.href);
if (!c.ok) {
state.goaways[state.goaways.length - 1].refused = c.reason;
state.ended = { code: 0, reason: "GOAWAY to another origin refused" };
state.phase = "ended";
log(`refused to move after GOAWAY to ${g.uri}: ${c.reason}`);
status(`The relay asked this page to move its session to ${g.uri}, which is not this page's own (${c.reason}). The page refused, and its session has ended. Reload the page to enter again.`);
old.close(0, "GOAWAY to another origin refused");
return;
}
if (c.url.startsWith("https://")) { opts.webtransport = c.url; opts.force = "webtransport"; }
else if (c.url.startsWith("ws")) { opts.websocket = c.url; opts.force = "websocket"; }
}
transportOpts = opts;
const s = await openSession(opts);
if (window.roomTest.role !== "clock") await publishTracks(s);
// This end's tracks now go to the new session, so the old one closes, by this end and
// before the relay's timeout; then every subscription is made again there.
old.close(0, "moved after GOAWAY");
state.goaways[state.goaways.length - 1].moved = { transport: s.t.kind, at: performance.now() };
log(`moved to ${g.uri || "the same relay"} after GOAWAY`);
if (window.roomTest.role !== "clock") {
const others = [...subscribed.keys()];
// Every stream of the old session ended with it; the new session's are new streams.
for (const sub of subscribed.values()) for (const [m, st] of Object.entries(sub.tracks)) endStream(sub, m, st, "ended", false);
subscribed.clear();
eventsSubscribed.clear();
await subscribeEvents(fx.clock.principal);
for (const P of others) await subscribeParticipant(P);
log(`subscriptions made again on the new session: ${others.length} participant(s)`);
}
} catch (e) {
fail(e);
}
}
// ---------------------------------------------------------------------------- the views
function renderTimeline(e) {
const li = document.createElement("li");
li.dataset.kind = e.kind;
const p = e.payload || {};
const what = e.kind === "caption" ? `“${p.text}” (${p.origin_label})` : e.kind === "chat" ? p.text : p.detail ? `${(p.subject || "").slice(0, 8)} ${JSON.stringify(p.detail)}` : "";
li.textContent = `t=${(e.t / 1e6).toFixed(3)}s ${e.who} ${e.kind} ${what}`;
$("timeline").appendChild(li);
}
function renderCaption() {
const c = state.captions[state.captions.length - 1];
const el = $("caption");
el.innerHTML = "";
const label = document.createElement("span");
label.className = `origin origin-${c.origin}`;
label.textContent = c.origin === "human" ? "Human captioner" : c.origin === "machine" ? "Machine-generated" : "Machine-generated, reviewed";
const text = document.createElement("span");
text.className = "text";
text.textContent = c.text;
el.append(label, text);
el.dataset.origin = c.origin;
}
// A participant's own events: captions and chats, signed with its key.
window.roomTest.sendCaption = async ({ text, origin = 1, model = null, lang = "en", duration_ms = 3000 }) => {
if (cowatch) throw new Error("a watch room's page sends its co-watch statements alone");
const p = wai.captionPayload(duration_ms, origin, model || undefined, lang, text);
const r = await eventsTrack.emit(0x0a, p);
await (records = records.then(() => onRecord(r.record)));
return r.view;
};
window.roomTest.sendChat = async (text) => {
if (cowatch) throw new Error("a watch room's page sends its co-watch statements alone");
const r = await eventsTrack.emit(0x03, wai.chatPayload(text));
await (records = records.then(() => onRecord(r.record)));
return r.view;
};
window.roomTest.snapshot = () => ({
phase: state.phase,
refused: state.refused,
mode: state.mode,
me: me && { principal: me.principal, role: me.role || window.roomTest.role },
room: fx && fx.room,
grant: state.grant,
ended: state.ended,
errors: state.errors,
transport: state.transport,
sessions: state.sessions,
published: state.published,
codec: state.publishCodec,
capture: state.capture,
subs: state.subs,
dvr: state.dvr,
captions: state.captions,
timeline: state.timeline.map((e) => ({ t: e.t, who: e.who, kind: e.kind })),
goaways: state.goaways,
roomState: state.room,
frame: compositor && compositor.frame,
layouts: compositor && compositor.layouts,
drawn: compositor && Object.fromEntries(compositor.drawn),
draws: compositor && compositor.draws,
samples: compositor && compositor.samples(),
mixed: compositor && compositor.mixed,
worklet: compositor && compositor.workletStats,
audioState: compositor && compositor.ac && compositor.ac.state,
session: session && { counters: session.counters, transport: session.t.stats ? session.t.stats() : {} },
verifiers: Object.fromEntries([...subscribed.entries()].map(([P, s]) => [P, Object.fromEntries(Object.entries(s.tracks).map(([m, st]) => [m, JSON.parse(st.verifier.stats())]))])),
e2ee: e2ee && { ...state.e2ee, ...e2ee.stats, at: e2ee.at, stopped: e2ee.stopped, member: e2ee.state(), dom: $("e2ee") && !$("e2ee").hidden ? $("e2ee").textContent : null },
age: state.age,
co: co && co.snapshot(),
cowatch: cowatch && { ...cowatch.snapshot(), dom: $("cw-sync") ? $("cw-sync").textContent : null },
termination: terminationSnapshot(),
});
// A shared control of a watch room (co-watch §5): `play`, `pause`, `seek` (to, µs) or `rate`
// (millionths), at the policy's lead from now. Resolves with the instant it takes effect.
window.roomTest.watchControl = (name, arg = 0) => watchControl(name, arg);
// A participant's grant presented as a bare token, with no proof: the relay must refuse
// the session (live-rooms §10.5.2, UNAUTHORIZED). Resolves with how the session ended.
window.roomTest.tryBareGrant = async () => {
const t = await connect({ ...transportOpts, log: () => {} });
let setup = "exchanged";
try {
await MoqSession.connect(t, moq, { token: unhex(me.grant), implementation: "joule-moq-web/room-bare", log: () => {} });
} catch (e) {
setup = String(e.message || e);
}
const closed = await Promise.race([t.closed, new Promise((r) => setTimeout(() => r(null), 8000))]);
return { transport: t.kind, setup, closed };
};
// This page's grant presented with a proof made by another key: refused the session too
// (§10.5.4 step 4, pop_wrong_key).
window.roomTest.tryWrongKey = async () => {
const t = await connect({ ...transportOpts, log: () => {} });
let setup = "exchanged";
try {
await MoqSession.connect(t, moq, { grant: { record: unhex(me.grant), seed: unhex(me.seed), proofBy: crypto.getRandomValues(new Uint8Array(32)) }, implementation: "joule-moq-web/room-wrong-key", log: () => {} });
} catch (e) {
setup = String(e.message || e);
}
const closed = await Promise.race([t.closed, new Promise((r) => setTimeout(() => r(null), 8000))]);
return { transport: t.kind, setup, closed };
};
// End-to-end encryption, for the tests.
window.roomTest.e2eeSent = (groups) => groups.map((g) => e2eeSent.get(g)).filter(Boolean);
// The next video group is sealed and then has a byte changed, as a relay could change it.
window.roomTest.e2eeTamperNext = () => { state.e2ee.tamperNext = true; };
// An object sealed under this page's latest epoch on its own video track, not sent.
window.roomTest.e2eeSealAsMe = (group) => {
const ns = [fx.room, me.principal, "video"];
const sl = e2ee.seal(ns, group, PRIORITY.video, enc.encode("sealed by a member, kept back"), true, VIDEO_TIMEOUT_MS);
return sl && { ns, group, priority: PRIORITY.video, props: hex(sl.props), payload: hex(sl.payload), epoch: sl.epoch };
};
// An object as another page sealed it, opened here through the receiving checks.
window.roomTest.e2eeTryOpen = (o) => {
const v = e2ee.open(o.ns, "0", o.group, 0, o.priority, unhex(o.props), unhex(o.payload));
delete v.payload;
return v;
};
// The next video object received from `P`, before it is opened, presented again with each
// change a relay could make to it, then as it came, then once more: each outcome.
window.roomTest.e2eeTamperSuite = (P) => new Promise((resolve, reject) => {
const sub = subscribed.get(P);
const st = sub && sub.tracks.video;
if (!st) return reject(new Error(`no video of ${P.slice(0, 8)}`));
const t = setTimeout(() => { st.intercept = null; reject(new Error("no object arrived")); }, 20000);
st.intercept = (x) => { clearTimeout(t); resolve(tamperSuite(st, x)); };
});
// Stop publishing and stay: PUBLISH_DONE on each media track, the session and every
// subscription kept. The room's clock records the leave; the page keeps receiving.
window.roomTest.e2eeStopPublishing = async () => {
state.stoppedPublishing = true;
if (state.capture && state.capture.stream) for (const t of state.capture.stream.getTracks()) t.stop();
let n = 0;
for (const r of published) {
if (!r.ns || !["audio", "video"].includes(r.ns[2])) continue;
await r.writer.write(moq.encodeControl(JSON.stringify({ message: "PUBLISH_DONE", status: 2, reason: "stopped publishing" })));
n++;
}
return n;
};
function tamperSuite(st, x) {
const { group, payload, meta } = x;
const s = e2ee.state();
const open = (props, pl, o = {}) => {
const v = e2ee.open(st.ns, st.tr.name, o.group != null ? o.group : group, o.object != null ? o.object : meta.object || 0, o.priority != null ? o.priority : meta.priority, props, pl);
return v.ok ? "opened" : v.reason || `pending ${v.pending}`;
};
const alter = (kind, kid = 0n) => moq.e2eeAlteredProperties(meta.props, kind, kid);
const flipped = payload.slice();
flipped[flipped.length - 1] ^= 1;
const out = {
payload_byte_changed: open(meta.props, flipped),
priority_changed: open(meta.props, payload, { priority: (meta.priority + 1) & 255 }),
moved_to_another_group: open(meta.props, payload, { group: group + 1000000 }),
key_id_removed: open(alter("drop_key_id"), payload),
key_id_outside_too: open(alter("key_id_outside", 1n), payload),
object_id_past_32_bits: open(meta.props, payload, { object: 2 ** 32 }),
kid_of_an_empty_leaf: open(alter("key_id", moq.e2eeKid(BigInt(s.epoch), 1048575)), payload),
kid_of_this_member: open(alter("key_id", moq.e2eeKid(BigInt(s.epoch), s.leaf)), payload),
as_sent: open(meta.props, payload),
again: open(meta.props, payload),
};
return { group, epoch: s.epoch, outcomes: out };
}
window.roomTest.roomJson = () => room.roomJson();
// Co-performance, for the tests.
window.roomTest.coState = () => co && co.snapshot();
window.roomTest.coAnnounce = async (o) => { const r = await co.announce(o); return { at: r.at, intent: r.intent }; };
window.roomTest.coInput = (o) => co.input(o);
window.roomTest.coInputDetached = (o) => co.inputDetached(o);
window.roomTest.coPlayOutOfTurn = (ms) => co.playOutOfTurn(ms);
window.roomTest.coStampNextLate = (ms) => co.stampNextLate(ms);
window.roomTest.coRequest = (path, body) => co.ask(path, body);
window.roomTest.coObjects = () => coObjects();
// The room as this page holds it, verified as co-performance §9 verifies it, with the objects
// given (or the clock's whole log).
window.roomTest.coVerify = async (objects) => JSON.parse(room.coVerify(JSON.stringify({ policies: [fx.co.policy], objects: objects || await coObjects(), payloads: [] })));
window.roomTest.binding = () => ({
tiles: bindingRows(),
routes: state.binding.routes,
asked: state.binding.asked,
table: { refusals: bindings.refusals, struck: bindings.struck, bound: bindings.bound_frames, unbound: bindings.unbound_frames },
metadata: state.binding.metadata || (state.binding.metadata = metadataProbe()),
dom: $("binding-debug") && !$("binding-debug").hidden ? [...$("binding-debug").querySelectorAll("tbody tr")].map((tr) => ({ principal: tr.dataset.principal, state: tr.dataset.state, text: tr.textContent })) : null,
});
window.roomTest.compose = () => fx && fx.compose;
window.roomTest.frameAt = (t) => JSON.parse(room.frame(unhex(fx.compose), BigInt(t)));
window.roomTest.now = () => roomNow();
// A human caption, typed by this participant.
async function sendTypedCaption(e) {
e.preventDefault();
const el = $("caption-text");
const text = el.value.trim();
if (!text || state.phase !== "publishing") return;
el.value = "";
try { await window.roomTest.sendCaption({ text, origin: 1, lang: document.documentElement.lang || "en" }); } catch (err) { fail(err); }
}
// The room service: the link's, held to the page's own origin, or the page's own markup's.
service = queryEndpoint("service", "http") || (q.get("service") ? "" : ($("room-app") && $("room-app").dataset.service) || "");
if (state.refused.length) {
state.phase = "refused";
for (const id of ["join", "watch"]) if ($(id)) $(id).disabled = true;
} else if (service) {
window.roomTest.enter = (role, given) => enter(role, given);
window.roomTest.leave = leave;
const buttons = ["join", "watch"].map($).filter(Boolean);
const go = (role) => async () => {
for (const b of buttons) b.disabled = true;
try {
const phase = await enter(role);
if (phase === "refused") return;
if (phase === "full") for (const b of buttons) b.disabled = b.id === "join";
else if ($("leave")) $("leave").hidden = false;
if (phase === "publishing" && $("caption-form")) $("caption-form").hidden = false;
} catch (e) {
fail(e);
state.mode = state.mode === "service" && state.phase === "entering" ? null : state.mode;
for (const b of buttons) b.disabled = false;
}
};
if ($("join")) $("join").addEventListener("click", go("publisher"));
if ($("watch")) $("watch").addEventListener("click", go("viewer"));
if ($("leave")) $("leave").addEventListener("click", () => { $("leave").disabled = true; if ($("caption-form")) $("caption-form").hidden = true; leave(); });
if ($("caption-form")) $("caption-form").addEventListener("submit", sendTypedCaption);
// A watch room's shared controls, each at the room's lead (co-watch §5.3).
const cw = (fn) => () => { try { fn(); } catch (e) { fail(e); } };
const at = () => (cowatch ? cowatch.timeline.positionAt(cowatch.now()) : 0);
if ($("cw-play")) $("cw-play").addEventListener("click", cw(() => watchControl("play")));
if ($("cw-pause")) $("cw-pause").addEventListener("click", cw(() => watchControl("pause")));
if ($("cw-back")) $("cw-back").addEventListener("click", cw(() => watchControl("seek", Math.max(0, at() - 10e6))));
if ($("cw-fwd")) $("cw-fwd").addEventListener("click", cw(() => watchControl("seek", Math.min(cowatch.policy.duration_us, at() + 10e6))));
if ($("cw-rate")) $("cw-rate").addEventListener("change", cw(() => watchControl("rate", Number($("cw-rate").value))));
// Before entering: an end-to-end encrypted room says so, and offers no watching, since a
// viewer is not a member of its group and opens nothing, and no seeking back.
api("/room").then((r) => {
if (r.status !== 200 || !r.body || !r.body.e2ee) return;
if ($("e2ee")) { $("e2ee").hidden = false; $("e2ee-state").textContent = "only the room's members open its media; the relay and the room service hold no key"; }
if ($("watch")) $("watch").hidden = true;
if ($("dvr")) $("dvr").hidden = true;
}).catch(() => {});
state.phase = "idle";
} else {
boot().catch(fail);
}