diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4288aefc..5de0bc32 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -322,7 +322,7 @@ jobs: - name: Build moq-relay for relay e2e tests run: | set -euo pipefail - MOQ_RELAY_REF="moq-relay-v0.10.10" + MOQ_RELAY_REF="moq-relay-v0.14.12" RELAY_BIN="target/moq-relay/moq-relay" REF_FILE="target/moq-relay/.ref" if [ -f "$RELAY_BIN" ] && [ -f "$REF_FILE" ] && [ "$(cat "$REF_FILE")" = "$MOQ_RELAY_REF" ]; then diff --git a/Cargo.lock b/Cargo.lock index 29cad33a..a8d46bce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -950,6 +985,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -1494,6 +1539,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1745,6 +1799,18 @@ dependencies = [ "const-random", ] +[[package]] +name = "dns-lookup" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" +dependencies = [ + "cfg-if", + "libc", + "socket2", + "windows-sys 0.60.2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1847,6 +1913,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" +[[package]] +name = "enum-assoc" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "env-libvpx-sys" version = "5.1.3" @@ -2254,6 +2331,21 @@ dependencies = [ "libc", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2305,6 +2397,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -2461,9 +2563,9 @@ dependencies = [ [[package]] name = "hang" -version = "0.19.5" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e5b6a314aa44f88d1342e1b259ddaa78b22f723b9548d80b695407fa4fd0f1b" +checksum = "589242ee78fc98effc9187b0be2060533312fe1217c737567b0aa233fc437711" dependencies = [ "bytes", "derive_more", @@ -2856,6 +2958,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -2978,6 +3086,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3267,11 +3384,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kio" -version = "0.4.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b31d86314938a5b2c2bea39f271424a3a2432d5b01412555a02181ecbb656af3" +checksum = "7fa984c3b737520c30b414041ec63b013614abba31418585d83c5c792fb5bb7d" dependencies = [ + "loom", "smallvec", + "web-async", ] [[package]] @@ -3452,6 +3571,20 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3602,27 +3735,20 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moq-lite" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b292079fd46d82b5f9382c4027a48814243606bdc88c33f73bffc9cc007900f" -dependencies = [ - "moq-net", -] - [[package]] name = "moq-native" -version = "0.17.8" +version = "0.19.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d719256ae586b3c02dc18ba6bf50c09cc5b05e410084d73e2257b74b121ef1e8" +checksum = "6058e1958ce277bd9e631b30137498a541617882ea7d27efb9aadb0e27ebba13" dependencies = [ "clap", + "dns-lookup", "futures", "hex", "humantime", "humantime-serde", "jni", + "libc", "moq-net", "notify", "parking_lot", @@ -3643,6 +3769,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "web-transport-noq", "web-transport-quinn", "web-transport-trait", "webpki-roots", @@ -3651,9 +3778,9 @@ dependencies = [ [[package]] name = "moq-net" -version = "0.1.18" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb0a3c15c09e6a4673a3b40f7741228c2290b01a59aebd83ac629f4461ee204e" +checksum = "97c03b3d222f9271b1b11ccbb8805472391954facd0c68d1e0522df4a5dcad28" dependencies = [ "bytes", "futures", @@ -3661,9 +3788,7 @@ dependencies = [ "num_enum", "rand 0.10.2", "serde", - "serde_json", "thiserror 2.0.20", - "tokio", "tracing", "web-async", "web-transport-trait", @@ -3869,6 +3994,70 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "bytes", + "derive_more", + "enum-assoc", + "fastbloom", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "notify" version = "8.2.0" @@ -4182,6 +4371,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "opendal" version = "0.57.0" @@ -4580,6 +4775,18 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4862,12 +5069,13 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qmux" -version = "0.3.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502cf8592d72a612142907f4281e8f6459d6144218bf39f9f36ec5140dc93e73" +checksum = "af3a86f3c22d13c8b2159b795eced3fa4eee0316527be16b56626f3d063b02a1" dependencies = [ "bytes", "futures", + "libc", "rustls", "thiserror 2.0.20", "tokio", @@ -5869,6 +6077,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -6265,6 +6479,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "spin" version = "0.9.9" @@ -6419,8 +6639,8 @@ dependencies = [ "futures-util", "hang", "image", - "moq-lite", "moq-native", + "moq-net", "ogg", "opendal", "openh264", @@ -6554,8 +6774,8 @@ dependencies = [ "libloading 0.9.0", "liblzma", "mime_guess", - "moq-lite", "moq-native", + "moq-net", "multer", "ogg", "opentelemetry", @@ -7180,6 +7400,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util 0.7.19", ] [[package]] @@ -7698,6 +7919,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -8396,6 +8627,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-transport-noq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dfe08820de549204c3ab02e901c4a316616ba7daae26df4478a86f4a2d48cd" +dependencies = [ + "bytes", + "futures", + "http", + "kio", + "noq", + "rustls", + "rustls-native-certs", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", + "web-transport-proto", + "web-transport-trait", +] + [[package]] name = "web-transport-proto" version = "0.6.0" @@ -8412,13 +8664,14 @@ dependencies = [ [[package]] name = "web-transport-quinn" -version = "0.11.12" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48ada8c56f3dc126fa3c9e6b8f31f3bff5dfd24458f6664743d936ecad741d15" +checksum = "63a239cba370789ab705c39b66d223e3b8aef85e2762046fd6b59e560f156756" dependencies = [ "bytes", "futures", "http", + "kio", "quinn", "rustls", "rustls-native-certs", @@ -8432,9 +8685,9 @@ dependencies = [ [[package]] name = "web-transport-trait" -version = "0.3.7" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6394e09415ddc1e7c88d605443a3d2f333bf278f74c17020a2b6c44e97507883" +checksum = "2a916df726e858e8e6cc21e852129f59e6277d3a01bb2254c0506dcf829fdd5d" dependencies = [ "bytes", ] diff --git a/apps/skit/Cargo.toml b/apps/skit/Cargo.toml index 9edf31d1..afd9562a 100644 --- a/apps/skit/Cargo.toml +++ b/apps/skit/Cargo.toml @@ -119,7 +119,7 @@ jemalloc_pprof = { version = "0.9", features = ["symbolize"], optional = true } dhat = { version = "0.3", optional = true } # MoQ support (optional) -moq-native = { version = "0.17.4", optional = true } +moq-native = { version = "0.19.12", optional = true } async-trait = { workspace = true } # For glob pattern matching in permissions @@ -137,7 +137,7 @@ getrandom = "0.4" aws-lc-rs = "1" # For MoQ auth path matching (optional, with moq feature) -moq-lite = { version = "0.17.0", optional = true } +moq-net = { version = "0.2.13", optional = true } blake2 = "0.10.6" async-stream = "0.3.6" @@ -161,7 +161,7 @@ profiling = ["dep:pprof", "dep:tikv-jemallocator", "dep:jemalloc_pprof"] # DHAT allocation profiling - tracks allocation counts/rates (mutually exclusive with profiling) # Use this to find hot allocation sites. Output is written on graceful shutdown. dhat-heap = ["dep:dhat"] -moq = ["dep:moq-native", "dep:moq-lite"] +moq = ["dep:moq-native", "dep:moq-net"] mcp = ["dep:rmcp", "dep:http"] script = ["streamkit-nodes/script", "streamkit-engine/script"] compositor = ["streamkit-nodes/compositor", "streamkit-engine/compositor"] diff --git a/apps/skit/src/auth/moq.rs b/apps/skit/src/auth/moq.rs index 1c5263dd..34efefc0 100644 --- a/apps/skit/src/auth/moq.rs +++ b/apps/skit/src/auth/moq.rs @@ -17,7 +17,7 @@ //! - Token with `root: "/moq"` DOES match `/moq/session1` (prefix segments) use super::{AuthError, MoqClaims}; -use moq_lite::{Path, PathOwned}; +use moq_net::{Path, PathOwned}; use streamkit_core::moq_gateway::MoqAuthChecker; /// Verified MoQ auth context with permissions reduced by connection path depth. @@ -224,7 +224,7 @@ mod tests { assert!(ctx.can_subscribe("audio/stereo/left")); assert!(!ctx.can_subscribe("video")); // Note: "audiovisual" should NOT match because it's not a segment prefix - // moq_lite uses segment-based matching, not string prefix + // moq-net uses segment-based matching, not string prefix assert!(!ctx.can_subscribe("audiovisual")); } diff --git a/apps/skit/src/server/moq.rs b/apps/skit/src/server/moq.rs index 3a67def2..b7fb5100 100644 --- a/apps/skit/src/server/moq.rs +++ b/apps/skit/src/server/moq.rs @@ -94,12 +94,7 @@ pub(super) fn start_moq_webtransport_acceptor( tokio::spawn(async move { match moq_config.init() { Ok(mut server) => { - let fingerprints = server - .tls_info() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .fingerprints - .clone(); + let fingerprints = server.certificates().fingerprints(); gateway.set_fingerprints(fingerprints.clone()).await; for (i, fp) in fingerprints.iter().enumerate() { diff --git a/crates/nodes/Cargo.toml b/crates/nodes/Cargo.toml index 30555f27..ab0bf118 100644 --- a/crates/nodes/Cargo.toml +++ b/crates/nodes/Cargo.toml @@ -47,13 +47,13 @@ url = { version = "2.5.8", optional = true, features = ["serde"] } rquickjs = { version = "0.12", features = ["array-buffer", "futures", "loader", "parallel"], optional = true } wildmatch = { version = "2.6", optional = true } -moq-native = { version = "0.17.4", optional = true } -moq-lite = { version = "0.17.0", optional = true } -hang = { version = "0.19.2", optional = true } +moq-native = { version = "0.19.12", optional = true } +moq-net = { version = "0.2.13", optional = true } +hang = { version = "0.20.6", optional = true } # For local dev, debugging moq stuff # moq-native = { path = "../../moq/rs/moq-native", optional = true } -# moq-lite = { path = "../../moq/rs/moq", optional = true } +# moq-net = { path = "../../moq/rs/moq-net", optional = true } # hang = { path = "../../moq/rs/hang", optional = true } serde_json = { version = "1.0", optional = true } @@ -154,7 +154,7 @@ moq = [ "dep:schemars", "dep:url", "dep:moq-native", - "dep:moq-lite", + "dep:moq-net", "dep:hang", "dep:serde_json", ] diff --git a/crates/nodes/src/transport/moq/catalog_consumer.rs b/crates/nodes/src/transport/moq/catalog_consumer.rs index 9ce0732e..1b16a5bb 100644 --- a/crates/nodes/src/transport/moq/catalog_consumer.rs +++ b/crates/nodes/src/transport/moq/catalog_consumer.rs @@ -5,20 +5,27 @@ /// Receives catalog updates from a MoQ track. /// /// Vendored from hang 0.15.x; upstream removed this in hang 0.16. -#[derive(Clone)] pub struct CatalogConsumer { - pub track: moq_lite::TrackConsumer, - group: Option, + pub track: moq_net::track::Subscriber, + group: Option, } impl CatalogConsumer { - pub const fn new(track: moq_lite::TrackConsumer) -> Self { + pub const fn new(track: moq_net::track::Subscriber) -> Self { Self { track, group: None } } pub async fn next(&mut self) -> Result, hang::Error> { loop { + // biased: drain the held group's snapshot first so a ready + // successor group cannot displace an unread catalog frame. tokio::select! { + biased; + Some(frame) = async { self.group.as_mut()?.read_frame().await.transpose() } => { + self.group.take(); + let catalog = hang::catalog::Catalog::from_slice(&frame?.payload)?; + return Ok(Some(catalog)); + }, res = self.track.next_group() => { match res? { Some(group) => { @@ -26,19 +33,14 @@ impl CatalogConsumer { } None => return Ok(None), } - }, - Some(frame) = async { self.group.as_mut()?.read_frame().await.transpose() } => { - self.group.take(); - let catalog = hang::catalog::Catalog::from_slice(&frame?)?; - return Ok(Some(catalog)); } } } } } -impl From for CatalogConsumer { - fn from(inner: moq_lite::TrackConsumer) -> Self { +impl From for CatalogConsumer { + fn from(inner: moq_net::track::Subscriber) -> Self { Self::new(inner) } } @@ -48,34 +50,40 @@ impl From for CatalogConsumer { mod tests { use super::*; - fn make_track_pair() -> (moq_lite::TrackProducer, moq_lite::TrackConsumer) { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("test-broadcast").expect("create_broadcast"); - let track = moq_lite::Track { name: ".catalog".to_string(), priority: 0 }; - let producer = broadcast.create_track(track.clone()).expect("create_track"); + async fn make_track_pair() -> ( + moq_net::origin::Producer, + moq_net::broadcast::Producer, + moq_net::track::Producer, + moq_net::track::Subscriber, + ) { + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .expect("create_broadcast"); + let producer = super::super::create_catalog_track(&mut broadcast).expect("create_track"); let consumer = origin.consume(); - let bc = consumer.get_broadcast("test-broadcast").expect("get_broadcast"); - let consumer_track = bc.subscribe_track(&track).expect("subscribe_track"); - (producer, consumer_track) + let bc = consumer.announced_broadcast("test-broadcast").await.expect("announced_broadcast"); + let consumer_track = super::super::subscribe_catalog(&bc).await.expect("subscribe"); + (origin, broadcast, producer, consumer_track) } #[tokio::test] async fn new_initialises_empty_state() { - let (_producer, consumer) = make_track_pair(); + let (_origin, _broadcast, _producer, consumer) = make_track_pair().await; let cc = CatalogConsumer::new(consumer); assert!(cc.group.is_none()); } #[tokio::test] async fn from_trait_constructs_consumer() { - let (_producer, consumer) = make_track_pair(); + let (_origin, _broadcast, _producer, consumer) = make_track_pair().await; let cc = CatalogConsumer::from(consumer); assert!(cc.group.is_none()); } #[tokio::test] async fn next_returns_none_when_track_finished() { - let (mut producer, consumer) = make_track_pair(); + let (_origin, _broadcast, mut producer, consumer) = make_track_pair().await; let mut cc = CatalogConsumer::new(consumer); producer.finish().expect("finish"); let result = cc.next().await.expect("next should not error"); @@ -84,25 +92,79 @@ mod tests { #[tokio::test] async fn next_returns_catalog_from_written_frame() { - let (mut producer, consumer) = make_track_pair(); + let (_origin, _broadcast, mut producer, consumer) = make_track_pair().await; let mut cc = CatalogConsumer::new(consumer); let catalog = hang::catalog::Catalog::default(); - let payload = serde_json::to_vec(&catalog).expect("serialize catalog"); - - let mut group = producer.append_group().expect("append_group"); - group.write_frame(bytes::Bytes::from(payload)).expect("write_frame"); - group.finish().expect("finish group"); + write_catalog(&mut producer, &catalog); let result = cc.next().await.expect("next should not error"); assert!(result.is_some(), "should have received a catalog"); } - fn write_catalog(producer: &mut moq_lite::TrackProducer, catalog: &hang::catalog::Catalog) { - let payload = serde_json::to_vec(catalog).expect("serialize catalog"); - let mut group = producer.append_group().expect("append_group"); - group.write_frame(bytes::Bytes::from(payload)).expect("write_frame"); - group.finish().expect("finish group"); + fn write_catalog(producer: &mut moq_net::track::Producer, catalog: &hang::catalog::Catalog) { + let payload = catalog.to_vec().expect("serialize catalog"); + super::super::write_catalog_json(producer, payload).expect("write catalog"); + } + + /// Dynamic track add/remove republishes the catalog repeatedly; every + /// snapshot lands in its own group, so a consumer keeping pace observes + /// each successive update. + #[tokio::test] + async fn next_observes_each_republished_snapshot() { + let (_origin, _broadcast, mut producer, consumer) = make_track_pair().await; + let mut cc = CatalogConsumer::new(consumer); + + for count in 1..=3usize { + let mut catalog = hang::catalog::Catalog::default(); + for i in 0..count { + catalog.audio.renditions.insert( + format!("audio/{i}"), + hang::catalog::AudioConfig::new( + super::super::constants::catalog_audio_codec( + streamkit_core::types::AudioCodec::Opus, + ), + 48000, + 2, + ), + ); + } + write_catalog(&mut producer, &catalog); + + let result = cc.next().await.expect("next").expect("catalog snapshot"); + assert_eq!(result.audio.renditions.len(), count, "republish {count} not observed"); + } + } + + /// Snapshots written back-to-back mean a successor group is already ready + /// while the held group's frame is still unread; the biased select must + /// drain the held snapshot instead of displacing it. + #[tokio::test] + async fn next_drains_held_group_before_switching_to_ready_successor() { + let (_origin, _broadcast, mut producer, consumer) = make_track_pair().await; + let mut cc = CatalogConsumer::new(consumer); + + for count in 1..=3usize { + let mut catalog = hang::catalog::Catalog::default(); + for i in 0..count { + catalog.audio.renditions.insert( + format!("audio/{i}"), + hang::catalog::AudioConfig::new( + super::super::constants::catalog_audio_codec( + streamkit_core::types::AudioCodec::Opus, + ), + 48000, + 2, + ), + ); + } + write_catalog(&mut producer, &catalog); + } + + for count in 1..=3usize { + let result = cc.next().await.expect("next").expect("catalog snapshot"); + assert_eq!(result.audio.renditions.len(), count, "snapshot {count} skipped"); + } } /// `MoqPullNode::run_connection` polls `next()` as one arm of a `select!` @@ -112,7 +174,7 @@ mod tests { /// afterwards is still observed (no buffered position is lost on cancel). #[tokio::test] async fn next_survives_repeated_cancellation() { - let (mut producer, consumer) = make_track_pair(); + let (_origin, _broadcast, mut producer, consumer) = make_track_pair().await; let mut cc = CatalogConsumer::new(consumer); write_catalog(&mut producer, &hang::catalog::Catalog::default()); diff --git a/crates/nodes/src/transport/moq/mod.rs b/crates/nodes/src/transport/moq/mod.rs index 492bcf51..73b783c3 100644 --- a/crates/nodes/src/transport/moq/mod.rs +++ b/crates/nodes/src/transport/moq/mod.rs @@ -28,8 +28,8 @@ static SHARED_INSECURE_CLIENT: OnceLock> = On /// Returns a cached `moq_native::Client` with TLS verification disabled. /// -/// In moq-native 0.12, publish/consume origins are set on the `Client` via builder methods -/// (`with_publish` / `with_consume`) before calling `connect()`. The cached client has +/// Publish/subscribe origins are set on the `Client` via builder methods +/// (`with_publisher` / `with_subscriber`) before calling `connect()`. The cached client has /// neither set, so callers must clone and configure it for each connection. fn shared_insecure_client() -> Result { let client = SHARED_INSECURE_CLIENT.get_or_init(|| { @@ -46,6 +46,63 @@ fn shared_insecure_client() -> Result { } } +/// Name + subscription priority for a track subscription. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct TrackRef { + pub name: String, + pub priority: u8, +} + +pub(super) async fn subscribe_track( + broadcast: &moq_net::broadcast::Consumer, + name: &str, + priority: u8, +) -> Result { + broadcast + .track(name)? + .subscribe(moq_net::track::Subscription::default().with_priority(priority)) + .await +} + +pub(super) async fn subscribe_catalog( + broadcast: &moq_net::broadcast::Consumer, +) -> Result { + broadcast + .track(hang::catalog::Catalog::DEFAULT_NAME)? + .subscribe(hang::catalog::Catalog::default_subscription()) + .await +} + +/// Create a media track carrying hang legacy-container frames. +/// +/// `hang::container::track_info()` pins the microsecond timescale the legacy +/// container encodes with; `Info::default()` (milliseconds) would quantize the +/// net-level frame timestamps. +pub(super) fn create_media_track( + broadcast: &mut moq_net::broadcast::Producer, + name: &str, + priority: u8, +) -> Result { + broadcast.create_track(name, hang::container::track_info().with_priority(priority)) +} + +pub(super) fn create_catalog_track( + broadcast: &mut moq_net::broadcast::Producer, +) -> Result { + broadcast.create_track( + hang::catalog::Catalog::DEFAULT_NAME, + hang::catalog::Catalog::default_track_info(), + ) +} + +/// Publish one catalog snapshot as its own single-frame group. +pub(super) fn write_catalog_json( + producer: &mut moq_net::track::Producer, + json: impl Into, +) -> Result<(), moq_net::Error> { + producer.write_frame(moq_net::Timestamp::now(), json.into()) +} + pub(super) fn redact_url_str_for_logs(raw: &str) -> String { raw.parse::().map_or_else( |_| raw.split(['?', '#']).next().unwrap_or(raw).to_string(), diff --git a/crates/nodes/src/transport/moq/ordered_producer.rs b/crates/nodes/src/transport/moq/ordered_producer.rs index 1baccbc3..51bb2c3b 100644 --- a/crates/nodes/src/transport/moq/ordered_producer.rs +++ b/crates/nodes/src/transport/moq/ordered_producer.rs @@ -15,7 +15,7 @@ pub enum VideoWrite { DroppedLeadingDelta, } -/// Wraps a `moq_lite::TrackProducer` with MoQ group boundary management. +/// Wraps a `moq_net::track::Producer` with MoQ group boundary management. /// /// Groups can be managed explicitly via [`keyframe()`](Self::keyframe) or automatically /// via [`with_max_group_duration()`](Self::with_max_group_duration). @@ -26,8 +26,8 @@ pub enum VideoWrite { #[derive(Clone)] #[allow(dead_code)] // vendored API surface retained for parity with upstream hang pub struct OrderedProducer { - pub track: moq_lite::TrackProducer, - group: Option, + pub track: moq_net::track::Producer, + group: Option, group_start: Option, group_frames: u64, max_group_duration: Option, @@ -35,7 +35,7 @@ pub struct OrderedProducer { #[allow(dead_code)] // vendored API surface retained for parity with upstream hang impl OrderedProducer { - pub const fn new(inner: moq_lite::TrackProducer) -> Self { + pub const fn new(inner: moq_net::track::Producer) -> Self { Self { track: inner, group: None, @@ -122,7 +122,7 @@ impl OrderedProducer { #[allow(clippy::unwrap_used)] // is_none branch above guarantees Some let mut group = self.group.take().unwrap(); - frame.encode(&mut group)?; + frame.write_to(&mut group)?; self.group.replace(group); self.group_frames += 1; @@ -155,14 +155,14 @@ impl OrderedProducer { } } -impl From for OrderedProducer { - fn from(inner: moq_lite::TrackProducer) -> Self { +impl From for OrderedProducer { + fn from(inner: moq_net::track::Producer) -> Self { Self::new(inner) } } impl std::ops::Deref for OrderedProducer { - type Target = moq_lite::TrackProducer; + type Target = moq_net::track::Producer; fn deref(&self) -> &Self::Target { &self.track @@ -174,13 +174,14 @@ impl std::ops::Deref for OrderedProducer { mod tests { use super::*; - fn make_track_producer() -> moq_lite::TrackProducer { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = - origin.create_broadcast("test-broadcast").expect("create_broadcast should succeed"); - broadcast - .create_track(moq_lite::Track { name: "test/track".to_string(), priority: 0 }) - .expect("create_track should succeed") + fn make_track_producer() -> (moq_net::origin::Producer, moq_net::track::Producer) { + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .expect("create_broadcast should succeed"); + let track = super::super::create_media_track(&mut broadcast, "test/track", 0) + .expect("create_track should succeed"); + (origin, track) } fn ts(micros: u64) -> Timestamp { @@ -193,7 +194,7 @@ mod tests { #[tokio::test] async fn new_initialises_empty_state() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let op = OrderedProducer::new(tp); assert!(op.group.is_none()); assert!(op.group_start.is_none()); @@ -203,28 +204,28 @@ mod tests { #[tokio::test] async fn with_max_group_duration_sets_limit() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let op = OrderedProducer::new(tp).with_max_group_duration(ts(1_000_000)); assert_eq!(op.max_group_duration, Some(ts(1_000_000))); } #[tokio::test] async fn from_trait_constructs_producer() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let op = OrderedProducer::from(tp); assert!(op.group.is_none()); } #[tokio::test] async fn deref_exposes_inner_track() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let op = OrderedProducer::new(tp); - let _: &moq_lite::TrackProducer = &op; + let _: &moq_net::track::Producer = &op; } #[tokio::test] async fn write_creates_group_on_first_frame() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); assert!(op.group.is_none()); op.write(&make_frame(0)).expect("write should succeed"); @@ -235,7 +236,7 @@ mod tests { #[tokio::test] async fn write_multiple_frames_increments_counter() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); op.write(&make_frame(0)).unwrap(); op.write(&make_frame(1000)).unwrap(); @@ -245,7 +246,7 @@ mod tests { #[tokio::test] async fn keyframe_on_empty_is_noop() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); op.keyframe().expect("keyframe on empty should succeed"); assert!(op.group.is_none()); @@ -253,7 +254,7 @@ mod tests { #[tokio::test] async fn keyframe_closes_active_group() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); op.write(&make_frame(0)).unwrap(); assert!(op.group.is_some()); @@ -263,7 +264,7 @@ mod tests { #[tokio::test] async fn keyframe_then_write_starts_new_group() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); op.write(&make_frame(0)).unwrap(); assert_eq!(op.group_start, Some(ts(0))); @@ -275,7 +276,7 @@ mod tests { #[tokio::test] async fn max_duration_auto_closes_old_group_and_starts_new() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp).with_max_group_duration(ts(10_000)); op.write(&make_frame(0)).unwrap(); @@ -290,7 +291,7 @@ mod tests { #[tokio::test] async fn finish_closes_group_and_track() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); op.write(&make_frame(0)).unwrap(); op.finish().expect("finish should succeed"); @@ -299,14 +300,14 @@ mod tests { #[tokio::test] async fn finish_on_empty_finishes_track() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); op.finish().expect("finish on empty should succeed"); } #[tokio::test] async fn write_video_drops_leading_deltas_until_first_keyframe() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); // Deltas before any keyframe must not open a group: a group that @@ -322,7 +323,7 @@ mod tests { #[tokio::test] async fn write_video_opens_group_on_keyframe_and_appends_deltas() { - let tp = make_track_producer(); + let (_origin, tp) = make_track_producer(); let mut op = OrderedProducer::new(tp); // First keyframe opens group 0. diff --git a/crates/nodes/src/transport/moq/peer/config.rs b/crates/nodes/src/transport/moq/peer/config.rs index 5913914c..bc84ae19 100644 --- a/crates/nodes/src/transport/moq/peer/config.rs +++ b/crates/nodes/src/transport/moq/peer/config.rs @@ -64,7 +64,7 @@ pub(super) enum TrackExit { /// Track closed cleanly, stream ended, or shutdown was requested. /// The caller should not retry. Finished, - /// The publisher cancelled the subscription (`moq_lite::Error::Cancel`). + /// The publisher cancelled the subscription (`moq_net::Error::Cancel`). /// /// This typically happens when the browser's `@moq/hang` publish pipeline /// transiently tears down the track producer — e.g. when `camera.source` @@ -121,7 +121,7 @@ pub(super) struct BidirectionalTaskConfig { } pub(super) struct PublisherReceiveLoopWithSlotConfig { - pub subscribe: moq_lite::OriginConsumer, + pub subscribe: moq_net::origin::Consumer, pub broadcast_name: String, pub publisher_slot: Arc, pub publisher_events: mpsc::UnboundedSender, diff --git a/crates/nodes/src/transport/moq/peer/mod.rs b/crates/nodes/src/transport/moq/peer/mod.rs index 112a8c3d..1b26e813 100644 --- a/crates/nodes/src/transport/moq/peer/mod.rs +++ b/crates/nodes/src/transport/moq/peer/mod.rs @@ -23,7 +23,6 @@ use crate::transport::moq::discovered::{ }; use crate::video::{AV1_CONTENT_TYPE, H264_CONTENT_TYPE, VP9_CONTENT_TYPE}; use async_trait::async_trait; -use bytes::Buf; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -939,12 +938,12 @@ impl MoqPeerNode { .send(streamkit_core::moq_gateway::MoqConnectionResult::Accepted); // Create origin for receiving from client - let client_publish_origin = moq_lite::Origin::random().produce(); + let client_publish_origin = moq_net::Origin::random().produce(); let receive_origin = client_publish_origin.consume(); // Accept MoQ session (publisher only sends, no server publish needed) let session = request - .with_consume(client_publish_origin) + .with_subscriber(client_publish_origin) .ok() .await .map_err(|e| StreamKitError::Runtime(format!("Failed to accept session: {e}")))?; @@ -992,15 +991,15 @@ impl MoqPeerNode { .send(streamkit_core::moq_gateway::MoqConnectionResult::Accepted); // Create origins for full bidirectional MoQ - let server_publish_origin = moq_lite::Origin::random().produce(); + let server_publish_origin = moq_net::Origin::random().produce(); let send_origin = server_publish_origin.clone(); - let client_publish_origin = moq_lite::Origin::random().produce(); + let client_publish_origin = moq_net::Origin::random().produce(); let receive_origin = client_publish_origin.consume(); let session = request - .with_publish(server_publish_origin.consume()) - .with_consume(client_publish_origin) + .with_publisher(server_publish_origin.consume()) + .with_subscriber(client_publish_origin) .ok() .await .map_err(|e| StreamKitError::Runtime(format!("Failed to accept session: {e}")))?; @@ -1018,7 +1017,7 @@ impl MoqPeerNode { // Create per-broadcast OriginConsumer clones BEFORE moving // receive_origin into the primary loop. - let extra_consumers: Vec<(String, moq_lite::OriginConsumer)> = + let extra_consumers: Vec<(String, moq_net::origin::Consumer)> = if config.input_broadcasts.len() > 1 { config.input_broadcasts[1..] .iter() @@ -1202,7 +1201,7 @@ impl MoqPeerNode { /// Publisher receive loop - receives audio/video from client and sends to pipeline async fn publisher_receive_loop( - subscribe: moq_lite::OriginConsumer, + subscribe: moq_net::origin::Consumer, broadcast_name: String, shutdown_rx: &mut broadcast::Receiver<()>, routing: TrackRouting, @@ -1223,21 +1222,22 @@ impl MoqPeerNode { /// Wait for the publisher to announce the expected broadcast async fn wait_for_broadcast_announcement( - mut subscribe: moq_lite::OriginConsumer, + subscribe: moq_net::origin::Consumer, broadcast_name: &str, shutdown_rx: &mut broadcast::Receiver<()>, - ) -> Result, StreamKitError> { + ) -> Result, StreamKitError> { + let mut announced = subscribe.announced(); loop { tokio::select! { - announcement = subscribe.announced() => { + announcement = announced.next() => { match announcement { - Some((path, Some(consumer))) => { + Some(moq_net::announce::Update { path, broadcast: Some(consumer) }) => { tracing::info!("Publisher announced broadcast: {}", path.as_str()); if path.as_str() == broadcast_name { return Ok(Some(consumer)); } } - Some((path, None)) => { + Some(moq_net::announce::Update { path, broadcast: None }) => { tracing::info!("Publisher unannounced broadcast: {}", path.as_str()); } None => { @@ -1309,7 +1309,7 @@ impl MoqPeerNode { /// Supports N tracks per broadcast — each rendition in the catalog gets its /// own track processor task, keyed by track name in a `HashMap`. async fn watch_catalog_and_process( - broadcast_consumer: &moq_lite::BroadcastConsumer, + broadcast_consumer: &moq_net::broadcast::Consumer, shutdown_rx: &mut broadcast::Receiver<()>, routing: &TrackRouting, ) -> Result<(), StreamKitError> { @@ -1333,7 +1333,7 @@ impl MoqPeerNode { /// would cause track-name collisions (e.g. two `"video/hd"` entries). fn subscribe_catalog_tracks( catalog: &hang::catalog::Catalog, - broadcast_consumer: &moq_lite::BroadcastConsumer, + broadcast_consumer: &moq_net::broadcast::Consumer, shutdown_rx: &broadcast::Receiver<()>, routing: &TrackRouting, pin_prefix: Option<&str>, @@ -1426,15 +1426,15 @@ impl MoqPeerNode { /// `{name}/{track_name}` (e.g. `screen-input/video/hd`). When `None`, /// track names are used directly (e.g. `video/hd`). async fn watch_catalog_and_process_inner( - broadcast_consumer: &moq_lite::BroadcastConsumer, + broadcast_consumer: &moq_net::broadcast::Consumer, shutdown_rx: &mut broadcast::Receiver<()>, routing: &TrackRouting, pin_prefix: Option<&str>, ) -> Result<(), StreamKitError> { let catalog_track = - broadcast_consumer.subscribe_track(&hang::catalog::Catalog::default_track()).map_err( - |e| StreamKitError::Runtime(format!("Failed to subscribe to catalog track: {e}")), - )?; + crate::transport::moq::subscribe_catalog(broadcast_consumer).await.map_err(|e| { + StreamKitError::Runtime(format!("Failed to subscribe to catalog track: {e}")) + })?; let mut catalog_consumer = crate::transport::moq::catalog_consumer::CatalogConsumer::new(catalog_track); @@ -1515,7 +1515,7 @@ impl MoqPeerNode { /// from `routing.video_codec`, the local fallback) — it labels the frame /// `content_type` for video tracks. fn spawn_track_processor_with_pin( - broadcast_consumer: &moq_lite::BroadcastConsumer, + broadcast_consumer: &moq_net::broadcast::Consumer, track_name: &str, is_video: bool, shutdown_rx: &broadcast::Receiver<()>, @@ -1527,7 +1527,7 @@ impl MoqPeerNode { const RESUBSCRIBE_INITIAL_BACKOFF: Duration = Duration::from_millis(100); let broadcast = broadcast_consumer.clone(); - let track = moq_lite::Track { name: track_name.to_string(), priority: 2 }; + let track = crate::transport::moq::TrackRef { name: track_name.to_string(), priority: 2 }; let sender = routing.output_sender.clone(); let mut task_shutdown = shutdown_rx.resubscribe(); let stats = routing.stats_delta_tx.clone(); @@ -1539,12 +1539,15 @@ impl MoqPeerNode { let mut attempt: u32 = 0; loop { - let consumer = broadcast.subscribe_track(&track).map_err(|e| { - StreamKitError::Runtime(format!( - "Failed to subscribe to track '{}': {e}", - track.name - )) - })?; + let consumer = + crate::transport::moq::subscribe_track(&broadcast, &track.name, track.priority) + .await + .map_err(|e| { + StreamKitError::Runtime(format!( + "Failed to subscribe to track '{}': {e}", + track.name + )) + })?; let exit = Self::process_publisher_frames( consumer, sender.clone(), @@ -1662,7 +1665,7 @@ impl MoqPeerNode { // Dynamic output routing adds parameters beyond the static-pin version. #[allow(clippy::too_many_arguments)] async fn process_publisher_frames( - mut track_consumer: moq_lite::TrackConsumer, + mut track_consumer: moq_net::track::Subscriber, mut output_sender: streamkit_core::OutputSender, output_pin: &str, is_video: bool, @@ -1673,7 +1676,7 @@ impl MoqPeerNode { ) -> TrackExit { let mut frame_count = 0u64; let mut last_log = std::time::Instant::now(); - let mut current_group: Option = None; + let mut current_group: Option = None; // Base timestamp for normalization — the first MoQ timestamp on this // track is subtracted from all subsequent timestamps so that every // track's timeline starts near 0. This prevents overflow in @@ -1694,7 +1697,7 @@ impl MoqPeerNode { is_first_in_group = true; }, Ok(None) => return TrackExit::Finished, // Stream ended or shutdown - Err(moq_lite::Error::Cancel) => return TrackExit::Cancelled, + Err(moq_net::Error::Cancel) => return TrackExit::Cancelled, Err(e) => { return TrackExit::Error(StreamKitError::Runtime(format!( "Error getting group: {e}" @@ -1735,16 +1738,16 @@ impl MoqPeerNode { /// Get the next group from the track consumer. /// - /// Surfaces the raw [`moq_lite::Error`] so the caller can distinguish - /// [`moq_lite::Error::Cancel`] (publisher dropped the track producer — + /// Surfaces the raw [`moq_net::Error`] so the caller can distinguish + /// [`moq_net::Error::Cancel`] (publisher dropped the track producer — /// retryable) from other failures. The `tracing::warn!` here will still /// fire for cancellations; that's intentional since they're unexpected /// in steady state even if we recover. async fn get_next_group( - track_consumer: &mut moq_lite::TrackConsumer, + track_consumer: &mut moq_net::track::Subscriber, shutdown_rx: &mut broadcast::Receiver<()>, output_pin: &str, - ) -> Result, moq_lite::Error> { + ) -> Result, moq_net::Error> { tokio::select! { biased; group_result = track_consumer.next_group() => { @@ -1856,7 +1859,7 @@ impl MoqPeerNode { /// group, which in the hang protocol corresponds to a keyframe boundary. #[allow(clippy::too_many_arguments, clippy::cognitive_complexity)] async fn process_frame_from_group( - group: &mut moq_lite::GroupConsumer, + group: &mut moq_net::group::Consumer, output_sender: &mut streamkit_core::OutputSender, output_pin: &str, is_video: bool, @@ -1874,7 +1877,7 @@ impl MoqPeerNode { biased; frame_result = group.read_frame() => { match frame_result { - Ok(Some(mut payload)) => { + Ok(Some(net_frame)) => { *frame_count += 1; if last_log.elapsed() > Duration::from_secs(1) { @@ -1883,10 +1886,11 @@ impl MoqPeerNode { *last_log = std::time::Instant::now(); } - // Decode the hang protocol timestamp (varint-encoded microseconds) - // and propagate it as PacketMetadata so downstream nodes have timing. - let timestamp = match hang::container::Timestamp::decode(&mut payload) { - Ok(ts) => ts, + // Decode the hang container frame (varint-encoded microsecond + // timestamp prefix + payload) and propagate the timestamp as + // PacketMetadata so downstream nodes have timing. + let frame = match hang::container::Frame::decode(net_frame.payload) { + Ok(frame) => frame, Err(e) => { tracing::warn!("Failed to decode frame timestamp: {e}"); let _ = stats_delta_tx @@ -1895,7 +1899,7 @@ impl MoqPeerNode { }, }; #[allow(clippy::cast_possible_truncation)] // MoQ timestamps fit in u64 - let raw_timestamp_us = timestamp.as_micros() as u64; + let raw_timestamp_us = frame.timestamp.as_micros() as u64; // Normalize: subtract the first timestamp so the track // starts near 0. This avoids WebM timecode overflow @@ -1909,11 +1913,11 @@ impl MoqPeerNode { "MoQ first frame: video={is_video} raw_ts={raw_timestamp_us}us \ normalized_ts={timestamp_us}us base={base}us \ keyframe={is_keyframe} size={}B", - payload.remaining() + frame.payload.len() ); } - let data = payload.copy_to_bytes(payload.remaining()); + let data = frame.payload; let content_type = if is_video { Some(std::borrow::Cow::Borrowed(match video_codec { VideoCodec::Av1 => AV1_CONTENT_TYPE, @@ -1983,12 +1987,12 @@ impl MoqPeerNode { .send(streamkit_core::moq_gateway::MoqConnectionResult::Accepted); // Create origin for sending to client - let server_publish_origin = moq_lite::Origin::random().produce(); + let server_publish_origin = moq_net::Origin::random().produce(); let send_origin = server_publish_origin.clone(); // Accept MoQ session (subscriber only receives, no client publish needed) let session = request - .with_publish(server_publish_origin.consume()) + .with_publisher(server_publish_origin.consume()) .ok() .await .map_err(|e| StreamKitError::Runtime(format!("Failed to accept session: {e}")))?; @@ -2115,7 +2119,7 @@ impl MoqPeerNode { // media_state_rx adds a necessary parameter for dynamic media-type resolution. #[allow(clippy::too_many_arguments)] async fn subscriber_send_loop( - publish: moq_lite::OriginProducer, + publish: moq_net::origin::Producer, broadcast_name: String, node_id: String, broadcast_rx: broadcast::Receiver, @@ -2169,29 +2173,37 @@ impl MoqPeerNode { /// Setup broadcast, media tracks, and catalog for subscriber fn setup_subscriber_broadcast( - publish: &moq_lite::OriginProducer, + publish: &moq_net::origin::Producer, broadcast_name: &str, media: &SubscriberMediaConfig, ) -> Result< ( - moq_lite::BroadcastProducer, + moq_net::broadcast::Producer, Option, Option, - moq_lite::TrackProducer, + moq_net::track::Producer, ), StreamKitError, > { // Create broadcast - let mut broadcast_producer = publish.create_broadcast(broadcast_name).ok_or_else(|| { - StreamKitError::Runtime(format!("Failed to create broadcast '{broadcast_name}'")) - })?; + let mut broadcast_producer = publish + .create_broadcast(broadcast_name, moq_net::broadcast::Route::announced()) + .map_err(|e| { + StreamKitError::Runtime(format!( + "Failed to create broadcast '{broadcast_name}': {e}" + )) + })?; // Create audio track (if audio input connected) let audio_track = if media.has_audio { - let track = moq_lite::Track { name: "audio/data".to_string(), priority: 80 }; - let producer = broadcast_producer.create_track(track.clone()).map_err(|e| { - StreamKitError::Runtime(format!("Failed to create audio track: {e}")) - })?; + let track = + crate::transport::moq::TrackRef { name: "audio/data".to_string(), priority: 80 }; + let producer = crate::transport::moq::create_media_track( + &mut broadcast_producer, + &track.name, + track.priority, + ) + .map_err(|e| StreamKitError::Runtime(format!("Failed to create audio track: {e}")))?; Some((track, crate::transport::moq::ordered_producer::OrderedProducer::from(producer))) } else { None @@ -2199,10 +2211,14 @@ impl MoqPeerNode { // Create video track (if video input connected) let video_track = if media.has_video { - let track = moq_lite::Track { name: "video/data".to_string(), priority: 60 }; - let producer = broadcast_producer.create_track(track.clone()).map_err(|e| { - StreamKitError::Runtime(format!("Failed to create video track: {e}")) - })?; + let track = + crate::transport::moq::TrackRef { name: "video/data".to_string(), priority: 60 }; + let producer = crate::transport::moq::create_media_track( + &mut broadcast_producer, + &track.name, + track.priority, + ) + .map_err(|e| StreamKitError::Runtime(format!("Failed to create video track: {e}")))?; Some((track, crate::transport::moq::ordered_producer::OrderedProducer::from(producer))) } else { None @@ -2230,14 +2246,14 @@ impl MoqPeerNode { /// Create and publish the catalog with audio and/or video track info #[allow(clippy::too_many_arguments)] fn create_and_publish_catalog( - broadcast_producer: &mut moq_lite::BroadcastProducer, - audio_track: Option<&moq_lite::Track>, - video_track: Option<&moq_lite::Track>, + broadcast_producer: &mut moq_net::broadcast::Producer, + audio_track: Option<&crate::transport::moq::TrackRef>, + video_track: Option<&crate::transport::moq::TrackRef>, video_width: u32, video_height: u32, video_codec: VideoCodec, audio_codec: AudioCodec, - ) -> Result { + ) -> Result { let mut audio_renditions = std::collections::BTreeMap::new(); if let Some(audio_track) = audio_track { audio_renditions.insert(audio_track.name.clone(), { @@ -2270,24 +2286,16 @@ impl MoqPeerNode { }); } - let catalog = hang::catalog::Catalog { - audio: hang::catalog::Audio { renditions: audio_renditions }, - video: hang::catalog::Video { - renditions: video_renditions, - display: None, - rotation: None, - flip: None, - }, - }; + let mut catalog = hang::catalog::Catalog::default(); + catalog.audio.renditions = audio_renditions; + catalog.video.renditions = video_renditions; - let mut catalog_producer = broadcast_producer - .create_track(hang::catalog::Catalog::default_track()) + let mut catalog_producer = crate::transport::moq::create_catalog_track(broadcast_producer) .map_err(|e| StreamKitError::Runtime(format!("Failed to create catalog track: {e}")))?; let catalog_json = catalog - .to_string() + .to_json() .map_err(|e| StreamKitError::Runtime(format!("Failed to serialize catalog: {e}")))?; - catalog_producer - .write_frame(catalog_json.into_bytes()) + crate::transport::moq::write_catalog_json(&mut catalog_producer, catalog_json) .map_err(|e| StreamKitError::Runtime(format!("Failed to write catalog frame: {e}")))?; Ok(catalog_producer) @@ -2805,21 +2813,21 @@ mod tests { } // These tests drive the publisher and subscriber loops in-process using - // `moq_lite::Origin::random().produce()` producer/consumer pairs — no + // `moq_net::Origin::random().produce()` producer/consumer pairs — no // network or relay required. Frames carry a hang varint timestamp prefix, // matching the wire format the loops decode. - fn ts_payload(timestamp_us: u64, data: &[u8]) -> bytes::Bytes { - let mut buf = bytes::BytesMut::new(); - hang::container::Timestamp::from_micros(timestamp_us).unwrap().encode(&mut buf).unwrap(); - buf.extend_from_slice(data); - buf.freeze() + fn container_frame(timestamp_us: u64, data: &[u8]) -> hang::container::Frame { + hang::container::Frame { + timestamp: hang::container::Timestamp::from_micros(timestamp_us).unwrap(), + payload: bytes::Bytes::copy_from_slice(data), + } } - fn write_group(producer: &mut moq_lite::TrackProducer, frames: &[(u64, &[u8])]) { + fn write_group(producer: &mut moq_net::track::Producer, frames: &[(u64, &[u8])]) { let mut group = producer.append_group().unwrap(); for (ts, data) in frames { - group.write_frame(ts_payload(*ts, data)).unwrap(); + container_frame(*ts, data).write_to(&mut group).unwrap(); } group.finish().unwrap(); } @@ -2928,7 +2936,7 @@ mod tests { /// Read the next MoQ group off `sub` and return its frame payloads in /// order, so tests can assert wire-level group boundaries (a group must /// open on a keyframe) rather than internal producer state. - async fn read_group_payloads(sub: &mut moq_lite::TrackConsumer) -> Vec> { + async fn read_group_payloads(sub: &mut moq_net::track::Subscriber) -> Vec> { let mut group = tokio::time::timeout(Duration::from_secs(2), sub.next_group()) .await .expect("next_group should not hang") @@ -2940,46 +2948,52 @@ mod tests { .expect("read_frame should not hang") .expect("read_frame should succeed") { - payloads.push(hang::container::Frame::decode(frame).unwrap().payload.to_vec()); + payloads.push(hang::container::Frame::decode(frame.payload).unwrap().payload.to_vec()); } payloads } struct SingleTrackFixture { - _origin: moq_lite::OriginProducer, - _broadcast: moq_lite::BroadcastProducer, - producer: moq_lite::TrackProducer, - consumer: moq_lite::TrackConsumer, + _origin: moq_net::origin::Producer, + _broadcast: moq_net::broadcast::Producer, + producer: moq_net::track::Producer, + consumer: moq_net::track::Subscriber, } - fn make_single_track(track_name: &str) -> SingleTrackFixture { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("input").unwrap(); - let track = moq_lite::Track { name: track_name.to_string(), priority: 2 }; - let producer = broadcast.create_track(track.clone()).unwrap(); - let consumer = - origin.consume().get_broadcast("input").unwrap().subscribe_track(&track).unwrap(); + async fn make_single_track(track_name: &str) -> SingleTrackFixture { + let origin = moq_net::Origin::random().produce(); + let mut broadcast = + origin.create_broadcast("input", moq_net::broadcast::Route::announced()).unwrap(); + let producer = + crate::transport::moq::create_media_track(&mut broadcast, track_name, 2).unwrap(); + let bc = origin.consume().announced_broadcast("input").await.unwrap(); + let consumer = crate::transport::moq::subscribe_track(&bc, track_name, 2).await.unwrap(); SingleTrackFixture { _origin: origin, _broadcast: broadcast, producer, consumer } } struct PublisherBroadcastFixture { - _origin: moq_lite::OriginProducer, - _broadcast: moq_lite::BroadcastProducer, - _catalog: moq_lite::TrackProducer, - audio: moq_lite::TrackProducer, - video: moq_lite::TrackProducer, - consumer: moq_lite::OriginConsumer, + _origin: moq_net::origin::Producer, + _broadcast: moq_net::broadcast::Producer, + _catalog: moq_net::track::Producer, + audio: moq_net::track::Producer, + video: moq_net::track::Producer, + consumer: moq_net::origin::Consumer, } fn make_publisher_broadcast() -> PublisherBroadcastFixture { - let origin = moq_lite::Origin::random().produce(); + let origin = moq_net::Origin::random().produce(); let consumer = origin.consume(); - let mut broadcast = origin.create_broadcast("input").unwrap(); - - let audio_track = moq_lite::Track { name: "audio/data".to_string(), priority: 2 }; - let video_track = moq_lite::Track { name: "video/data".to_string(), priority: 2 }; - let audio = broadcast.create_track(audio_track.clone()).unwrap(); - let video = broadcast.create_track(video_track.clone()).unwrap(); + let mut broadcast = + origin.create_broadcast("input", moq_net::broadcast::Route::announced()).unwrap(); + + let audio_track = + crate::transport::moq::TrackRef { name: "audio/data".to_string(), priority: 2 }; + let video_track = + crate::transport::moq::TrackRef { name: "video/data".to_string(), priority: 2 }; + let audio = crate::transport::moq::create_media_track(&mut broadcast, &audio_track.name, 2) + .unwrap(); + let video = crate::transport::moq::create_media_track(&mut broadcast, &video_track.name, 2) + .unwrap(); let catalog_producer = MoqPeerNode::create_and_publish_catalog( &mut broadcast, @@ -3004,7 +3018,7 @@ mod tests { #[tokio::test] async fn process_publisher_frames_emits_video_packets_with_metadata() { - let mut fx = make_single_track("video/data"); + let mut fx = make_single_track("video/data").await; write_group(&mut fx.producer, &[(1000, b"key0"), (2000, b"delta1")]); write_group(&mut fx.producer, &[(3000, b"key1")]); fx.producer.finish().unwrap(); @@ -3060,7 +3074,7 @@ mod tests { #[tokio::test] async fn process_publisher_frames_audio_has_no_content_type() { - let mut fx = make_single_track("audio/data"); + let mut fx = make_single_track("audio/data").await; write_group(&mut fx.producer, &[(0, b"opus0")]); fx.producer.finish().unwrap(); @@ -3093,11 +3107,13 @@ mod tests { #[tokio::test] async fn process_publisher_frames_discards_undecodable_frame() { - let mut fx = make_single_track("audio/data"); + let mut fx = make_single_track("audio/data").await; let mut group = fx.producer.append_group().unwrap(); // Empty payload has no varint timestamp — decode fails (DecodeError::Short). - group.write_frame(bytes::Bytes::new()).unwrap(); - group.write_frame(ts_payload(0, b"ok")).unwrap(); + group + .write_frame(hang::container::Timestamp::from_micros(0).unwrap(), bytes::Bytes::new()) + .unwrap(); + container_frame(0, b"ok").write_to(&mut group).unwrap(); group.finish().unwrap(); fx.producer.finish().unwrap(); @@ -3128,7 +3144,7 @@ mod tests { #[tokio::test] async fn process_publisher_frames_routes_to_dynamic_output() { - let mut fx = make_single_track("audio/data"); + let mut fx = make_single_track("audio/data").await; write_group(&mut fx.producer, &[(0, b"opus0"), (20_000, b"opus1")]); fx.producer.finish().unwrap(); @@ -3164,7 +3180,7 @@ mod tests { #[tokio::test] async fn get_next_group_returns_group_then_finished() { - let mut fx = make_single_track("video/data"); + let mut fx = make_single_track("video/data").await; write_group(&mut fx.producer, &[(0, b"a")]); fx.producer.finish().unwrap(); let (_shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1); @@ -3179,7 +3195,7 @@ mod tests { #[tokio::test] async fn get_next_group_returns_none_on_shutdown() { - let mut fx = make_single_track("video/data"); + let mut fx = make_single_track("video/data").await; let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1); shutdown_tx.send(()).unwrap(); let g = MoqPeerNode::get_next_group(&mut fx.consumer, &mut shutdown_rx, "video/data").await; @@ -3260,7 +3276,7 @@ mod tests { #[tokio::test] async fn wait_for_broadcast_announcement_returns_none_on_shutdown() { - let origin = moq_lite::Origin::random().produce(); + let origin = moq_net::Origin::random().produce(); let consumer = origin.consume(); let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1); shutdown_tx.send(()).unwrap(); @@ -3421,15 +3437,15 @@ mod tests { #[tokio::test] async fn setup_subscriber_broadcast_publishes_catalog_with_both_tracks() { - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, true); let (bcast, audio, video, catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); assert!(audio.is_some()); assert!(video.is_some()); - let bc = publish.consume().get_broadcast("output").unwrap(); - let cat_track = bc.subscribe_track(&hang::catalog::Catalog::default_track()).unwrap(); + let bc = publish.consume().announced_broadcast("output").await.unwrap(); + let cat_track = crate::transport::moq::subscribe_catalog(&bc).await.unwrap(); let mut cc = crate::transport::moq::catalog_consumer::CatalogConsumer::new(cat_track); let cat = tokio::time::timeout(Duration::from_secs(2), cc.next()) .await @@ -3444,7 +3460,7 @@ mod tests { #[tokio::test] async fn setup_subscriber_broadcast_audio_only_omits_video_track() { - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, false); let (_bcast, audio, video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); @@ -3454,9 +3470,11 @@ mod tests { #[tokio::test] async fn create_and_publish_catalog_aac_advertises_stereo() { - let publish = moq_lite::Origin::random().produce(); - let mut bcast = publish.create_broadcast("output").unwrap(); - let audio_track = moq_lite::Track { name: "audio/data".to_string(), priority: 80 }; + let publish = moq_net::Origin::random().produce(); + let mut bcast = + publish.create_broadcast("output", moq_net::broadcast::Route::announced()).unwrap(); + let audio_track = + crate::transport::moq::TrackRef { name: "audio/data".to_string(), priority: 80 }; let catalog = MoqPeerNode::create_and_publish_catalog( &mut bcast, Some(&audio_track), @@ -3468,8 +3486,8 @@ mod tests { ) .unwrap(); - let bc = publish.consume().get_broadcast("output").unwrap(); - let cat_track = bc.subscribe_track(&hang::catalog::Catalog::default_track()).unwrap(); + let bc = publish.consume().announced_broadcast("output").await.unwrap(); + let cat_track = crate::transport::moq::subscribe_catalog(&bc).await.unwrap(); let mut cc = crate::transport::moq::catalog_consumer::CatalogConsumer::new(cat_track); let cat = tokio::time::timeout(Duration::from_secs(2), cc.next()) .await @@ -3482,7 +3500,7 @@ mod tests { #[tokio::test] async fn run_subscriber_send_loop_forwards_audio_and_video() { - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, true); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); @@ -3503,7 +3521,7 @@ mod tests { #[tokio::test] async fn run_subscriber_send_loop_records_lagged_discards() { - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, false); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); @@ -3528,7 +3546,7 @@ mod tests { #[tokio::test] async fn run_subscriber_send_loop_stops_on_shutdown() { - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, true); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); @@ -3551,18 +3569,13 @@ mod tests { // open its first MoQ group with a delta frame, or a late-joining // subscriber's decoder wedges on "a key frame is required after // configure()". - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(false, true); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); - let video_track = moq_lite::Track { name: "video/data".to_string(), priority: 60 }; - let mut sub = publish - .consume() - .get_broadcast("output") - .unwrap() - .subscribe_track(&video_track) - .unwrap(); + let bc = publish.consume().announced_broadcast("output").await.unwrap(); + let mut sub = crate::transport::moq::subscribe_track(&bc, "video/data", 60).await.unwrap(); // The peer attaches mid-stream: two delta frames arrive before the // first keyframe. A trailing delta then lands in the keyframe-led @@ -3623,18 +3636,13 @@ mod tests { // the lag still sees a keyframe-led group). Re-gating on a bare lag // count would freeze video until the next keyframe — up to a full GOP — // even for a lag that only dropped audio. - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(false, true); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); - let video_track = moq_lite::Track { name: "video/data".to_string(), priority: 60 }; - let mut sub = publish - .consume() - .get_broadcast("output") - .unwrap() - .subscribe_track(&video_track) - .unwrap(); + let bc = publish.consume().announced_broadcast("output").await.unwrap(); + let mut sub = crate::transport::moq::subscribe_track(&bc, "video/data", 60).await.unwrap(); let (stats_tx, mut stats_rx) = mpsc::channel::(256); let mut ctx = make_test_ctx(&mut audio, &mut video, &stats_tx, 40); @@ -3677,18 +3685,13 @@ mod tests { // drift permanently. Interleave audio with the dropped deltas (matched // 20ms durations) and assert the kept keyframe lands at the same media // time as the concurrent audio — on the wire, not just in a counter. - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, true); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); - let video_track = moq_lite::Track { name: "video/data".to_string(), priority: 60 }; - let mut sub = publish - .consume() - .get_broadcast("output") - .unwrap() - .subscribe_track(&video_track) - .unwrap(); + let bc = publish.consume().announced_broadcast("output").await.unwrap(); + let mut sub = crate::transport::moq::subscribe_track(&bc, "video/data", 60).await.unwrap(); let video_delta = |data: &'static [u8]| BroadcastFrame { data: bytes::Bytes::from_static(data), @@ -3728,7 +3731,7 @@ mod tests { .expect("read_frame should not hang") .expect("read_frame should succeed") .expect("the keyframe should be published"); - let decoded = hang::container::Frame::decode(first).unwrap(); + let decoded = hang::container::Frame::decode(first.payload).unwrap(); assert_eq!(decoded.payload.as_ref(), b"k0"); assert_eq!( decoded.timestamp.as_micros(), @@ -3741,7 +3744,7 @@ mod tests { #[tokio::test] async fn run_subscriber_send_loop_skips_frame_without_matching_producer() { - let publish = moq_lite::Origin::random().produce(); + let publish = moq_net::Origin::random().produce(); let media = sub_media(true, false); let (_bcast, mut audio, mut video, _catalog) = MoqPeerNode::setup_subscriber_broadcast(&publish, "output", &media).unwrap(); @@ -4068,12 +4071,12 @@ mod tests { cfg }); - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("input").unwrap(); - let _track = broadcast - .create_track(moq_lite::Track { name: "video/hd".to_string(), priority: 2 }) - .unwrap(); - let consumer = origin.consume().get_broadcast("input").unwrap(); + let origin = moq_net::Origin::random().produce(); + let mut broadcast = + origin.create_broadcast("input", moq_net::broadcast::Route::announced()).unwrap(); + let _track = + crate::transport::moq::create_media_track(&mut broadcast, "video/hd", 2).unwrap(); + let consumer = origin.consume().announced_broadcast("input").await.unwrap(); let mock = crate::test_utils::MockOutputSender::new(); let sender = mock.to_output_sender("test_node".to_string()); @@ -4121,12 +4124,12 @@ mod tests { hang::catalog::AudioConfig::new(catalog_audio_codec(AudioCodec::Aac), 48000, 2), ); - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("input").unwrap(); - let _track = broadcast - .create_track(moq_lite::Track { name: "audio/data".to_string(), priority: 2 }) - .unwrap(); - let consumer = origin.consume().get_broadcast("input").unwrap(); + let origin = moq_net::Origin::random().produce(); + let mut broadcast = + origin.create_broadcast("input", moq_net::broadcast::Route::announced()).unwrap(); + let _track = + crate::transport::moq::create_media_track(&mut broadcast, "audio/data", 2).unwrap(); + let consumer = origin.consume().announced_broadcast("input").await.unwrap(); let mock = crate::test_utils::MockOutputSender::new(); let sender = mock.to_output_sender("test_node".to_string()); diff --git a/crates/nodes/src/transport/moq/pull.rs b/crates/nodes/src/transport/moq/pull.rs index 96356892..8ca5a16e 100644 --- a/crates/nodes/src/transport/moq/pull.rs +++ b/crates/nodes/src/transport/moq/pull.rs @@ -4,7 +4,6 @@ use crate::video::{AV1_CONTENT_TYPE, H264_CONTENT_TYPE, VP9_CONTENT_TYPE}; use async_trait::async_trait; -use bytes::Buf; use schemars::JsonSchema; use serde::Deserialize; use std::borrow::Cow; @@ -43,10 +42,10 @@ type DynamicOutputs = Arc /// A catalog-discovered track with optional codec metadata. /// -/// Wraps [`moq_lite::Track`] with the codec detected from the MoQ +/// Wraps the track name/priority with the codec detected from the MoQ /// catalog so that output pins can advertise the correct codec type. struct DiscoveredTrack { - track: moq_lite::Track, + track: super::TrackRef, /// `Some(codec)` for video tracks; `None` for audio. video_codec: Option, /// `Some(codec)` for audio tracks; `None` for video. @@ -346,14 +345,16 @@ impl MoqPullNode { /// (unlike [`Self::verify_pin_codecs`] at connection setup): tearing down a /// live pipeline over a late optional track is worse than degraded output /// on the affected pin. - fn attach_late_audio( + async fn attach_late_audio( &self, - broadcast: &moq_lite::BroadcastConsumer, + broadcast: &moq_net::broadcast::Consumer, new_tracks: &[DiscoveredTrack], ) -> Option<(LateTrack, AudioCodec)> { let dt = new_tracks.iter().find(|dt| dt.audio_codec.is_some())?; let codec = dt.audio_codec?; - let consumer = match broadcast.subscribe_track(&dt.track) { + let consumer = match super::subscribe_track(broadcast, &dt.track.name, dt.track.priority) + .await + { Ok(consumer) => consumer, Err(e) => { tracing::warn!(error = %e, track = %dt.track.name, "MoqPullNode: failed to subscribe to late audio track"); @@ -387,13 +388,15 @@ impl MoqPullNode { /// video track (e.g. video added to an audio-only stream) is streamed /// mid-session. Returns the subscription and its catalog content-type, or /// `None` if there is no video track or the subscribe fails. - fn attach_late_video( + async fn attach_late_video( &self, - broadcast: &moq_lite::BroadcastConsumer, + broadcast: &moq_net::broadcast::Consumer, new_tracks: &[DiscoveredTrack], ) -> Option<(LateTrack, &'static str)> { let dt = new_tracks.iter().find(|dt| dt.video_codec.is_some())?; - let consumer = match broadcast.subscribe_track(&dt.track) { + let consumer = match super::subscribe_track(broadcast, &dt.track.name, dt.track.priority) + .await + { Ok(consumer) => consumer, Err(e) => { tracing::warn!(error = %e, track = %dt.track.name, "MoqPullNode: failed to subscribe to late video track"); @@ -658,6 +661,12 @@ enum StreamEndReason { Reconnect, } +/// Outcome of waiting for a broadcast announcement. +enum BroadcastWait { + Broadcast(moq_net::broadcast::Consumer), + End(StreamEndReason), +} + /// Result of routing a track frame to an output pin. enum RouteOutcome { /// Delivered to a connected pin. @@ -672,8 +681,8 @@ enum RouteOutcome { /// A track subscribed mid-session by the catalog watch, plus the read-loop /// state the caller needs to start consuming it. struct LateTrack { - track: moq_lite::Track, - consumer: moq_lite::TrackConsumer, + track: super::TrackRef, + consumer: moq_net::track::Subscriber, pin_name: String, pin_registered: bool, } @@ -689,24 +698,24 @@ const CANCEL_YIELD_THRESHOLD: u32 = 5; impl MoqPullNode { fn strip_hang_timestamp_header( - mut payload: bytes::Bytes, - ) -> Result<(u64, bytes::Bytes), moq_lite::Error> { + payload: bytes::Bytes, + ) -> Result<(u64, bytes::Bytes), hang::Error> { // hang protocol: frame payload is prefixed with a varint timestamp in microseconds. // We parse it and forward the remaining bytes (Opus frame data). - let timestamp = hang::container::Timestamp::decode(&mut payload)?; + let frame = hang::container::Frame::decode(payload)?; #[allow(clippy::cast_possible_truncation)] // MoQ timestamps fit in u64 - let timestamp_us = timestamp.as_micros() as u64; - Ok((timestamp_us, payload.copy_to_bytes(payload.remaining()))) + let timestamp_us = frame.timestamp.as_micros() as u64; + Ok((timestamp_us, frame.payload)) } /// Read the next raw MoQ frame, returning the payload and whether this /// frame is the first in a newly opened MoQ group (i.e. a keyframe /// boundary in the hang protocol). async fn read_next_raw_moq( - track_consumer: &mut moq_lite::TrackConsumer, - current_group: &mut Option, + track_consumer: &mut moq_net::track::Subscriber, + current_group: &mut Option, is_first_in_group: &mut bool, - ) -> Result, moq_lite::Error> { + ) -> Result, moq_net::Error> { loop { if current_group.is_none() { match track_consumer.next_group().await { @@ -724,7 +733,7 @@ impl MoqPullNode { }; match group.read_frame().await { - Ok(Some(payload)) => return Ok(Some(payload)), + Ok(Some(frame)) => return Ok(Some(frame.payload)), Ok(None) => { // Group ended; move to the next group. *current_group = None; @@ -756,44 +765,35 @@ impl MoqPullNode { let client = super::shared_insecure_client()?; - let origin = moq_lite::Origin::random().produce(); + let origin = moq_net::Origin::random().produce(); let consumer = origin.consume(); let _consumer_session = - Box::pin(client.clone().with_consume(origin).connect(url)).await.map_err(|e| { + Box::pin(client.clone().with_subscriber(origin).connect(url)).await.map_err(|e| { StreamKitError::Runtime(format!("Failed to create consumer session: {e}")) })?; // Wait for the broadcast to be announced. During dynamic session - // initialization the publisher (browser) may not have connected yet, so - // we poll with a short delay up to the timeout. - let broadcast_poll_interval = Duration::from_millis(250); + // initialization the publisher (browser) may not have connected yet. let discovery_timeout = Duration::from_secs(15); - let discovery_start = tokio::time::Instant::now(); - let broadcast = loop { - if let Some(b) = consumer.get_broadcast(&self.config.broadcast) { - break b; - } - if discovery_start.elapsed() >= discovery_timeout { - tracing::debug!( - broadcast = %self.config.broadcast, - "Broadcast not announced within {}s; using default output pin", - discovery_timeout.as_secs() - ); - return Ok(Vec::new()); - } - tracing::trace!( + let Ok(Some(broadcast)) = tokio::time::timeout( + discovery_timeout, + consumer.announced_broadcast(self.config.broadcast.as_str()), + ) + .await + else { + tracing::debug!( broadcast = %self.config.broadcast, - "Broadcast not yet announced, retrying..." + "Broadcast not announced within {}s; using default output pin", + discovery_timeout.as_secs() ); - tokio::time::sleep(broadcast_poll_interval).await; + return Ok(Vec::new()); }; // Subscribe to the catalog track - let raw_catalog_track = - broadcast.subscribe_track(&hang::catalog::Catalog::default_track()).map_err(|e| { - StreamKitError::Runtime(format!("Failed to subscribe to catalog track: {e}")) - })?; + let raw_catalog_track = super::subscribe_catalog(&broadcast).await.map_err(|e| { + StreamKitError::Runtime(format!("Failed to subscribe to catalog track: {e}")) + })?; let mut catalog_consumer = super::catalog_consumer::CatalogConsumer::new(raw_catalog_track); // Parse the catalog to discover tracks @@ -816,7 +816,7 @@ impl MoqPullNode { if let Some(codec) = audio_codec_from_catalog(&config.codec) { tracing::info!(track = %name, ?codec, "found audio track"); tracks.push(DiscoveredTrack { - track: moq_lite::Track { name: name.clone(), priority: 80 }, + track: super::TrackRef { name: name.clone(), priority: 80 }, video_codec: None, audio_codec: Some(codec), }); @@ -829,7 +829,7 @@ impl MoqPullNode { if let Some(codec) = video_codec_from_catalog(&config.codec) { tracing::info!(track = %name, ?codec, "found video track"); tracks.push(DiscoveredTrack { - track: moq_lite::Track { name: name.clone(), priority: 60 }, + track: super::TrackRef { name: name.clone(), priority: 60 }, video_codec: Some(codec), audio_codec: None, }); @@ -894,6 +894,48 @@ impl MoqPullNode { } } + /// Wait for `broadcast` to be announced, servicing control messages. + /// + /// Non-shutdown control messages (e.g. the engine's startup `Start`) are + /// ignored so a pull node waiting for a late publisher keeps waiting. + /// Restarting `announced_broadcast` after a control message is safe: each + /// call replays the currently active broadcast set, so an announcement + /// observed between iterations isn't lost. + async fn wait_for_announced_broadcast( + control_rx: &mut mpsc::Receiver, + consumer: &moq_net::origin::Consumer, + broadcast: &str, + ) -> BroadcastWait { + loop { + tokio::select! { + msg = control_rx.recv() => { + match msg { + Some(streamkit_core::control::NodeControlMessage::Shutdown) => { + tracing::info!("MoQ pull received shutdown signal while waiting for broadcast"); + return BroadcastWait::End(StreamEndReason::Natural); + } + Some(control_msg) => { + tracing::debug!("MoQ pull ignoring control message while waiting: {:?}", control_msg); + } + None => { + // Control channel closed - engine is shutting down + tracing::info!("MoQ pull control channel closed while waiting for broadcast"); + return BroadcastWait::End(StreamEndReason::Natural); + } + } + } + maybe_broadcast = consumer.announced_broadcast(broadcast) => { + if let Some(bc) = maybe_broadcast { + tracing::info!("Broadcast '{broadcast}' has been announced"); + return BroadcastWait::Broadcast(bc); + } + tracing::warn!("Announcement channel closed before broadcast '{broadcast}' was announced, will reconnect"); + return BroadcastWait::End(StreamEndReason::Reconnect); + } + } + } + } + // MoQ connection state machine with multiplexed track handling and error recovery // High complexity is inherent to protocol handling (track management, object streaming, packet routing) #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] @@ -912,7 +954,7 @@ impl MoqPullNode { /// One iteration of the multiplexed select loop: a track frame, or a /// catalog update that may reveal a late-arriving track. enum LoopEvent { - Frame(Result, moq_lite::Error>, ReadSource), + Frame(Result, moq_net::Error>, ReadSource), // Boxed: a parsed catalog is far larger than a frame result, so an // unboxed variant bloats every `LoopEvent` (clippy::large_enum_variant). Catalog(Box, hang::Error>>), @@ -932,77 +974,34 @@ impl MoqPullNode { let client = super::shared_insecure_client()?; // Create origin for consuming broadcasts only (no publishing to avoid cycles) - let origin = moq_lite::Origin::random().produce(); + let origin = moq_net::Origin::random().produce(); let consumer = origin.consume(); let _consumer_session = - Box::pin(client.clone().with_consume(origin).connect(url)).await.map_err(|e| { + Box::pin(client.clone().with_subscriber(origin).connect(url)).await.map_err(|e| { StreamKitError::Runtime(format!("Failed to create consumer session: {e}")) })?; - // Wait for broadcast to become available - let broadcast = { - let mut announcements = consumer.clone(); - - if let Some(broadcast) = consumer.get_broadcast(&self.config.broadcast) { - tracing::info!("Broadcast '{}' is immediately available", self.config.broadcast); - broadcast - } else { - // Wait for announcement - tracing::debug!( - "Waiting for broadcast '{}' to be announced...", - self.config.broadcast - ); - - loop { - tokio::select! { - msg = context.control_rx.recv() => { - match msg { - Some(streamkit_core::control::NodeControlMessage::Shutdown) => { - tracing::info!("MoQ pull received shutdown signal while waiting for broadcast"); - return Ok(StreamEndReason::Natural); - } - Some(control_msg) => { - tracing::debug!("MoQ pull received control message while waiting: {:?}", control_msg); - } - None => { - // Control channel closed - engine is shutting down - tracing::info!("MoQ pull control channel closed while waiting for broadcast"); - return Ok(StreamEndReason::Natural); - } - } - } - Some((path, maybe_broadcast)) = announcements.announced() => { - if let Some(broadcast) = maybe_broadcast { - if path.as_str() == self.config.broadcast { - tracing::info!("Broadcast '{}' has been announced", self.config.broadcast); - break broadcast; - } - // Different broadcast announced, continue waiting - tracing::trace!("Different broadcast announced: {}", path.as_str()); - } - } - else => { - tracing::warn!("Announcement channel closed before broadcast '{}' was announced, will reconnect", self.config.broadcast); - return Ok(StreamEndReason::Reconnect); - } - } - } - } + tracing::debug!("Waiting for broadcast '{}' to be announced...", self.config.broadcast); + let broadcast = match Self::wait_for_announced_broadcast( + &mut context.control_rx, + &consumer, + self.config.broadcast.as_str(), + ) + .await + { + BroadcastWait::Broadcast(broadcast) => broadcast, + BroadcastWait::End(reason) => return Ok(reason), }; tracing::info!("Subscribed to broadcast '{}'", self.config.broadcast); // Get the catalog to find available tracks - let raw_catalog_track = - broadcast.subscribe_track(&hang::catalog::Catalog::default_track()).map_err(|e| { - StreamKitError::Runtime(format!("Failed to subscribe to catalog track: {e}")) - })?; + let raw_catalog_track = super::subscribe_catalog(&broadcast).await.map_err(|e| { + StreamKitError::Runtime(format!("Failed to subscribe to catalog track: {e}")) + })?; let mut catalog_consumer = super::catalog_consumer::CatalogConsumer::new(raw_catalog_track); - tracing::debug!( - "subscribed to catalog track: {}", - hang::catalog::Catalog::default_track().name - ); + tracing::debug!("subscribed to catalog track: {}", hang::catalog::Catalog::DEFAULT_NAME); // Wait for catalog data with timeout let discovered_tracks = self.parse_catalog(&mut catalog_consumer).await?; @@ -1038,14 +1037,19 @@ impl MoqPullNode { tracing::info!("subscribing to audio track: {}", dt.track.name); let pin_name = dt.track.name.clone(); let pin_registered = self.output_pins.iter().any(|p| p.name == pin_name); - let consumer = broadcast.subscribe_track(&dt.track).map_err(|e| { - StreamKitError::Runtime(format!("Failed to subscribe to audio track: {e}")) - })?; + let consumer = + super::subscribe_track(&broadcast, &dt.track.name, dt.track.priority) + .await + .map_err(|e| { + StreamKitError::Runtime(format!( + "Failed to subscribe to audio track: {e}" + )) + })?; (Some(consumer), Some(pin_name), pin_registered) } else { (None, None, false) }; - let mut audio_sub_track: Option = audio_track.map(|dt| dt.track.clone()); + let mut audio_sub_track: Option = audio_track.map(|dt| dt.track.clone()); // Subscribe to video track let (mut video_track_consumer, mut video_track_pin_name, mut video_track_pin_registered) = @@ -1053,17 +1057,22 @@ impl MoqPullNode { tracing::info!("subscribing to video track: {}", dt.track.name); let pin_name = dt.track.name.clone(); let pin_registered = self.output_pins.iter().any(|p| p.name == pin_name); - let consumer = broadcast.subscribe_track(&dt.track).map_err(|e| { - StreamKitError::Runtime(format!("Failed to subscribe to video track: {e}")) - })?; + let consumer = + super::subscribe_track(&broadcast, &dt.track.name, dt.track.priority) + .await + .map_err(|e| { + StreamKitError::Runtime(format!( + "Failed to subscribe to video track: {e}" + )) + })?; (Some(consumer), Some(pin_name), pin_registered) } else { (None, None, false) }; - let mut video_sub_track: Option = video_track.map(|dt| dt.track.clone()); + let mut video_sub_track: Option = video_track.map(|dt| dt.track.clone()); - let mut audio_current_group: Option = None; - let mut video_current_group: Option = None; + let mut audio_current_group: Option = None; + let mut video_current_group: Option = None; // Resolve the video content_type from the discovered track's codec. // Falls back to "video/vp9" if no codec info is available. Reassigned @@ -1192,7 +1201,7 @@ impl MoqPullNode { } }; - let (read_result, source): (Result, moq_lite::Error>, ReadSource) = + let (read_result, source): (Result, moq_net::Error>, ReadSource) = match event { LoopEvent::Frame(result, source) => (result, source), LoopEvent::Catalog(boxed) => match *boxed { @@ -1202,7 +1211,7 @@ impl MoqPullNode { if audio_sub_track.is_none() { if let Some((late, codec)) = - self.attach_late_audio(&broadcast, &new_tracks) + self.attach_late_audio(&broadcast, &new_tracks).await { resolved_audio_codec = codec; audio_track_pin_registered = late.pin_registered; @@ -1218,7 +1227,7 @@ impl MoqPullNode { if video_sub_track.is_none() { if let Some((late, content_type)) = - self.attach_late_video(&broadcast, &new_tracks) + self.attach_late_video(&broadcast, &new_tracks).await { video_content_type = content_type; video_track_pin_registered = late.pin_registered; @@ -1418,7 +1427,13 @@ impl MoqPullNode { if audio_resubscribe_attempts < MAX_RESUBSCRIBE_ATTEMPTS { if let Some(track) = audio_sub_track.as_ref() { audio_resubscribe_attempts += 1; - match broadcast.subscribe_track(track) { + match super::subscribe_track( + &broadcast, + &track.name, + track.priority, + ) + .await + { Ok(new_consumer) => { tracing::info!( attempt = audio_resubscribe_attempts, @@ -1448,7 +1463,13 @@ impl MoqPullNode { if video_resubscribe_attempts < MAX_RESUBSCRIBE_ATTEMPTS { if let Some(track) = video_sub_track.as_ref() { video_resubscribe_attempts += 1; - match broadcast.subscribe_track(track) { + match super::subscribe_track( + &broadcast, + &track.name, + track.priority, + ) + .await + { Ok(new_consumer) => { tracing::info!( attempt = video_resubscribe_attempts, @@ -1476,7 +1497,7 @@ impl MoqPullNode { return Ok(StreamEndReason::Natural); } }, - Err(moq_lite::Error::Cancel) => { + Err(moq_net::Error::Cancel) => { consecutive_cancels = consecutive_cancels.saturating_add(1); tracing::debug!( session_packet_count, @@ -1520,13 +1541,14 @@ impl MoqPullNode { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { + use super::super::TrackRef; use super::*; use bytes::BytesMut; #[test] fn test_output_pins_for_tracks_includes_stable_out() { let tracks = vec![DiscoveredTrack { - track: moq_lite::Track { name: "audio/data".to_string(), priority: 0 }, + track: TrackRef { name: "audio/data".to_string(), priority: 0 }, video_codec: None, audio_codec: Some(AudioCodec::Opus), }]; @@ -1538,7 +1560,7 @@ mod tests { #[test] fn test_output_pins_for_tracks_dedupes_out_track_name() { let tracks = vec![DiscoveredTrack { - track: moq_lite::Track { name: "out".to_string(), priority: 0 }, + track: TrackRef { name: "out".to_string(), priority: 0 }, video_codec: None, audio_codec: Some(AudioCodec::Opus), }]; @@ -1549,7 +1571,7 @@ mod tests { #[test] fn test_output_pins_for_tracks_uses_av1_codec() { let tracks = vec![DiscoveredTrack { - track: moq_lite::Track { name: "video/data".to_string(), priority: 60 }, + track: TrackRef { name: "video/data".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Av1), audio_codec: None, }]; @@ -1564,7 +1586,7 @@ mod tests { #[test] fn test_output_pins_for_tracks_defaults_vp9() { let tracks = vec![DiscoveredTrack { - track: moq_lite::Track { name: "video/data".to_string(), priority: 60 }, + track: TrackRef { name: "video/data".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Vp9), audio_codec: None, }]; @@ -1579,7 +1601,7 @@ mod tests { #[test] fn test_output_pins_for_tracks_aac_audio() { let tracks = vec![DiscoveredTrack { - track: moq_lite::Track { name: "audio/data".to_string(), priority: 80 }, + track: TrackRef { name: "audio/data".to_string(), priority: 80 }, video_codec: None, audio_codec: Some(AudioCodec::Aac), }]; @@ -1599,7 +1621,7 @@ mod tests { #[test] fn test_output_pins_for_tracks_uses_config_default_when_no_audio_discovered() { let tracks = vec![DiscoveredTrack { - track: moq_lite::Track { name: "video/data".to_string(), priority: 60 }, + track: TrackRef { name: "video/data".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Vp9), audio_codec: None, }]; @@ -1635,11 +1657,11 @@ mod tests { #[test] fn test_strip_hang_timestamp_header() { let mut buf = BytesMut::new(); - hang::container::Timestamp::from_micros(123) - .expect("valid timestamp") - .encode(&mut buf) - .expect("encode succeeds"); - buf.extend_from_slice(b"opus-frame-bytes"); + let frame = hang::container::Frame { + timestamp: hang::container::Timestamp::from_micros(123).expect("valid timestamp"), + payload: bytes::Bytes::from_static(b"opus-frame-bytes"), + }; + frame.encode(&mut buf).expect("encode succeeds"); let payload = buf.freeze(); let (ts, stripped) = match MoqPullNode::strip_hang_timestamp_header(payload) { @@ -1689,12 +1711,12 @@ mod tests { fn test_track_selection_uses_codec_fields() { let tracks = [ DiscoveredTrack { - track: moq_lite::Track { name: "my-custom-audio".to_string(), priority: 80 }, + track: TrackRef { name: "my-custom-audio".to_string(), priority: 80 }, video_codec: None, audio_codec: Some(AudioCodec::Opus), }, DiscoveredTrack { - track: moq_lite::Track { name: "my-custom-video".to_string(), priority: 60 }, + track: TrackRef { name: "my-custom-video".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Vp9), audio_codec: None, }, @@ -1708,7 +1730,7 @@ mod tests { #[test] fn test_output_pins_for_tracks_uses_codec_not_name() { let tracks = [DiscoveredTrack { - track: moq_lite::Track { name: "non-standard-name".to_string(), priority: 60 }, + track: TrackRef { name: "non-standard-name".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Av1), audio_codec: None, }]; @@ -1734,7 +1756,7 @@ mod tests { fn video_discovered_track(codec: VideoCodec) -> DiscoveredTrack { DiscoveredTrack { - track: moq_lite::Track { name: "video/data".to_string(), priority: 60 }, + track: TrackRef { name: "video/data".to_string(), priority: 60 }, video_codec: Some(codec), audio_codec: None, } @@ -1742,7 +1764,7 @@ mod tests { fn audio_discovered_track(codec: AudioCodec) -> DiscoveredTrack { DiscoveredTrack { - track: moq_lite::Track { name: "audio/data".to_string(), priority: 50 }, + track: TrackRef { name: "audio/data".to_string(), priority: 50 }, video_codec: None, audio_codec: Some(codec), } @@ -1821,12 +1843,12 @@ mod tests { #[test] fn test_video_codec_mismatch_on_second_track_is_detected() { let hd = DiscoveredTrack { - track: moq_lite::Track { name: "video/hd".to_string(), priority: 60 }, + track: TrackRef { name: "video/hd".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Vp9), audio_codec: None, }; let sd_vp9 = DiscoveredTrack { - track: moq_lite::Track { name: "video/sd".to_string(), priority: 60 }, + track: TrackRef { name: "video/sd".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Vp9), audio_codec: None, }; @@ -1834,12 +1856,12 @@ mod tests { node.output_pins = MoqPullNode::output_pins_for_tracks(&[hd, sd_vp9], AudioCodec::Opus); let hd_again = DiscoveredTrack { - track: moq_lite::Track { name: "video/hd".to_string(), priority: 60 }, + track: TrackRef { name: "video/hd".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Vp9), audio_codec: None, }; let sd_av1 = DiscoveredTrack { - track: moq_lite::Track { name: "video/sd".to_string(), priority: 60 }, + track: TrackRef { name: "video/sd".to_string(), priority: 60 }, video_codec: Some(VideoCodec::Av1), audio_codec: None, }; @@ -1901,33 +1923,18 @@ mod tests { /// startup of the audio relay. The first non-empty catalog is authoritative. #[tokio::test] async fn test_parse_catalog_audio_only_returns_promptly() { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("test-broadcast").unwrap(); - let track = hang::catalog::Catalog::default_track(); - let mut producer = broadcast.create_track(track.clone()).unwrap(); + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .unwrap(); + let mut producer = super::super::create_catalog_track(&mut broadcast).unwrap(); let consumer = origin.consume(); - let bc = consumer.get_broadcast("test-broadcast").unwrap(); - let consumer_track = bc.subscribe_track(&track).unwrap(); + let bc = consumer.announced_broadcast("test-broadcast").await.unwrap(); + let consumer_track = super::super::subscribe_catalog(&bc).await.unwrap(); - let mut audio_renditions = std::collections::BTreeMap::new(); - audio_renditions.insert("audio/data".to_string(), { - let mut cfg = hang::catalog::AudioConfig::new( - super::super::constants::catalog_audio_codec(AudioCodec::Opus), - 48000, - 2, - ); - cfg.bitrate = Some(128_000); - cfg - }); - let catalog = hang::catalog::Catalog { - audio: hang::catalog::Audio { renditions: audio_renditions }, - ..Default::default() - }; - let payload = catalog.to_string().unwrap(); - let mut group = producer.append_group().unwrap(); - group.write_frame(bytes::Bytes::from(payload)).unwrap(); - group.finish().unwrap(); + let catalog = audio_only_catalog(); + super::super::write_catalog_json(&mut producer, catalog.to_vec().unwrap()).unwrap(); let node = MoqPullNode::new(MoqPullConfig::default()); let mut cc = super::super::catalog_consumer::CatalogConsumer::new(consumer_track); @@ -1956,10 +1963,9 @@ mod tests { cfg.bitrate = Some(128_000); cfg }); - hang::catalog::Catalog { - audio: hang::catalog::Audio { renditions: audio_renditions }, - ..Default::default() - } + let mut catalog = hang::catalog::Catalog::default(); + catalog.audio.renditions = audio_renditions; + catalog } fn video_catalog(codec: VideoCodec) -> hang::catalog::Catalog { @@ -1987,22 +1993,20 @@ mod tests { /// catalog watch in `run_connection` subscribes to the late track. #[tokio::test] async fn test_catalog_watch_discovers_late_video_track() { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("test-broadcast").unwrap(); - let track = hang::catalog::Catalog::default_track(); - let mut producer = broadcast.create_track(track.clone()).unwrap(); + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .unwrap(); + let mut producer = super::super::create_catalog_track(&mut broadcast).unwrap(); let consumer = origin.consume(); - let bc = consumer.get_broadcast("test-broadcast").unwrap(); - let consumer_track = bc.subscribe_track(&track).unwrap(); + let bc = consumer.announced_broadcast("test-broadcast").await.unwrap(); + let consumer_track = super::super::subscribe_catalog(&bc).await.unwrap(); let mut cc = super::super::catalog_consumer::CatalogConsumer::new(consumer_track); - let write_catalog = |producer: &mut moq_lite::TrackProducer, + let write_catalog = |producer: &mut moq_net::track::Producer, catalog: &hang::catalog::Catalog| { - let payload = catalog.to_string().unwrap(); - let mut group = producer.append_group().unwrap(); - group.write_frame(bytes::Bytes::from(payload)).unwrap(); - group.finish().unwrap(); + super::super::write_catalog_json(producer, catalog.to_vec().unwrap()).unwrap(); }; // First update: audio only — initial discovery returns immediately. @@ -2167,35 +2171,35 @@ mod tests { /// Keeps the producer handles alive for the lifetime of the test; dropping /// them would close the broadcast and make `subscribe_track` fail. struct TestBroadcast { - _origin: moq_lite::OriginProducer, - _broadcast: moq_lite::BroadcastProducer, - _tracks: Vec, - consumer: moq_lite::BroadcastConsumer, + _origin: moq_net::origin::Producer, + _broadcast: moq_net::broadcast::Producer, + _tracks: Vec, + consumer: moq_net::broadcast::Consumer, } - fn broadcast_with_tracks(names: &[&str]) -> TestBroadcast { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("test-broadcast").unwrap(); + async fn broadcast_with_tracks(names: &[&str]) -> TestBroadcast { + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .unwrap(); let tracks = names .iter() - .map(|name| { - broadcast - .create_track(moq_lite::Track { name: (*name).to_string(), priority: 0 }) - .unwrap() - }) + .map(|name| super::super::create_media_track(&mut broadcast, name, 0).unwrap()) .collect(); - let consumer = origin.consume().get_broadcast("test-broadcast").unwrap(); + let consumer = origin.consume().announced_broadcast("test-broadcast").await.unwrap(); TestBroadcast { _origin: origin, _broadcast: broadcast, _tracks: tracks, consumer } } #[tokio::test] async fn test_attach_late_audio_subscribes_and_reports_pin() { - let tb = broadcast_with_tracks(&["audio/data"]); + let tb = broadcast_with_tracks(&["audio/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let new_tracks = MoqPullNode::extract_tracks(&audio_only_catalog()); - let (late, codec) = - node.attach_late_audio(&tb.consumer, &new_tracks).expect("audio track should attach"); + let (late, codec) = node + .attach_late_audio(&tb.consumer, &new_tracks) + .await + .expect("audio track should attach"); assert_eq!(late.pin_name, "audio/data"); assert_eq!(codec, AudioCodec::Opus); assert!(!late.pin_registered, "audio/data is not a statically advertised pin"); @@ -2203,7 +2207,7 @@ mod tests { #[tokio::test] async fn test_attach_late_audio_none_without_audio_track() { - let tb = broadcast_with_tracks(&["video/data"]); + let tb = broadcast_with_tracks(&["video/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let video_only = { let mut c = audio_video_catalog(); @@ -2211,17 +2215,19 @@ mod tests { c }; let new_tracks = MoqPullNode::extract_tracks(&video_only); - assert!(node.attach_late_audio(&tb.consumer, &new_tracks).is_none()); + assert!(node.attach_late_audio(&tb.consumer, &new_tracks).await.is_none()); } #[tokio::test] async fn test_attach_late_video_subscribes_and_reports_content_type() { - let tb = broadcast_with_tracks(&["video/data"]); + let tb = broadcast_with_tracks(&["video/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let new_tracks = MoqPullNode::extract_tracks(&audio_video_catalog()); - let (late, content_type) = - node.attach_late_video(&tb.consumer, &new_tracks).expect("video track should attach"); + let (late, content_type) = node + .attach_late_video(&tb.consumer, &new_tracks) + .await + .expect("video track should attach"); assert_eq!(late.pin_name, "video/data"); assert_eq!(content_type, VP9_CONTENT_TYPE); assert!(!late.pin_registered); @@ -2283,14 +2289,16 @@ mod tests { /// discovered codec) while warning that downstream consumers may misdecode. #[tokio::test] async fn test_attach_late_audio_reports_mismatch_codec() { - let tb = broadcast_with_tracks(&["audio/data"]); + let tb = broadcast_with_tracks(&["audio/data"]).await; let config = MoqPullConfig { audio_codec: Some(AudioCodec::Aac), ..MoqPullConfig::default() }; let node = MoqPullNode::new(config); let new_tracks = MoqPullNode::extract_tracks(&audio_only_catalog()); - let (late, codec) = - node.attach_late_audio(&tb.consumer, &new_tracks).expect("audio track should attach"); + let (late, codec) = node + .attach_late_audio(&tb.consumer, &new_tracks) + .await + .expect("audio track should attach"); assert_eq!(codec, AudioCodec::Opus, "discovered codec wins for the live subscription"); assert_eq!(node.advertised_out_audio_codec(), Some(AudioCodec::Aac)); assert_eq!(late.pin_name, "audio/data"); @@ -2312,35 +2320,39 @@ mod tests { #[tokio::test] async fn test_attach_late_video_av1_content_type() { - let tb = broadcast_with_tracks(&["video/data"]); + let tb = broadcast_with_tracks(&["video/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let new_tracks = MoqPullNode::extract_tracks(&video_catalog(VideoCodec::Av1)); - let (_late, content_type) = - node.attach_late_video(&tb.consumer, &new_tracks).expect("av1 track should attach"); + let (_late, content_type) = node + .attach_late_video(&tb.consumer, &new_tracks) + .await + .expect("av1 track should attach"); assert_eq!(content_type, AV1_CONTENT_TYPE); } #[tokio::test] async fn test_attach_late_video_h264_content_type() { - let tb = broadcast_with_tracks(&["video/data"]); + let tb = broadcast_with_tracks(&["video/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let new_tracks = MoqPullNode::extract_tracks(&video_catalog(VideoCodec::H264)); - let (_late, content_type) = - node.attach_late_video(&tb.consumer, &new_tracks).expect("h264 track should attach"); + let (_late, content_type) = node + .attach_late_video(&tb.consumer, &new_tracks) + .await + .expect("h264 track should attach"); assert_eq!(content_type, H264_CONTENT_TYPE); } /// A broadcast whose producers have been dropped: `subscribe_track` fails, /// exercising the attach helpers' subscribe-error paths. - fn closed_broadcast(track_names: &[&str]) -> moq_lite::BroadcastConsumer { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("test-broadcast").unwrap(); + async fn closed_broadcast(track_names: &[&str]) -> moq_net::broadcast::Consumer { + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .unwrap(); for name in track_names { - let _ = broadcast - .create_track(moq_lite::Track { name: (*name).to_string(), priority: 0 }) - .unwrap(); + let _ = super::super::create_media_track(&mut broadcast, name, 0).unwrap(); } - let bc = origin.consume().get_broadcast("test-broadcast").unwrap(); + let bc = origin.consume().announced_broadcast("test-broadcast").await.unwrap(); drop(broadcast); drop(origin); bc @@ -2348,18 +2360,18 @@ mod tests { #[tokio::test] async fn test_attach_late_audio_none_when_subscribe_fails() { - let bc = closed_broadcast(&["audio/data"]); + let bc = closed_broadcast(&["audio/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let new_tracks = MoqPullNode::extract_tracks(&audio_only_catalog()); - assert!(node.attach_late_audio(&bc, &new_tracks).is_none()); + assert!(node.attach_late_audio(&bc, &new_tracks).await.is_none()); } #[tokio::test] async fn test_attach_late_video_none_when_subscribe_fails() { - let bc = closed_broadcast(&["video/data"]); + let bc = closed_broadcast(&["video/data"]).await; let node = MoqPullNode::new(MoqPullConfig::default()); let new_tracks = MoqPullNode::extract_tracks(&audio_video_catalog()); - assert!(node.attach_late_video(&bc, &new_tracks).is_none()); + assert!(node.attach_late_video(&bc, &new_tracks).await.is_none()); } /// A statically advertised pin routes through the engine's `output_sender` @@ -2413,89 +2425,108 @@ mod tests { ); } - fn track_pair(name: &str) -> (moq_lite::TrackProducer, moq_lite::TrackConsumer) { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast("test-broadcast").unwrap(); - let track = moq_lite::Track { name: name.to_string(), priority: 0 }; - let producer = broadcast.create_track(track.clone()).unwrap(); - let consumer = origin.consume().get_broadcast("test-broadcast").unwrap(); - let track_consumer = consumer.subscribe_track(&track).unwrap(); - (producer, track_consumer) + struct TrackPair { + _origin: moq_net::origin::Producer, + _broadcast: moq_net::broadcast::Producer, + producer: moq_net::track::Producer, + consumer: moq_net::track::Subscriber, + } + + async fn track_pair(name: &str) -> TrackPair { + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast("test-broadcast", moq_net::broadcast::Route::announced()) + .unwrap(); + let producer = super::super::create_media_track(&mut broadcast, name, 0).unwrap(); + let bc = origin.consume().announced_broadcast("test-broadcast").await.unwrap(); + let consumer = super::super::subscribe_track(&bc, name, 0).await.unwrap(); + TrackPair { _origin: origin, _broadcast: broadcast, producer, consumer } } - fn write_group(producer: &mut moq_lite::TrackProducer, frames: &[&[u8]]) { + fn write_group(producer: &mut moq_net::track::Producer, frames: &[&[u8]]) { let mut group = producer.append_group().unwrap(); - for f in frames { - group.write_frame(bytes::Bytes::copy_from_slice(f)).unwrap(); + for (i, f) in frames.iter().enumerate() { + let ts = moq_net::Timestamp::from_micros(i as u64).unwrap(); + group.write_frame(ts, bytes::Bytes::copy_from_slice(f)).unwrap(); } group.finish().unwrap(); } #[tokio::test] async fn test_read_next_raw_moq_walks_groups_and_flags_first_frame() { - let (mut producer, mut consumer) = track_pair("audio/data"); - write_group(&mut producer, &[b"a", b"b"]); - write_group(&mut producer, &[b"c"]); - producer.finish().unwrap(); + let mut tp = track_pair("audio/data").await; + write_group(&mut tp.producer, &[b"a", b"b"]); + write_group(&mut tp.producer, &[b"c"]); + tp.producer.finish().unwrap(); - let mut group: Option = None; + let mut group: Option = None; let mut is_first = false; - let p1 = - MoqPullNode::read_next_raw_moq(&mut consumer, &mut group, &mut is_first).await.unwrap(); + let p1 = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first) + .await + .unwrap(); assert_eq!(p1.as_deref(), Some(&b"a"[..])); assert!(is_first, "first frame of a freshly opened group is flagged"); is_first = false; - let p2 = - MoqPullNode::read_next_raw_moq(&mut consumer, &mut group, &mut is_first).await.unwrap(); + let p2 = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first) + .await + .unwrap(); assert_eq!(p2.as_deref(), Some(&b"b"[..])); assert!(!is_first, "subsequent frame in the same group is not flagged"); - let p3 = - MoqPullNode::read_next_raw_moq(&mut consumer, &mut group, &mut is_first).await.unwrap(); + let p3 = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first) + .await + .unwrap(); assert_eq!(p3.as_deref(), Some(&b"c"[..])); assert!(is_first, "first frame of the second group is flagged again"); - let end = - MoqPullNode::read_next_raw_moq(&mut consumer, &mut group, &mut is_first).await.unwrap(); + let end = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first) + .await + .unwrap(); assert!(end.is_none(), "finished track reads as end-of-stream"); } #[tokio::test] async fn test_read_next_raw_moq_propagates_group_error() { - let (mut producer, mut consumer) = track_pair("audio/data"); - let mut g = producer.append_group().unwrap(); - g.write_frame(bytes::Bytes::from_static(b"a")).unwrap(); - g.abort(moq_lite::Error::Cancel).unwrap(); + let mut tp = track_pair("audio/data").await; + let mut g = tp.producer.append_group().unwrap(); + g.write_frame(moq_net::Timestamp::from_micros(0).unwrap(), bytes::Bytes::from_static(b"a")) + .unwrap(); - let mut group: Option = None; + let mut group: Option = None; let mut is_first = false; - // Aborting a group drops its cached frames (moq-net), so a consumer that - // hasn't drained surfaces the abort error instead of the buffered frame. - let err = MoqPullNode::read_next_raw_moq(&mut consumer, &mut group, &mut is_first).await; + let p1 = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first) + .await + .unwrap(); + assert_eq!(p1.as_deref(), Some(&b"a"[..])); + + // Aborting the group mid-read surfaces the abort error to the consumer + // instead of a silently truncated stream. + g.abort(moq_net::Error::Cancel).unwrap(); + let err = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first).await; assert!(err.is_err(), "an aborted group should surface as an error"); assert!(group.is_none(), "the errored group is cleared"); } #[tokio::test] async fn test_read_next_raw_moq_propagates_next_group_error() { - let (mut producer, mut consumer) = track_pair("audio/data"); - producer.abort(moq_lite::Error::Cancel).unwrap(); + let mut tp = track_pair("audio/data").await; + tp.producer.abort(moq_net::Error::Cancel).unwrap(); - let mut group: Option = None; + let mut group: Option = None; let mut is_first = false; - let err = MoqPullNode::read_next_raw_moq(&mut consumer, &mut group, &mut is_first).await; + let err = MoqPullNode::read_next_raw_moq(&mut tp.consumer, &mut group, &mut is_first).await; assert!(err.is_err(), "an aborted track should surface as an error"); } #[tokio::test] async fn test_parse_catalog_errors_when_track_closed() { - let (mut producer, consumer) = track_pair(".catalog"); - producer.finish().unwrap(); + let mut tp = track_pair(hang::catalog::Catalog::DEFAULT_NAME).await; + tp.producer.finish().unwrap(); let node = MoqPullNode::new(MoqPullConfig::default()); - let mut cc = super::super::catalog_consumer::CatalogConsumer::new(consumer); + let mut cc = super::super::catalog_consumer::CatalogConsumer::new(tp.consumer); let Err(err) = node.parse_catalog(&mut cc).await else { panic!("expected a track-closed error"); @@ -2506,9 +2537,9 @@ mod tests { // `start_paused` lets the catalog timeout elapse in virtual time. #[tokio::test(start_paused = true)] async fn test_parse_catalog_times_out_without_catalog() { - let (_producer, consumer) = track_pair(".catalog"); + let tp = track_pair(hang::catalog::Catalog::DEFAULT_NAME).await; let node = MoqPullNode::new(MoqPullConfig::default()); - let mut cc = super::super::catalog_consumer::CatalogConsumer::new(consumer); + let mut cc = super::super::catalog_consumer::CatalogConsumer::new(tp.consumer); let Err(err) = node.parse_catalog(&mut cc).await else { panic!("expected a timeout error"); @@ -2628,4 +2659,70 @@ mod tests { "empty URL should surface as a fatal configuration error, got: {result:?}" ); } + + #[tokio::test] + async fn test_wait_for_broadcast_ignores_non_shutdown_control_messages() { + let origin = moq_net::Origin::random().produce(); + let consumer = origin.consume(); + let (control_tx, mut control_rx) = mpsc::channel(8); + + control_tx.send(streamkit_core::control::NodeControlMessage::Start).await.unwrap(); + + let wait = + MoqPullNode::wait_for_announced_broadcast(&mut control_rx, &consumer, "late-cast"); + tokio::pin!(wait); + + // The Start message must be consumed without ending the wait. + let early = tokio::time::timeout(Duration::from_millis(100), wait.as_mut()).await; + assert!(early.is_err(), "Start must not end the wait for a late publisher"); + + let _broadcast = origin + .create_broadcast("late-cast", moq_net::broadcast::Route::announced()) + .expect("create_broadcast"); + + let outcome = tokio::time::timeout(Duration::from_secs(2), wait) + .await + .expect("announcement should resolve the wait"); + assert!(matches!(outcome, BroadcastWait::Broadcast(_))); + } + + #[tokio::test] + async fn test_wait_for_broadcast_sees_announcement_predating_control_message() { + let origin = moq_net::Origin::random().produce(); + let consumer = origin.consume(); + let (control_tx, mut control_rx) = mpsc::channel(8); + + // The broadcast is already announced before the wait restarts its + // announcement future to consume the control message. The replayed + // initial announcement set must still resolve the wait. + let _broadcast = origin + .create_broadcast("early-cast", moq_net::broadcast::Route::announced()) + .expect("create_broadcast"); + control_tx.send(streamkit_core::control::NodeControlMessage::Start).await.unwrap(); + + let outcome = tokio::time::timeout( + Duration::from_secs(2), + MoqPullNode::wait_for_announced_broadcast(&mut control_rx, &consumer, "early-cast"), + ) + .await + .expect("replayed announcement should resolve the wait"); + assert!(matches!(outcome, BroadcastWait::Broadcast(_))); + } + + #[tokio::test] + async fn test_wait_for_broadcast_shutdown_ends_naturally() { + let origin = moq_net::Origin::random().produce(); + let consumer = origin.consume(); + let (control_tx, mut control_rx) = mpsc::channel(8); + + control_tx.send(streamkit_core::control::NodeControlMessage::Shutdown).await.unwrap(); + + let outcome = tokio::time::timeout( + Duration::from_secs(2), + MoqPullNode::wait_for_announced_broadcast(&mut control_rx, &consumer, "late-cast"), + ) + .await + .expect("shutdown should resolve the wait"); + assert!(matches!(outcome, BroadcastWait::End(StreamEndReason::Natural))); + } } diff --git a/crates/nodes/src/transport/moq/push.rs b/crates/nodes/src/transport/moq/push.rs index ede850fb..6d25b8fc 100644 --- a/crates/nodes/src/transport/moq/push.rs +++ b/crates/nodes/src/transport/moq/push.rs @@ -187,9 +187,9 @@ impl ProcessorNode for MoqPushNode { }, }; - let publisher_origin = moq_lite::Origin::random().produce(); + let publisher_origin = moq_net::Origin::random().produce(); let _publisher_session = - match Box::pin(client.clone().with_publish(publisher_origin.consume()).connect(url)) + match Box::pin(client.clone().with_publisher(publisher_origin.consume()).connect(url)) .await { Ok(session) => session, @@ -201,10 +201,14 @@ impl ProcessorNode for MoqPushNode { }; // Create a transcoded broadcast and publish it - let mut broadcast = - publisher_origin.create_broadcast(&self.config.broadcast).ok_or_else(|| { + let mut broadcast = publisher_origin + .create_broadcast( + self.config.broadcast.as_str(), + moq_net::broadcast::Route::announced(), + ) + .map_err(|e| { StreamKitError::Runtime(format!( - "Failed to create broadcast '{}'", + "Failed to create broadcast '{}': {e}", self.config.broadcast )) })?; @@ -240,15 +244,16 @@ impl ProcessorNode for MoqPushNode { // Create audio track if audio input is connected let audio_track = if has_audio { - Some(moq_lite::Track { name: "audio/data".to_string(), priority: 80 }) + Some(super::TrackRef { name: "audio/data".to_string(), priority: 80 }) } else { None }; let mut audio_producer: Option = if let Some(ref at) = audio_track { - let producer = broadcast.create_track(at.clone()).map_err(|e| { - StreamKitError::Runtime(format!("Failed to create audio track: {e}")) - })?; + let producer = super::create_media_track(&mut broadcast, &at.name, at.priority) + .map_err(|e| { + StreamKitError::Runtime(format!("Failed to create audio track: {e}")) + })?; Some(producer.into()) } else { None @@ -256,15 +261,16 @@ impl ProcessorNode for MoqPushNode { // Create video track if video input is connected let video_track = if has_video { - Some(moq_lite::Track { name: "video/data".to_string(), priority: 60 }) + Some(super::TrackRef { name: "video/data".to_string(), priority: 60 }) } else { None }; let mut video_producer: Option = if let Some(ref vt) = video_track { - let producer = broadcast.create_track(vt.clone()).map_err(|e| { - StreamKitError::Runtime(format!("Failed to create video track: {e}")) - })?; + let producer = super::create_media_track(&mut broadcast, &vt.name, vt.priority) + .map_err(|e| { + StreamKitError::Runtime(format!("Failed to create video track: {e}")) + })?; Some(producer.into()) } else { None @@ -294,21 +300,14 @@ impl ProcessorNode for MoqPushNode { }); } - let mut catalog = hang::catalog::Catalog { - audio: hang::catalog::Audio { renditions: audio_renditions }, - video: hang::catalog::Video { - renditions: video_renditions, - display: None, - rotation: None, - flip: None, - }, - }; + let mut catalog = hang::catalog::Catalog::default(); + catalog.audio.renditions = audio_renditions; + catalog.video.renditions = video_renditions; // Create catalog track and publish the catalog data - let mut catalog_producer = broadcast - .create_track(hang::catalog::Catalog::default_track()) + let mut catalog_producer = super::create_catalog_track(&mut broadcast) .map_err(|e| StreamKitError::Runtime(format!("Failed to create catalog track: {e}")))?; - let catalog_json = match catalog.to_string() { + let catalog_json = match catalog.to_json() { Ok(json) => json, Err(e) => { let err = StreamKitError::Runtime(format!("Failed to serialize catalog: {e}")); @@ -316,15 +315,10 @@ impl ProcessorNode for MoqPushNode { return Err(err); }, }; - let catalog_data = catalog_json.into_bytes(); - tracing::debug!( - "publishing catalog JSON: {}", - std::str::from_utf8(&catalog_data).unwrap_or("") - ); + tracing::debug!("publishing catalog JSON: {catalog_json}"); - catalog_producer - .write_frame(catalog_data) + super::write_catalog_json(&mut catalog_producer, catalog_json) .map_err(|e| StreamKitError::Runtime(format!("Failed to write catalog frame: {e}")))?; // Keep catalog producer and state alive so we can re-publish when // dynamic tracks are added at runtime. @@ -663,11 +657,11 @@ impl MoqPushNode { #[allow(clippy::too_many_arguments)] fn handle_pin_management( msg: PinManagementMessage, - broadcast: &mut moq_lite::BroadcastProducer, + broadcast: &mut moq_net::broadcast::Producer, dynamic_inputs: &mut Vec, initial_delay_ms: u64, catalog: &mut hang::catalog::Catalog, - catalog_producer: &mut moq_lite::TrackProducer, + catalog_producer: &mut moq_net::track::Producer, channels: u32, video_codec: VideoCodec, audio_codec: AudioCodec, @@ -731,11 +725,11 @@ impl MoqPushNode { /// serialization or write failure (logged at error level). fn republish_catalog( catalog: &hang::catalog::Catalog, - catalog_producer: &mut moq_lite::TrackProducer, + catalog_producer: &mut moq_net::track::Producer, ) -> bool { - match catalog.to_string() { + match catalog.to_json() { Ok(json) => { - if let Err(e) = catalog_producer.write_frame(json.into_bytes()) { + if let Err(e) = super::write_catalog_json(catalog_producer, json) { tracing::error!("Failed to re-publish catalog: {e}"); false } else { @@ -756,11 +750,11 @@ impl MoqPushNode { fn activate_dynamic_input( pin: &InputPin, channel: tokio::sync::mpsc::Receiver, - broadcast: &mut moq_lite::BroadcastProducer, + broadcast: &mut moq_net::broadcast::Producer, dynamic_inputs: &mut Vec, initial_delay_ms: u64, catalog: &mut hang::catalog::Catalog, - catalog_producer: &mut moq_lite::TrackProducer, + catalog_producer: &mut moq_net::track::Producer, channels: u32, video_codec: VideoCodec, audio_codec: AudioCodec, @@ -770,9 +764,8 @@ impl MoqPushNode { let is_video = is_video_pin(&pin.name); let track_name = track_name_from_pin(&pin.name); - let track = - moq_lite::Track { name: track_name.clone(), priority: if is_video { 60 } else { 80 } }; - let producer = match broadcast.create_track(track) { + let priority = if is_video { 60 } else { 80 }; + let producer = match super::create_media_track(broadcast, &track_name, priority) { Ok(p) => p, Err(e) => { tracing::error!( @@ -881,23 +874,23 @@ mod tests { const BROADCAST: &str = "push-cov-test"; /// Uses in-process MoQ so dynamic-pin tests need no network relay. - fn push_fixture() -> ( - moq_lite::OriginProducer, - moq_lite::BroadcastProducer, - moq_lite::TrackProducer, + async fn push_fixture() -> ( + moq_net::origin::Producer, + moq_net::broadcast::Producer, + moq_net::track::Producer, CatalogConsumer, ) { - let origin = moq_lite::Origin::random().produce(); - let mut broadcast = origin.create_broadcast(BROADCAST).expect("create_broadcast"); - let catalog_producer = broadcast - .create_track(hang::catalog::Catalog::default_track()) - .expect("create catalog track"); + let origin = moq_net::Origin::random().produce(); + let mut broadcast = origin + .create_broadcast(BROADCAST, moq_net::broadcast::Route::announced()) + .expect("create_broadcast"); + let catalog_producer = + crate::transport::moq::create_catalog_track(&mut broadcast).expect("catalog track"); let consumer = origin.consume(); - let bc = consumer.get_broadcast(BROADCAST).expect("get_broadcast"); - let catalog_track = bc - .subscribe_track(&hang::catalog::Catalog::default_track()) - .expect("subscribe catalog track"); + let bc = consumer.announced_broadcast(BROADCAST).await.expect("announced_broadcast"); + let catalog_track = + crate::transport::moq::subscribe_catalog(&bc).await.expect("subscribe catalog track"); (origin, broadcast, catalog_producer, CatalogConsumer::new(catalog_track)) } @@ -910,7 +903,7 @@ mod tests { } } - // A bounded await so a moq-lite regression that stops delivering frames fails + // A bounded await so a moq-net regression that stops delivering frames fails // fast instead of hanging the whole test binary. async fn next_catalog(consumer: &mut CatalogConsumer) -> hang::catalog::Catalog { tokio::time::timeout(std::time::Duration::from_secs(5), consumer.next()) @@ -984,7 +977,8 @@ mod tests { #[tokio::test] async fn republish_catalog_writes_observable_frame() { - let (_origin, _broadcast, mut catalog_producer, mut catalog_consumer) = push_fixture(); + let (_origin, _broadcast, mut catalog_producer, mut catalog_consumer) = + push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); MoqPushNode::insert_catalog_rendition( @@ -1004,7 +998,8 @@ mod tests { #[tokio::test] async fn activate_dynamic_input_registers_track_and_keeps_channel() { - let (_origin, mut broadcast, mut catalog_producer, mut catalog_consumer) = push_fixture(); + let (_origin, mut broadcast, mut catalog_producer, mut catalog_consumer) = + push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); let mut dynamic_inputs: Vec = Vec::new(); @@ -1044,7 +1039,8 @@ mod tests { // replace branch is therefore unreachable while the old producer is retained.) #[tokio::test] async fn activate_dynamic_input_duplicate_pin_name_is_noop() { - let (_origin, mut broadcast, mut catalog_producer, _catalog_consumer) = push_fixture(); + let (_origin, mut broadcast, mut catalog_producer, _catalog_consumer) = + push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); let mut dynamic_inputs: Vec = Vec::new(); @@ -1089,10 +1085,10 @@ mod tests { #[allow(clippy::too_many_arguments)] // mirrors the production handle_pin_management signature fn handle( msg: PinManagementMessage, - broadcast: &mut moq_lite::BroadcastProducer, + broadcast: &mut moq_net::broadcast::Producer, dynamic_inputs: &mut Vec, catalog: &mut hang::catalog::Catalog, - catalog_producer: &mut moq_lite::TrackProducer, + catalog_producer: &mut moq_net::track::Producer, ) { MoqPushNode::handle_pin_management( msg, @@ -1109,7 +1105,7 @@ mod tests { #[tokio::test] async fn handle_pin_management_request_add_input_pin() { - let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture(); + let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); let mut dynamic_inputs = Vec::new(); @@ -1144,7 +1140,7 @@ mod tests { #[tokio::test] async fn handle_pin_management_added_then_removed_input_pin() { - let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture(); + let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); let mut dynamic_inputs = Vec::new(); @@ -1177,7 +1173,7 @@ mod tests { #[tokio::test] async fn handle_pin_management_rejects_output_pin_requests() { - let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture(); + let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); let mut dynamic_inputs = Vec::new(); @@ -1195,7 +1191,7 @@ mod tests { #[tokio::test] async fn handle_pin_management_ignores_unrelated_messages() { - let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture(); + let (_origin, mut broadcast, mut catalog_producer, _cc) = push_fixture().await; let mut catalog = hang::catalog::Catalog::default(); let mut dynamic_inputs = Vec::new(); diff --git a/e2e/tests/auth-helpers.ts b/e2e/tests/auth-helpers.ts index 845473a9..bb261056 100644 --- a/e2e/tests/auth-helpers.ts +++ b/e2e/tests/auth-helpers.ts @@ -50,6 +50,45 @@ export function getAuthHeaders(): Record { return { Authorization: `Bearer ${adminToken}` }; } +/** + * Mints a short-lived MoQ JWT via the admin API for tests that establish + * WebTransport sessions against an auth-enabled gateway. + * + * Returns null when auth is disabled or no admin token is available. The + * token root is derived from the configured gateway URL path, granting + * subscribe+publish on everything beneath it. + */ +export async function mintMoqToken(page: Page): Promise { + if (!adminToken) return null; + + const meResponse = await page.request.get('/api/v1/auth/me'); + const meBody = (await meResponse.json()) as { auth_enabled?: boolean }; + if (meBody.auth_enabled !== true) return null; + + let root = '/moq'; + const configResponse = await page.request.get('/api/v1/config'); + if (configResponse.ok()) { + const config = (await configResponse.json()) as { moq_gateway_url?: string | null }; + if (config.moq_gateway_url) { + try { + root = new URL(config.moq_gateway_url).pathname; + } catch { + // Keep the default root when the gateway URL is not parseable. + } + } + } + + const response = await page.request.post('/api/v1/auth/moq-tokens', { + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + data: { root, subscribe: [''], publish: [''], label: 'e2e-stream', ttl_secs: 3600 }, + }); + if (!response.ok()) { + throw new Error(`Failed to mint MoQ token: ${response.status()} ${await response.text()}`); + } + const body = (await response.json()) as { token?: string }; + return body.token ?? null; +} + /** * Logs in via the UI if the login view is currently shown. * diff --git a/e2e/tests/stream.spec.ts b/e2e/tests/stream.spec.ts index 6a8f94b3..5037c35a 100644 --- a/e2e/tests/stream.spec.ts +++ b/e2e/tests/stream.spec.ts @@ -2,9 +2,9 @@ // // SPDX-License-Identifier: MPL-2.0 -import { test, expect, request } from '@playwright/test'; +import { test, expect, request, type Page } from '@playwright/test'; -import { ensureLoggedIn, getAuthHeaders } from './auth-helpers'; +import { ensureLoggedIn, getAuthHeaders, mintMoqToken } from './auth-helpers'; import { type ConsoleErrorCollector, MOQ_BENIGN_PATTERNS, @@ -14,6 +14,17 @@ import { verifyCanvasRendering, } from './test-helpers'; +// The MoQ gateway validates the `?jwt=` query parameter after the +// WebTransport session is accepted, so an auth-enabled server closes +// tokenless connections post-establishment. Fill the Stream view's token +// input before creating a session so the auto-connect carries a valid JWT. +async function fillMoqTokenIfNeeded(page: Page): Promise { + const token = await mintMoqToken(page); + if (token) { + await page.locator('#moq-token').fill(token); + } +} + test.describe('Stream View - Dynamic Pipeline', () => { let collector: ConsoleErrorCollector; // Track the active session ID so afterEach can clean it up via the API @@ -43,6 +54,8 @@ test.describe('Stream View - Dynamic Pipeline', () => { await expect(templateCard).toBeVisible({ timeout: 10_000 }); await templateCard.click(); + await fillMoqTokenIfNeeded(page); + const createButton = page.getByRole('button', { name: /Create Session/i }); await expect(createButton).toBeEnabled({ timeout: 5_000 }); await createButton.click(); @@ -99,6 +112,8 @@ test.describe('Stream View - Dynamic Pipeline', () => { await expect(templateCard).toBeVisible({ timeout: 10_000 }); await templateCard.click(); + await fillMoqTokenIfNeeded(page); + const createButton = page.getByRole('button', { name: /Create Session/i }); await expect(createButton).toBeEnabled({ timeout: 5_000 }); await createButton.click(); @@ -115,7 +130,7 @@ test.describe('Stream View - Dynamic Pipeline', () => { // either the "Relay: connected" label appears, or the "Connect & Stream" // button reappears (meaning auto-connect failed and the UI reset). const connected = page.getByText('Relay: connected'); - const disconnected = page.getByText('Disconnected'); + const disconnected = page.getByText('Disconnected', { exact: true }); const connectButton = page.getByRole('button', { name: /Connect & Stream/i, }); @@ -239,6 +254,8 @@ test.describe('Stream View - Video MoQ Color Bars Pipeline', () => { await expect(templateCard).toBeVisible({ timeout: 10_000 }); await templateCard.click(); + await fillMoqTokenIfNeeded(page); + // Create session. const createButton = page.getByRole('button', { name: /Create Session/i }); await expect(createButton).toBeEnabled({ timeout: 5_000 }); @@ -253,7 +270,7 @@ test.describe('Stream View - Video MoQ Color Bars Pipeline', () => { // Wait for MoQ connection (auto-connect or manual). const connected = page.getByText('Relay: connected'); - const disconnected = page.getByText('Disconnected'); + const disconnected = page.getByText('Disconnected', { exact: true }); const connectButton = page.getByRole('button', { name: /Connect & Stream/i, }); @@ -381,6 +398,8 @@ test.describe('Stream View - Webcam PiP Pipeline', () => { await expect(templateCard).toBeVisible({ timeout: 10_000 }); await templateCard.click(); + await fillMoqTokenIfNeeded(page); + // Create session. const createButton = page.getByRole('button', { name: /Create Session/i }); await expect(createButton).toBeEnabled({ timeout: 5_000 }); @@ -395,7 +414,7 @@ test.describe('Stream View - Webcam PiP Pipeline', () => { // Wait for MoQ connection (auto-connect or manual). const connected = page.getByText('Relay: connected'); - const disconnected = page.getByText('Disconnected'); + const disconnected = page.getByText('Disconnected', { exact: true }); const connectButton = page.getByRole('button', { name: /Connect & Stream/i, }); diff --git a/justfile b/justfile index c9e9b860..a30dae82 100644 --- a/justfile +++ b/justfile @@ -1402,7 +1402,7 @@ show-versions: # --- E2E Tests --- # Pinned moq-relay release ref for e2e relay tests -moq_relay_ref := "moq-relay-v0.10.10" +moq_relay_ref := "moq-relay-v0.14.12" moq_relay_repo := "https://github.com/moq-dev/moq.git" # Build moq-relay binary for e2e relay tests diff --git a/ui/bun.lock b/ui/bun.lock index c9c44bd6..452a73c7 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -11,10 +11,10 @@ "@codemirror/view": "^6.43.9", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@moq/hang": "^0.2.16", - "@moq/publish": "^0.2.17", - "@moq/signals": "^0.1.10", - "@moq/watch": "^0.2.19", + "@moq/net": "^0.3.2", + "@moq/publish": "^0.4.4", + "@moq/signals": "^0.2.2", + "@moq/watch": "^0.5.1", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-dialog": "^1.1.23", @@ -86,6 +86,8 @@ }, "overrides": { "@codemirror/view": "^6.43.9", + "@moq/hang": "^0.4.1", + "@moq/net": "^0.3.2", }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -234,11 +236,11 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], @@ -332,23 +334,23 @@ "@moq/flate": ["@moq/flate@0.1.2", "", { "dependencies": { "pako": "^3.0.1" } }, "sha512-/vtXPGZQ0skmj9/HQlz1DoTJLlnT8/m2QkguNp8R5J68cwa8NtrUr9J89xqQwVCdi4Ybqy71d03bJU2P7i9uyA=="], - "@moq/hang": ["@moq/hang@0.2.16", "", { "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.9.8", "@moq/json": "^0.2.1", "@moq/loc": "^0.1.2", "@moq/net": "^0.1.9", "@moq/signals": "^0.1.10", "@svta/cml-iso-bmff": "^1.0.2", "zod": "^4.4.3" } }, "sha512-Zggd8Y4C6EVrVBc1+ZqXP04Hg9ymWYyD4YBimMSvufOr4PNHkz7QqRZMe1wrkw++pVq61iYuFalqyQdbXNaGpA=="], + "@moq/hang": ["@moq/hang@0.4.1", "", { "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.9.8", "@moq/json": "^0.3.2", "@moq/loc": "^0.2.2", "@moq/net": "^0.3.2", "@moq/signals": "^0.2.2", "@svta/cml-iso-bmff": "^1.0.4", "@svta/cml-utils": "1.6.0", "zod": "^4.4.3" } }, "sha512-oYTEVFah/QPa4xNrmJRS62tuC/TsHcwthzwLOOu+TE02ASfvYjuYeRcrL5YoCowWzpM92YchQ9HrG+eRws0GlA=="], - "@moq/json": ["@moq/json@0.2.2", "", { "dependencies": { "@moq/flate": "^0.1.1", "@moq/net": "^0.1.9", "@moq/signals": "^0.1.10" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-7eKNczxuxMsfMrCYYneR+Y4C3MnzFS6hb+D94vwaIZbP0cxXHaGxzyGmUE0e3C7fLTC9W1jNR0pZbeC+IeFt2A=="], + "@moq/json": ["@moq/json@0.3.2", "", { "dependencies": { "@moq/flate": "^0.1.2", "@moq/net": "^0.3.2", "@moq/signals": "^0.2.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-6ewNcj5u0XR4EwbJyrZjhEs5PnwzU95YFga5ti/w1ysAHvPDyC7jYZssPH9dxZ9mJX/v60vz8d36RDeSXI/4/g=="], - "@moq/loc": ["@moq/loc@0.1.2", "", { "dependencies": { "@moq/net": "^0.1.7" } }, "sha512-7hqereQwKCf6JMtKPKskgcSJC3naLQbB5/FsArKRUTMlQIF/QRu3gcaYflW8qSXaHrKm3hmpl7ra/oPLm4F7GA=="], + "@moq/loc": ["@moq/loc@0.2.2", "", { "dependencies": { "@moq/net": "^0.3.2" } }, "sha512-J1M+G0+naOB7hch4W9722kWnHHSwYXDvuBlcDQS5PSYuAFo9x53ULRpU64/vSxAvjvAs1Y+ek+8iRNl0DdDxRQ=="], - "@moq/msf": ["@moq/msf@0.1.4", "", { "dependencies": { "@moq/net": "^0.1.7", "zod": "^4.4.3" } }, "sha512-cdAcWz3lFCnAu44nf4zsugAp0X84N8w/o/b5rynPMB/1uelXCdQpcbnYUK4kZhq0co8lDM28oAUOGwyL9BjIXg=="], + "@moq/msf": ["@moq/msf@0.2.1", "", { "dependencies": { "@moq/net": "^0.3.2", "zod": "^4.4.3" } }, "sha512-nwSGupyfMoDQVJw0g349er4SlrReu+OC/oiAWyxH6KDpgbAu2vuhIKN6JcyOWsS3EIHy6G4zfizMme8yIFYqGQ=="], - "@moq/net": ["@moq/net@0.1.9", "", { "dependencies": { "@moq/qmux": "^0.1.3", "@moq/signals": "^0.1.10", "async-mutex": "^0.5.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-S6qE57ZbUAd1+pem/BUMja2T/sKhXT5mjAqo063llbtIy5DOybe3XSjEhKGaLT+/GZVbISFAeTdnuohoeI7XYw=="], + "@moq/net": ["@moq/net@0.3.2", "", { "dependencies": { "@moq/qmux": "^0.3.3", "@moq/signals": "^0.2.2", "async-mutex": "^0.5.0", "bowser": "^2.14.1" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-k4ze//i3jBUX5paIzC8pKh04s+AuRrIgf4dtRwMXKkCjxsHuw46ojTRSW7KOxJLvs9yLZhSsRUKq/8dQ+j05XQ=="], - "@moq/publish": ["@moq/publish@0.2.17", "", { "dependencies": { "@moq/hang": "^0.2.13", "@moq/json": "^0.1.2", "@moq/net": "^0.1.7", "@moq/signals": "^0.1.10" } }, "sha512-6qDoh9FxEN3ee/raNnmQ4mTpnbhqcrzAQzft97tPVxZRgzie8vtTlRZYuDobRIYbz6iQ4tlpWuy1Fuc1xuDS6w=="], + "@moq/publish": ["@moq/publish@0.4.4", "", { "dependencies": { "@moq/hang": "^0.3.5", "@moq/net": "^0.2.8", "@moq/signals": "^0.2.2" } }, "sha512-S1qeXWDkENnEkDIGIi/SM36fchMG0AIIB48is2k7elwbWGpGk2Z4LZeEOaNwD2WsHnKca1Nwpup09Ofi9OT2Kg=="], - "@moq/qmux": ["@moq/qmux@0.1.3", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.0" } }, "sha512-naGmMMOxCSMeLyEATrLiY9RGyeZENC4dHFVVNWNcA17LXE9NwM3aQrQY218xXypWahYIgIkl/ZCutIP2xHibgA=="], + "@moq/qmux": ["@moq/qmux@0.3.3", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.1" } }, "sha512-Ez1GCVwpyNGB0eWvhKIXOjXrfRlMdmO0lpJ7gqInZFzsOsYTyML0WRgh24Itczp9Kev0CXsUA/K+XrGs1dRKUA=="], - "@moq/signals": ["@moq/signals@0.1.10", "", { "peerDependencies": { "@types/react": "^19.2.17", "react": "^19.0.0", "solid-js": "^1.9.13" }, "optionalPeers": ["@types/react", "react", "solid-js"] }, "sha512-TSnQwcaywn/gIwC3UpwZbqYnXm2Zo8KZO6Y043jPiPnbnSlFWJVZ9TVdGAvQqrXGiaNihdcioF/0IEDABsfovw=="], + "@moq/signals": ["@moq/signals@0.2.2", "", { "peerDependencies": { "@types/react": "^19.2.17", "react": "^19.2.8", "solid-js": "^1.9.14" }, "optionalPeers": ["@types/react", "react", "solid-js"] }, "sha512-BnVp70shiD+HcEM612Ra8tKwnneV7m9gzIFJrRCRVcZW2L+t9P7pVpd0pzj8tydfmInKh97SBPlkaaU+rEM2ng=="], - "@moq/watch": ["@moq/watch@0.2.19", "", { "dependencies": { "@moq/hang": "^0.2.13", "@moq/json": "^0.1.2", "@moq/msf": "^0.1.4", "@moq/net": "^0.1.7", "@moq/signals": "^0.1.10" } }, "sha512-vSNcRUyOzUArabfMljf+DTP5gnM/Z0lSuZM/hoszLWK88PdU+ddckHF90q1IGg3pJl7Ti26voQ6ayFhM8bKFWA=="], + "@moq/watch": ["@moq/watch@0.5.1", "", { "dependencies": { "@moq/hang": "^0.4.1", "@moq/msf": "^0.2.1", "@moq/net": "^0.3.2", "@moq/signals": "^0.2.2" } }, "sha512-/nXgrZkN4Ves1hRgIPjXMXcWVkddoekA5PTH0INX2fLLZ64qmsPFMaTg9N/tBovWxt/ZEVdoCuQ0BQt4O6Re8A=="], "@moq/web-socket-stream": ["@moq/web-socket-stream@0.1.1", "", {}, "sha512-VKi/GE/CLqmbEZlgNbBLbAFk2pQTxT+KUD2DjCSoxqZxbS3KBe5BBZ0m3fila4fEGyll2JT5MmgEyVr4zkZEVA=="], @@ -416,7 +418,7 @@ "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.143.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw=="], - "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], @@ -570,35 +572,33 @@ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], @@ -626,7 +626,7 @@ "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.5", "", { "peerDependencies": { "@svta/cml-utils": "1.6.0" } }, "sha512-9JM/i5q81/ckFb0V5z2gESX4ICyoBOCwYhLmltPgQXNo55DenORjfY8czgSdlGuMOKxyX1yvwe/OpY4twmqT2g=="], - "@svta/cml-utils": ["@svta/cml-utils@1.5.0", "", {}, "sha512-JMqclD7Akd+GSJiuaYNUHOP2wNtf/nauKeszlYeivSHfi0Lp3pmSW5PXDvJ2dO4aPmmSOQbF0ztTsW9Vcs2Whw=="], + "@svta/cml-utils": ["@svta/cml-utils@1.6.0", "", {}, "sha512-h8jd5Y0nrr8wHWkI7XB03Denxy6QR7dgROxchZ2S0VQ4PFINrIu6J0k9N1IGHmtJnJjG3ttszsJd4LS5V/DqcA=="], "@tanstack/query-core": ["@tanstack/query-core@5.102.0", "", {}, "sha512-tvBzr11Q7StuMCEsIJdqX8TAWt6WZIzfw/yrSAjObZDerwTTPeCxeLXN2R8ZSn4ZxFpQV819Xn8QmoO+vtnDvw=="], @@ -844,6 +844,8 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1458,7 +1460,7 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1702,14 +1704,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@moq/publish/@moq/json": ["@moq/json@0.1.2", "", { "dependencies": { "@moq/flate": "^0.1.1", "@moq/net": "^0.1.7", "@moq/signals": "^0.1.10" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-sQz86Iz29lzJIyzY6OtBqJHXxqtcDj2DypPNSEbHSwUQCcOxEzkMS2vhNZEK+oTRN6DpblTmelBeZJPg4RjkjA=="], - - "@moq/watch/@moq/json": ["@moq/json@0.1.2", "", { "dependencies": { "@moq/flate": "^0.1.1", "@moq/net": "^0.1.7", "@moq/signals": "^0.1.10" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-sQz86Iz29lzJIyzY6OtBqJHXxqtcDj2DypPNSEbHSwUQCcOxEzkMS2vhNZEK+oTRN6DpblTmelBeZJPg4RjkjA=="], - - "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], @@ -1722,10 +1716,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@unrs/resolver-binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@unrs/resolver-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -1800,8 +1790,6 @@ "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "vite/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1822,14 +1810,12 @@ "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@testing-library/dom/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@testing-library/dom/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@unrs/resolver-binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "babel-plugin-macros/cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], @@ -1954,36 +1940,6 @@ "react-doctor/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], - - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], - - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], - - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], - - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], - - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], - - "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], - - "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], - - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], - - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], - - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], - - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], - - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], - "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -2011,11 +1967,5 @@ "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], } } diff --git a/ui/package.json b/ui/package.json index 60f5f993..e2632a83 100644 --- a/ui/package.json +++ b/ui/package.json @@ -66,10 +66,10 @@ "@codemirror/view": "^6.43.9", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@moq/hang": "^0.2.16", - "@moq/publish": "^0.2.17", - "@moq/signals": "^0.1.10", - "@moq/watch": "^0.2.19", + "@moq/net": "^0.3.2", + "@moq/publish": "^0.4.4", + "@moq/signals": "^0.2.2", + "@moq/watch": "^0.5.1", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-dialog": "^1.1.23", @@ -99,6 +99,8 @@ "zustand": "^5.0.15" }, "overrides": { - "@codemirror/view": "$@codemirror/view" + "@codemirror/view": "$@codemirror/view", + "@moq/hang": "^0.4.1", + "@moq/net": "$@moq/net" } } diff --git a/ui/src/hooks/useMoqYamlSync.test.ts b/ui/src/hooks/useMoqYamlSync.test.ts index 67b8c91f..ba30d678 100644 --- a/ui/src/hooks/useMoqYamlSync.test.ts +++ b/ui/src/hooks/useMoqYamlSync.test.ts @@ -11,6 +11,11 @@ import { useMoqYamlSync } from './useMoqYamlSync'; // useMoqYamlSync transitively imports the stream store, which pulls in the // WebTransport-backed @moq/* libraries; stub them so the module loads in jsdom. +vi.mock('@moq/net', () => ({ + default: {}, + Connection: { Reload: vi.fn() }, + Path: { from: vi.fn() }, +})); vi.mock('@moq/hang', () => ({ default: {} })); vi.mock('@moq/watch', () => ({ default: {}, Broadcast: vi.fn() })); vi.mock('@moq/publish', () => ({ default: {}, Broadcast: vi.fn() })); diff --git a/ui/src/stores/streamStore.test.ts b/ui/src/stores/streamStore.test.ts index b3b52f25..22429c7d 100644 --- a/ui/src/stores/streamStore.test.ts +++ b/ui/src/stores/streamStore.test.ts @@ -8,6 +8,11 @@ import { useStreamStore, type ConnectionStatus } from './streamStore'; import * as configService from '../services/config'; // Mock the Hang library to avoid import errors +vi.mock('@moq/net', () => ({ + default: {}, + Connection: { Reload: vi.fn() }, + Path: { from: vi.fn() }, +})); vi.mock('@moq/hang', () => ({ default: {}, Moq: { diff --git a/ui/src/stores/streamStore.ts b/ui/src/stores/streamStore.ts index e31700a1..b5476d79 100644 --- a/ui/src/stores/streamStore.ts +++ b/ui/src/stores/streamStore.ts @@ -2,19 +2,22 @@ // // SPDX-License-Identifier: MPL-2.0 -// `Hang` is only needed for the `Moq.Connection.Reload` type — -// `Watch` and `Publish` provide the media-specific APIs. -import * as Hang from '@moq/hang'; -import * as Publish from '@moq/publish'; +import type * as Moq from '@moq/net'; import { Effect } from '@moq/signals'; import * as Watch from '@moq/watch'; import { create } from 'zustand'; import { + type AudioEmitterHandle, + type CameraHandle, cleanupConnectAttempt, decideConnect, + type MicrophoneHandle, NULL_MOQ_REFS, performConnect, + type PublishHandle, + type ScreenHandle, + type VideoRendererHandle, } from './streamStoreHelpers'; import { fetchConfig } from '../services/config'; import { getLogger } from '../utils/logger'; @@ -96,25 +99,25 @@ interface StreamState { connectingStep: string; // MoQ references (stored but not serialized) - publish: Publish.Broadcast | null; + publish: PublishHandle | null; watch: Watch.Broadcast | null; watchSync: Watch.Sync | null; audioSource: Watch.Audio.Source | null; audioDecoder: Watch.Audio.Decoder | null; - audioEmitter: Watch.Audio.Emitter | null; + audioEmitter: AudioEmitterHandle | null; videoSource: Watch.Video.Source | null; videoDecoder: Watch.Video.Decoder | null; - videoRenderer: Watch.Video.Renderer | null; - connection: Hang.Moq.Connection.Reload | null; - microphone: Publish.Source.Microphone | null; - camera: Publish.Source.Camera | null; - screen: Publish.Source.Screen | null; + videoRenderer: VideoRendererHandle | null; + connection: Moq.Connection.Reload | null; + microphone: MicrophoneHandle | null; + camera: CameraHandle | null; + screen: ScreenHandle | null; healthEffect: Effect | null; // Secondary broadcast references (multi-broadcast support) - secondaryPublish: Publish.Broadcast | null; - secondaryCamera: Publish.Source.Camera | null; - secondaryScreen: Publish.Source.Screen | null; + secondaryPublish: PublishHandle | null; + secondaryCamera: CameraHandle | null; + secondaryScreen: ScreenHandle | null; setServerUrl: (url: string) => void; setMoqToken: (token: string) => void; @@ -146,19 +149,19 @@ interface StreamState { // Store references to MoQ objects setMoqRefs: (refs: { - publish: Publish.Broadcast; + publish: PublishHandle; watch: Watch.Broadcast; watchSync: Watch.Sync; audioSource: Watch.Audio.Source; audioDecoder: Watch.Audio.Decoder; - audioEmitter: Watch.Audio.Emitter; + audioEmitter: AudioEmitterHandle; videoSource: Watch.Video.Source; videoDecoder: Watch.Video.Decoder; - videoRenderer: Watch.Video.Renderer; - connection: Hang.Moq.Connection.Reload; - microphone: Publish.Source.Microphone; - camera: Publish.Source.Camera; - screen: Publish.Source.Screen | null; + videoRenderer: VideoRendererHandle; + connection: Moq.Connection.Reload; + microphone: MicrophoneHandle; + camera: CameraHandle; + screen: ScreenHandle | null; }) => void; } diff --git a/ui/src/stores/streamStoreHelpers.test.ts b/ui/src/stores/streamStoreHelpers.test.ts index 744fbb14..4b90c5b0 100644 --- a/ui/src/stores/streamStoreHelpers.test.ts +++ b/ui/src/stores/streamStoreHelpers.test.ts @@ -2,9 +2,9 @@ // // SPDX-License-Identifier: MPL-2.0 -import * as Hang from '@moq/hang'; +import * as Moq from '@moq/net'; import * as Publish from '@moq/publish'; -import { Effect, type Getter } from '@moq/signals'; +import { Effect, Signal, type Getter } from '@moq/signals'; import * as Watch from '@moq/watch'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -22,6 +22,12 @@ import { validateTrackCodecs, NULL_MOQ_REFS, performConnect, + VideoRendererHandle, + AudioEmitterHandle, + MicrophoneHandle, + CameraHandle, + ScreenHandle, + PublishHandle, type ConnectAttempt, type ConnectableState, type ConnectDecision, @@ -42,25 +48,55 @@ function makeTrack(overrides: Partial = {}): PublishTrackCon } // Mock the MoQ libraries to avoid ESM resolution errors in the test environment. -vi.mock('@moq/hang', () => ({ - Moq: { Connection: { Reload: vi.fn() } }, +vi.mock('@moq/net', () => ({ + Connection: { Reload: vi.fn() }, + Path: { from: vi.fn((s: string) => s) }, })); vi.mock('@moq/publish', () => ({ Broadcast: vi.fn(), - Lite: { Path: { from: vi.fn() } }, + Audio: { Encoder: vi.fn() }, + Video: { Capture: vi.fn(), Encoder: vi.fn() }, Source: { Microphone: vi.fn(), Camera: vi.fn(), Screen: vi.fn() }, })); -vi.mock('@moq/watch', () => ({ - Broadcast: vi.fn(), - Sync: vi.fn(), - Lite: { Path: { from: vi.fn() } }, - Audio: { Source: vi.fn(), Decoder: vi.fn(), Emitter: vi.fn() }, - Video: { Source: vi.fn(), Decoder: vi.fn(), Renderer: vi.fn() }, -})); -vi.mock('@moq/signals', () => ({ - Effect: vi.fn(), - Signal: vi.fn(), -})); +vi.mock('@moq/watch', () => { + const AudioDecoder = vi.fn() as ReturnType & { supported?: unknown }; + const VideoDecoder = vi.fn() as ReturnType & { supported?: unknown }; + AudioDecoder.supported = vi.fn(); + VideoDecoder.supported = vi.fn(); + return { + Broadcast: vi.fn(), + Sync: vi.fn(), + Audio: { Source: vi.fn(), Decoder: AudioDecoder, Emitter: vi.fn() }, + Video: { Source: vi.fn(), Decoder: VideoDecoder, Renderer: vi.fn() }, + }; +}); +vi.mock('@moq/signals', () => { + class MockSignal { + #value: T; + #listeners: ((v: T) => void)[] = []; + constructor(value: T) { + this.#value = value; + } + peek() { + return this.#value; + } + set(value: T) { + this.#value = value; + for (const l of [...this.#listeners]) l(value); + } + subscribe(fn: (v: T) => void) { + this.#listeners.push(fn); + return () => { + const idx = this.#listeners.indexOf(fn); + if (idx >= 0) this.#listeners.splice(idx, 1); + }; + } + } + return { + Effect: vi.fn(), + Signal: MockSignal, + }; +}); // decideConnect @@ -960,23 +996,32 @@ describe('performConnect', () => { const catalog = createMockSignal( catalogReady ? { ready: true } : undefined ); - const audioEnabledSet = vi.fn(); + const audioEncoderInputs: { enabled?: { peek(): boolean } }[] = []; asMock(Publish.Source.Microphone).mockImplementation(function () { - return { source: micSource, close: vi.fn() }; + return { out: { source: micSource }, close: vi.fn() }; }); asMock(Publish.Source.Camera).mockImplementation(function () { - return { source: camSource, close: vi.fn() }; + return { out: { source: camSource }, close: vi.fn() }; + }); + asMock(Publish.Video.Capture).mockImplementation(function () { + return { out: { display: createMockSignal(undefined) }, close: vi.fn() }; + }); + asMock(Publish.Video.Encoder).mockImplementation(function () { + return { out: { catalog }, close: vi.fn() }; + }); + asMock(Publish.Audio.Encoder).mockImplementation(function ( + _name: string, + inputs: { enabled?: { peek(): boolean } } + ) { + audioEncoderInputs.push(inputs); + return { close: vi.fn() }; }); asMock(Publish.Broadcast).mockImplementation(function () { - return { - video: { catalog }, - audio: { enabled: { set: audioEnabledSet } }, - close: vi.fn(), - }; + return { close: vi.fn() }; }); - return { micSource, camSource, catalog, audioEnabledSet }; + return { micSource, camSource, catalog, audioEncoderInputs }; } beforeEach(() => { @@ -991,20 +1036,20 @@ describe('performConnect', () => { }); get = () => state; - asMock(Hang.Moq.Connection.Reload).mockImplementation(function () { + asMock(Moq.Connection.Reload).mockImplementation(function () { return { established: connEstablished, status: connStatus, close: vi.fn() }; }); asMock(Effect).mockImplementation(function () { return mockEffect; }); asMock(Watch.Broadcast).mockImplementation(function () { - return { status: watchStatusSig, close: vi.fn() }; + return { out: { status: watchStatusSig }, close: vi.fn() }; }); asMock(Watch.Sync).mockImplementation(function () { return { close: vi.fn() }; }); asMock(Watch.Audio.Source).mockImplementation(function () { - return { close: vi.fn() }; + return { out: { jitter: createMockSignal(undefined) }, close: vi.fn() }; }); asMock(Watch.Audio.Decoder).mockImplementation(function () { return { close: vi.fn() }; @@ -1013,7 +1058,7 @@ describe('performConnect', () => { return { close: vi.fn() }; }); asMock(Watch.Video.Source).mockImplementation(function () { - return { close: vi.fn() }; + return { out: { jitter: createMockSignal(undefined) }, close: vi.fn() }; }); asMock(Watch.Video.Decoder).mockImplementation(function () { return { close: vi.fn() }; @@ -1055,7 +1100,7 @@ describe('performConnect', () => { it('syncs watchStatus from the broadcast signal during watch setup', async () => { watchStatusSig = createMockSignal('pending'); asMock(Watch.Broadcast).mockImplementation(function () { - return { status: watchStatusSig, close: vi.fn() }; + return { out: { status: watchStatusSig }, close: vi.fn() }; }); const decision = makeOkDecision({ shouldWatch: true, shouldPublish: false }); @@ -1086,7 +1131,7 @@ describe('performConnect', () => { it('defers audio publishing until the video catalog is ready', async () => { state = makeState({ pipelineNeedsAudio: true, pipelineNeedsVideo: true }); - const { audioEnabledSet } = setupPublishMocks(); + const { audioEncoderInputs } = setupPublishMocks(); const decision = makeOkDecision({ shouldWatch: false, shouldPublish: true }); const abort = new AbortController(); @@ -1096,7 +1141,8 @@ describe('performConnect', () => { // For combined audio+video publish, audio is started disabled and explicitly // re-enabled after the video catalog is observed (prevents the ~0.7s A/V // desync caused by VP9 encoder startup). - expect(audioEnabledSet).toHaveBeenCalledWith(true); + expect(audioEncoderInputs).toHaveLength(1); + expect(audioEncoderInputs[0]?.enabled?.peek()).toBe(true); }); it('sets micStatus to requesting when mic source is initially unavailable', async () => { @@ -1118,7 +1164,7 @@ describe('performConnect', () => { vi.useFakeTimers(); connEstablished = createMockSignal(undefined); - asMock(Hang.Moq.Connection.Reload).mockImplementation(function () { + asMock(Moq.Connection.Reload).mockImplementation(function () { return { established: connEstablished, status: connStatus, close: vi.fn() }; }); @@ -1138,7 +1184,7 @@ describe('performConnect', () => { it('returns false without overwriting state when aborted mid-connect', async () => { connEstablished = createMockSignal(undefined); - asMock(Hang.Moq.Connection.Reload).mockImplementation(function () { + asMock(Moq.Connection.Reload).mockImplementation(function () { return { established: connEstablished, status: connStatus, close: vi.fn() }; }); @@ -1157,7 +1203,7 @@ describe('performConnect', () => { it('cleans up resources and reports error when watch setup throws', async () => { const connClose = vi.fn(); - asMock(Hang.Moq.Connection.Reload).mockImplementation(function () { + asMock(Moq.Connection.Reload).mockImplementation(function () { return { established: connEstablished, status: connStatus, close: connClose }; }); asMock(Watch.Broadcast).mockImplementation(function () { @@ -1181,7 +1227,7 @@ describe('performConnect', () => { watchStatusSig = createMockSignal('pending'); asMock(Watch.Broadcast).mockImplementation(function () { - return { status: watchStatusSig, close: vi.fn() }; + return { out: { status: watchStatusSig }, close: vi.fn() }; }); const decision = makeOkDecision({ shouldWatch: true, shouldPublish: false }); @@ -1214,7 +1260,7 @@ describe('performConnect', () => { watchStatusSig = createMockSignal('pending'); asMock(Watch.Broadcast).mockImplementation(function () { - return { status: watchStatusSig, close: vi.fn() }; + return { out: { status: watchStatusSig }, close: vi.fn() }; }); const decision = makeOkDecision({ shouldWatch: true, shouldPublish: false }); @@ -1327,3 +1373,99 @@ describe('performConnect', () => { }); }); }); + +// Resource handles + +describe('resource handles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('VideoRendererHandle wires the canvas signal into the renderer and closes it', () => { + const close = vi.fn(); + vi.mocked(Watch.Video.Renderer).mockImplementation(function (this: { close: typeof close }) { + this.close = close; + } as never); + + const decoder = {} as Watch.Video.Decoder; + const handle = new VideoRendererHandle(decoder); + + expect(Watch.Video.Renderer).toHaveBeenCalledWith(decoder, { canvas: handle.canvas }); + handle.close(); + expect(close).toHaveBeenCalled(); + }); + + it('AudioEmitterHandle wires muted/volume signals into the emitter and closes it', () => { + const close = vi.fn(); + vi.mocked(Watch.Audio.Emitter).mockImplementation(function (this: { close: typeof close }) { + this.close = close; + } as never); + + const decoder = {} as Watch.Audio.Decoder; + const handle = new AudioEmitterHandle(decoder); + + expect(Watch.Audio.Emitter).toHaveBeenCalledWith(decoder, { + muted: handle.muted, + volume: handle.volume, + }); + expect(handle.muted.peek()).toBe(false); + expect(handle.volume.peek()).toBe(0.5); + handle.close(); + expect(close).toHaveBeenCalled(); + }); + + it.each([ + ['MicrophoneHandle', Publish.Source.Microphone, () => new MicrophoneHandle()], + ['CameraHandle', Publish.Source.Camera, () => new CameraHandle()], + ['ScreenHandle', Publish.Source.Screen, () => new ScreenHandle()], + ] as const)( + '%s exposes the capture source getter and closes the inner source', + (_name, ctor, make) => { + const close = vi.fn(); + const source = { kind: 'source' }; + vi.mocked(ctor).mockImplementation(function (this: { + close: typeof close; + out: { source: typeof source }; + }) { + this.close = close; + this.out = { source }; + } as never); + + const handle = make(); + + expect(handle.enabled.peek()).toBe(true); + expect(handle.source).toBe(source); + handle.close(); + expect(close).toHaveBeenCalled(); + } + ); + + it('PublishHandle closes every owned resource', () => { + const broadcast = { close: vi.fn() }; + const capture = { close: vi.fn() }; + const video = { close: vi.fn() }; + const encoder = { close: vi.fn() }; + const enabled = new Signal(true); + + const handle = new PublishHandle({ + broadcast: broadcast as unknown as Publish.Broadcast, + capture: capture as unknown as Publish.Video.Capture, + video: video as unknown as Publish.Video.Encoder, + audio: { enabled, encoder: encoder as unknown as Publish.Audio.Encoder }, + }); + + handle.close(); + expect(video.close).toHaveBeenCalled(); + expect(encoder.close).toHaveBeenCalled(); + expect(capture.close).toHaveBeenCalled(); + expect(broadcast.close).toHaveBeenCalled(); + }); + + it('PublishHandle tolerates missing optional resources on close', () => { + const broadcast = { close: vi.fn() }; + const handle = new PublishHandle({ broadcast: broadcast as unknown as Publish.Broadcast }); + + handle.close(); + expect(broadcast.close).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/stores/streamStoreHelpers.ts b/ui/src/stores/streamStoreHelpers.ts index c5ea1c33..a1142028 100644 --- a/ui/src/stores/streamStoreHelpers.ts +++ b/ui/src/stores/streamStoreHelpers.ts @@ -4,7 +4,7 @@ /** Helpers for streamStore — connection lifecycle, teardown, and health warnings. */ -import * as Hang from '@moq/hang'; +import * as Moq from '@moq/net'; import * as Publish from '@moq/publish'; import type { Getter } from '@moq/signals'; import { Effect, Signal } from '@moq/signals'; @@ -109,27 +109,154 @@ export type ConnectDecision = } | { ok: false; errorMessage: string }; +/** Track names must match the pins expected by the `transport::moq::peer` + * node and the sample pipelines (e.g. `moq_peer.video/hd`). */ +export const VIDEO_TRACK_NAME = 'video/hd'; +export const AUDIO_TRACK_NAME = 'audio/data'; + +/** + * Owns the renderer plus the writable canvas signal, preserving the + * `videoRenderer.canvas.set(el)` binding contract used by the views. + */ +export class VideoRendererHandle { + readonly canvas = new Signal(undefined); + readonly renderer: Watch.Video.Renderer; + + constructor(decoder: Watch.Video.Decoder) { + this.renderer = new Watch.Video.Renderer(decoder, { canvas: this.canvas }); + } + + close(): void { + this.renderer.close(); + } +} + +/** + * Owns the emitter plus writable muted/volume signals, preserving the + * `audioEmitter.muted` / `audioEmitter.volume` contract used by the views. + */ +export class AudioEmitterHandle { + readonly muted = new Signal(false); + readonly volume = new Signal(0.5); + readonly emitter: Watch.Audio.Emitter; + + constructor(decoder: Watch.Audio.Decoder) { + this.emitter = new Watch.Audio.Emitter(decoder, { + muted: this.muted, + volume: this.volume, + }); + } + + close(): void { + this.emitter.close(); + } +} + +/** Owns a capture source plus the writable enabled signal used by the toggles. */ +export class MicrophoneHandle { + readonly enabled = new Signal(true); + readonly inner: Publish.Source.Microphone; + + constructor() { + this.inner = new Publish.Source.Microphone({ enabled: this.enabled }); + } + + get source(): Getter { + return this.inner.out.source; + } + + close(): void { + this.inner.close(); + } +} + +export class CameraHandle { + readonly enabled = new Signal(true); + readonly inner: Publish.Source.Camera; + + constructor(constraints?: { width?: number; height?: number }) { + this.inner = new Publish.Source.Camera({ enabled: this.enabled, constraints }); + } + + get source(): Getter { + return this.inner.out.source; + } + + close(): void { + this.inner.close(); + } +} + +export class ScreenHandle { + readonly enabled = new Signal(true); + readonly inner: Publish.Source.Screen; + + constructor(videoConstraints?: { width?: number; height?: number }) { + this.inner = new Publish.Source.Screen({ enabled: this.enabled, video: videoConstraints }); + } + + get source(): Getter<{ audio?: Publish.Audio.Source; video?: Publish.Video.Source } | undefined> { + return this.inner.out.source; + } + + close(): void { + this.inner.close(); + } +} + +/** + * A published broadcast plus its encoders. `@moq/publish` splits the old + * monolithic Broadcast config into per-rendition encoders wired to a shared + * capture, so this handle owns the whole publish graph for teardown and + * exposes the writable enable signals the store toggles need. + */ +export class PublishHandle { + readonly broadcast: Publish.Broadcast; + readonly capture: Publish.Video.Capture | null; + readonly video: Publish.Video.Encoder | null; + readonly audio: { enabled: Signal; encoder: Publish.Audio.Encoder } | null; + + constructor(opts: { + broadcast: Publish.Broadcast; + capture?: Publish.Video.Capture | null; + video?: Publish.Video.Encoder | null; + audio?: { enabled: Signal; encoder: Publish.Audio.Encoder } | null; + }) { + this.broadcast = opts.broadcast; + this.capture = opts.capture ?? null; + this.video = opts.video ?? null; + this.audio = opts.audio ?? null; + } + + close(): void { + this.video?.close(); + this.audio?.encoder.close(); + this.capture?.close(); + this.broadcast.close(); + } +} + export type ConnectAttempt = { - connection: Hang.Moq.Connection.Reload | null; + connection: Moq.Connection.Reload | null; healthEffect: Effect | null; watch: Watch.Broadcast | null; watchSync: Watch.Sync | null; audioSource: Watch.Audio.Source | null; audioDecoder: Watch.Audio.Decoder | null; - audioEmitter: Watch.Audio.Emitter | null; + audioEmitter: AudioEmitterHandle | null; videoSource: Watch.Video.Source | null; videoDecoder: Watch.Video.Decoder | null; - videoRenderer: Watch.Video.Renderer | null; - microphone: Publish.Source.Microphone | null; - camera: Publish.Source.Camera | null; - screen: Publish.Source.Screen | null; - publish: Publish.Broadcast | null; + videoRenderer: VideoRendererHandle | null; + microphone: MicrophoneHandle | null; + camera: CameraHandle | null; + screen: ScreenHandle | null; + publish: PublishHandle | null; /** Secondary publish broadcast for multi-broadcast mode (e.g. camera PiP). */ - secondaryPublish: Publish.Broadcast | null; + secondaryPublish: PublishHandle | null; /** Secondary camera source for multi-broadcast mode. */ - secondaryCamera: Publish.Source.Camera | null; + secondaryCamera: CameraHandle | null; /** Secondary screen source for multi-broadcast mode. */ - secondaryScreen: Publish.Source.Screen | null; + secondaryScreen: ScreenHandle | null; }; /** Minimal slice of StreamState needed by helper functions. */ @@ -243,16 +370,15 @@ export function waitForSignalValue( /** Wait for a broadcast to appear on the relay before subscribing. */ async function waitForBroadcastAnnouncement( - connection: Hang.Moq.Connection.Reload, + connection: Moq.Connection.Reload, broadcastName: string, timeoutMs = 15_000, abortSignal?: AbortSignal ): Promise { if (abortSignal?.aborted) throw new DOMException('Aborted', 'AbortError'); - const conn = connection.established.peek(); - if (!conn) return; + if (!connection.established.peek()) return; logger.info(`Waiting for broadcast '${broadcastName}' announcement...`); - const announcements = conn.announced(); + const announcements = connection.announced(); const deadline = Date.now() + timeoutMs; try { const abortPromise = abortSignal @@ -318,9 +444,7 @@ export function formatConnectError(error: unknown): string { } /** Shut down a media source that may expose `.close()` or only `.enabled`. */ -function shutdownMediaSource( - source: Publish.Source.Microphone | Publish.Source.Camera | Publish.Source.Screen | null -): void { +function shutdownMediaSource(source: MicrophoneHandle | CameraHandle | ScreenHandle | null): void { if (!source) return; if (typeof source.close === 'function') { source.close(); @@ -361,7 +485,7 @@ export function cleanupConnectAttempt(attempt: ConnectAttempt): void { function setupConnectionStatusSync( healthEffect: Effect, - connection: Hang.Moq.Connection.Reload, + connection: Moq.Connection.Reload, get: () => ConnectableState, set: StateSetter ): void { @@ -391,7 +515,7 @@ function setupConnectionStatusSync( function setupWatchPath( healthEffect: Effect, - connection: Hang.Moq.Connection.Reload, + connection: Moq.Connection.Reload, outputBroadcast: string, outputsAudio: boolean, outputsVideo: boolean, @@ -401,47 +525,58 @@ function setupWatchPath( watchSync: Watch.Sync; audioSource: Watch.Audio.Source | null; audioDecoder: Watch.Audio.Decoder | null; - audioEmitter: Watch.Audio.Emitter | null; + audioEmitter: AudioEmitterHandle | null; videoSource: Watch.Video.Source | null; videoDecoder: Watch.Video.Decoder | null; - videoRenderer: Watch.Video.Renderer | null; + videoRenderer: VideoRendererHandle | null; } { logger.info('Step 2: Creating watch broadcast (subscribe FIRST)'); const watch = new Watch.Broadcast({ connection: connection.established, enabled: true, - name: Watch.Lite.Path.from(outputBroadcast), + name: Moq.Path.from(outputBroadcast), }); - const watchSync = new Watch.Sync(); - let audioSource: Watch.Audio.Source | null = null; - let audioDecoder: Watch.Audio.Decoder | null = null; - let audioEmitter: Watch.Audio.Emitter | null = null; + let videoSource: Watch.Video.Source | null = null; if (outputsAudio) { - logger.info('Step 3: Creating audio source/decoder/emitter'); - audioSource = new Watch.Audio.Source(watchSync, { broadcast: watch }); - audioDecoder = new Watch.Audio.Decoder(audioSource); - audioEmitter = new Watch.Audio.Emitter(audioDecoder, { - muted: false, - volume: 0.5, + logger.info('Step 3: Creating audio source'); + audioSource = new Watch.Audio.Source({ + broadcast: watch, + supported: Watch.Audio.Decoder.supported, + }); + } + if (outputsVideo) { + logger.info('Step 3b: Creating video source'); + videoSource = new Watch.Video.Source({ + broadcast: watch, + supported: Watch.Video.Decoder.supported, }); } - let videoSource: Watch.Video.Source | null = null; - let videoDecoder: Watch.Video.Decoder | null = null; - let videoRenderer: Watch.Video.Renderer | null = null; + const watchSync = new Watch.Sync({ + connection: connection.established, + audio: audioSource?.out.jitter, + video: videoSource?.out.jitter, + }); - if (outputsVideo) { - logger.info('Step 3b: Creating video source/decoder/renderer'); - videoSource = new Watch.Video.Source(watchSync, { broadcast: watch }); - videoDecoder = new Watch.Video.Decoder(videoSource); - videoRenderer = new Watch.Video.Renderer(videoDecoder); + let audioDecoder: Watch.Audio.Decoder | null = null; + let audioEmitter: AudioEmitterHandle | null = null; + if (audioSource) { + audioDecoder = new Watch.Audio.Decoder(audioSource, watchSync, { enabled: true }); + audioEmitter = new AudioEmitterHandle(audioDecoder); + } + + let videoDecoder: Watch.Video.Decoder | null = null; + let videoRenderer: VideoRendererHandle | null = null; + if (videoSource) { + videoDecoder = new Watch.Video.Decoder(videoSource, watchSync, { enabled: true }); + videoRenderer = new VideoRendererHandle(videoDecoder); } - set({ watchStatus: watch.status.peek() }); - healthEffect.subscribe(watch.status, (value) => { + set({ watchStatus: watch.out.status.peek() }); + healthEffect.subscribe(watch.out.status, (value) => { set({ watchStatus: value }); }); @@ -463,14 +598,12 @@ function setupWatchPath( * types (Publish.Source.Screen vs Publish.Source.Camera have different APIs). */ async function setupScreenCapture( healthEffect: Effect, - microphone: Publish.Source.Microphone | null, + microphone: MicrophoneHandle | null, set: StateSetter, abortSignal?: AbortSignal, videoConstraints?: { width?: number; height?: number } -): Promise { - const screenProps: ConstructorParameters[0] = { enabled: true }; - if (videoConstraints) screenProps.video = videoConstraints; - const screen = new Publish.Source.Screen(screenProps); +): Promise { + const screen = new ScreenHandle(videoConstraints); set({ cameraStatus: screen.source.peek()?.video ? 'ready' : 'requesting' }); healthEffect.subscribe(screen.source, (v) => set({ cameraStatus: v?.video ? 'ready' : 'requesting' }) @@ -496,14 +629,12 @@ async function setupScreenCapture( /** Set up camera capture with 15s timeout for permission dialog. */ async function setupCameraCapture( healthEffect: Effect, - microphone: Publish.Source.Microphone | null, + microphone: MicrophoneHandle | null, set: StateSetter, abortSignal?: AbortSignal, videoConstraints?: { width?: number; height?: number } -): Promise { - const cameraProps: ConstructorParameters[0] = { enabled: true }; - if (videoConstraints) cameraProps.constraints = videoConstraints; - const camera = new Publish.Source.Camera(cameraProps); +): Promise { + const camera = new CameraHandle(videoConstraints); set({ cameraStatus: camera.source.peek() ? 'ready' : 'requesting' }); healthEffect.subscribe(camera.source, (v) => set({ cameraStatus: v ? 'ready' : 'requesting' })); if (!camera.source.peek()) { @@ -533,15 +664,15 @@ async function setupMediaSources( abortSignal?: AbortSignal, videoConstraints?: { width?: number; height?: number } ): Promise<{ - microphone: Publish.Source.Microphone | null; - camera: Publish.Source.Camera | null; - screen: Publish.Source.Screen | null; + microphone: MicrophoneHandle | null; + camera: CameraHandle | null; + screen: ScreenHandle | null; }> { - let microphone: Publish.Source.Microphone | null = null; - let camera: Publish.Source.Camera | null = null; - let screen: Publish.Source.Screen | null = null; + let microphone: MicrophoneHandle | null = null; + let camera: CameraHandle | null = null; + let screen: ScreenHandle | null = null; if (needsAudio) { - microphone = new Publish.Source.Microphone({ enabled: true }); + microphone = new MicrophoneHandle(); set({ micStatus: microphone.source.peek() ? 'ready' : 'requesting' }); healthEffect.subscribe(microphone.source, (v) => set({ micStatus: v ? 'ready' : 'requesting' }) @@ -569,9 +700,76 @@ async function setupMediaSources( return { microphone, camera, screen }; } +/** Build the shared video capture from a screen or camera handle. + * Screen capture: derive a video-only signal from the composite + * Screen.source signal ({ audio?, video? } | undefined). System audio from + * screen capture is ignored — mic remains the sole audio source. */ +function createVideoCapture( + healthEffect: Effect, + screen: ScreenHandle | null, + camera: CameraHandle | null +): Publish.Video.Capture | null { + if (screen) { + const videoOnlySignal = new Signal( + screen.source.peek()?.video + ); + healthEffect.subscribe(screen.source, (v) => videoOnlySignal.set(v?.video)); + return new Publish.Video.Capture({ source: videoOnlySignal }); + } + if (camera) { + return new Publish.Video.Capture({ source: camera.source }); + } + return null; +} + +/** Construct the publish encoders, closing the already-created capture and + * broadcast if either encoder constructor throws so nothing leaks before a + * PublishHandle owns them. */ +function createPublishEncoders( + broadcast: Publish.Broadcast, + capture: Publish.Video.Capture | null, + microphone: MicrophoneHandle | null, + audioEnabledInitially: boolean, + encoderConfig: Publish.Video.EncoderProps['config'] +): { + audio: { enabled: Signal; encoder: Publish.Audio.Encoder } | null; + video: Publish.Video.Encoder | null; +} { + let audio: { enabled: Signal; encoder: Publish.Audio.Encoder } | null = null; + try { + if (microphone) { + const audioEnabled = new Signal(audioEnabledInitially); + audio = { + enabled: audioEnabled, + encoder: new Publish.Audio.Encoder(AUDIO_TRACK_NAME, { + broadcast, + enabled: audioEnabled, + source: microphone.source, + }), + }; + } + + const video = capture + ? new Publish.Video.Encoder(VIDEO_TRACK_NAME, { + broadcast, + capture, + enabled: true, + config: encoderConfig, + }) + : null; + + return { audio, video }; + } catch (e) { + audio?.encoder.close(); + capture?.close(); + broadcast.close(); + throw e; + } +} + async function setupPublishPath( healthEffect: Effect, - connection: Hang.Moq.Connection.Reload, + connection: Moq.Connection.Reload, inputBroadcast: string, needsAudio: boolean, needsVideo: boolean, @@ -580,10 +778,10 @@ async function setupPublishPath( set: StateSetter, abortSignal?: AbortSignal ): Promise<{ - microphone: Publish.Source.Microphone | null; - camera: Publish.Source.Camera | null; - screen: Publish.Source.Screen | null; - publish: Publish.Broadcast; + microphone: MicrophoneHandle | null; + camera: CameraHandle | null; + screen: ScreenHandle | null; + publish: PublishHandle; }> { // Resolve per-track media hints for the primary video track. const videoTrack = tracks.find((t) => t.kind === 'video') ?? null; @@ -600,74 +798,70 @@ async function setupPublishPath( ); logger.info('Step 5: Creating publish broadcast'); - const broadcastConfig: ConstructorParameters[0] = { - connection: connection.established, - enabled: true, - name: Publish.Lite.Path.from(inputBroadcast), - }; // When both audio and video are needed, start audio disabled and enable it // after the video encoder is ready. This ensures both tracks begin MoQ // publishing at the same time, preventing the ~0.7s A/V desync from the // VP9 encoder's slower startup. const deferAudioUntilVideo = needsAudio && needsVideo; - if (needsAudio && microphone) { - broadcastConfig.audio = { - enabled: !deferAudioUntilVideo, - source: microphone.source, - }; - } - if (needsVideo) { - if (videoSourceType === 'screen' && screen) { - // Screen capture: derive a video-only signal from the composite - // Screen.source signal ({ audio?, video? } | undefined). - // System audio from screen capture is ignored — mic remains the - // sole audio source. - const videoOnlySignal = new Signal( - screen.source.peek()?.video - ); - healthEffect.subscribe(screen.source, (v) => videoOnlySignal.set(v?.video)); - broadcastConfig.video = { - source: videoOnlySignal, - hd: { enabled: true, config: encoderConfig }, - }; - } else if (camera) { - broadcastConfig.video = { - source: camera.source, - hd: { enabled: true, config: encoderConfig }, - }; - } - } - const publish = new Publish.Broadcast(broadcastConfig); + let publish: PublishHandle | undefined; + try { + const capture = needsVideo + ? createVideoCapture(healthEffect, videoSourceType === 'screen' ? screen : null, camera) + : null; - // Wait for the video encoder to produce a catalog entry before returning. - if (needsVideo) { - logger.info('Step 5b: Waiting for video catalog...'); + let broadcast: Publish.Broadcast; try { + broadcast = new Publish.Broadcast({ + connection: connection.established, + enabled: true, + name: Moq.Path.from(inputBroadcast), + display: capture?.out.display, + }); + } catch (e) { + capture?.close(); + throw e; + } + + const { audio, video } = createPublishEncoders( + broadcast, + capture, + needsAudio ? microphone : null, + !deferAudioUntilVideo, + encoderConfig + ); + + publish = new PublishHandle({ broadcast, capture, video, audio }); + + // Wait for the video encoder to produce a catalog entry before returning. + if (needsVideo && video) { + logger.info('Step 5b: Waiting for video catalog...'); await waitForSignalValue( - publish.video.catalog, + video.out.catalog, (v) => v !== undefined, 10_000, 'Video encoder failed to initialize', abortSignal ); - } catch (e) { - publish.close(); - shutdownMediaSource(screen); - shutdownMediaSource(camera); - shutdownMediaSource(microphone); - throw e; - } - logger.info('Step 5b: Video catalog ready'); - // Now that video is publishing, enable audio so both tracks start - // at the same time on the server side. - if (deferAudioUntilVideo) { - publish.audio.enabled.set(true); - logger.info('Step 5c: Audio enabled (deferred until video ready)'); + logger.info('Step 5b: Video catalog ready'); + // Now that video is publishing, enable audio so both tracks start + // at the same time on the server side. + if (deferAudioUntilVideo && audio) { + audio.enabled.set(true); + logger.info('Step 5c: Audio enabled (deferred until video ready)'); + } } - } - return { microphone, camera, screen, publish }; + return { microphone, camera, screen, publish }; + } catch (e) { + // createPublishEncoders and the Broadcast guard above already closed the + // capture/broadcast when publish was never constructed. + publish?.close(); + shutdownMediaSource(screen); + shutdownMediaSource(camera); + shutdownMediaSource(microphone); + throw e; + } } /** Create a video capture source for a secondary broadcast and wait for device readiness. */ @@ -676,18 +870,14 @@ async function createSecondaryVideoSource( abortSignal?: AbortSignal, videoConstraints?: { width?: number; height?: number } ): Promise<{ - camera: Publish.Source.Camera | null; - screen: Publish.Source.Screen | null; + camera: CameraHandle | null; + screen: ScreenHandle | null; }> { if (sourceType === 'screen') { logger.info( 'Creating secondary screen capture source — the OS may show an additional picker dialog' ); - const screenProps: ConstructorParameters[0] = { enabled: true }; - if (videoConstraints) { - screenProps.video = videoConstraints; - } - const screen = new Publish.Source.Screen(screenProps); + const screen = new ScreenHandle(videoConstraints); if (!screen.source.peek()?.video) { try { await waitForSignalValue( @@ -705,11 +895,7 @@ async function createSecondaryVideoSource( return { camera: null, screen }; } - const cameraProps: ConstructorParameters[0] = { enabled: true }; - if (videoConstraints) { - cameraProps.constraints = videoConstraints; - } - const camera = new Publish.Source.Camera(cameraProps); + const camera = new CameraHandle(videoConstraints); if (!camera.source.peek()) { try { await waitForSignalValue( @@ -774,14 +960,14 @@ export function filterSecondaryTracks( async function setupSecondaryPublishPath( healthEffect: Effect, - connection: Hang.Moq.Connection.Reload, + connection: Moq.Connection.Reload, broadcastName: string, broadcastTracks: PublishTrackConfig[], abortSignal?: AbortSignal ): Promise<{ - secondaryPublish: Publish.Broadcast; - secondaryCamera: Publish.Source.Camera | null; - secondaryScreen: Publish.Source.Screen | null; + secondaryPublish: PublishHandle; + secondaryCamera: CameraHandle | null; + secondaryScreen: ScreenHandle | null; }> { const { needsVideo, videoSourceType, warnings } = analyzeSecondaryBroadcastTracks( broadcastName, @@ -793,8 +979,8 @@ async function setupSecondaryPublishPath( const videoTrack = broadcastTracks.find((t) => t.kind === 'video') ?? null; const { encoderConfig, constraints: videoConstraints } = buildVideoEncoderConfig(videoTrack); - let secondaryCamera: Publish.Source.Camera | null = null; - let secondaryScreen: Publish.Source.Screen | null = null; + let secondaryCamera: CameraHandle | null = null; + let secondaryScreen: ScreenHandle | null = null; if (needsVideo) { const sources = await createSecondaryVideoSource( @@ -807,37 +993,34 @@ async function setupSecondaryPublishPath( } logger.info(`Setting up secondary publish broadcast '${broadcastName}'`); - const broadcastConfig: ConstructorParameters[0] = { + const capture = needsVideo + ? createVideoCapture(healthEffect, secondaryScreen, secondaryCamera) + : null; + + const broadcast = new Publish.Broadcast({ connection: connection.established, enabled: true, - name: Publish.Lite.Path.from(broadcastName), - }; + name: Moq.Path.from(broadcastName), + display: capture?.out.display, + }); - if (needsVideo) { - if (secondaryScreen) { - const videoOnlySignal = new Signal( - secondaryScreen.source.peek()?.video - ); - healthEffect.subscribe(secondaryScreen.source, (v) => videoOnlySignal.set(v?.video)); - broadcastConfig.video = { - source: videoOnlySignal, - hd: { enabled: true, config: encoderConfig }, - }; - } else if (secondaryCamera) { - broadcastConfig.video = { - source: secondaryCamera.source, - hd: { enabled: true, config: encoderConfig }, - }; - } + let video: Publish.Video.Encoder | null = null; + if (capture) { + video = new Publish.Video.Encoder(VIDEO_TRACK_NAME, { + broadcast, + capture, + enabled: true, + config: encoderConfig, + }); } - const secondaryPublish = new Publish.Broadcast(broadcastConfig); + const secondaryPublish = new PublishHandle({ broadcast, capture, video }); - if (needsVideo) { + if (needsVideo && video) { logger.info(`Waiting for secondary broadcast '${broadcastName}' video catalog...`); try { await waitForSignalValue( - secondaryPublish.video.catalog, + video.out.catalog, (v) => v !== undefined, 10_000, `Secondary broadcast '${broadcastName}' video encoder failed to initialize`, @@ -867,7 +1050,7 @@ function schedulePostConnectWarnings( const watchRef = attempt.watch; setTimeout(() => { if (get().status !== 'connected') return; - if (watchRef.status.peek() !== 'live') { + if (watchRef.out.status.peek() !== 'live') { set({ errorMessage: `Connected to relay, but output broadcast "${get().outputBroadcast}" is not live yet.`, }); @@ -1054,7 +1237,7 @@ function createConnectionAndHealth( moqToken: string, get: () => ConnectableState, set: StateSetter -): { connection: Hang.Moq.Connection.Reload; healthEffect: Effect } { +): { connection: Moq.Connection.Reload; healthEffect: Effect } { logger.info('Step 1: Creating connection to relay server'); const url = new URL(serverUrl); const jwt = moqToken.trim(); @@ -1062,7 +1245,7 @@ function createConnectionAndHealth( url.searchParams.set('jwt', jwt); } - const connection = new Hang.Moq.Connection.Reload({ url, enabled: true }); + const connection = new Moq.Connection.Reload({ url, enabled: true }); const healthEffect = new Effect(); setupConnectionStatusSync(healthEffect, connection, get, set); return { connection, healthEffect }; diff --git a/ui/src/views/StreamView.loadSamples.test.ts b/ui/src/views/StreamView.loadSamples.test.ts index 4d2604d7..a0816e13 100644 --- a/ui/src/views/StreamView.loadSamples.test.ts +++ b/ui/src/views/StreamView.loadSamples.test.ts @@ -12,6 +12,11 @@ import { loadAndApplySamples } from './streamSamples'; // streamSamples imports the stream store, which pulls in the WebTransport-backed // @moq/* libraries; stub them so the module loads in jsdom. +vi.mock('@moq/net', () => ({ + default: {}, + Connection: { Reload: vi.fn() }, + Path: { from: vi.fn() }, +})); vi.mock('@moq/hang', () => ({ default: {} })); vi.mock('@moq/watch', () => ({ default: {}, Broadcast: vi.fn() })); vi.mock('@moq/publish', () => ({ default: {}, Broadcast: vi.fn() }));