From c2db3c6727badd44289cc3acb49cc7c48b730836 Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Sat, 21 Feb 2026 01:49:46 -0800 Subject: [PATCH] web+publisher: align moq watch client and disable passthrough by default --- apps/web/README.md | 2 +- apps/web/app.js | 87 +++++++++++++++---- crates/ec-node/src/main.rs | 6 +- .../ECP-0063-cloudflare-moq-webtransport.md | 14 ++- nix/modules/ec-node.nix | 2 +- 5 files changed, 88 insertions(+), 23 deletions(-) diff --git a/apps/web/README.md b/apps/web/README.md index b905ca9..fa841a2 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -2,7 +2,7 @@ This is a static web watcher site. -It embeds the upstream `@kixelated/hang` WebTransport player component (``). +It embeds the upstream `@moq/watch` WebTransport player component (``). ## Dev diff --git a/apps/web/app.js b/apps/web/app.js index f58c8dd..85a0ad3 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1,11 +1,12 @@ // every.channel web watcher // -// This uses the upstream hang web component (WebTransport + WebCodecs). +// This uses the upstream moq watch web component (WebTransport + WebCodecs). // It is intentionally dependency-light: no framework, no bundler. const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon"; -const HANG_WATCH_MODULE_URL = "https://esm.sh/@kixelated/hang@0.7.0/watch/element.js"; -let hangWatchModulePromise = null; +const MOQ_WATCH_MODULE_URL = "https://esm.sh/@moq/watch@0.1.1/element"; +let moqWatchModulePromise = null; +let disposePlayerSignals = null; function $(id) { const el = document.getElementById(id); @@ -47,14 +48,69 @@ function setShareLink(text) { el.textContent = text || ""; } +function clearPlayerSignals() { + if (typeof disposePlayerSignals === "function") { + disposePlayerSignals(); + } + disposePlayerSignals = null; +} + +function bindPlayerSignals(watch, name) { + const cleanup = []; + + const maybeWatch = (signal, onValue) => { + if (!signal || typeof signal.watch !== "function") return; + const dispose = signal.watch(onValue); + if (typeof dispose === "function") cleanup.push(dispose); + }; + + let sawLoading = false; + maybeWatch(watch?.broadcast?.status, (status) => { + if (status === "loading") { + sawLoading = true; + setHint(`Connecting to relay and subscribing: ${name}`, "ok"); + return; + } + if (status === "live") { + setHint(`Live: subscribed to ${name}`, "ok"); + return; + } + if (status === "offline" && sawLoading) { + setHint(`Stream offline or ended: ${name}`, "warn"); + } + }); + + maybeWatch(watch?.broadcast?.catalog, (catalog) => { + if (!catalog) return; + const hasVideo = Boolean(catalog.video && catalog.video.renditions); + const hasAudio = Boolean(catalog.audio && catalog.audio.renditions); + if (hasVideo || hasAudio) { + setHint(`Live: subscribed to ${name}`, "ok"); + } + }); + + if (cleanup.length) { + disposePlayerSignals = () => { + for (const fn of cleanup) { + try { + fn(); + } catch (_) { + // Best-effort cleanup only. + } + } + }; + } +} + function mountPlayer(relayUrl, name) { + clearPlayerSignals(); + const mount = $("playerMount"); mount.textContent = ""; - const watch = document.createElement("hang-watch"); + const watch = document.createElement("moq-watch"); watch.setAttribute("url", relayUrl); - watch.setAttribute("name", name); - watch.setAttribute("controls", ""); + watch.setAttribute("path", name); // A canvas enables video rendering. Without it, only audio is played. const canvas = document.createElement("canvas"); @@ -62,16 +118,17 @@ function mountPlayer(relayUrl, name) { watch.appendChild(canvas); mount.appendChild(watch); + bindPlayerSignals(watch, name); } -async function ensureHangWatchElement() { - if (window.customElements && window.customElements.get("hang-watch")) return; - if (!hangWatchModulePromise) { - hangWatchModulePromise = import(HANG_WATCH_MODULE_URL); +async function ensureMoqWatchElement() { + if (window.customElements && window.customElements.get("moq-watch")) return; + if (!moqWatchModulePromise) { + moqWatchModulePromise = import(MOQ_WATCH_MODULE_URL); } - await hangWatchModulePromise; - if (!(window.customElements && window.customElements.get("hang-watch"))) { - throw new Error("hang-watch custom element is unavailable"); + await moqWatchModulePromise; + if (!(window.customElements && window.customElements.get("moq-watch"))) { + throw new Error("moq-watch custom element is unavailable"); } } @@ -162,10 +219,10 @@ function main() { } try { - await ensureHangWatchElement(); + await ensureMoqWatchElement(); } catch (e) { setHint( - `Failed to load web player dependency: ${String(e)}. Disable script blockers for esm.sh and retry.`, + `Failed to load MoQ web player dependency: ${String(e)}. Disable script blockers for esm.sh and retry.`, "warn", ); return; diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs index 6506c99..584f7b6 100644 --- a/crates/ec-node/src/main.rs +++ b/crates/ec-node/src/main.rs @@ -401,8 +401,8 @@ struct WsSubscribeArgs { #[derive(Parser, Debug)] struct WtPublishArgs { /// Relay URL (WebTransport) to connect to. - /// Default points at Cloudflare's MoQ technical preview relay. - #[arg(long, default_value = "https://relay.cloudflare.mediaoverquic.com/")] + /// Default points at moq.dev's public relay. + #[arg(long, default_value = "https://cdn.moq.dev/anon")] url: String, /// Broadcast name to publish. /// @@ -418,7 +418,7 @@ struct WtPublishArgs { transcode: bool, /// Transmit fMP4 fragments directly (passthrough mode). /// When false, the importer may reframe into CMAF fragments. - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + #[arg(long, default_value_t = false, action = clap::ArgAction::Set)] passthrough: bool, /// Danger: disable TLS verification for the relay. #[arg(long, default_value_t = false)] diff --git a/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md b/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md index 3213fdf..b75a2ad 100644 --- a/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md +++ b/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md @@ -9,7 +9,7 @@ Adopt Cloudflare's MoQ relay preview as the default "global" distribution layer Concrete changes: 1. `ec-node` gains a WebTransport MoQ publisher path that can publish a live CMAF (fMP4) stream to a relay URL (default: `https://cdn.moq.dev/anon`). -2. `every.channel` (the deployed static site) becomes a real web watcher by embedding a WebTransport-capable MoQ player component (``). +2. `every.channel` (the deployed static site) becomes a real web watcher by embedding a WebTransport-capable MoQ player component (`` from `@moq/watch`). 3. The existing WebRTC/WS bootstrap directory/relay remains temporarily for compatibility, but is treated as deprecated once MoQ/WebTransport is stable. ## Motivation @@ -48,11 +48,19 @@ As of February 21, 2026, browser sessions against `https://interop-relay.cloudfl ### Web player -Use the `@kixelated/hang` web component: -- `` +Use the `@moq/watch` web component: +- `` This is WebTransport + WebCodecs based, and is expected to interoperate with Cloudflare's current relay preview. +### CMAF passthrough default + +`ec-node wt-publish` defaults `--passthrough=false` (and Nix module default `passthrough = false`). + +Reason: +- Browser `@moq/watch` playback against live OTA ingest currently hits repeated `Invalid sample duration 0 in trun` decode failures when fed raw passthrough fMP4 fragments. +- Non-passthrough mode avoids this incompatibility and restores end-to-end playback reliability. + ### Transport compatibility Cloudflare's public relay currently implements a subset of the IETF MoQ Transport draft-07 and may not interoperate with diff --git a/nix/modules/ec-node.nix b/nix/modules/ec-node.nix index 946e83e..f10b992 100644 --- a/nix/modules/ec-node.nix +++ b/nix/modules/ec-node.nix @@ -60,7 +60,7 @@ in passthrough = lib.mkOption { type = lib.types.bool; - default = true; + default = false; description = "Whether to transmit fMP4 fragments directly (moq-mux passthrough)."; };