open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js
Transaction Science 4e8e4fb098
All checks were successful
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 20s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 26s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 24s
WAI operation catalogue / does this change touch it (pull_request) Successful in 13s
wai cross-architecture byte-equality / does this change touch it (pull_request) Successful in 21s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (pull_request) Successful in 46s
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 29s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 18s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 34s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 29s
WAI conformance / does this change touch it (pull_request) Successful in 16s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 8s
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Successful in 12s
wai-key-proxy / does this change touch it (pull_request) Successful in 13s
WAI neural sink gates / does this change touch it (pull_request) Successful in 12s
wai-ledger service / does this change touch it (pull_request) Successful in 14s
WAI neural decoders end-to-end / does this change touch it (pull_request) Successful in 14s
wai-ops integer operators / does this change touch it (pull_request) Successful in 6s
WAI JPEG-AI whole decode (pinned inputs, required) / does this change touch it (pull_request) Successful in 17s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 6s
wai-quantum command line / does this change touch it (pull_request) Successful in 5s
wai-quantum contract / does this change touch it (pull_request) Successful in 5s
wai-fleet service / does this change touch it (pull_request) Successful in 21s
WAI energy receipts / does this change touch it (pull_request) Successful in 24s
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Successful in 20s
wai-quantum docs, course and locales / does this change touch it (pull_request) Successful in 5s
wai-quantum CUDA member / does this change touch it (pull_request) Successful in 7s
wai-quantum dataflow member / does this change touch it (pull_request) Successful in 8s
wai-quantum signal processor / does this change touch it (pull_request) Successful in 8s
wai-quantum editors / does this change touch it (pull_request) Successful in 6s
wai-quantum resource estimation / does this change touch it (pull_request) Successful in 7s
wai-quantum FPGA member / does this change touch it (pull_request) Successful in 6s
wai-quantum OpenQASM reader / does this change touch it (pull_request) Successful in 7s
wai-quantum Metal member / does this change touch it (pull_request) Successful in 7s
wai-quantum npu-cores member / does this change touch it (pull_request) Successful in 9s
wai-quantum OpenCL member / does this change touch it (pull_request) Successful in 7s
wai-quantum neural-processor member / does this change touch it (pull_request) Successful in 10s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 36s
wai-quantum HIP member / does this change touch it (pull_request) Successful in 17s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 20s
wai-quantum other formats / does this change touch it (pull_request) Successful in 22s
wai-quantum pulse and dynamics / does this change touch it (pull_request) Successful in 11s
wai-quantum for Python / does this change touch it (pull_request) Successful in 10s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 1m20s
wai-quantum error correction and mitigation / does this change touch it (pull_request) Successful in 10s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m14s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 21s
wai-quantum-qpu / does this change touch it (pull_request) Successful in 10s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 1m48s
wai-quantum extension points / does this change touch it (pull_request) Successful in 11s
wai-quantum service / does this change touch it (pull_request) Successful in 11s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 1m58s
wai cross-architecture byte-equality / linux-aarch64 (pull_request) Successful in 34s
wai-quantum matrix-extension member / does this change touch it (pull_request) Successful in 10s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 10s
wai-quantum CUDA member / member · CUDA (jetson-hub) (pull_request) Successful in 1m58s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 58s
wai-quantum tensor-engine member / does this change touch it (pull_request) Successful in 9s
wai-quantum portable tensor program / does this change touch it (pull_request) Successful in 9s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 34s
wai-quantum CUDA member / member · CUDA (thor) (pull_request) Successful in 2m20s
wai-quantum variational and algorithm library / does this change touch it (pull_request) Successful in 9s
wai-quantum on every processor / emulated · armv7-unknown-linux-gnueabihf (pull_request) Successful in 2m25s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 2m57s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 3m49s
wai-quantum CPU vector units / does this change touch it (pull_request) Successful in 10s
wai-quantum OpenCL member / member · OpenCL GPU and CPU (jetson-hub) (pull_request) Successful in 38s
wai-quantum verdicts and repairs / does this change touch it (pull_request) Successful in 5s
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 5s
wai-quantum views in agent hosts / does this change touch it (pull_request) Successful in 10s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 5s
WAI mbt2018_mean integer decode (regenerated vectors) / does this change touch it (pull_request) Successful in 5s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 10s
WAI Studio / does this change touch it (pull_request) Successful in 5s
wai-vector vector graphics / does this change touch it (pull_request) Successful in 9s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 5s
wai-web wasm32 / does this change touch it (pull_request) Successful in 5s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 4m34s
wai-quantum on every processor / emulated · loongarch64-unknown-linux-gnu (pull_request) Successful in 3m12s
wai-quantum OpenCL member / member · OpenCL GPU and CPU (thor) (pull_request) Successful in 1m2s
wai-quantum npu-cores member / member · npu-cores, neural processor (framework-128gb silicon) (pull_request) Successful in 1m17s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 4m30s
wai-quantum service / service energy · linux packages (pull_request) Successful in 1m36s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m1s
wai-quantum OpenCL member / member · OpenCL GPU (framework-128gb) (pull_request) Successful in 25s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 28s
wai-quantum portable tensor program / tensor program · GPUs (jetson-hub) (pull_request) Successful in 34s
wai-ledger service / service · macos (pull_request) Successful in 49s
wai-quantum on every processor / emulated · s390x-unknown-linux-gnu (pull_request) Successful in 5m3s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 2m2s
wai-quantum HIP member / member · HIP (framework-128gb) (pull_request) Successful in 1m7s
wai-quantum on every processor / emulated · riscv64gc-unknown-linux-gnu (pull_request) Successful in 5m24s
wai-quantum portable tensor program / tensor program · integrated GPU (framework-128gb) (pull_request) Successful in 13s
wai-ops integer operators / macos-arm64 (pull_request) Successful in 48s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 7m0s
wai-quantum CPU vector units / member · vector units, avx2 and avx512 (framework-128gb silicon) (pull_request) Successful in 19s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 20s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 36s
wai-quantum on every processor / emulated · powerpc64-unknown-linux-gnu (pull_request) Successful in 6m9s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 3m10s
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Successful in 5s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 32s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 47s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m50s
wai-key-proxy / proxy · linux (pull_request) Successful in 1m13s
wai-pack / wai-pack · macos (pull_request) Successful in 2m42s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m35s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 2m11s
WAI neural sink gates / the exact JPEG-AI decoder reports its path (pinned inputs, required) (pull_request) Successful in 2m12s
wai-ledger service / service · linux (pull_request) Successful in 1m47s
wai-quantum on every processor / emulated · powerpc64le-unknown-linux-gnu (pull_request) Successful in 9m29s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 2m46s
wai-quantum command line / command line · macos (pull_request) Successful in 1m17s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 8m26s
wai-ops integer operators / wasm32 under Node (pull_request) Successful in 30s
wai-ops integer operators / wasm32-wasip2 (wasmtime) (pull_request) Successful in 36s
WAI neural decoders end-to-end / synthetic fixtures → Rust decode → reference (±4) (pull_request) Successful in 1m32s
wai-ops integer operators / linux-x86_64 (pull_request) Successful in 1m2s
wai-ops integer operators / independent checker (Python) (pull_request) Successful in 14s
wai-quantum on every processor / emulated · sparc64-unknown-linux-gnu (pull_request) Successful in 10m23s
wai-quantum contract / the policy is linked, and the deprecations page reads (pull_request) Successful in 7s
wai-fleet service / service · macos (pull_request) Successful in 54s
wai-quantum on every processor / emulated · aarch64-unknown-linux-gnu (pull_request) Successful in 12m16s
wai-pack / wai-pack · linux (pull_request) Successful in 2m6s
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Successful in 13m3s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 10m18s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 11m27s
wai-quantum command line / command line · linux (pull_request) Successful in 3m18s
wai-quantum dataflow member / member · dataflow device absent (macos) (pull_request) Successful in 21s
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Successful in 8m28s
wai-fleet service / service · linux (pull_request) Successful in 2m21s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 9m18s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 6m39s
wai-quantum docs, course and locales / the Studio's words in every locale · linux (pull_request) Successful in 8s
wai-quantum contract / API compatibility · linux (pull_request) Successful in 5m21s
wai-quantum editors / editors · macos (pull_request) Successful in 2m8s
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Successful in 3m53s
wai-quantum docs, course and locales / the course and the command line's languages · linux (pull_request) Successful in 2m18s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 14m36s
wai-quantum contract / schemas and the committed tool lists · linux (pull_request) Successful in 6m46s
wai-quantum FPGA member / member · FPGA datapath, simulated (macos) (pull_request) Successful in 1m25s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 18m0s
wai-quantum dataflow member / member · dataflow device, simulated (pci 1e52:401e) (pull_request) Successful in 1m45s
wai-quantum CUDA member / member · CUDA absent (linux) (pull_request) Successful in 4m6s
wai-quantum FPGA member / member · FPGA simulation absent (linux) (pull_request) Successful in 23s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 8m20s
wai-quantum Metal member / member · Metal absent (linux) (pull_request) Successful in 33s
wai-quantum OpenCL member / member · OpenCL GPU (macos) (pull_request) Successful in 50s
wai-quantum on every processor / emulated · i686-unknown-linux-gnu (pull_request) Successful in 18m7s
wai-quantum OpenCL member / member · OpenCL absent (linux) (pull_request) Successful in 26s
wai-quantum editors / editors · linux (pull_request) Successful in 2m28s
wai-quantum Metal member / member · Metal (macos) (pull_request) Successful in 1m19s
wai-quantum resource estimation / estimation and calibration · linux, release (pull_request) Successful in 1m54s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 18m56s
wai-quantum neural-processor member / member · neural processor absent (linux) (pull_request) Successful in 50s
wai-quantum OpenQASM reader / reader and corpus · linux (pull_request) Successful in 1m47s
wai-quantum pulse and dynamics / the same bits · macos (pull_request) Successful in 1m7s
wai-quantum OpenQASM reader / corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m52s
wai-quantum dataflow member / member · dataflow device, simulated (pci 1e52:b140) (pull_request) Successful in 4m13s
wai-quantum-qpu / credentials in the keychain (macos) (pull_request) Successful in 52s
wai-quantum pulse and dynamics / reader, simulator and pulse jobs under wasm32-wasip2 (wasmtime) (pull_request) Successful in 52s
wai-quantum other formats / the set under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m38s
wai-quantum matrix-extension member / member · matrix extension absent (macos) (pull_request) Successful in 34s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 10m58s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 16m0s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 8m6s
wai-quantum-qpu / clients against their contracts (linux) (pull_request) Successful in 46s
wai-quantum extension points / the registry under wasm32-wasip2 (wasmtime) (pull_request) Successful in 56s
wai-quantum for Python / python · macos (pull_request) Successful in 2m59s
wai-quantum matrix-extension member / member · matrix extension, emulated (aarch64 Linux) (pull_request) Successful in 20s
wai-quantum portable tensor program / tensor program · Metal and the arm64 CPU (macos) (pull_request) Successful in 26s
wai-quantum other formats / readers, writers and the set · linux (pull_request) Successful in 3m42s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 3m28s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 21m22s
wai-quantum error correction and mitigation / codes, decoders, texts and mitigation · linux, release (pull_request) Successful in 2m41s
wai-quantum neural-processor member / member · neural processor (macos) (pull_request) Successful in 4m40s
wai-quantum CPU vector units / member · vector unit absent (macos) (pull_request) Successful in 35s
wai-quantum extension points / the registry and the example · linux (pull_request) Successful in 2m36s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 10m41s
wai-quantum portable tensor program / tensor program · CPU, mutants, GPU builds (pull_request) Successful in 40s
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Successful in 12m33s
wai-quantum service / service · macos (pull_request) Successful in 4m1s
wai-quantum CPU vector units / member · vector unit, rvv 1024-bit (riscv64, emulated) (pull_request) Successful in 23s
wai-quantum pulse and dynamics / reader, simulator and pulse jobs · linux, release (pull_request) Successful in 4m41s
wai-quantum CPU vector units / member · vector unit, sve 128-bit (aarch64, emulated) (pull_request) Successful in 37s
wai-quantum CPU vector units / member · vector unit, sve 2048-bit (aarch64, emulated) (pull_request) Successful in 11s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 7m28s
wai-quantum CPU vector units / member · vector unit, avx2 (x86_64 silicon) (pull_request) Successful in 26s
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Successful in 2m7s
wai-quantum variational and algorithm library / parameters, gradients, optimisers, VQE, QAOA and builders · linux, release (pull_request) Successful in 1m59s
wai-quantum CPU vector units / member · vector unit, sve 512-bit (aarch64, emulated) (pull_request) Successful in 10s
wai-quantum CPU vector units / member · vector unit, rvv 128-bit (riscv64, emulated) (pull_request) Successful in 1m18s
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Successful in 2m30s
wai-quantum service / service · linux (pull_request) Successful in 4m43s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 5m17s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 3m51s
wai-quantum WebGPU member / member · GPU, Vulkan (jetson-hub) (pull_request) Successful in 21m17s
wai-quantum verdicts and repairs / the intent set and the verdict under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m51s
wai-vector vector graphics / macos-arm64 (pull_request) Successful in 2m7s
wai-quantum verdicts and repairs / verdicts, repairs and the intent set · linux (pull_request) Successful in 2m30s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 1m48s
wai-quantum WebGPU member / member · GPU, Vulkan (thor) (pull_request) Successful in 24m55s
wai-quantum tensor-engine member / member · tensor engine, the kernel toolchain's simulator (linux) (pull_request) Successful in 5m59s
wai-quantum for Python / python · linux (pull_request) Successful in 9m4s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m0s
wai-quantum views in agent hosts / views · the page and its engine (pull_request) Successful in 3m13s
wai-vector vector graphics / wasm32-wasip2 (wasmtime) (pull_request) Successful in 50s
wai-vector vector graphics / wasm32 under Node (pull_request) Successful in 25s
wai-vector vector graphics / fuzz, peak allocation, comparison count (pull_request) Successful in 16s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 7m38s
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Successful in 5m54s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 7m4s
wai-quantum views in agent hosts / views · a scripted host (pull_request) Successful in 4m8s
wai-quantum signal processor / processor and member · signal processor, emulated (pull_request) Successful in 15m47s
wai-vector vector graphics / wasm32 in three browser engines (pull_request) Successful in 2m35s
wai-vector vector graphics / independent checker (Python) (pull_request) Successful in 3m0s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 3m54s
wai-vector vector graphics / linux-x86_64 (pull_request) Successful in 5m37s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Successful in 9m14s
wai-quantum docs, course and locales / every command and snippet in the docs · linux (pull_request) Successful in 21m22s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 7m27s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 10m15s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 11m59s
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Successful in 6m48s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 6m44s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 9m5s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 33m23s
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Successful in 7m11s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 18m7s
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Successful in 2m19s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m20s
wai-ops integer operators / linux-aarch64 (pull_request) Successful in 1m2s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m45s
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Successful in 32m36s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 32s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 16s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 7m5s
wai-quantum npu-cores member / member · npu-cores absent (linux-arm64) (pull_request) Successful in 27s
wai-quantum HIP member / member · HIP absent (linux-arm64) (pull_request) Successful in 58s
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Successful in 39m55s
wai-quantum pulse and dynamics / the same bits · linux-arm64 (pull_request) Successful in 1m24s
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Successful in 40m34s
wai-quantum tensor-engine member / member · tensor engine absent (linux-arm64) (pull_request) Successful in 20s
wai-quantum variational and algorithm library / parameters, gradients, optimisers, VQE, QAOA and builders · linux-arm64, release (pull_request) Successful in 2m40s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 2m39s
wai-quantum extension points / the registry and the example · linux-arm64 (pull_request) Successful in 4m1s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 11m3s
wai-vector vector graphics / linux-aarch64 (pull_request) Successful in 4m17s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h10m6s
watch-together: the page makes its audio context before its join; a slot is set apart only when the machine ran nothing of the page
The watch-together example failed twice on the macOS runner for #616, on a loaded host (load
average about 50, then about 30). Each time the first page in the browser, Ada's, signed its
first presentation statement after its join, at 3.83 s and then 3.44 s on the room clock, and
then none in the slots from 4 s and 5 s (and 6 s the second time), while it was still entering
(begin()): it followed the log from 7.49 s, then 8.49 s, 3.7 s and 5.0 s after its first
statement, where Ben's page took 1.2 s and 1.0 s. Ada's page was silent at least 2.17 s, then
at least 3.56 s, with nothing refused, and was never taken for gone. What held it on the runner
was not measured.

What was measured:
- On the CI host, with CI jobs running (load average 30 to 39), in a browser launched as the
  example's test launches it (headless, the same flags, at nice 10), on a blank page with
  nothing else in it: the first audio context in a fresh browser held its page's thread 172 to
  192 ms (3 runs), for the constructor call alone; a second page's took 3.8 to 4.0 ms; neither
  page's thread had a gap over 60 ms in the 1.5 s after. That is a tenth or less of Ada's
  silences. Moving the context before the join takes about 0.2 s, as measured there, out of the
  stretch in which the page must state; what held Ada's page for the rest is unexplained. The
  rest of begin() (its audio worklet, its relay session, its WebAssembly, its first draws, the
  camera and microphone) was not measured there, nor an audio context made beside that work.
- On a laptop, in 25 runs of the example: the first page's audio context 94 to 128 ms, the
  second's 0.8 to 1.4 ms.
- On a laptop, on main, two injected faults each leave empty the slots just after the first
  page's first statement, as in CI: the audio-context call held 2.5 s (a busy wait in it; the
  slots from 1 s and 2 s, 1 run), and the first page's renderer process stopped 2.5 s from its
  join (SIGSTOP; the slot from 2 s, 2 of 2 runs). Neither was measured on the CI host.

This change is not shown to make the example pass on the CI host. If what held Ada's page there
was the page's own work on its thread, or the host running the page's other threads and not
that one, the check below still fails (its heartbeat ticking), and what it prints does not tell
those two apart.

1. The page makes the composition's audio context before it enters the co-watch log (app.js
   enter(), the new Compositor.openAudio()), since from that join it states in every slot
   (co-watch §7.3), so that hold is not in the stretch in which it must state. The rest of the
   entry (begin()) still runs after the join. begin() takes the compositor the entry made; in
   any other room it makes one, as before. The snapshot carries when the context was made, on
   the room clock, and how long making it held the thread (cowatch.audio_open).

2. A compositor made for an entry that failed is closed when the next entry makes its own
   (Compositor.close(): its mix timer, its worklet node, its drawing, its frames and its audio
   context). Before, it stayed, its audio context open and its mix timer running.

3. The statement loop records its stalls (cowatch.js run(), snapshot().stalls): each gap over
   half a slot between two of its passes, `{ from, to }` on the page's estimate of the room
   clock, with how long its wait and its signing took in it, on that clock (`timer_us`,
   `signing_us`); the rest of a stall is the loop's own work. It states every half slot while
   it runs, so it leaves a slot empty only inside such a stall. Its wait is a method (`wait`),
   and roomTime(ms) gives the room clock at an instant of the page's clock. The snapshot
   carries the loop's wait (`pass_ms`) and the page's clock beside the room clock (`wall_ms`).
   The co-watch rule and §7.3 are unchanged.

4. The example's slot check (co-watch §7.3: a statement in every slot while present) sets an
   empty slot apart only if the machine ran nothing of the page in the slot's second half: a
   stall of the loop spans that half; a heartbeat worker that the test starts in each page,
   ticking every 20 ms, did not tick in it either (to within a pass at each end); and the stall
   is the loop's wait and its signing but for at most one pass of its own work. Otherwise the
   check fails and says why: no stall of the loop (a statement lost); the heartbeat ticking (the
   page's thread did not run its loop while the worker ran: the page's own work holding the
   thread, or the host running the worker and not the page's thread, which the check does not
   tell apart); or more than a pass of the loop's own work (its own code, an await outside its
   measures, or the thread taken off the processor inside it). On a host that runs the page's
   threads unevenly, the check can fail in these ways with no defect in the page. Pass or fail,
   it prints each page's audio-context time, its loop's stalls and its heartbeat's silences over
   100 ms. They show whether the page's thread or the whole page was held, not what held it.

Unit tests (cowatch.test.mjs) run the loop on a room clock the test moves: no stall and every
slot held while it runs; a thread held while the loop waited, recorded once with all of the
stall its wait, and the only empty slots those it spans; a gap just over half a slot recorded
and one of half a slot not; a stall in a pass's own work recorded with a wait of one pass, the
rest neither wait nor signing.

Verified on a laptop with this change on main (152aab5c7), in a browser launched as the
example's test launches it and niced:
- From an archive build.sh made, with the WebAssembly build-web.sh made from crates this change
  does not touch, run-examples.sh browser passes video-room, watch-together, messaging,
  remote-desktop and package-and-deliver in each of 2 runs, and watch-together alone 2 more.
- With this room client served beside an earlier archive's binaries (the same Rust but for the
  relay's DVR change in the commit before), watch-together passes 3 of 3, and the faults below
  ran on that set. In each passing run the first page's audio context was made before its join
  and Ada's loop had no stall. Ben's deliberate hold, a synchronous request, shows as a stall of
  3.8 to 4.2 s with his heartbeat never silent.
- The room client's unit tests pass under node --test (77).
- Falsified, each fault put into a scratch copy of the room client, 2 runs each unless said:
  the audio context made in begin() again and that call held 2.5 s (a busy wait), the check
  fails: no statement in the slots from 1 s and 2 s (Ada) and 4 s and 5 s (Ben), each in a
  stall of the loop with its heartbeat ticking, the context printed as made after the join in
  2.5 to 2.6 s. A 2.5 s busy wait just after the join, or 4 s after it, fails it the same way:
  the slots it spans, with the heartbeat ticking. Every statement of the third whole slot after
  each join dropped fails it (no stall of its loop). One 1.5 s await in the loop's own work
  fails it (heartbeat ticking, 1.5 s neither its wait nor its signing); the same await inside
  the loop's wait fails it on the heartbeat alone. The page's renderer process stopped 2.5 s
  (SIGSTOP) at Ada's join, or 2.5 s after it, or Ben's 1 s after his (1 run): it passes, the
  slots inside the stop set apart; the heartbeat was silent and the loop stalled over the stop,
  to within 25 ms. The stop at Ada's join and a 2.5 s busy wait 4 s after it (1 run): the
  stop's slot is set apart and the busy wait's fail.
- In the unit tests, the wait measured on another clock than the stall, no stall recorded, a
  stall recorded only past a whole slot, and the wait left out of the record each fail one or
  two of the stall tests.
- A video room: one page publishes; the other presses Watch, its first relay connection is
  refused (a fault the probe injects), and it presses Watch again. Then that page holds one
  audio context open (the first closed) and one mix timer, and mixes 50 blocks of 20 ms a
  second. On main it holds two open and two timers; with close() doing nothing, two open. The
  same in a watch room: one open with this change, two on main.
- Not shown: that the example passes on the CI host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:56:36 -05:00

2382 lines
121 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, readStatement } 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) {
// A text node a line, as many as are kept: one more line never rebuilds what is shown.
el.append(line + "\n");
if (el.childNodes.length > 2000) el.firstChild.remove();
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. From that join the page states what it presents in every slot
// (co-watch §7.3), so the composition's audio context is made before it: making one waits, on
// the page's thread, until the browser has its audio output (the first context in a browser
// starts it, about 0.2 s on a loaded test runner; a few ms for the next). The rest of its
// entry (begin()) still runs after the join.
const made = v.watch && fx.compose ? makeCompositor() : null;
if (made) made.openAudio();
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.
try {
await begin(remoteRoom ? "viewer" : ROLE_OF[role], stream, made);
} catch (e) {
// A watch room's page that has no session follows no log and applies no control: it stops
// stating, rather than state a presentation it does not keep, and the room takes it for gone.
if (cowatch) cowatch.stop(`the page did not enter the room: ${(e && e.message) || e}`);
throw e;
}
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's composition, drawn on the page's canvas (./compose.js), made for one entry. One an
// earlier entry made, which failed, is closed first: its mix, its drawing and its audio context.
function makeCompositor() {
if (compositor) compositor.close();
compositor = new Compositor(wai, $("canvas"), unhex(fx.compose), { log, bindingOf: (vf) => SideTable.of(vf), onError: fail });
return compositor;
}
// The room, the composition and the session, once the page knows who it is. `made`: the
// composition its entry made already (a watch room's, its audio context made before its join);
// without one, begin() makes it.
async function begin(role, stream, made = null) {
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 = made || makeCompositor();
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();
compositor.start();
}
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();
// A present viewer states what it presents at least once a slot (co-watch §7.3), and the room
// takes one silent past its limit for gone: the statements start with the join, not once the
// page has its relay session, which on a loaded machine can take longer than that limit.
cowatch.run(() => state.phase !== "left").catch(fail);
}
// The log is followed once the room's grants are read (`begin`): whether a control's author may
// send it is read from them (§5.2).
function runCoWatch() {
const principal = me.principal;
cowatch.follow(() => state.phase !== "left").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}`;
const list = $("timeline");
list.appendChild(li);
// What is shown is bounded; `state.timeline` holds the whole of it.
if (list.childElementCount > 1000) list.firstElementChild.remove();
}
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,
ui: compositor && compositor.stats(),
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(),
// `audio_open`: when the composition's audio context was made, on the room clock, and how
// long making it held the page's thread, beside the stalls of the page's statement loop.
cowatch: cowatch && {
...cowatch.snapshot(),
audio_open: compositor && compositor.audioOpened ? { at: cowatch.roomTime(compositor.audioOpened.wall_ms), took_us: compositor.audioOpened.took_us } : null,
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);
// This page's thread held, as a page's can be on a loaded machine: nothing of the page runs, so
// it states nothing, until the room's co-watch log carries the clock's `leave` for it, which the
// room signs once a viewer is silent past its limit (or `ms` passed). The log is read with
// blocking requests, which hold the thread without spinning. Then, in the same task and so
// before the page can enter again, the control `then` (`[name, arg]`), if given, is signed.
// Resolves with `{ began, held_ms, left, at }`: `began` the room clock when the hold began, on
// this page's estimate, and `at` the control's instant as signed then (signed again after the
// page enters again, it may move).
window.roomTest.watchHoldUntilGone = (ms, then = null) => {
if (!cowatch || cowatch.joinT == null) throw new Error("not in a watch room's co-watch log");
const began = cowatch.now();
const start = Date.now();
let from = 0;
let left = false;
while (!left && Date.now() - start < ms) {
const x = new XMLHttpRequest();
x.open("GET", `${service}/watch?room=${cowatch.room}&from=${from}&wait_ms=500`, false);
x.send();
if (x.status !== 200) break;
const page = JSON.parse(x.responseText);
for (const e of page.entries) {
const v = JSON.parse(wai.eventRead(unhex(e.event)));
if (v.kind_name === "leave" && v.payload && v.payload.subject === me.principal && v.t > cowatch.joinT) left = true;
}
from = page.next;
}
const at = then ? watchControl(...then) : null;
return { began, held_ms: Date.now() - start, left, at };
};
// The watch room's co-watch log as the room carries it, each record read and its signature
// checked: its principal, `seq`, `t` and kind, a lifecycle event's subject, and a co-watch
// statement's kind and fields (co-watch §4.2: a control's `action` and `at`, a presentation
// statement's `at`). With the policy's lead and slot, and the room clock now on this page's
// estimate.
window.roomTest.watchLog = async () => {
if (!cowatch) throw new Error("not a watch room");
const entries = [];
for (let from = 0; ;) {
const r = await api(`/watch?room=${cowatch.room}&from=${from}&wait_ms=0`);
if (r.status !== 200 || !r.body) throw new Error(`the room's log: ${r.status}`);
for (const e of r.body.entries) {
const bytes = unhex(e.event);
const v = JSON.parse(wai.eventRead(bytes));
if (!v.ok) throw new Error(`entry ${e.seq} of the room's log does not verify: ${v.reason}`);
const s = v.kind === 1 ? readStatement(readEvent(bytes).payload) : null;
entries.push({ entry: e.seq, principal: v.principal, seq: v.seq, t: v.t, kind: s ? s.kind : v.kind_name, subject: v.payload && v.payload.subject, action: s && s.action, at: s && s.at });
}
if (r.body.next >= r.body.end) break;
from = r.body.next;
}
return { entries, lead_ms: cowatch.policy.lead_ms, period_ms: cowatch.policy.period_ms, now: cowatch.now() };
};
// 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);
}