- JavaScript 86.7%
- Rust 11.1%
- HTML 2%
- Shell 0.2%
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 |
||
|---|---|---|
| wasm-core | ||
| www | ||
| .gitignore | ||
| AGENTS.md | ||
| build.sh | ||
| README.md | ||
| wrangler.toml | ||
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 thewasm32-unknown-unknowntarget- A browser with WebTransport, WebCodecs
VideoDecoder(H.264), andAudioDecoder(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:
window.PIRROR_CONNECTION(set beforeapp.jsloads), or- URL query params, or
- 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 are0x01H.264,0x02Opus,0x03FLAC (audio-only mode),0xFFkeepalive. Frames larger than the negotiated datagram cap are split across datagrams with running offsets.wasm-core/src/chunk.rsreassembles 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 bystall.rs. adaptive-video-decoder.jsprobes hardware, then software, H.264 decoding and falls back mid-stream on hardware decode errors.