web+publisher: align moq watch client and disable passthrough by default

This commit is contained in:
every.channel 2026-02-21 01:49:46 -08:00
parent 5bce56ee79
commit c2db3c6727
No known key found for this signature in database
5 changed files with 88 additions and 23 deletions

View file

@ -2,7 +2,7 @@
This is a static web watcher site. This is a static web watcher site.
It embeds the upstream `@kixelated/hang` WebTransport player component (`<hang-watch>`). It embeds the upstream `@moq/watch` WebTransport player component (`<moq-watch>`).
## Dev ## Dev

View file

@ -1,11 +1,12 @@
// every.channel web watcher // 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. // It is intentionally dependency-light: no framework, no bundler.
const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon"; 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"; const MOQ_WATCH_MODULE_URL = "https://esm.sh/@moq/watch@0.1.1/element";
let hangWatchModulePromise = null; let moqWatchModulePromise = null;
let disposePlayerSignals = null;
function $(id) { function $(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -47,14 +48,69 @@ function setShareLink(text) {
el.textContent = 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) { function mountPlayer(relayUrl, name) {
clearPlayerSignals();
const mount = $("playerMount"); const mount = $("playerMount");
mount.textContent = ""; mount.textContent = "";
const watch = document.createElement("hang-watch"); const watch = document.createElement("moq-watch");
watch.setAttribute("url", relayUrl); watch.setAttribute("url", relayUrl);
watch.setAttribute("name", name); watch.setAttribute("path", name);
watch.setAttribute("controls", "");
// A canvas enables video rendering. Without it, only audio is played. // A canvas enables video rendering. Without it, only audio is played.
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@ -62,16 +118,17 @@ function mountPlayer(relayUrl, name) {
watch.appendChild(canvas); watch.appendChild(canvas);
mount.appendChild(watch); mount.appendChild(watch);
bindPlayerSignals(watch, name);
} }
async function ensureHangWatchElement() { async function ensureMoqWatchElement() {
if (window.customElements && window.customElements.get("hang-watch")) return; if (window.customElements && window.customElements.get("moq-watch")) return;
if (!hangWatchModulePromise) { if (!moqWatchModulePromise) {
hangWatchModulePromise = import(HANG_WATCH_MODULE_URL); moqWatchModulePromise = import(MOQ_WATCH_MODULE_URL);
} }
await hangWatchModulePromise; await moqWatchModulePromise;
if (!(window.customElements && window.customElements.get("hang-watch"))) { if (!(window.customElements && window.customElements.get("moq-watch"))) {
throw new Error("hang-watch custom element is unavailable"); throw new Error("moq-watch custom element is unavailable");
} }
} }
@ -162,10 +219,10 @@ function main() {
} }
try { try {
await ensureHangWatchElement(); await ensureMoqWatchElement();
} catch (e) { } catch (e) {
setHint( 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", "warn",
); );
return; return;

View file

@ -401,8 +401,8 @@ struct WsSubscribeArgs {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct WtPublishArgs { struct WtPublishArgs {
/// Relay URL (WebTransport) to connect to. /// Relay URL (WebTransport) to connect to.
/// Default points at Cloudflare's MoQ technical preview relay. /// Default points at moq.dev's public relay.
#[arg(long, default_value = "https://relay.cloudflare.mediaoverquic.com/")] #[arg(long, default_value = "https://cdn.moq.dev/anon")]
url: String, url: String,
/// Broadcast name to publish. /// Broadcast name to publish.
/// ///
@ -418,7 +418,7 @@ struct WtPublishArgs {
transcode: bool, transcode: bool,
/// Transmit fMP4 fragments directly (passthrough mode). /// Transmit fMP4 fragments directly (passthrough mode).
/// When false, the importer may reframe into CMAF fragments. /// 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, passthrough: bool,
/// Danger: disable TLS verification for the relay. /// Danger: disable TLS verification for the relay.
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]

View file

@ -9,7 +9,7 @@ Adopt Cloudflare's MoQ relay preview as the default "global" distribution layer
Concrete changes: 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`). 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 (`<hang-watch>`). 2. `every.channel` (the deployed static site) becomes a real web watcher by embedding a WebTransport-capable MoQ player component (`<moq-watch>` 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. 3. The existing WebRTC/WS bootstrap directory/relay remains temporarily for compatibility, but is treated as deprecated once MoQ/WebTransport is stable.
## Motivation ## Motivation
@ -48,11 +48,19 @@ As of February 21, 2026, browser sessions against `https://interop-relay.cloudfl
### Web player ### Web player
Use the `@kixelated/hang` web component: Use the `@moq/watch` web component:
- `<hang-watch url="..." name="..." controls>` - `<moq-watch url="..." path="...">`
This is WebTransport + WebCodecs based, and is expected to interoperate with Cloudflare's current relay preview. 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 ### Transport compatibility
Cloudflare's public relay currently implements a subset of the IETF MoQ Transport draft-07 and may not interoperate with Cloudflare's public relay currently implements a subset of the IETF MoQ Transport draft-07 and may not interoperate with

View file

@ -60,7 +60,7 @@ in
passthrough = lib.mkOption { passthrough = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = false;
description = "Whether to transmit fMP4 fragments directly (moq-mux passthrough)."; description = "Whether to transmit fMP4 fragments directly (moq-mux passthrough).";
}; };