pirror web client
  • JavaScript 86.7%
  • Rust 11.1%
  • HTML 2%
  • Shell 0.2%
Find a file
tna 7dbb92e6d0 Fix video twitching and delayed audio; add ?debug=1 overlay
Video
- Tearing/flashing: the WebGL canvas was created with `desynchronized:
  true`, which draws into the on-screen buffer while present() clears then
  redraws it, so a mid-frame scanout showed black/old picture below a tear
  line in whatever was changing (worse at higher fps). Off by default;
  `?desync=1` restores the old behaviour for comparison.
- Frame pacing: a single held-frame slot that each new frame overwrote
  froze the picture when audio ran behind arrival, and showing the newest due
  frame discarded frames that landed in the same refresh (skip, repeat, skip).
  Frames now queue in order and each refresh shows the oldest due one; only a
  backlog of more than 2 due frames is trimmed (`?backlog=N` to tune).
  A held frame is shown after 500 ms regardless, so a stalled audio clock
  cannot freeze the picture.

Audio
- The audio worklet queued decoded audio without limit while the context was
  suspended, so the first tap played a backlog and audio ran seconds late.
  Audio decoded while not running is dropped, the queue and sync clock reset
  when the context starts running, and the worklet caps its queue at 200 ms.
- resume() is retried on every gesture (touch/click/key). A resume issued
  at load, before any gesture, never settles in Chrome and an in-progress
  guard swallowed the tap; the guard is gone.
- The "Enable audio" button is kept as the visible fallback.

Service worker
- Network first, cache only as the offline fallback: a cached index.html
  served against a newer app.js crashed the page (null audio button). Cache
  name bumped to v9. The audio button is also treated as optional.

Diagnostics
- `?debug=1` shows per second: datagrams and Mbit/s, video gaps (frames whose
  datagrams did not all arrive), payload rates, max video gap, decoder
  in/out/queue/errors/keyframe skips, frames shown, late/overflow/superseded
  drops and latency. It showed no loss and no decoder errors, which pointed
  the investigation at presentation instead of the network.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TmgXcZUg38V3cgavTAjUFA
2026-09-19 20:10:27 +02:00
wasm-core Import pirror-client-web (state before the pacing and audio fixes) 2026-09-19 20:10:27 +02:00
www Fix video twitching and delayed audio; add ?debug=1 overlay 2026-09-19 20:10:27 +02:00
.gitignore Import pirror-client-web (state before the pacing and audio fixes) 2026-09-19 20:10:27 +02:00
AGENTS.md Import pirror-client-web (state before the pacing and audio fixes) 2026-09-19 20:10:27 +02:00
build.sh Import pirror-client-web (state before the pacing and audio fixes) 2026-09-19 20:10:27 +02:00
README.md Import pirror-client-web (state before the pacing and audio fixes) 2026-09-19 20:10:27 +02:00
wrangler.toml Import pirror-client-web (state before the pacing and audio fixes) 2026-09-19 20:10:27 +02:00

pirror web client

Browser sink for pirror. Loads as a static site, opens a direct WebTransport connection to pirror-server-linux, and decodes/renders the H.264 + audio stream. No server in the media path, no framework, no bundler.

Layout

wasm-core/   Rust crate -> wasm32-unknown-unknown (link, AV sync, stall, compositor, chunk)
www/         static site (publish dir); wasm-bindgen output is committed here
build.sh     rebuilds wasm-core and stages it into www/
wrangler.toml  Cloudflare Pages config (pages_build_output_dir = "www")

Requirements

  • wasm-pack (cargo install wasm-pack) and the wasm32-unknown-unknown target
  • A browser with WebTransport, WebCodecs VideoDecoder (H.264), and AudioDecoder (Opus; FLAC optional)

Pairing

The page holds no server address of its own. On the PC (pirror-desktop), pick Serve to: Web, enter this site's address, and choose Pair a browser (or run pirror-desktop pair-web <site-url>). Scan its QR (it carries the full link, so it works even if the PC's short-link port is blocked; the scanned text lands in the page's text field) with the page's Scan QR code button (camera; decoded on the device, using the browser's BarcodeDetector or the vendored jsQR.js, Apache-2.0), paste the link, or open it directly: the page asks Connect to <server>? and shows the server certificate's fingerprint to compare with the PC. Yes saves {name, host, port, token, certHash} in localStorage and connects to https://host:port/<token>; No saves nothing. The link's parameters sit in the URL fragment (never sent to the host serving this page) and are removed from the address bar once read. The server pushes its name and any rotated certificate hash over the session, and tells the page when the PC unpairs it (the page then returns to the pairing screen). Logic lives in www/pairing.js (plain JS, no wasm rebuild). Remote deployments must be served over https://; localhost is fine for development.

Build

./build.sh

This runs wasm-pack build --target web in wasm-core/ and copies wasm_core.js, wasm_core_bg.wasm, and wasm_core.d.ts into www/.

Run locally

WebTransport requires a secure context, so serve over localhost (or HTTPS), not a plain LAN IP:

python3 -m http.server 8080 -d www
# open http://localhost:8080

Connecting

The page needs { host, port, path, certHash }. It reads, in order:

  1. window.PIRROR_CONNECTION (set before app.js loads), or
  2. URL query params, or
  3. hardcoded defaults in app.js (10.10.10.200:9000).

Example:

http://localhost:8080/?host=10.10.10.200&port=9000&certHash=ab12…ef

certHash is the server's self-signed certificate SHA-256, as hex or base64. Omit it only if the server presents a publicly trusted certificate. The certificate is short-lived and must be rotated on the server; re-supply the new hash each time. Pairing UX (QR/LAN discovery) is out of scope here.

Audio starts only after a user gesture (browser autoplay policy); click the page or the Enable audio button.

Deploy (Cloudflare Pages)

Point the Pages project at this repo with build output directory www. Either commit the prebuilt www/ (simplest) or run ./build.sh as the build command with the Rust/wasm-pack toolchain available. No Worker, no relay: Cloudflare only serves the static shell.

Notes

  • Packet framing is [1 byte type][4 byte BE total length][4 byte BE offset] [payload], one chunk per datagram; types are 0x01 H.264, 0x02 Opus, 0x03 FLAC (audio-only mode), 0xFF keepalive. Frames larger than the negotiated datagram cap are split across datagrams with running offsets. wasm-core/src/chunk.rs reassembles them, discarding a frame whole on any gap (WebTransport datagrams are unreliable and unordered).
  • Audio is the master clock; video is dropped/held by av_sync.rs, and the "Unstable Connection" banner is driven by stall.rs.
  • adaptive-video-decoder.js probes hardware, then software, H.264 decoding and falls back mid-stream on hardware decode errors.