From ad7086c862c9e87f451b2ffdc4183a496248bc44 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 23 Jan 2026 00:00:48 +0100 Subject: [PATCH 001/590] JWK sets (#809) Co-authored-by: Luke Curley --- Cargo.lock | 71 +++++- doc/guide/authentication.md | 133 ++++++++++++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-relay/src/auth.rs | 298 ++++++++++++++++++------- rs/moq-relay/src/main.rs | 2 +- rs/moq-token/Cargo.toml | 4 + rs/moq-token/src/lib.rs | 2 + rs/moq-token/src/set.rs | 421 ++++++++++++++++++++++++++++++++++++ 8 files changed, 844 insertions(+), 89 deletions(-) create mode 100644 doc/guide/authentication.md create mode 100644 rs/moq-token/src/set.rs diff --git a/Cargo.lock b/Cargo.lock index 69ff062ba1..9e7fb1d640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1579,7 +1588,7 @@ dependencies = [ "mp4-atom", "num_enum", "regex", - "reqwest", + "reqwest 0.12.28", "scuffle-h265", "serde", "serde_json", @@ -1894,9 +1903,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2160,7 +2171,7 @@ dependencies = [ "pkcs8 0.11.0-rc.8", "portmapper", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "rustls", "rustls-pki-types", "rustls-platform-verifier 0.5.3", @@ -2308,7 +2319,7 @@ dependencies = [ "pkarr", "postcard", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "rustls", "rustls-pki-types", "serde", @@ -2644,7 +2655,7 @@ dependencies = [ "quinn", "rand 0.9.2", "rcgen", - "reqwest", + "reqwest 0.12.28", "rustls", "rustls-native-certs", "rustls-pemfile", @@ -2700,6 +2711,7 @@ dependencies = [ "jsonwebtoken", "p256", "p384", + "reqwest 0.13.1", "rsa", "serde", "serde_json", @@ -3250,7 +3262,7 @@ dependencies = [ "log", "lru", "ntimestamp", - "reqwest", + "reqwest 0.12.28", "self_cell", "serde", "sha1_smol", @@ -3692,6 +3704,44 @@ dependencies = [ "webpki-roots 1.0.5", ] +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier 0.6.2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -5471,6 +5521,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/doc/guide/authentication.md b/doc/guide/authentication.md new file mode 100644 index 0000000000..9954a43955 --- /dev/null +++ b/doc/guide/authentication.md @@ -0,0 +1,133 @@ +--- +title: moq-authentication +description: Authentication for the moq-relay +--- + +# MoQ authentication + +The MoQ Relay authenticates via JWT-based tokens. Generally there are two different approaches you can choose from: +- asymmetric keys: using a public and private key to separate signing and verifying keys for more security +- symmetric key: using a single secret key for signing and verifying, less secure + +## Symmetric key + +1. Generate a secret key: +```bash +moq-token --key root.jwk generate --algorithm HS256 +``` +:::details You can also choose a different algorithm +- HS256 +- HS384 +- HS512 +::: + +2. Configure relay: +:::code-group +```toml [relay.toml] +[auth] +# public = "anon" # Optional: allow anonymous access to anon/** +key = "root.jwk" # JWT key for authenticated paths +``` +::: + +3. Generate tokens: +```bash +moq-token --key root.jwk sign \ + --root "rooms/123" \ + --publish "alice" \ + --subscribe "" \ + --expires 1735689600 > alice.jwt +``` + +## Asymmetric keys + +Generally asymmetric keys can be more secure because you don't need to distribute the signing key to every relay instance, the relays only need to verifying (public) key. + +1. Generate a public and private key: +```bash +moq-token --key private.jwk generate --public public.jwk --algorithm RS256 +``` +:::details You can also choose a different algorithm +- RS256 +- RS384 +- RS512 +- PS256 +- PS384 +- PS512 +- EC256 +- EC384 +- EdDSA +::: + +2. Now the relay only requires the public key: +:::code-group +```toml [relay.toml] +[auth] +# public = "anon" # Optional: allow anonymous access to anon/** +key = "public.jwk" # JWT key for authenticated paths +``` +::: + +3. Generate tokens using the private key: +```bash +moq-token --key private.jwk sign \ + --root "rooms/123" \ + --publish "alice" \ + --subscribe "" \ + --expires 1735689600 > alice.jwt +``` + +## JWK set authentication + +Instead of storing a public key locally in a file, it may also be retrieved from a server hosting a JWK set. This can be a simple static site serving a JSON file, or a fully OIDC compliant Identity Provider. That way you can easily implement automatic key rotation. + +::: info +This approach only works with asymmetric authentication. +::: + +To set this up, you need to have an HTTPS server hosting a JWK set that looks like this: +```json +{ + "keys": [ + { + "kid": "2026-01-01", + "alg": "RS256", + "key_ops": [ + "verify" + ], + "kty": "RSA", + "n": "zMsjX1oDV2SMQKZFTx4_qCaD3iIek9s1lvVaymr8bEGzO4pe6syCwBwLmFwaixRv7MMsuZ0nIpoR3Slpo-ZVyRxOc8yc3DcBZx49S_UQcM76E4MYbH6oInrEP8QL2bsstHrYTqTyPPjGwQJVp_sZdkjKlF5N-v5ohpn36sI8PXELvfRY3O3bad-RmSZ8ZOG8CYnJvMj_g2lYtGMMThnddnJ49560ahUNqAbH6ru---sHtdYHcjTIaWX4HYP6Y_KjA6siDZTGTThpaEW45LKcDQWM9sYvx_eAstaC-1rz8Z_6fDgKFWr7qcP5U2NmJ0c-IGSu_8OkftgRH4--Z5mzBQ", + "e": "AQAB" + }, + { + "kid": "2025-12-01", + "alg": "EdDSA", + "key_ops": [ + "verify" + ], + "kty": "OKP", + "crv": "Ed25519", + "x": "2FSK2q_o_d5ernBmNQLNMFxiA4-ypBSa4LsN30ZjUeU" + } + ] +} +``` + +:::tip The following must be considered: +- Every JWK MUST be public and contain no private key information +- If your JWK set contains more than one key: + 1. Every JWK MUST have a `kid` so they can be identified on verification + 2. Your JWT tokens MUST contain a `kid` in their header + 3. `kid` can be an arbitrary string +::: + +Configure the relay: +:::code-group +```toml [relay.toml] +[auth] +# public = "anon" # Optional: allow anonymous access to anon/** + +key = "https://auth.example.com/keys.json" # JWK set URL for authenticated paths +refresh_interval = 86400 # Optional: refresh the JWK set every N seconds, no refreshing if omitted +``` +::: diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index ffbeac6ec1..3c6d6c3573 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -30,7 +30,7 @@ futures = "0.3" http-body = "1" moq-lite = { workspace = true, features = ["serde"] } moq-native = { workspace = true, features = ["aws-lc-rs"] } -moq-token = { workspace = true } +moq-token = { workspace = true, features = ["jwks-loader"] } rustls = { version = "0.23", features = [ "aws-lc-rs", ], default-features = false } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 060602b173..b011c97a8c 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1,8 +1,10 @@ -use std::sync::Arc; - +use anyhow::Context; use axum::http; use moq_lite::{AsPath, Path, PathOwned}; +use moq_token::KeySet; use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; #[derive(thiserror::Error, Debug, Clone)] pub enum AuthError { @@ -34,11 +36,17 @@ impl axum::response::IntoResponse for AuthError { #[derive(clap::Args, Clone, Debug, Serialize, Deserialize, Default)] #[serde(default)] pub struct AuthConfig { - /// The root authentication key. + /// Either the root authentication key or a URI to a JWK set. /// If present, all paths will require a token unless they are in the public list. #[arg(long = "auth-key", env = "MOQ_AUTH_KEY")] pub key: Option, + /// How often to refresh the JWK set (in seconds), will be ignored if the `key` is not a valid URI. + /// If not provided, there won't be any refreshing, the JWK set will only be loaded once at startup. + /// Minimum value: 30, defaults to None + #[arg(long = "auth-refresh-interval", env = "MOQ_AUTH_REFRESH_INTERVAL")] + pub refresh_interval: Option, + /// The prefix that will be public for reading and writing. /// If present, unauthorized users will be able to read and write to this prefix ONLY. /// If a user provides a token, then they can only access the prefix only if it is specified in the token. @@ -47,8 +55,8 @@ pub struct AuthConfig { } impl AuthConfig { - pub fn init(self) -> anyhow::Result { - Auth::new(self) + pub async fn init(self) -> anyhow::Result { + Auth::new(self).await } } @@ -60,27 +68,134 @@ pub struct AuthToken { pub cluster: bool, } +const REFRESH_ERROR_INTERVAL: Duration = Duration::from_mins(5); + #[derive(Clone)] pub struct Auth { - key: Option>, + key: Option>>, public: Option, + refresh_task: Option>>, +} + +impl Drop for Auth { + fn drop(&mut self) { + if let Some(handle) = self.refresh_task.as_ref() + && Arc::strong_count(handle) == 1 + { + handle.abort(); + } + } } impl Auth { - pub fn new(config: AuthConfig) -> anyhow::Result { - let key = config.key.as_deref().map(moq_token::Key::from_file).transpose()?; + fn compare_key_sets(previous: &KeySet, new: &KeySet) { + for new_key in new.keys.iter() { + if new_key.kid.is_some() && !previous.keys.iter().any(|k| k.kid == new_key.kid) { + tracing::info!("found new JWK \"{}\"", new_key.kid.as_deref().unwrap()) + } + } + for old_key in previous.keys.iter() { + if old_key.kid.is_some() && !new.keys.iter().any(|k| k.kid == old_key.kid) { + tracing::info!("removed JWK \"{}\"", old_key.kid.as_deref().unwrap()) + } + } + } + + async fn refresh_key_set(jwks_uri: &str, key_set: &Mutex) -> anyhow::Result<()> { + let new_keys = moq_token::load_keys(jwks_uri).await?; + + let mut key_set = key_set.lock().expect("keyset mutex poisoned"); + Self::compare_key_sets(&key_set, &new_keys); + *key_set = new_keys; + + Ok(()) + } + + async fn refresh_task(interval: Duration, key_set: Arc>, jwks_uri: String) { + loop { + tokio::time::sleep(interval).await; + + if let Err(e) = Self::refresh_key_set(&jwks_uri, key_set.as_ref()).await { + if interval > REFRESH_ERROR_INTERVAL * 2 { + tracing::error!( + "failed to load JWKS, will retry in {} seconds: {:?}", + REFRESH_ERROR_INTERVAL.as_secs(), + e + ); + tokio::time::sleep(REFRESH_ERROR_INTERVAL).await; + + if let Err(e) = Self::refresh_key_set(&jwks_uri, key_set.as_ref()).await { + tracing::error!("failed to load JWKS again, giving up this time: {:?}", e); + } else { + tracing::info!("successfully loaded JWKS on the second try"); + } + } else { + // Don't retry because the next refresh is going to happen very soon + tracing::error!("failed to refresh JWKS: {:?}", e); + } + } + } + } - let public = config.public; + pub async fn new(config: AuthConfig) -> anyhow::Result { + if let Some(key) = config.key { + if config.public.is_some() { + anyhow::bail!("root key and public path cannot be configured together"); + } - match (&key, &public) { - (None, None) => anyhow::bail!("no root key or public path configured"), - (Some(_), Some(public)) if public.is_empty() => anyhow::bail!("root key but fully public access"), - _ => (), + if key.starts_with("http://") || key.starts_with("https://") { + Self::new_remote(key, config.refresh_interval).await + } else { + Self::new_local(key) + } + } else if let Some(public) = config.public { + Ok(Self { + key: None, + public: Some(public.as_path().to_owned()), + refresh_task: None, + }) + } else { + anyhow::bail!("no root key or public path configured"); } + } + + async fn new_remote(uri: String, refresh_interval: Option) -> anyhow::Result { + // Start with an empty KeySet + let key_set = Arc::new(Mutex::new(KeySet::default())); + + tracing::info!(%uri, "loading JWK set"); + + Self::refresh_key_set(&uri, key_set.as_ref()).await?; + + let refresh_task = if let Some(refresh_interval_secs) = refresh_interval { + anyhow::ensure!(refresh_interval_secs >= 30, "refresh_interval cannot be less than 30"); + + Some(Arc::new(tokio::spawn(Self::refresh_task( + Duration::from_secs(refresh_interval_secs), + key_set.clone(), + uri, + )))) + } else { + None + }; Ok(Self { - key: key.map(Arc::new), - public: public.map(|p| p.as_path().to_owned()), + key: Some(key_set), + public: None, + refresh_task, + }) + } + + fn new_local(key: String) -> anyhow::Result { + let key = moq_token::Key::from_file(&key).context("cannot load key")?; + let key_set = Arc::new(Mutex::new(KeySet { + keys: vec![Arc::new(key)], + })); + + Ok(Self { + key: Some(key_set), + public: None, + refresh_task: None, }) } @@ -90,9 +205,12 @@ impl Auth { // Find the token in the query parameters. // ?jwt=... let claims = if let Some(token) = token - && let Some(key) = self.key.as_ref() + && let Some(key) = self.key.as_deref() { - key.decode(token).map_err(|_| AuthError::DecodeFailed)? + key.lock() + .expect("key mutex poisoned") + .decode(token) + .map_err(|_| AuthError::DecodeFailed)? } else if let Some(_token) = token { return Err(AuthError::UnexpectedToken); } else if let Some(public) = &self.public { @@ -115,7 +233,7 @@ impl Auth { return Err(AuthError::IncorrectRoot); }; - // If a more specific path is is provided, reduce the permissions. + // If a more specific path is provided, reduce the permissions. let subscribe = claims .subscribe .into_iter() @@ -164,13 +282,14 @@ mod tests { Ok((key_file, key)) } - #[test] - fn test_anonymous_access_with_public_path() -> anyhow::Result<()> { + #[tokio::test] + async fn test_anonymous_access_with_public_path() -> anyhow::Result<()> { // Test anonymous access to /anon path let auth = Auth::new(AuthConfig { - key: None, public: Some("anon".to_string()), - })?; + ..Default::default() + }) + .await?; // Should succeed for anonymous path let token = auth.verify("/anon", None)?; @@ -187,13 +306,14 @@ mod tests { Ok(()) } - #[test] - fn test_anonymous_access_fully_public() -> anyhow::Result<()> { + #[tokio::test] + async fn test_anonymous_access_fully_public() -> anyhow::Result<()> { // Test fully public access (public = "") let auth = Auth::new(AuthConfig { - key: None, public: Some("".to_string()), - })?; + ..Default::default() + }) + .await?; // Should succeed for any path let token = auth.verify("/any/path", None)?; @@ -204,13 +324,14 @@ mod tests { Ok(()) } - #[test] - fn test_anonymous_access_denied_wrong_prefix() -> anyhow::Result<()> { + #[tokio::test] + async fn test_anonymous_access_denied_wrong_prefix() -> anyhow::Result<()> { // Test anonymous access denied for wrong prefix let auth = Auth::new(AuthConfig { - key: None, public: Some("anon".to_string()), - })?; + ..Default::default() + }) + .await?; // Should fail for non-anonymous path let result = auth.verify("/secret", None); @@ -219,13 +340,14 @@ mod tests { Ok(()) } - #[test] - fn test_no_token_no_public_path_fails() -> anyhow::Result<()> { + #[tokio::test] + async fn test_no_token_no_public_path_fails() -> anyhow::Result<()> { let (key_file, _) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Should fail when no token and no public path let result = auth.verify("/any/path", None); @@ -234,12 +356,13 @@ mod tests { Ok(()) } - #[test] - fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { + #[tokio::test] + async fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { let auth = Auth::new(AuthConfig { - key: None, public: Some("anon".to_string()), - })?; + ..Default::default() + }) + .await?; // Should fail when token provided but no key configured let result = auth.verify("/any/path", Some("fake-token")); @@ -248,13 +371,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_basic_validation() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_basic_validation() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Create a token with basic permissions let claims = moq_token::Claims { @@ -274,13 +398,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_wrong_root_path() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_wrong_root_path() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Create a token for room/123 let claims = moq_token::Claims { @@ -298,13 +423,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_with_restricted_publish_subscribe() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_with_restricted_publish_subscribe() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Create a token with specific pub/sub restrictions let claims = moq_token::Claims { @@ -324,13 +450,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_read_only() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_read_only() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Create a read-only token (no publish permissions) let claims = moq_token::Claims { @@ -348,13 +475,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_write_only() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_write_only() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Create a write-only token (no subscribe permissions) let claims = moq_token::Claims { @@ -372,13 +500,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_basic() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_basic() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Create a token with root at room/123 and unrestricted pub/sub let claims = moq_token::Claims { @@ -401,13 +530,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_with_publish_restrictions() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_with_publish_restrictions() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Token allows publishing only to alice/* let claims = moq_token::Claims { @@ -430,13 +560,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_with_subscribe_restrictions() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_with_subscribe_restrictions() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Token allows subscribing only to bob/* let claims = moq_token::Claims { @@ -458,13 +589,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_loses_access() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_loses_access() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Token allows publishing to alice/* and subscribing to bob/* let claims = moq_token::Claims { @@ -496,13 +628,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_nested_paths() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_nested_paths() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Token with nested publish/subscribe paths let claims = moq_token::Claims { @@ -533,13 +666,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_preserves_read_write_only() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_preserves_read_write_only() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, - })?; + ..Default::default() + }) + .await?; // Read-only token let claims = moq_token::Claims { diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index 0411ea82f4..b188ce9849 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -43,7 +43,7 @@ async fn main() -> anyhow::Result<()> { client.with_iroh(iroh); } - let auth = config.auth.init()?; + let auth = config.auth.init().await?; let cluster = Cluster::new(config.cluster, client); let cloned = cluster.clone(); diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index 6937f43581..2ac7390f15 100644 --- a/rs/moq-token/Cargo.toml +++ b/rs/moq-token/Cargo.toml @@ -8,6 +8,9 @@ license = "MIT OR Apache-2.0" version = "0.5.6" edition = "2024" +[features] +jwks-loader = ["reqwest"] + [dependencies] anyhow = "1" aws-lc-rs = "1" @@ -16,6 +19,7 @@ elliptic-curve = "0.13.8" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } p256 = "0.13.2" p384 = "0.13.1" +reqwest = { version = "0.13.1", optional = true } rsa = "0.9.10" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rs/moq-token/src/lib.rs b/rs/moq-token/src/lib.rs index 38ffcc84bf..a8ca603876 100644 --- a/rs/moq-token/src/lib.rs +++ b/rs/moq-token/src/lib.rs @@ -9,7 +9,9 @@ mod algorithm; mod claims; mod generate; mod key; +mod set; pub use algorithm::*; pub use claims::*; pub use key::*; +pub use set::*; diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs new file mode 100644 index 0000000000..9b87788425 --- /dev/null +++ b/rs/moq-token/src/set.rs @@ -0,0 +1,421 @@ +use crate::{Claims, Key, KeyOperation}; +use anyhow::Context; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +/// JWK Set to spec +#[derive(Default, Clone)] +pub struct KeySet { + /// Vec of an arbitrary number of Json Web Keys + pub keys: Vec>, +} + +impl Serialize for KeySet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Serialize as a struct with a `keys` field + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("KeySet", 1)?; + state.serialize_field("keys", &self.keys.iter().map(|k| k.as_ref()).collect::>())?; + state.end() + } +} + +impl<'de> Deserialize<'de> for KeySet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize into a temporary Vec + #[derive(Deserialize)] + struct RawKeySet { + keys: Vec, + } + + let raw = RawKeySet::deserialize(deserializer)?; + Ok(KeySet { + keys: raw.keys.into_iter().map(Arc::new).collect(), + }) + } +} + +impl KeySet { + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> anyhow::Result { + Ok(serde_json::from_str(s)?) + } + + pub fn from_file>(path: P) -> anyhow::Result { + let json = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&json)?) + } + + pub fn to_str(&self) -> anyhow::Result { + Ok(serde_json::to_string(&self)?) + } + + pub fn to_file>(&self, path: P) -> anyhow::Result<()> { + let json = serde_json::to_string(&self)?; + std::fs::write(path, json)?; + Ok(()) + } + + pub fn to_public_set(&self) -> anyhow::Result { + Ok(KeySet { + keys: self + .keys + .iter() + .map(|key| { + key.as_ref() + .to_public() + .map(Arc::new) + .map_err(|e| anyhow::anyhow!("failed to get public key from jwks: {:?}", e)) + }) + .collect::>, _>>()?, + }) + } + + pub fn find_key(&self, kid: &str) -> Option> { + self.keys.iter().find(|k| k.kid.as_deref() == Some(kid)).cloned() + } + + pub fn find_supported_key(&self, operation: &KeyOperation) -> Option> { + self.keys.iter().find(|key| key.operations.contains(operation)).cloned() + } + + pub fn encode(&self, payload: &Claims) -> anyhow::Result { + let key = self + .find_supported_key(&KeyOperation::Sign) + .context("cannot find signing key")?; + key.encode(payload) + } + + pub fn decode(&self, token: &str) -> anyhow::Result { + let header = jsonwebtoken::decode_header(token).context("failed to decode JWT header")?; + + let key = match header.kid { + Some(kid) => self + .find_key(kid.as_str()) + .ok_or_else(|| anyhow::anyhow!("cannot find key with kid {kid}")), + None => { + // If we only have one key we can use it without a kid + if self.keys.len() == 1 { + Ok(self.keys[0].clone()) + } else { + anyhow::bail!("missing kid in JWT header") + } + } + }?; + + key.decode(token) + } +} + +#[cfg(feature = "jwks-loader")] +pub async fn load_keys(jwks_uri: &str) -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .context("failed to build reqwest client")?; + + let jwks_json = client + .get(jwks_uri) + .send() + .await + .with_context(|| format!("failed to GET JWKS from {}", jwks_uri))? + .error_for_status() + .with_context(|| format!("JWKS endpoint returned error: {}", jwks_uri))? + .text() + .await + .context("failed to read JWKS response body")?; + + // Parse the JWKS into a KeySet + KeySet::from_str(&jwks_json).context("Failed to parse JWKS into KeySet") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Algorithm; + use std::time::{Duration, SystemTime}; + + fn create_test_claims() -> Claims { + Claims { + root: "test-path".to_string(), + publish: vec!["test-pub".into()], + cluster: false, + subscribe: vec!["test-sub".into()], + expires: Some(SystemTime::now() + Duration::from_secs(3600)), + issued: Some(SystemTime::now()), + } + } + + fn create_test_key(kid: Option) -> Key { + Key::generate(Algorithm::ES256, kid).expect("failed to generate key") + } + + #[test] + fn test_keyset_from_str_valid() { + let json = r#"{"keys":[{"kty":"oct","k":"2AJvfDJMVfWe9WMRPJP-4zCGN8F62LOy3dUr--rogR8","alg":"HS256","key_ops":["verify","sign"],"kid":"1"}]}"#; + let set = KeySet::from_str(json); + assert!(set.is_ok()); + let set = set.unwrap(); + assert_eq!(set.keys.len(), 1); + assert_eq!(set.keys[0].kid.as_deref(), Some("1")); + assert!(set.find_key("1").is_some()); + } + + #[test] + fn test_keyset_from_str_invalid_json() { + let result = KeySet::from_str("invalid json"); + assert!(result.is_err()); + } + + #[test] + fn test_keyset_from_str_empty() { + let json = r#"{"keys":[]}"#; + let set = KeySet::from_str(json).unwrap(); + assert!(set.keys.is_empty()); + } + + #[test] + fn test_keyset_to_str() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let json = set.to_str().unwrap(); + assert!(json.contains("\"keys\"")); + assert!(json.contains("\"kid\":\"1\"")); + } + + #[test] + fn test_keyset_serde_round_trip() { + let key1 = create_test_key(Some("1".to_string())); + let key2 = create_test_key(Some("2".to_string())); + let set = KeySet { + keys: vec![Arc::new(key1), Arc::new(key2)], + }; + + let json = set.to_str().unwrap(); + let deserialized = KeySet::from_str(&json).unwrap(); + + assert_eq!(deserialized.keys.len(), 2); + assert!(deserialized.find_key("1").is_some()); + assert!(deserialized.find_key("2").is_some()); + } + + #[test] + fn test_find_key_success() { + let key = create_test_key(Some("my-key".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let found = set.find_key("my-key"); + assert!(found.is_some()); + assert_eq!(found.unwrap().kid.as_deref(), Some("my-key")); + } + + #[test] + fn test_find_key_missing() { + let key = create_test_key(Some("my-key".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let found = set.find_key("other-key"); + assert!(found.is_none()); + } + + #[test] + fn test_find_key_no_kid() { + let key = create_test_key(None); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let found = set.find_key("any-key"); + assert!(found.is_none()); + } + + #[test] + fn test_find_supported_key() { + let mut sign_key = create_test_key(Some("sign".to_string())); + sign_key.operations = [KeyOperation::Sign].into(); + + let mut verify_key = create_test_key(Some("verify".to_string())); + verify_key.operations = [KeyOperation::Verify].into(); + + let set = KeySet { + keys: vec![Arc::new(sign_key), Arc::new(verify_key)], + }; + + let found_sign = set.find_supported_key(&KeyOperation::Sign); + assert!(found_sign.is_some()); + assert_eq!(found_sign.unwrap().kid.as_deref(), Some("sign")); + + let found_verify = set.find_supported_key(&KeyOperation::Verify); + assert!(found_verify.is_some()); + assert_eq!(found_verify.unwrap().kid.as_deref(), Some("verify")); + } + + #[test] + fn test_to_public_set() { + // Use asymmetric key (ES256) so we can separate public/private + let key = create_test_key(Some("1".to_string())); + + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let public_set = set.to_public_set().expect("failed to convert to public set"); + assert_eq!(public_set.keys.len(), 1); + + let public_key = &public_set.keys[0]; + assert_eq!(public_key.kid.as_deref(), Some("1")); + assert!(public_key.operations.contains(&KeyOperation::Verify)); + assert!(!public_key.operations.contains(&KeyOperation::Sign)); + } + + #[test] + fn test_to_public_set_fails_for_symmetric() { + let key = Key::generate(Algorithm::HS256, Some("sym".to_string())).unwrap(); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let result = set.to_public_set(); + assert!(result.is_err()); + } + + #[test] + fn test_encode_success() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + let claims = create_test_claims(); + + let token = set.encode(&claims).unwrap(); + assert!(!token.is_empty()); + } + + #[test] + fn test_encode_no_signing_key() { + let mut key = create_test_key(Some("1".to_string())); + key.operations = [KeyOperation::Verify].into(); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + let claims = create_test_claims(); + + let result = set.encode(&claims); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot find signing key")); + } + + #[test] + fn test_decode_success_with_kid() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + let claims = create_test_claims(); + + let token = set.encode(&claims).unwrap(); + let decoded = set.decode(&token).unwrap(); + + assert_eq!(decoded.root, claims.root); + } + + #[test] + fn test_decode_success_single_key_no_kid() { + // Create a key without KID + let key = create_test_key(None); + let claims = create_test_claims(); + + // Encode using the key directly + let token = key.encode(&claims).unwrap(); + + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + // Decode using the set + let decoded = set.decode(&token).unwrap(); + assert_eq!(decoded.root, claims.root); + } + + #[test] + fn test_decode_fail_multiple_keys_no_kid() { + let key1 = create_test_key(None); + let key2 = create_test_key(None); + + let set = KeySet { + keys: vec![Arc::new(key1), Arc::new(key2)], + }; + + let claims = create_test_claims(); + // Encode with one of the keys directly + let token = set.keys[0].encode(&claims).unwrap(); + + let result = set.decode(&token); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing kid")); + } + + #[test] + fn test_decode_fail_unknown_kid() { + let key1 = create_test_key(Some("1".to_string())); + let key2 = create_test_key(Some("2".to_string())); + + let set1 = KeySet { + keys: vec![Arc::new(key1)], + }; + let set2 = KeySet { + keys: vec![Arc::new(key2)], + }; + + let claims = create_test_claims(); + let token = set1.encode(&claims).unwrap(); + + let result = set2.decode(&token); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot find key with kid 1")); + } + + #[test] + fn test_file_io() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let dir = std::env::temp_dir(); + // Use a random-ish name to avoid collisions + let filename = format!( + "test_keyset_{}.json", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = dir.join(filename); + + set.to_file(&path).expect("failed to write to file"); + + let loaded = KeySet::from_file(&path).expect("failed to read from file"); + assert_eq!(loaded.keys.len(), 1); + assert_eq!(loaded.keys[0].kid.as_deref(), Some("1")); + + // Clean up + let _ = std::fs::remove_file(path); + } +} From afe586770bbe9c635f41db48ec742640bb7bb8fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Jan 2026 15:07:39 -0800 Subject: [PATCH 002/590] Add universal libmoq build for macos (#861) Co-authored-by: Emil Santurio --- .github/workflows/libmoq.yml | 4 +- flake.nix | 4 ++ rs/libmoq/build.sh | 95 ++++++++++++++++++++++++------------ 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index 185d227693..c7b99bad51 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -25,6 +25,8 @@ jobs: os: macos-15-intel - target: aarch64-apple-darwin os: macos-latest + - target: universal-apple-darwin + os: macos-latest - target: x86_64-pc-windows-msvc os: windows-latest @@ -34,7 +36,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.target }} + targets: ${{ matrix.target == 'universal-apple-darwin' && 'x86_64-apple-darwin,aarch64-apple-darwin' || matrix.target }} - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' diff --git a/flake.nix b/flake.nix index 950ddaf251..57e8e1337f 100644 --- a/flake.nix +++ b/flake.nix @@ -40,6 +40,10 @@ "rust-src" "rust-analyzer" ]; + targets = pkgs.lib.optionals pkgs.stdenv.isDarwin [ + "x86_64-apple-darwin" + "aarch64-apple-darwin" + ]; }; # Rust dependencies diff --git a/rs/libmoq/build.sh b/rs/libmoq/build.sh index 5240bde668..1725c2241c 100755 --- a/rs/libmoq/build.sh +++ b/rs/libmoq/build.sh @@ -43,29 +43,70 @@ while [[ $# -gt 0 ]]; do esac done -# Detect target if not specified -if [[ -z "$TARGET" ]]; then - TARGET=$(rustc -vV | grep host | cut -d' ' -f2) - echo "Detected target: $TARGET" -fi - # Get version from Cargo.toml if not specified if [[ -z "$VERSION" ]]; then VERSION=$(grep '^version' "$SCRIPT_DIR/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') echo "Detected version: $VERSION" fi -echo "Building libmoq for $TARGET..." +if [[ "$TARGET" == "universal-apple-darwin" ]]; then + if [[ "$(uname)" != "Darwin" ]]; then + echo "Error: Universal builds are only supported on macOS" >&2 + exit 1 + fi + + echo "Building libmoq for $TARGET..." + + # Build x86_64 + echo "Building for x86_64-apple-darwin..." + cargo build --release --package libmoq --target x86_64-apple-darwin --manifest-path "$WORKSPACE_DIR/Cargo.toml" + + # Build arm64 + echo "Building for aarch64-apple-darwin..." + cargo build --release --package libmoq --target aarch64-apple-darwin --manifest-path "$WORKSPACE_DIR/Cargo.toml" + + # Define sources for packaging + # Use arm64 as the reference for headers/pkgconfig (they should be identical) + REF_TARGET="aarch64-apple-darwin" + INCLUDE_SOURCE="$WORKSPACE_DIR/target/$REF_TARGET/include/moq.h" + PKGCONFIG_SOURCE="$WORKSPACE_DIR/target/$REF_TARGET/pkgconfig/moq.pc" + + # Libraries to combine + LIB_X86="$WORKSPACE_DIR/target/x86_64-apple-darwin/release/libmoq.a" + LIB_ARM64="$WORKSPACE_DIR/target/aarch64-apple-darwin/release/libmoq.a" + LIB_FILE="libmoq.a" -# Set up cross-compilation for Linux ARM64 -if [[ "$TARGET" == "aarch64-unknown-linux-gnu" ]]; then - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc -fi +else + # Detect target if not specified + if [[ -z "$TARGET" ]]; then + TARGET=$(rustc -vV | grep host | cut -d' ' -f2) + echo "Detected target: $TARGET" + fi + + echo "Building libmoq for $TARGET..." + + # Set up cross-compilation for Linux ARM64 + if [[ "$TARGET" == "aarch64-unknown-linux-gnu" ]]; then + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + fi -cargo build --release --package libmoq --target "$TARGET" --manifest-path "$WORKSPACE_DIR/Cargo.toml" + cargo build --release --package libmoq --target "$TARGET" --manifest-path "$WORKSPACE_DIR/Cargo.toml" + + # Define sources for packaging + INCLUDE_SOURCE="$WORKSPACE_DIR/target/$TARGET/include/moq.h" + PKGCONFIG_SOURCE="$WORKSPACE_DIR/target/$TARGET/pkgconfig/moq.pc" + + TARGET_DIR="$WORKSPACE_DIR/target/$TARGET/release" + if [[ "$TARGET" == *"-windows-"* ]]; then + LIB_SOURCE="$TARGET_DIR/moq.lib" + LIB_FILE="moq.lib" + else + LIB_SOURCE="$TARGET_DIR/libmoq.a" + LIB_FILE="libmoq.a" + fi +fi # Determine paths -TARGET_DIR="$WORKSPACE_DIR/target/$TARGET/release" NAME="moq-${VERSION}-${TARGET}" PACKAGE_DIR="$OUTPUT_DIR/$NAME" @@ -75,36 +116,26 @@ echo "Packaging $NAME..." rm -rf "$PACKAGE_DIR" mkdir -p "$PACKAGE_DIR/include" "$PACKAGE_DIR/lib" -# Copy header (generated in target/$TARGET/include/ by build.rs) -cp "$WORKSPACE_DIR/target/$TARGET/include/moq.h" "$PACKAGE_DIR/include/" +# Copy header +cp "$INCLUDE_SOURCE" "$PACKAGE_DIR/include/" # Copy static library -case "$TARGET" in - *-windows-*) - cp "$TARGET_DIR/moq.lib" "$PACKAGE_DIR/lib/" - ;; - *) - # Unix-like (macOS, Linux, etc.) - cp "$TARGET_DIR/libmoq.a" "$PACKAGE_DIR/lib/" - ;; -esac +if [[ "$TARGET" == "universal-apple-darwin" ]]; then + echo "Creating universal binary..." + lipo -create "$LIB_X86" "$LIB_ARM64" -output "$PACKAGE_DIR/lib/$LIB_FILE" +else + cp "$LIB_SOURCE" "$PACKAGE_DIR/lib/" +fi # Copy pkg-config file (generated in target/$TARGET/pkgconfig/ by build.rs, not for Windows) if [[ "$TARGET" != *"-windows-"* ]]; then mkdir -p "$PACKAGE_DIR/lib/pkgconfig" - cp "$WORKSPACE_DIR/target/$TARGET/pkgconfig/moq.pc" "$PACKAGE_DIR/lib/pkgconfig/" + cp "$PKGCONFIG_SOURCE" "$PACKAGE_DIR/lib/pkgconfig/" fi # Generate CMake config files from templates mkdir -p "$PACKAGE_DIR/lib/cmake/moq" -# Determine library filename -if [[ "$TARGET" == *"-windows-"* ]]; then - LIB_FILE="moq.lib" -else - LIB_FILE="libmoq.a" -fi - # Extract major version MAJOR_VERSION="${VERSION%%.*}" From d277555aa63d37efcc9f99337e398a2171bb5953 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Jan 2026 08:44:28 -0800 Subject: [PATCH 003/590] Add a builder pattern for constructing clients/servers (#862) Co-authored-by: Claude Opus 4.5 --- rs/hang-cli/src/client.rs | 2 +- rs/hang-cli/src/main.rs | 10 +-- rs/hang-cli/src/server.rs | 2 +- rs/hang/examples/video.rs | 8 +- rs/libmoq/src/session.rs | 6 +- rs/moq-clock/src/main.rs | 6 +- rs/moq-lite/src/client.rs | 104 ++++++++++++++++++++++ rs/moq-lite/src/lib.rs | 4 + rs/moq-lite/src/server.rs | 111 +++++++++++++++++++++++ rs/moq-lite/src/session.rs | 155 +++------------------------------ rs/moq-native/examples/chat.rs | 4 +- rs/moq-native/src/client.rs | 93 ++++++++++---------- rs/moq-native/src/server.rs | 103 ++++++++++++++++------ rs/moq-relay/src/cluster.rs | 9 +- rs/moq-relay/src/connection.rs | 9 +- rs/moq-relay/src/main.rs | 13 ++- rs/moq-relay/src/web.rs | 8 +- 17 files changed, 396 insertions(+), 251 deletions(-) create mode 100644 rs/moq-lite/src/client.rs create mode 100644 rs/moq-lite/src/server.rs diff --git a/rs/hang-cli/src/client.rs b/rs/hang-cli/src/client.rs index 8d65d27e28..fa14f569e8 100644 --- a/rs/hang-cli/src/client.rs +++ b/rs/hang-cli/src/client.rs @@ -11,7 +11,7 @@ pub async fn run_client(client: moq_native::Client, url: Url, name: String, publ tracing::info!(%url, %name, "connecting"); // Establish the connection, not providing a subscriber. - let session = client.connect_with_fallback(url, origin.consumer, None).await?; + let session = client.with_publish(origin.consumer).connect(url).await?; #[cfg(unix)] // Notify systemd that we're ready. diff --git a/rs/hang-cli/src/main.rs b/rs/hang-cli/src/main.rs index 1c6f321dcd..968fb0e9ca 100644 --- a/rs/hang-cli/src/main.rs +++ b/rs/hang-cli/src/main.rs @@ -98,10 +98,9 @@ async fn main() -> anyhow::Result<()> { Command::Serve { config, dir, name, .. } => { let web_bind = config.bind.unwrap_or("[::]:443".parse().unwrap()); - #[allow(unused_mut)] - let mut server = config.init()?; + let server = config.init()?; #[cfg(feature = "iroh")] - server.with_iroh(iroh); + let server = server.with_iroh(iroh); let web_tls = server.tls_info(); @@ -112,11 +111,10 @@ async fn main() -> anyhow::Result<()> { } } Command::Publish { config, url, name, .. } => { - #[allow(unused_mut)] - let mut client = config.init()?; + let client = config.init()?; #[cfg(feature = "iroh")] - client.with_iroh(iroh); + let client = client.with_iroh(iroh); run_client(client, url, name, publish).await } diff --git a/rs/hang-cli/src/server.rs b/rs/hang-cli/src/server.rs index 3e3d57ecb3..02ce9def7f 100644 --- a/rs/hang-cli/src/server.rs +++ b/rs/hang-cli/src/server.rs @@ -43,7 +43,7 @@ async fn run_session( origin.producer.publish_broadcast(&name, consumer); // Blindly accept the session (WebTransport or QUIC), regardless of the URL. - let session = session.accept(origin.consumer, None).await?; + let session = session.with_publish(origin.consumer).accept().await?; tracing::info!(id, "accepted session"); diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index a6e554f8d5..d2d9e9e2c8 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -27,10 +27,10 @@ async fn run_session(origin: moq_lite::OriginConsumer) -> anyhow::Result<()> { // The "anon" path is usually configured to bypass authentication; be careful! let url = url::Url::parse("https://cdn.moq.dev/anon/video-example").unwrap(); - // Establish a WebTransport/QUIC connection and MoQ handshake. - // None means we're not consuming anything from the session, otherwise we would provide an OriginProducer. - // Optional: Use connect_with_fallback if you also want to support WebSocket. - let session = client.connect(url, origin, None).await?; + // Establish a WebTransport/QUIC connection and MoQ handshake for publishing. + // with_publish() registers an OriginConsumer for outgoing data. + // Use with_consume() if you also want to subscribe/consume from the session. + let session = client.with_publish(origin).connect(url).await?; // Wait until the session is closed. session.closed().await.map_err(Into::into) diff --git a/rs/libmoq/src/session.rs b/rs/libmoq/src/session.rs index 223054fca2..4e3ff477be 100644 --- a/rs/libmoq/src/session.rs +++ b/rs/libmoq/src/session.rs @@ -48,10 +48,14 @@ impl Session { let client = moq_native::ClientConfig::default() .init() .map_err(|err| Error::Connect(Arc::new(err)))?; + let session = client - .connect(url, publish, consume) + .with_publish(publish) + .with_consume(consume) + .connect(url) .await .map_err(|err| Error::Connect(Arc::new(err)))?; + callback.call(()); session.closed().await?; diff --git a/rs/moq-clock/src/main.rs b/rs/moq-clock/src/main.rs index f1a2ce50ae..a1c14eb614 100644 --- a/rs/moq-clock/src/main.rs +++ b/rs/moq-clock/src/main.rs @@ -68,9 +68,7 @@ async fn main() -> anyhow::Result<()> { origin.producer.publish_broadcast(&config.broadcast, broadcast.consumer); - let session = client - .connect_with_fallback(config.url, Some(origin.consumer), None) - .await?; + let session = client.with_publish(origin.consumer).connect(config.url).await?; tokio::select! { res = session.closed() => res.context("session closed"), @@ -78,7 +76,7 @@ async fn main() -> anyhow::Result<()> { } } Command::Subscribe => { - let session = client.connect_with_fallback(config.url, None, origin.producer).await?; + let session = client.with_consume(origin.producer).connect(config.url).await?; // NOTE: We could just call `session.consume_broadcast(&config.broadcast)` instead, // However that won't work with IETF MoQ and the current OriginConsumer API the moment. diff --git a/rs/moq-lite/src/client.rs b/rs/moq-lite/src/client.rs new file mode 100644 index 0000000000..d38b126b67 --- /dev/null +++ b/rs/moq-lite/src/client.rs @@ -0,0 +1,104 @@ +// TODO: Uncomment when observability feature is merged +// use std::sync::Arc; + +use crate::{ + Error, OriginConsumer, OriginProducer, Session, VERSIONS, + coding::{Decode, Encode, Stream}, + ietf, lite, setup, +}; + +/// A MoQ client session builder. +#[derive(Default, Clone)] +pub struct Client { + publish: Option, + consume: Option, + // TODO: Uncomment when observability feature is merged + // stats: Option>, +} + +impl Client { + pub fn new() -> Self { + Default::default() + } + + pub fn with_publish(mut self, publish: impl Into>) -> Self { + self.publish = publish.into(); + self + } + + pub fn with_consume(mut self, consume: impl Into>) -> Self { + self.consume = consume.into(); + self + } + + // TODO: Uncomment when observability feature is merged + // pub fn with_stats(mut self, stats: impl Into>>) -> Self { + // self.stats = stats.into(); + // self + // } + + /// Perform the MoQ handshake as a client negotiating the version. + pub async fn connect(&self, session: S) -> Result { + if self.publish.is_none() && self.consume.is_none() { + tracing::warn!("not publishing or consuming anything"); + } + + let mut stream = Stream::open(&session, setup::ServerKind::Ietf14).await?; + + let mut parameters = ietf::Parameters::default(); + parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); + parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); + let parameters = parameters.encode_bytes(()); + + let client = setup::Client { + // Unfortunately, we have to pick a single draft range to support. + // moq-lite can support this handshake. + kind: setup::ClientKind::Ietf14, + versions: VERSIONS.into(), + parameters, + }; + + // TODO pretty print the parameters. + tracing::trace!(?client, "sending client setup"); + stream.writer.encode(&client).await?; + + let mut server: setup::Server = stream.reader.decode().await?; + tracing::trace!(?server, "received server setup"); + + if let Ok(version) = lite::Version::try_from(server.version) { + let stream = stream.with_version(version); + lite::start( + session.clone(), + stream, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } else if let Ok(version) = ietf::Version::try_from(server.version) { + // Decode the parameters to get the initial request ID. + let parameters = ietf::Parameters::decode(&mut server.parameters, version)?; + let request_id_max = + ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); + + let stream = stream.with_version(version); + ietf::start( + session.clone(), + stream, + request_id_max, + true, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } else { + // unreachable, but just in case + return Err(Error::Version(client.versions, [server.version].into())); + } + + tracing::debug!(version = ?server.version, "connected"); + + Ok(Session::new(session)) + } +} diff --git a/rs/moq-lite/src/lib.rs b/rs/moq-lite/src/lib.rs index 9494a12845..bed0e98bdd 100644 --- a/rs/moq-lite/src/lib.rs +++ b/rs/moq-lite/src/lib.rs @@ -34,9 +34,11 @@ //! - Use [FrameProducer] and [FrameConsumer] for chunked frame writes/reads without allocating entire frames (useful for relaying). //! - Use [TrackProducer::create_group] instead of [TrackProducer::append_group] to produce groups out-of-order. +mod client; mod error; mod model; mod path; +mod server; mod session; mod setup; @@ -44,7 +46,9 @@ pub mod coding; pub mod ietf; pub mod lite; +pub use client::*; pub use error::*; pub use model::*; pub use path::*; +pub use server::*; pub use session::*; diff --git a/rs/moq-lite/src/server.rs b/rs/moq-lite/src/server.rs new file mode 100644 index 0000000000..6cb77c35e6 --- /dev/null +++ b/rs/moq-lite/src/server.rs @@ -0,0 +1,111 @@ +// TODO: Uncomment when observability feature is merged +// use std::sync::Arc; + +use crate::{ + Error, OriginConsumer, OriginProducer, Session, VERSIONS, + coding::{Decode, Encode, Stream}, + ietf, lite, setup, +}; + +/// A MoQ server session builder. +#[derive(Default, Clone)] +pub struct Server { + publish: Option, + consume: Option, + // TODO: Uncomment when observability feature is merged + // stats: Option>, +} + +impl Server { + pub fn new() -> Self { + Default::default() + } + + pub fn with_publish(mut self, publish: impl Into>) -> Self { + self.publish = publish.into(); + self + } + + pub fn with_consume(mut self, consume: impl Into>) -> Self { + self.consume = consume.into(); + self + } + + // TODO: Uncomment when observability feature is merged + // pub fn with_stats(mut self, stats: impl Into>>) -> Self { + // self.stats = stats.into(); + // self + // } + + /// Perform the MoQ handshake as a server for the given session. + pub async fn accept(&self, session: S) -> Result { + if self.publish.is_none() && self.consume.is_none() { + tracing::warn!("not publishing or consuming anything"); + } + + // Accept with an initial version; we'll switch to the negotiated version later + let mut stream = Stream::accept(&session, ()).await?; + let mut client: setup::Client = stream.reader.decode().await?; + tracing::trace!(?client, "received client setup"); + + // Choose the version to use + let version = client + .versions + .iter() + .find(|v| VERSIONS.contains(v)) + .copied() + .ok_or_else(|| Error::Version(client.versions.clone(), VERSIONS.into()))?; + + // Only encode parameters if we're using the IETF draft because it has max_request_id + let parameters = if ietf::Version::try_from(version).is_ok() && client.kind == setup::ClientKind::Ietf14 { + let mut parameters = ietf::Parameters::default(); + parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); + parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); + parameters.encode_bytes(()) + } else { + lite::Parameters::default().encode_bytes(()) + }; + + let server = setup::Server { version, parameters }; + tracing::trace!(?server, "sending server setup"); + + let mut stream = stream.with_version(client.kind.reply()); + stream.writer.encode(&server).await?; + + if let Ok(version) = lite::Version::try_from(version) { + let stream = stream.with_version(version); + lite::start( + session.clone(), + stream, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } else if let Ok(version) = ietf::Version::try_from(version) { + // Decode the client's parameters to get their max request ID. + let parameters = ietf::Parameters::decode(&mut client.parameters, version)?; + let request_id_max = + ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); + + let stream = stream.with_version(version); + ietf::start( + session.clone(), + stream, + request_id_max, + false, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } else { + // unreachable, but just in case + return Err(Error::Version(client.versions, VERSIONS.into())); + } + + tracing::debug!(?version, "connected"); + + Ok(Session::new(session)) + } +} diff --git a/rs/moq-lite/src/session.rs b/rs/moq-lite/src/session.rs index 0f1f6347b8..4f15e851b0 100644 --- a/rs/moq-lite/src/session.rs +++ b/rs/moq-lite/src/session.rs @@ -1,19 +1,6 @@ use std::{future::Future, pin::Pin, sync::Arc}; -use crate::{ - Error, OriginConsumer, OriginProducer, - coding::{self, Decode, Encode, Stream}, - ietf, lite, setup, -}; - -/// A MoQ transport session, wrapping a WebTransport connection. -/// -/// Created via: -/// - [`Session::connect`] for clients. -/// - [`Session::accept`] for servers. -pub struct Session { - session: Arc, -} +use crate::{Error, coding, ietf, lite}; /// The versions of MoQ that are supported by this implementation. /// @@ -27,142 +14,22 @@ pub const VERSIONS: [coding::Version; 3] = [ /// The ALPN strings for supported versions. pub const ALPNS: [&str; 2] = [lite::ALPN, ietf::ALPN]; +/// A MoQ transport session, wrapping a WebTransport connection. +/// +/// Created via: +/// - [`crate::Client::connect`] for clients. +/// - [`crate::Server::accept`] for servers. +pub struct Session { + session: Arc, +} + impl Session { - fn new(session: S) -> Self { + pub(super) fn new(session: S) -> Self { Self { session: Arc::new(session), } } - /// Perform the MoQ handshake as a client, negotiating the version. - /// - /// Publishing is performed with [OriginConsumer] and subscribing with [OriginProducer]. - /// The connection remains active until the session is closed. - pub async fn connect( - session: S, - publish: impl Into>, - subscribe: impl Into>, - ) -> Result { - let mut stream = Stream::open(&session, setup::ServerKind::Ietf14).await?; - - let mut parameters = ietf::Parameters::default(); - parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); - parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); - let parameters = parameters.encode_bytes(()); - - let client = setup::Client { - // Unfortunately, we have to pick a single draft range to support. - // moq-lite can support this handshake. - kind: setup::ClientKind::Ietf14, - versions: VERSIONS.into(), - parameters, - }; - - // TODO pretty print the parameters. - tracing::trace!(?client, "sending client setup"); - stream.writer.encode(&client).await?; - - let mut server: setup::Server = stream.reader.decode().await?; - tracing::trace!(?server, "received server setup"); - - if let Ok(version) = lite::Version::try_from(server.version) { - let stream = stream.with_version(version); - lite::start(session.clone(), stream, publish.into(), subscribe.into(), version).await?; - } else if let Ok(version) = ietf::Version::try_from(server.version) { - // Decode the parameters to get the initial request ID. - let parameters = ietf::Parameters::decode(&mut server.parameters, version)?; - let request_id_max = - ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); - - let stream = stream.with_version(version); - ietf::start( - session.clone(), - stream, - request_id_max, - true, - publish.into(), - subscribe.into(), - version, - ) - .await?; - } else { - // unreachable, but just in case - return Err(Error::Version(client.versions, [server.version].into())); - } - - tracing::debug!(version = ?server.version, "connected"); - - Ok(Self::new(session)) - } - - /// Perform the MoQ handshake as a server. - /// - /// Publishing is performed with [OriginConsumer] and subscribing with [OriginProducer]. - /// The connection remains active until the session is closed. - pub async fn accept( - session: S, - publish: impl Into>, - subscribe: impl Into>, - ) -> Result { - // Accept with an initial version; we'll switch to the negotiated version later - let mut stream = Stream::accept(&session, ()).await?; - let client: setup::Client = stream.reader.decode().await?; - tracing::trace!(?client, "received client setup"); - - // Choose the version to use - let version = client - .versions - .iter() - .find(|v| VERSIONS.contains(v)) - .copied() - .ok_or_else(|| Error::Version(client.versions.clone(), VERSIONS.into()))?; - - // Only encode parameters if we're using the IETF draft because it has max_request_id - let parameters = if ietf::Version::try_from(version).is_ok() && client.kind == setup::ClientKind::Ietf14 { - let mut parameters = ietf::Parameters::default(); - parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); - parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); - parameters.encode_bytes(()) - } else { - lite::Parameters::default().encode_bytes(()) - }; - - let mut server = setup::Server { version, parameters }; - tracing::trace!(?server, "sending server setup"); - - let mut stream = stream.with_version(client.kind.reply()); - stream.writer.encode(&server).await?; - - if let Ok(version) = lite::Version::try_from(version) { - let stream = stream.with_version(version); - lite::start(session.clone(), stream, publish.into(), subscribe.into(), version).await?; - } else if let Ok(version) = ietf::Version::try_from(version) { - // Decode the parameters to get the initial request ID. - let parameters = ietf::Parameters::decode(&mut server.parameters, version)?; - let request_id_max = - ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); - - let stream = stream.with_version(version); - ietf::start( - session.clone(), - stream, - request_id_max, - false, - publish.into(), - subscribe.into(), - version, - ) - .await?; - } else { - // unreachable, but just in case - return Err(Error::Version(client.versions, VERSIONS.into())); - } - - tracing::debug!(?version, "connected"); - - Ok(Self::new(session)) - } - /// Close the underlying transport session. pub fn close(self, err: Error) { self.session.close(err.to_code(), err.to_string().as_ref()); diff --git a/rs/moq-native/examples/chat.rs b/rs/moq-native/examples/chat.rs index 596a75c561..b73f4d7a35 100644 --- a/rs/moq-native/examples/chat.rs +++ b/rs/moq-native/examples/chat.rs @@ -27,9 +27,7 @@ async fn run_session(origin: moq_lite::OriginConsumer) -> anyhow::Result<()> { let url = url::Url::parse("https://cdn.moq.dev/anon/chat-example").unwrap(); // Establish a WebTransport/QUIC connection and MoQ handshake. - // Optional: You could do this as two separate steps, but this is more convenient. - // Optional: Use connect_with_fallback if you also want to support WebSocket too. - let session = client.connect(url, origin, None).await?; + let session = client.with_publish(origin).connect(url).await?; // Wait until the session is closed. session.closed().await.map_err(Into::into) diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index c858360bf9..89a91c5b94 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -45,6 +45,15 @@ pub struct ClientTls { #[serde(default, deny_unknown_fields)] #[non_exhaustive] pub struct ClientWebSocket { + /// Whether to enable WebSocket support. + #[arg( + id = "websocket-enabled", + long = "websocket-enabled", + env = "MOQ_CLIENT_WEBSOCKET_ENABLED", + default_value = "true" + )] + pub enabled: bool, + /// Delay in milliseconds before attempting WebSocket fallback (default: 200) /// If WebSocket won the previous race for a given server, this will be 0. #[arg( @@ -62,6 +71,7 @@ pub struct ClientWebSocket { impl Default for ClientWebSocket { fn default() -> Self { Self { + enabled: true, delay: Some(time::Duration::from_millis(200)), } } @@ -110,11 +120,13 @@ impl Default for ClientConfig { /// /// Create via [`ClientConfig::init`] or [`Client::new`]. #[derive(Clone)] +#[non_exhaustive] pub struct Client { + pub moq: moq_lite::Client, pub quic: quinn::Endpoint, pub tls: rustls::ClientConfig, pub transport: Arc, - pub websocket_delay: Option, + pub websocket: ClientWebSocket, #[cfg(feature = "iroh")] pub iroh: Option, } @@ -186,82 +198,73 @@ impl Client { quinn::Endpoint::new(endpoint_config, None, socket, runtime).context("failed to create QUIC endpoint")?; Ok(Self { + moq: moq_lite::Client::new(), quic, tls, transport, - websocket_delay: config.websocket.delay, + websocket: config.websocket, #[cfg(feature = "iroh")] iroh: None, }) } #[cfg(feature = "iroh")] - pub fn with_iroh(&mut self, iroh: Option) -> &mut Self { + pub fn with_iroh(mut self, iroh: Option) -> Self { self.iroh = iroh; self } - /// Establish a WebTransport/QUIC connection followed by a MoQ handshake. - pub async fn connect( - &self, - url: Url, - publish: impl Into>, - subscribe: impl Into>, - ) -> anyhow::Result { - #[cfg(feature = "iroh")] - if crate::iroh::is_iroh_url(&url) { - let session = self.connect_iroh(url).await?; - let session = moq_lite::Session::connect(session, publish, subscribe).await?; - return Ok(session); - } + pub fn with_publish(mut self, publish: impl Into>) -> Self { + self.moq = self.moq.with_publish(publish); + self + } - let session = self.connect_quic(url).await?; - let session = moq_lite::Session::connect(session, publish, subscribe).await?; - Ok(session) + pub fn with_consume(mut self, consume: impl Into>) -> Self { + self.moq = self.moq.with_consume(consume); + self } - /// Establish a WebTransport/QUIC connection or a WebSocket connection, whichever is available first. - /// - /// Establishes a MoQ handshake on the winning transport. - pub async fn connect_with_fallback( - &self, - url: Url, - publish: impl Into>, - subscribe: impl Into>, - ) -> anyhow::Result { + // TODO: Uncomment when observability feature is merged + // pub fn with_stats(mut self, stats: impl Into>>) -> Self { + // self.moq = self.moq.with_stats(stats); + // self + // } + + /// Establish a WebTransport/QUIC connection followed by a MoQ handshake. + pub async fn connect(&self, url: Url) -> anyhow::Result { #[cfg(feature = "iroh")] if crate::iroh::is_iroh_url(&url) { let session = self.connect_iroh(url).await?; - let session = moq_lite::Session::connect(session, publish, subscribe).await?; + let session = self.moq.connect(session).await?; return Ok(session); } // Create futures for both possible protocols let quic_url = url.clone(); let quic_handle = async { - match self.connect_quic(quic_url).await { - Ok(session) => Some(session), - Err(err) => { - tracing::warn!(%err, "QUIC connection failed"); - None - } + let res = self.connect_quic(quic_url).await; + if let Err(err) = &res { + tracing::warn!(%err, "QUIC connection failed"); } + res }; let ws_handle = async { - match self.connect_websocket(url).await { - Ok(session) => Some(session), - Err(err) => { - tracing::warn!(%err, "WebSocket connection failed"); - None - } + if !self.websocket.enabled { + return None; } + + let res = self.connect_websocket(url).await; + if let Err(err) = &res { + tracing::warn!(%err, "WebSocket connection failed"); + } + Some(res) }; // Race the connection futures Ok(tokio::select! { - Some(quic) = quic_handle => moq_lite::Session::connect(quic, publish, subscribe).await?, - Some(ws) = ws_handle => moq_lite::Session::connect(ws, publish, subscribe).await?, + Ok(quic) = quic_handle => self.moq.connect(quic).await?, + Some(Ok(ws)) = ws_handle => self.moq.connect(ws).await?, // If both attempts fail, return an error else => anyhow::bail!("failed to connect to server"), }) @@ -334,6 +337,8 @@ impl Client { } async fn connect_websocket(&self, mut url: Url) -> anyhow::Result { + anyhow::ensure!(self.websocket.enabled, "WebSocket support is disabled"); + let host = url.host_str().context("missing hostname")?.to_string(); let port = url.port().unwrap_or_else(|| match url.scheme() { "https" | "wss" | "moql" | "moqt" => 443, @@ -345,7 +350,7 @@ impl Client { // Apply a small penalty to WebSocket to improve odds for QUIC to connect first, // unless we've already had to fall back to WebSockets for this server. // TODO if let chain - match self.websocket_delay { + match self.websocket.delay { Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => { tokio::time::sleep(delay).await; tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback"); diff --git a/rs/moq-native/src/server.rs b/rs/moq-native/src/server.rs index d45e380d0d..1c7a67c5a2 100644 --- a/rs/moq-native/src/server.rs +++ b/rs/moq-native/src/server.rs @@ -95,6 +95,7 @@ impl ServerConfig { /// /// Create via [`ServerConfig::init`] or [`Server::new`]. pub struct Server { + moq: moq_lite::Server, quic: quinn::Endpoint, accept: FuturesUnordered>>, certs: Arc, @@ -171,17 +172,34 @@ impl Server { quic: quic.clone(), accept: Default::default(), certs, + moq: moq_lite::Server::new(), #[cfg(feature = "iroh")] iroh: None, }) } #[cfg(feature = "iroh")] - pub fn with_iroh(&mut self, iroh: Option) -> &mut Self { + pub fn with_iroh(mut self, iroh: Option) -> Self { self.iroh = iroh; self } + pub fn with_publish(mut self, publish: impl Into>) -> Self { + self.moq = self.moq.with_publish(publish); + self + } + + pub fn with_consume(mut self, consume: impl Into>) -> Self { + self.moq = self.moq.with_consume(consume); + self + } + + // TODO: Uncomment when observability feature is merged + // pub fn with_stats(mut self, stats: impl Into>>) -> Self { + // self.moq = self.moq.with_stats(stats); + // self + // } + #[cfg(unix)] async fn reload_certs(certs: Arc, tls_config: ServerTlsConfig) { use tokio::signal::unix::{SignalKind, signal}; @@ -229,13 +247,13 @@ impl Server { tokio::select! { res = self.quic.accept() => { let conn = res?; - self.accept.push(Self::accept_session(conn).boxed()); + self.accept.push(Self::accept_session(self.moq.clone(), conn).boxed()); } res = iroh_accept_fut => { #[cfg(feature = "iroh")] { let conn = res?; - self.accept.push(Self::accept_iroh_session(conn).boxed()); + self.accept.push(Self::accept_iroh_session(self.moq.clone(), conn).boxed()); } #[cfg(not(feature = "iroh"))] let _: () = res; @@ -257,7 +275,7 @@ impl Server { } } - async fn accept_session(conn: quinn::Incoming) -> anyhow::Result { + async fn accept_session(server: moq_lite::Server, conn: quinn::Incoming) -> anyhow::Result { let mut conn = conn.accept()?; let handshake = conn @@ -285,15 +303,21 @@ impl Server { let request = web_transport_quinn::Request::accept(conn) .await .context("failed to receive WebTransport request")?; - Ok(Request::WebTransport(request)) + Ok(Request { + server: server.clone(), + kind: RequestKind::WebTransport(request), + }) } - moq_lite::lite::ALPN | moq_lite::ietf::ALPN => Ok(Request::Quic(QuicRequest::accept(conn))), + moq_lite::lite::ALPN | moq_lite::ietf::ALPN => Ok(Request { + server: server.clone(), + kind: RequestKind::Quic(QuicRequest::accept(conn)), + }), _ => anyhow::bail!("unsupported ALPN: {alpn}"), } } #[cfg(feature = "iroh")] - async fn accept_iroh_session(conn: iroh::endpoint::Incoming) -> anyhow::Result { + async fn accept_iroh_session(server: moq_lite::Server, conn: iroh::endpoint::Incoming) -> anyhow::Result { let conn = conn.accept()?.await?; let alpn = String::from_utf8(conn.alpn().to_vec()).context("failed to decode ALPN")?; tracing::Span::current().record("id", conn.stable_id()); @@ -303,11 +327,17 @@ impl Server { let request = web_transport_iroh::H3Request::accept(conn) .await .context("failed to receive WebTransport request")?; - Ok(Request::IrohWebTransport(request)) + Ok(Request { + server: server.clone(), + kind: RequestKind::IrohWebTransport(request), + }) } moq_lite::lite::ALPN | moq_lite::ietf::ALPN => { let request = IrohQuicRequest::accept(conn); - Ok(Request::IrohQuic(request)) + Ok(Request { + server: server.clone(), + kind: RequestKind::IrohQuic(request), + }) } _ => Err(anyhow::anyhow!("unsupported ALPN: {alpn}")), } @@ -328,7 +358,7 @@ impl Server { } /// An incoming connection that can be accepted or rejected. -pub enum Request { +enum RequestKind { WebTransport(web_transport_quinn::Request), Quic(QuicRequest), #[cfg(feature = "iroh")] @@ -337,43 +367,60 @@ pub enum Request { IrohQuic(IrohQuicRequest), } +pub struct Request { + server: moq_lite::Server, + kind: RequestKind, +} + impl Request { /// Reject the session, returning your favorite HTTP status code. pub async fn reject(self, status: http::StatusCode) -> anyhow::Result<()> { - match self { - Self::WebTransport(request) => request.close(status).await?, - Self::Quic(request) => request.close(status), + match self.kind { + RequestKind::WebTransport(request) => request.close(status).await?, + RequestKind::Quic(request) => request.close(status), #[cfg(feature = "iroh")] - Request::IrohWebTransport(request) => request.close(status).await?, + RequestKind::IrohWebTransport(request) => request.close(status).await?, #[cfg(feature = "iroh")] - Request::IrohQuic(request) => request.close(status), + RequestKind::IrohQuic(request) => request.close(status), } Ok(()) } + pub fn with_publish(mut self, publish: impl Into>) -> Self { + self.server = self.server.with_publish(publish); + self + } + + pub fn with_consume(mut self, consume: impl Into>) -> Self { + self.server = self.server.with_consume(consume); + self + } + + // TODO: Uncomment when observability feature is merged + // pub fn with_stats(mut self, stats: impl Into>>) -> Self { + // self.server = self.server.with_stats(stats); + // self + // } + /// Accept the session, performing rest of the MoQ handshake. - pub async fn accept( - self, - publish: impl Into>, - subscribe: impl Into>, - ) -> anyhow::Result { - let session = match self { - Request::WebTransport(request) => Session::accept(request.ok().await?, publish, subscribe).await?, - Request::Quic(request) => Session::accept(request.ok(), publish, subscribe).await?, + pub async fn accept(self) -> anyhow::Result { + let session = match self.kind { + RequestKind::WebTransport(request) => self.server.accept(request.ok().await?).await?, + RequestKind::Quic(request) => self.server.accept(request.ok()).await?, #[cfg(feature = "iroh")] - Request::IrohWebTransport(request) => Session::accept(request.ok().await?, publish, subscribe).await?, + RequestKind::IrohWebTransport(request) => self.server.accept(request.ok().await?).await?, #[cfg(feature = "iroh")] - Request::IrohQuic(request) => Session::accept(request.ok(), publish, subscribe).await?, + RequestKind::IrohQuic(request) => self.server.accept(request.ok()).await?, }; Ok(session) } /// Returns the URL provided by the client. pub fn url(&self) -> Option<&Url> { - match self { - Request::WebTransport(request) => Some(request.url()), + match &self.kind { + RequestKind::WebTransport(request) => Some(request.url()), #[cfg(feature = "iroh")] - Request::IrohWebTransport(request) => Some(request.url()), + RequestKind::IrohWebTransport(request) => Some(request.url()), _ => None, } } diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 4637a75c84..09650902a8 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -262,13 +262,12 @@ impl Cluster { async fn run_remote_once(&mut self, url: &Url) -> anyhow::Result<()> { tracing::info!(%url, "connecting to remote"); - // Connect to the remote node. - let publish = Some(self.primary.consumer.consume()); - let subscribe = Some(self.secondary.producer.clone()); - let session = self .client - .connect(url.clone(), publish, subscribe) + .clone() + .with_publish(self.primary.consumer.consume()) + .with_consume(self.secondary.producer.clone()) + .connect(url.clone()) .await .context("failed to connect to remote")?; diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 12d1c4c0b6..f73ce8d6dc 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -50,7 +50,14 @@ impl Connection { // NOTE: subscribe and publish seem backwards because of how relays work. // We publish the tracks the client is allowed to subscribe to. // We subscribe to the tracks the client is allowed to publish. - let session = self.request.accept(subscribe, publish).await?; + let session = self + .request + .with_publish(subscribe) + .with_consume(publish) + // TODO: Uncomment when observability feature is merged + // .with_stats(stats) + .accept() + .await?; // Wait until the session is closed. session.closed().await.map_err(Into::into) diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index b188ce9849..6ab08a3cf6 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -31,17 +31,14 @@ async fn main() -> anyhow::Result<()> { let config = Config::load()?; let addr = config.server.bind.unwrap_or("[::]:443".parse().unwrap()); - let mut server = config.server.init()?; - - #[allow(unused_mut)] - let mut client = config.client.init()?; + let server = config.server.init()?; + let client = config.client.init()?; #[cfg(feature = "iroh")] - { + let (mut server, client) = { let iroh = config.iroh.bind().await?; - server.with_iroh(iroh.clone()); - client.with_iroh(iroh); - } + (server.with_iroh(iroh.clone()), client.with_iroh(iroh)) + }; let auth = config.auth.init().await?; diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 525a79baff..620fa9bbb4 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -216,7 +216,13 @@ where { // Wrap the WebSocket in a WebTransport compatibility layer. let ws = web_transport_ws::Session::new(socket, true); - let session = moq_lite::Session::accept(ws, subscribe, publish).await?; + let session = moq_lite::Server::new() + .with_publish(subscribe) + .with_consume(publish) + // TODO: Uncomment when observability feature is merged + // .with_stats(stats) + .accept(ws) + .await?; session.closed().await.map_err(Into::into) } From a755386773b1de351e63f67ca05a6c3dda4b8f23 Mon Sep 17 00:00:00 2001 From: Pietro Zanella Date: Fri, 23 Jan 2026 17:53:07 +0100 Subject: [PATCH 004/590] feat: restyling Publish UI (#857) --- .../publish/components/CameraSourceButton.tsx | 4 +- .../publish/components/FileSourceButton.tsx | 4 +- .../components/MediaSourceSelector.tsx | 13 +- .../components/MicrophoneSourceButton.tsx | 4 +- .../components/NothingSourceButton.tsx | 16 ++- .../publish/components/PublishControls.tsx | 6 +- .../components/PublishStatusIndicator.tsx | 42 +++++-- .../publish/components/ScreenSourceButton.tsx | 18 ++- js/hang-ui/src/publish/context.tsx | 32 ++++- js/hang-ui/src/publish/styles/controls.css | 18 +++ js/hang-ui/src/publish/styles/index.css | 57 +-------- .../src/publish/styles/media-selector.css | 31 +++++ .../src/publish/styles/source-button.css | 77 ++++++++++++ .../src/publish/styles/status-indicator.css | 116 ++++++++++++++++++ .../src/shared/components/icon/camera.svg | 2 +- js/hang-ui/src/shared/flex.css | 4 + js/hang-ui/src/shared/variables.css | 25 ++++ 17 files changed, 366 insertions(+), 103 deletions(-) create mode 100644 js/hang-ui/src/publish/styles/controls.css create mode 100644 js/hang-ui/src/publish/styles/media-selector.css create mode 100644 js/hang-ui/src/publish/styles/source-button.css create mode 100644 js/hang-ui/src/publish/styles/status-indicator.css diff --git a/js/hang-ui/src/publish/components/CameraSourceButton.tsx b/js/hang-ui/src/publish/components/CameraSourceButton.tsx index 2493f3baa1..12d58c3b9b 100644 --- a/js/hang-ui/src/publish/components/CameraSourceButton.tsx +++ b/js/hang-ui/src/publish/components/CameraSourceButton.tsx @@ -24,10 +24,10 @@ export default function CameraSourceButton() { }; return ( -
+
); } diff --git a/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx b/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx index 5d741ad818..bed25cffc3 100644 --- a/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx +++ b/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx @@ -24,10 +24,10 @@ export default function MicrophoneSourceButton() { }; return ( -
+
-
+ ); } diff --git a/js/hang-ui/src/publish/components/PublishControls.tsx b/js/hang-ui/src/publish/components/PublishControls.tsx index d56e93cc2d..78f61647d7 100644 --- a/js/hang-ui/src/publish/components/PublishControls.tsx +++ b/js/hang-ui/src/publish/components/PublishControls.tsx @@ -7,9 +7,9 @@ import ScreenSourceButton from "./ScreenSourceButton"; export default function PublishControls() { return ( -
-
- Source: +
+
+ Source: diff --git a/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx b/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx index f4d9d6c232..24b44a83f3 100644 --- a/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx +++ b/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx @@ -1,20 +1,38 @@ -import { Match, Switch } from "solid-js"; +import type { PublishStatus } from "../context"; import usePublishUIContext from "../hooks/use-publish-ui"; +type StatusIndicatorConfig = { variant: string; text: string }; + +const STATUS_MAP: Record = { + "no-url": { variant: "error", text: "No URL" }, + disconnected: { variant: "error", text: "Disconnected" }, + connecting: { variant: "connecting", text: "Connecting..." }, + "select-source": { variant: "warning", text: "Select Source" }, + "video-only": { variant: "video-only", text: "Video Only" }, + "audio-only": { variant: "audio-only", text: "Audio Only" }, + live: { variant: "live", text: "Live" }, +}; + +const unknownStatus: StatusIndicatorConfig = { variant: "error", text: "Unknown" }; + export default function PublishStatusIndicator() { const context = usePublishUIContext(); + const statusConfig = (): StatusIndicatorConfig => { + const status: PublishStatus = context.publishStatus(); + return STATUS_MAP[status] || unknownStatus; + }; + return ( - - - 🔴 No URL - 🔴 Disconnected - 🟡 Connecting... - 🟡 Select Source - 🟢 Video Only - 🟢 Audio Only - 🟢 Live - - +
+ + + {statusConfig().text} + +
); } diff --git a/js/hang-ui/src/publish/components/ScreenSourceButton.tsx b/js/hang-ui/src/publish/components/ScreenSourceButton.tsx index 528d835495..530fd673ed 100644 --- a/js/hang-ui/src/publish/components/ScreenSourceButton.tsx +++ b/js/hang-ui/src/publish/components/ScreenSourceButton.tsx @@ -7,18 +7,16 @@ export default function ScreenSourceButton() { const onClick = () => { context.hangPublish.source.set("screen"); context.hangPublish.invisible.set(false); - context.hangPublish.muted.set(false); + context.hangPublish.muted.set(true); }; return ( -
- -
+ ); } diff --git a/js/hang-ui/src/publish/context.tsx b/js/hang-ui/src/publish/context.tsx index f31f1d293f..472dbc582b 100644 --- a/js/hang-ui/src/publish/context.tsx +++ b/js/hang-ui/src/publish/context.tsx @@ -2,7 +2,14 @@ import type HangPublish from "@moq/hang/publish/element"; import type { JSX } from "solid-js"; import { createContext, createEffect, createSignal } from "solid-js"; -type PublishStatus = "no-url" | "disconnected" | "connecting" | "live" | "audio-only" | "video-only" | "select-source"; +export type PublishStatus = + | "no-url" + | "disconnected" + | "connecting" + | "live" + | "audio-only" + | "video-only" + | "select-source"; type PublishUIContextValue = { hangPublish: HangPublish; @@ -43,7 +50,7 @@ export default function PublishUIContextProvider(props: PublishUIContextProvider const setFile = (file: File) => { props.hangPublish.source.set(file); props.hangPublish.invisible.set(false); - props.hangPublish.muted.set(false); + props.hangPublish.muted.set(true); }; const value: PublishUIContextValue = { @@ -64,6 +71,11 @@ export default function PublishUIContextProvider(props: PublishUIContextProvider createEffect(() => { const publish = props.hangPublish; + // Initialize with "nothing" active on page load + publish.muted.set(true); + publish.invisible.set(true); + publish.source.set(undefined); + publish.signals.effect((effect) => { const clearCameraDevices = () => setCameraMediaDevices([]); const video = effect.get(publish.video); @@ -107,8 +119,11 @@ export default function PublishUIContextProvider(props: PublishUIContextProvider }); publish.signals.effect((effect) => { - const selectedSource = effect.get(publish.source); - setNothingActive(selectedSource === undefined); + const source = effect.get(publish.source); + const muted = effect.get(publish.muted); + const invisible = effect.get(publish.invisible); + + setNothingActive(source === undefined && muted && invisible); }); publish.signals.effect((effect) => { @@ -153,8 +168,13 @@ export default function PublishUIContextProvider(props: PublishUIContextProvider publish.signals.effect((effect) => { const url = effect.get(publish.connection.url); const status = effect.get(publish.connection.status); - const audio = effect.get(publish.broadcast.audio.source); - const video = effect.get(publish.broadcast.video.source); + const audioSource = effect.get(publish.broadcast.audio.source); + const videoSource = effect.get(publish.broadcast.video.source); + const muted = effect.get(publish.muted); + const invisible = effect.get(publish.invisible); + + const audio = audioSource && !muted; + const video = videoSource && !invisible; if (!url) { setPublishStatus("no-url"); diff --git a/js/hang-ui/src/publish/styles/controls.css b/js/hang-ui/src/publish/styles/controls.css new file mode 100644 index 0000000000..991fee97a7 --- /dev/null +++ b/js/hang-ui/src/publish/styles/controls.css @@ -0,0 +1,18 @@ +.publish-ui__controls { + gap: var(--spacing-16); + margin: var(--spacing-8) 0; + border-radius: var(--border-radius-lg); + border: var(--spacing-1) solid var(--color-white-alpha-10); + padding: var(--spacing-16); +} + +.publish-ui__source-label { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.1rem; + color: var(--color-white-alpha-90); +} + +.publish-ui__source-selector { + gap: var(--spacing-16); +} diff --git a/js/hang-ui/src/publish/styles/index.css b/js/hang-ui/src/publish/styles/index.css index bead668a67..820c6c7a1c 100644 --- a/js/hang-ui/src/publish/styles/index.css +++ b/js/hang-ui/src/publish/styles/index.css @@ -1,55 +1,10 @@ +/* Shared dependencies */ @import "../../shared/variables.css"; @import "../../shared/flex.css"; @import "../../shared/components/button/button.css"; -.publishControlsContainer { - display: flex; - justify-content: space-around; - gap: 16px; - margin: 8px 0; - align-content: center; -} - -.publishSourceSelectorContainer { - display: flex; - gap: 16px; -} - -.publishSourceButtonContainer { - display: flex; - position: relative; - align-items: center; -} - -.publishSourceButton { - opacity: 0.5; -} - -.publishSourceButton.active { - opacity: 1; -} - -.mediaSourceVisibilityToggle { - font-size: 0.75em; - cursor: pointer; - padding-left: 4px; -} - -.button.button--media-source-selector, -.button.button--media-source-selector:hover:not(:disabled) { - background-color: unset; - box-shadow: none; - border: unset; -} - -.mediaSourceSelector { - position: absolute; - top: 100%; - transform: translateX(-50%); - left: 50%; - display: block; -} - -.hidden { - display: none; -} +/* Component styles */ +@import "./controls.css"; +@import "./source-button.css"; +@import "./media-selector.css"; +@import "./status-indicator.css"; diff --git a/js/hang-ui/src/publish/styles/media-selector.css b/js/hang-ui/src/publish/styles/media-selector.css new file mode 100644 index 0000000000..eaa2c5c046 --- /dev/null +++ b/js/hang-ui/src/publish/styles/media-selector.css @@ -0,0 +1,31 @@ +.publish-ui__media-selector-wrapper { + position: relative; + gap: var(--spacing-8); +} + +.publish-ui__media-selector-toggle { + font-size: 0.75rem; + cursor: pointer; +} + +.publish-ui__media-selector-toggle.button, +.publish-ui__media-selector-toggle.button:hover:not(:disabled) { + background-color: unset; + box-shadow: none; + border: unset; +} + +.publish-ui__media-selector-dropdown { + position: absolute; + top: var(--spacing-48); + right: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + background: var(--color-black-alpha-85); + border: var(--spacing-1) solid var(--color-white-alpha-10); + border-radius: var(--border-radius-sm); + color: var(--color-white); + cursor: pointer; + z-index: 1; + outline: none; + box-shadow: none; +} diff --git a/js/hang-ui/src/publish/styles/source-button.css b/js/hang-ui/src/publish/styles/source-button.css new file mode 100644 index 0000000000..fe09881c3f --- /dev/null +++ b/js/hang-ui/src/publish/styles/source-button.css @@ -0,0 +1,77 @@ +.publish-ui__source-button-wrapper { + position: relative; +} + +.publish-ui__source-button { + width: 44px; + height: 44px; + border-radius: var(--border-radius-md); + background: var(--color-white-alpha-05); + backdrop-filter: blur(6px); + border: var(--spacing-1) solid var(--color-white-alpha-10); + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.15s ease; +} + +.publish-ui__source-button:hover { + border-color: var(--color-white-alpha-20); +} + +.publish-ui__source-button svg { + color: var(--color-white-alpha-70); + transition: color 0.2s ease; + position: relative; + z-index: 1; +} + +.publish-ui__source-button--active { + background: linear-gradient(135deg, var(--color-orange-500-alpha-30), var(--color-amber-600-alpha-30)); + border: var(--spacing-1) solid var(--color-orange-500-alpha-50); + box-shadow: 0 0 24px var(--color-orange-500-alpha-40); +} + +.publish-ui__source-button--active svg { + color: var(--color-orange-300); +} + +.publish-ui__source-button--active::before { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--border-radius-md); + background: linear-gradient(135deg, var(--color-orange-400-alpha-20), var(--color-amber-600-alpha-20)); + filter: blur(16px); + z-index: 0; +} + +.publish-ui__source-button--no-source { + background: var(--color-white-alpha-05); + border: var(--spacing-1) solid var(--color-white-alpha-10); +} + +.publish-ui__source-button--no-source svg { + color: var(--color-white-alpha-50); +} + +.publish-ui__source-button--no-source-active { + background: linear-gradient(135deg, var(--color-red-500-alpha-25), var(--color-red-900-alpha-35)); + border: var(--spacing-1) solid var(--color-red-500-alpha-60); + box-shadow: 0 0 18px var(--color-red-500-alpha-45); +} + +.publish-ui__source-button--no-source-active svg { + color: var(--color-red-300); +} + +.publish-ui__source-button--no-source-active::before { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--border-radius-md); + background: linear-gradient(135deg, var(--color-red-400-alpha-25), var(--color-red-600-alpha-25)); + filter: blur(16px); + z-index: 0; +} diff --git a/js/hang-ui/src/publish/styles/status-indicator.css b/js/hang-ui/src/publish/styles/status-indicator.css new file mode 100644 index 0000000000..63ccbad8eb --- /dev/null +++ b/js/hang-ui/src/publish/styles/status-indicator.css @@ -0,0 +1,116 @@ +.publish-ui__status-indicator { + gap: var(--spacing-12); + padding: var(--spacing-12) var(--spacing-24); + border-radius: 12px; + background: var(--color-black-alpha-60); + backdrop-filter: blur(16px); + border: var(--spacing-1) solid var(--color-white-alpha-10); +} + +.publish-ui__status-indicator-dot { + position: relative; + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + z-index: 1; +} + +.publish-ui__status-indicator-dot::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: currentColor; + transform: scale(1); + opacity: 0.5; + animation: status-pulse 1.5s ease-out infinite; + z-index: -1; +} + +.publish-ui__status-indicator-dot::after { + display: none; +} + +.publish-ui__status-indicator-dot--live::after, +.publish-ui__status-indicator-dot--warning::after, +.publish-ui__status-indicator-dot--connecting::after, +.publish-ui__status-indicator-dot--audio-only::after, +.publish-ui__status-indicator-dot--video-only::after { + display: block; +} + +.publish-ui__status-indicator-dot--error { + background: var(--color-gray-100); + color: var(--color-gray-100); +} + +.publish-ui__status-indicator-text--error { + color: var(--color-gray-100); +} + +.publish-ui__status-indicator-dot--warning { + background: var(--color-yellow-400); + color: var(--color-yellow-400); +} + +.publish-ui__status-indicator-text--warning { + color: var(--color-yellow-400); +} + +.publish-ui__status-indicator-dot--live { + background: var(--color-green-400); + color: var(--color-green-400); +} + +.publish-ui__status-indicator-text--live { + color: var(--color-green-400); +} + +.publish-ui__status-indicator-dot--audio-only { + background: var(--color-blue-500); + color: var(--color-blue-500); +} + +.publish-ui__status-indicator-text--audio-only { + color: var(--color-blue-500); +} + +.publish-ui__status-indicator-dot--video-only { + background: var(--color-purple-500); + color: var(--color-purple-500); +} + +.publish-ui__status-indicator-text--video-only { + color: var(--color-purple-500); +} + +.publish-ui__status-indicator-dot--connecting { + background: var(--color-gray-100); + color: var(--color-gray-100); +} + +.publish-ui__status-indicator-text--connecting { + color: var(--color-gray-100); +} + +.publish-ui__status-indicator-text { + font-size: 1rem; + letter-spacing: 0.1rem; +} + +@keyframes status-pulse { + 0% { + transform: scale(1); + opacity: 0.5; + } + + 70% { + opacity: 0.25; + } + + 100% { + transform: scale(2.5); + opacity: 0; + } +} diff --git a/js/hang-ui/src/shared/components/icon/camera.svg b/js/hang-ui/src/shared/components/icon/camera.svg index 6bf1096eb2..8747d812e9 100644 --- a/js/hang-ui/src/shared/components/icon/camera.svg +++ b/js/hang-ui/src/shared/components/icon/camera.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/js/hang-ui/src/shared/flex.css b/js/hang-ui/src/shared/flex.css index d67ea3c9fc..e2de54fadf 100644 --- a/js/hang-ui/src/shared/flex.css +++ b/js/hang-ui/src/shared/flex.css @@ -7,3 +7,7 @@ :is(.flex--align-center) { align-items: center; } + +:is(.flex--space-between) { + justify-content: space-between; +} diff --git a/js/hang-ui/src/shared/variables.css b/js/hang-ui/src/shared/variables.css index 1b4fe0c9c9..07535f594e 100644 --- a/js/hang-ui/src/shared/variables.css +++ b/js/hang-ui/src/shared/variables.css @@ -1,25 +1,46 @@ :host { --color-white: #ffffff; + --color-white-alpha-05: rgba(255, 255, 255, 0.05); --color-white-alpha-10: rgba(255, 255, 255, 0.1); --color-white-alpha-20: rgba(255, 255, 255, 0.2); --color-white-alpha-25: rgba(255, 255, 255, 0.25); --color-white-alpha-30: rgba(255, 255, 255, 0.3); + --color-white-alpha-70: rgba(255, 255, 255, 0.7); + --color-white-alpha-90: rgba(255, 255, 255, 0.9); --color-gray-100: #9ca3af; --color-blue-400: #00dfff; + --color-blue-500: #52a1ff; --color-blue-900: #1b3243; --color-purple-400: #a855f7; + --color-purple-500: #c27bff; --color-purple-900: #31144a; --color-green-400: #65ee2e; --color-green-900: #3d4928; + --color-yellow-400: #facc15; + --color-orange-300: rgb(253, 186, 116); --color-orange-400: #ff9900; --color-orange-500: #f97316; --color-orange-900: #4d301b; --color-amber-600: #d97706; + --color-amber-600-alpha-20: rgba(217, 119, 6, 0.2); + --color-amber-600-alpha-30: rgba(217, 119, 6, 0.3); + --color-orange-400-alpha-20: rgba(255, 153, 0, 0.2); + --color-orange-500-alpha-30: rgba(249, 115, 22, 0.3); + --color-orange-500-alpha-40: rgba(249, 115, 22, 0.4); + --color-orange-500-alpha-50: rgba(249, 115, 22, 0.5); --color-orange-500-alpha-60: rgba(249, 115, 22, 0.6); --color-orange-500-alpha-80: rgba(249, 115, 22, 0.8); + --color-red-300: rgb(252, 165, 165); + --color-red-400-alpha-25: rgba(248, 113, 113, 0.25); + --color-red-500-alpha-25: rgba(239, 68, 68, 0.25); + --color-red-500-alpha-45: rgba(239, 68, 68, 0.45); + --color-red-500-alpha-60: rgba(239, 68, 68, 0.6); + --color-red-600-alpha-25: rgba(220, 38, 38, 0.25); + --color-red-900-alpha-35: rgba(127, 29, 29, 0.35); --color-red: #e60000; --color-black-alpha-8: rgba(0, 0, 0, 0.08); --color-black-alpha-10: rgba(0, 0, 0, 0.1); + --color-black-alpha-60: rgba(0, 0, 0, 0.6); --color-black-alpha-85: rgba(0, 0, 0, 0.85); --color-black: #000000; --spacing-1: 0.0625rem; /* 1px */ @@ -29,4 +50,8 @@ --spacing-12: 0.75rem; /* 12px */ --spacing-16: 1rem; /* 16px */ --spacing-24: 1.5rem; /* 24px */ + --spacing-48: 3rem; /* 48px */ + --border-radius-sm: 0.5rem; + --border-radius-md: 0.75rem; + --border-radius-lg: 1rem; } From 78bc805a7683afdcaa1135a7dbebec6359b8528a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Jan 2026 16:38:32 -0800 Subject: [PATCH 005/590] Manually run release-plz (#864) --- Cargo.lock | 343 ++++++++++++++++++---------------- Cargo.toml | 6 +- rs/hang-cli/CHANGELOG.md | 7 + rs/hang-cli/Cargo.toml | 2 +- rs/hang/CHANGELOG.md | 14 ++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 9 + rs/libmoq/Cargo.toml | 2 +- rs/moq-clock/CHANGELOG.md | 7 + rs/moq-clock/Cargo.toml | 2 +- rs/moq-lite/CHANGELOG.md | 9 + rs/moq-lite/Cargo.toml | 2 +- rs/moq-native/CHANGELOG.md | 10 + rs/moq-native/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 9 + rs/moq-relay/Cargo.toml | 2 +- rs/moq-token-cli/CHANGELOG.md | 6 + rs/moq-token-cli/Cargo.toml | 2 +- rs/moq-token/CHANGELOG.md | 8 + rs/moq-token/Cargo.toml | 2 +- 20 files changed, 275 insertions(+), 171 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e7fb1d640..2ee2b9e515 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -198,13 +198,12 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" dependencies = [ "compression-codecs", "compression-core", - "futures-core", "pin-project-lite", "tokio", ] @@ -266,9 +265,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -277,9 +276,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" dependencies = [ "cc", "cmake", @@ -422,9 +421,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" @@ -525,9 +524,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.52" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -567,9 +566,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -627,9 +626,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "cmake" @@ -646,7 +645,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -667,9 +666,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" dependencies = [ "compression-core", "flate2", @@ -871,7 +870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" dependencies = [ "hybrid-array", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -917,7 +916,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.0-rc.3", "fiat-crypto", - "rand_core 0.9.3", + "rand_core 0.9.5", "rustc_version", "serde", "subtle", @@ -972,9 +971,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der" @@ -1137,9 +1136,9 @@ version = "3.0.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "594435fe09e345ee388e4e8422072ff7dfeca8729389fbd997b3f5504c44cd47" dependencies = [ - "pkcs8 0.11.0-rc.8", + "pkcs8 0.11.0-rc.9", "serde", - "signature 3.0.0-rc.6", + "signature 3.0.0-rc.8", ] [[package]] @@ -1150,10 +1149,10 @@ checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.9.3", + "rand_core 0.9.5", "serde", "sha2 0.11.0-rc.2", - "signature 3.0.0-rc.6", + "signature 3.0.0-rc.8", "subtle", "zeroize", ] @@ -1257,9 +1256,9 @@ dependencies = [ [[package]] name = "fastbloom" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18c1ddb9231d8554c2d6bdf4cfaabf0c59251658c68b6c95cd52dd0c513a912a" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", @@ -1291,9 +1290,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixedbitset" @@ -1303,9 +1302,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -1491,9 +1490,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1572,7 +1571,7 @@ checksum = "253b313319f7109de64e480ffb606f89475cd758bae82e096e00c5d95341d30e" [[package]] name = "hang" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "buf-list", @@ -1593,7 +1592,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -1601,7 +1600,7 @@ dependencies = [ [[package]] name = "hang-cli" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "axum", @@ -1705,7 +1704,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustls", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tokio", "tokio-rustls", @@ -1730,7 +1729,7 @@ dependencies = [ "resolv-conf", "rustls", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", @@ -1823,9 +1822,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" +checksum = "b41fb3dc24fe72c2e3a4685eed55917c2fb228851257f4a8f2d985da9443c3e5" dependencies = [ "typenum", "zeroize", @@ -1902,7 +1901,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -2168,7 +2167,7 @@ dependencies = [ "netwatch", "pin-project", "pkarr", - "pkcs8 0.11.0-rc.8", + "pkcs8 0.11.0-rc.9", "portmapper", "rand 0.9.2", "reqwest 0.12.28", @@ -2201,7 +2200,7 @@ dependencies = [ "derive_more", "ed25519-dalek", "n0-error", - "rand_core 0.9.3", + "rand_core 0.9.5", "serde", "url", "zeroize", @@ -2249,7 +2248,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.5.10", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2262,7 +2261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535" dependencies = [ "bytes", - "getrandom 0.2.16", + "getrandom 0.2.17", "rand 0.8.5", "ring", "rustc-hash", @@ -2270,7 +2269,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier 0.5.3", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2392,9 +2391,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2408,7 +2407,7 @@ checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" dependencies = [ "aws-lc-rs", "base64 0.22.1", - "getrandom 0.2.16", + "getrandom 0.2.17", "js-sys", "pem", "serde", @@ -2440,7 +2439,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmoq" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "cbindgen", @@ -2448,7 +2447,7 @@ dependencies = [ "moq-lite", "moq-native", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -2608,7 +2607,7 @@ dependencies = [ [[package]] name = "moq-clock" -version = "0.10.2" +version = "0.10.3" dependencies = [ "anyhow", "chrono", @@ -2622,7 +2621,7 @@ dependencies = [ [[package]] name = "moq-lite" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-channel", "bytes", @@ -2631,7 +2630,7 @@ dependencies = [ "num_enum", "rand 0.9.2", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-async", @@ -2640,7 +2639,7 @@ dependencies = [ [[package]] name = "moq-native" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "bytes", @@ -2674,7 +2673,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.10.2" +version = "0.10.3" dependencies = [ "anyhow", "axum", @@ -2691,7 +2690,7 @@ dependencies = [ "serde", "serde_with", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "toml", "tower-http", @@ -2702,7 +2701,7 @@ dependencies = [ [[package]] name = "moq-token" -version = "0.5.6" +version = "0.5.7" dependencies = [ "anyhow", "aws-lc-rs", @@ -2720,7 +2719,7 @@ dependencies = [ [[package]] name = "moq-token-cli" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "clap", @@ -2729,9 +2728,9 @@ dependencies = [ [[package]] name = "mp4-atom" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3af4894763f3d6e1eabd3d878853d88ad90add9bcff9165116fb84cbfb0207" +checksum = "5e8e949244bbd26ea7eb6d936af3a6a0202be68bcfc9afce700f3c9026860ff7" dependencies = [ "bytes", "derive_more", @@ -2745,9 +2744,9 @@ dependencies = [ [[package]] name = "n0-error" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5969a2f40e9d9ed121a789c415f4114ac2b28e5731c080bdefee217d3b3fb" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" dependencies = [ "n0-error-macros", "spez", @@ -2755,9 +2754,9 @@ dependencies = [ [[package]] name = "n0-error-macros" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a6908df844696d9af91c7c3950d50e52d67df327d02a95367f95bbf177d6556" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" dependencies = [ "proc-macro2", "quote", @@ -2845,17 +2844,17 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "netlink-sys" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ "bytes", - "futures", + "futures-util", "libc", "log", "tokio", @@ -2884,7 +2883,7 @@ dependencies = [ "netlink-sys", "pin-project-lite", "serde", - "socket2 0.6.1", + "socket2 0.6.2", "time", "tokio", "tokio-util", @@ -2913,7 +2912,7 @@ checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" dependencies = [ "base32", "document-features", - "getrandom 0.2.16", + "getrandom 0.2.17", "httpdate", "js-sys", "once_cell", @@ -3092,9 +3091,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "p256" @@ -3267,7 +3266,7 @@ dependencies = [ "serde", "sha1_smol", "simple-dns", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -3297,9 +3296,9 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.11.0-rc.8" +version = "0.11.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77089aec8290d0b7bb01b671b091095cf1937670725af4fd73d47249f03b12c0" +checksum = "80f8fa6196ede5a9f9ee95b44ca134bddc9b70e8913f9297bd58c909f5889a09" dependencies = [ "der 0.8.0-rc.10", "spki 0.8.0-rc.4", @@ -3342,7 +3341,7 @@ dependencies = [ "rand 0.9.2", "serde", "smallvec", - "socket2 0.6.1", + "socket2 0.6.2", "time", "tokio", "tokio-util", @@ -3420,18 +3419,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -3439,9 +3438,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools", @@ -3452,9 +3451,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ "prost", ] @@ -3472,8 +3471,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", - "thiserror 2.0.17", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3497,7 +3496,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier 0.6.2", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3512,16 +3511,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -3550,7 +3549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3570,7 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3579,23 +3578,23 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "rcgen" -version = "0.14.6" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" dependencies = [ "aws-lc-rs", "ring", @@ -3766,7 +3765,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted 0.9.0", "windows-sys 0.52.0", @@ -3794,9 +3793,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" @@ -3874,9 +3873,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3932,9 +3931,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -4251,6 +4250,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sfv" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d471eaefb14f4b30032525bdb124b36e55ba9cb1292080e06f1a236cd10fe87" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "ref-cast", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4338,9 +4348,9 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.6" +version = "3.0.0-rc.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d" +checksum = "c04b70a14ee5f15e2e0c785a5fdb2e9a51138dfe13ba3cf8eab037a9e60b1879" [[package]] name = "simd-adler32" @@ -4371,7 +4381,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -4405,9 +4415,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -4580,11 +4590,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4600,9 +4610,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -4620,31 +4630,31 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -4687,7 +4697,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -4860,7 +4870,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.1", + "socket2 0.6.2", "sync_wrapper", "tokio", "tokio-stream", @@ -4883,9 +4893,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -5042,7 +5052,7 @@ dependencies = [ "log", "rand 0.9.2", "sha1 0.10.6", - "thiserror 2.0.17", + "thiserror 2.0.18", "utf-8", ] @@ -5179,18 +5189,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -5201,11 +5211,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -5214,9 +5225,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5224,9 +5235,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -5237,9 +5248,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -5270,9 +5281,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -5300,11 +5311,11 @@ dependencies = [ "iroh-quinn", "n0-error", "n0-future", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", - "web-transport-proto", + "web-transport-proto 0.3.1", "web-transport-trait", ] @@ -5316,7 +5327,21 @@ checksum = "660175a6d1643adb93b71c4f853d4f20f0fce47f53ae579afe9f7711fe84870d" dependencies = [ "bytes", "http", - "thiserror 2.0.17", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "web-transport-proto" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee133824c73a492e5739a449fb088571d8a418c2de29520035639107d6a7d6bc" +dependencies = [ + "bytes", + "http", + "sfv", + "thiserror 2.0.18", "tokio", "url", ] @@ -5333,11 +5358,11 @@ dependencies = [ "quinn", "rustls", "rustls-native-certs", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", - "web-transport-proto", + "web-transport-proto 0.3.1", "web-transport-trait", ] @@ -5352,16 +5377,16 @@ dependencies = [ [[package]] name = "web-transport-ws" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee7ae0f2a6f3596dc2842e20f2ad0cf046211414979d67e445760348b60becf" +checksum = "ec6138c98bdd74c86c92a84e514b775108870ba1f64909ad612945fb75e30d2b" dependencies = [ "bytes", "futures", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.24.0", - "web-transport-proto", + "web-transport-proto 0.4.0", "web-transport-trait", ] @@ -5877,9 +5902,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "wmi" @@ -5891,7 +5916,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-core", ] @@ -5915,7 +5940,7 @@ dependencies = [ "pharos", "rustc_version", "send_wrapper", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5936,7 +5961,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -6089,6 +6114,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml index 4aa705b1fb..42ad62df05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,9 @@ members = [ resolver = "2" [workspace.dependencies] -hang = { version = "0.10", path = "rs/hang" } -moq-lite = { version = "0.11", path = "rs/moq-lite" } -moq-native = { version = "0.11", path = "rs/moq-native" } +hang = { version = "0.11", path = "rs/hang" } +moq-lite = { version = "0.12", path = "rs/moq-lite" } +moq-native = { version = "0.12", path = "rs/moq-native" } moq-token = { version = "0.5", path = "rs/moq-token" } serde = { version = "1", features = ["derive"] } diff --git a/rs/hang-cli/CHANGELOG.md b/rs/hang-cli/CHANGELOG.md index 11962ca1c8..85ca824a40 100644 --- a/rs/hang-cli/CHANGELOG.md +++ b/rs/hang-cli/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.4](https://github.com/moq-dev/moq/compare/hang-cli-v0.7.3...hang-cli-v0.7.4) - 2026-01-24 + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.7.3](https://github.com/moq-dev/moq/compare/hang-cli-v0.7.2...hang-cli-v0.7.3) - 2026-01-10 ### Added diff --git a/rs/hang-cli/Cargo.toml b/rs/hang-cli/Cargo.toml index e8bf114573..ca5e433fae 100644 --- a/rs/hang-cli/Cargo.toml +++ b/rs/hang-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.7.3" +version = "0.7.4" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 0e985e25c0..f48fcf0559 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0](https://github.com/moq-dev/moq/compare/hang-v0.10.0...hang-v0.11.0) - 2026-01-24 + +### Added + +- *(hang)* add feature flags for third-party dependencies ([#854](https://github.com/moq-dev/moq/pull/854)) + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- Add #[non_exhaustive] to moq-native configuration. ([#850](https://github.com/moq-dev/moq/pull/850)) +- bump mp4-atom to 0.10.0 ([#846](https://github.com/moq-dev/moq/pull/846)) +- simplify match statements using let-else syntax ([#840](https://github.com/moq-dev/moq/pull/840)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.10.0](https://github.com/moq-dev/moq/compare/hang-v0.9.1...hang-v0.10.0) - 2026-01-10 ### Added diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 7eb76ea73b..a05f97debf 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.0" +version = "0.11.0" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index 763baec12e..df9a56834e 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5](https://github.com/moq-dev/moq/compare/libmoq-v0.2.4...libmoq-v0.2.5) - 2026-01-24 + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- Add universal libmoq build for macos ([#861](https://github.com/moq-dev/moq/pull/861)) +- Add #[non_exhaustive] to moq-native configuration. ([#850](https://github.com/moq-dev/moq/pull/850)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.2.4](https://github.com/moq-dev/moq/compare/libmoq-v0.2.3...libmoq-v0.2.4) - 2026-01-12 ## [0.2.3](https://github.com/moq-dev/moq/compare/libmoq-v0.2.2...libmoq-v0.2.3) - 2026-01-10 diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 92589a088d..d2d498f32a 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.4" +version = "0.2.5" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-clock/CHANGELOG.md b/rs/moq-clock/CHANGELOG.md index e74d8d65c9..bb298b4d12 100644 --- a/rs/moq-clock/CHANGELOG.md +++ b/rs/moq-clock/CHANGELOG.md @@ -45,6 +45,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.3](https://github.com/moq-dev/moq/compare/moq-clock-v0.10.2...moq-clock-v0.10.3) - 2026-01-24 + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.10.2](https://github.com/moq-dev/moq/compare/moq-clock-v0.10.1...moq-clock-v0.10.2) - 2026-01-10 ### Other diff --git a/rs/moq-clock/Cargo.toml b/rs/moq-clock/Cargo.toml index dd1ad0bcd5..d1b78338fa 100644 --- a/rs/moq-clock/Cargo.toml +++ b/rs/moq-clock/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.2" +version = "0.10.3" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-lite/CHANGELOG.md b/rs/moq-lite/CHANGELOG.md index bb0bf5619f..53f33b32d3 100644 --- a/rs/moq-lite/CHANGELOG.md +++ b/rs/moq-lite/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0](https://github.com/moq-dev/moq/compare/moq-lite-v0.11.0...moq-lite-v0.12.0) - 2026-01-24 + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- simplify match statements using let-else syntax ([#840](https://github.com/moq-dev/moq/pull/840)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) +- Add documentation to Rust public APIs ([#834](https://github.com/moq-dev/moq/pull/834)) + ## [0.11.0](https://github.com/moq-dev/moq/compare/moq-lite-v0.10.1...moq-lite-v0.11.0) - 2026-01-10 ### Other diff --git a/rs/moq-lite/Cargo.toml b/rs/moq-lite/Cargo.toml index a95fa694e4..bd7539a3e3 100644 --- a/rs/moq-lite/Cargo.toml +++ b/rs/moq-lite/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.11.0" +version = "0.12.0" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-native/CHANGELOG.md b/rs/moq-native/CHANGELOG.md index 8e2f127987..d42780c56d 100644 --- a/rs/moq-native/CHANGELOG.md +++ b/rs/moq-native/CHANGELOG.md @@ -44,6 +44,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0](https://github.com/moq-dev/moq/compare/moq-native-v0.11.0...moq-native-v0.12.0) - 2026-01-24 + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- Add #[non_exhaustive] to moq-native configuration. ([#850](https://github.com/moq-dev/moq/pull/850)) +- moq-native: Implement QUIC-LB compatible CID generation ([#848](https://github.com/moq-dev/moq/pull/848)) +- Fix bugs with WebSocket fallback ([#844](https://github.com/moq-dev/moq/pull/844)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.11.0](https://github.com/moq-dev/moq/compare/moq-native-v0.10.1...moq-native-v0.11.0) - 2026-01-10 ### Added diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 325a56f0a9..6d75d73ed8 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.11.0" +version = "0.12.0" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 6c3b802a9f..0735e8d4b1 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -64,6 +64,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.3](https://github.com/moq-dev/moq/compare/moq-relay-v0.10.2...moq-relay-v0.10.3) - 2026-01-24 + +### Other + +- Add a builder pattern for constructing clients/servers ([#862](https://github.com/moq-dev/moq/pull/862)) +- JWK sets ([#809](https://github.com/moq-dev/moq/pull/809)) +- simplify match statements using let-else syntax ([#840](https://github.com/moq-dev/moq/pull/840)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.10.2](https://github.com/moq-dev/moq/compare/moq-relay-v0.10.1...moq-relay-v0.10.2) - 2026-01-10 ### Added diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 3c6d6c3573..56403ef2d3 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.2" +version = "0.10.3" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-token-cli/CHANGELOG.md b/rs/moq-token-cli/CHANGELOG.md index 9fa9cea77f..dc9a5c8342 100644 --- a/rs/moq-token-cli/CHANGELOG.md +++ b/rs/moq-token-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.7...moq-token-cli-v0.5.8) - 2026-01-24 + +### Other + +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.5.7](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.6...moq-token-cli-v0.5.7) - 2026-01-10 ### Other diff --git a/rs/moq-token-cli/Cargo.toml b/rs/moq-token-cli/Cargo.toml index 51fa40fbbc..1026db8189 100644 --- a/rs/moq-token-cli/Cargo.toml +++ b/rs/moq-token-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.7" +version = "0.5.8" edition = "2024" [[bin]] diff --git a/rs/moq-token/CHANGELOG.md b/rs/moq-token/CHANGELOG.md index b7c154d4b6..7d8e335f61 100644 --- a/rs/moq-token/CHANGELOG.md +++ b/rs/moq-token/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.7](https://github.com/moq-dev/moq/compare/moq-token-v0.5.6...moq-token-v0.5.7) - 2026-01-24 + +### Other + +- JWK sets ([#809](https://github.com/moq-dev/moq/pull/809)) +- Fix link to auth docs ([#842](https://github.com/moq-dev/moq/pull/842)) +- upgrade to Rust edition 2024 ([#838](https://github.com/moq-dev/moq/pull/838)) + ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-token-v0.5.5...moq-token-v0.5.6) - 2026-01-10 ### Other diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index 2ac7390f15..145bbec2e5 100644 --- a/rs/moq-token/Cargo.toml +++ b/rs/moq-token/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.6" +version = "0.5.7" edition = "2024" [features] From 049732456b93bc4627a74a5328cf6699a43e2f58 Mon Sep 17 00:00:00 2001 From: Jeremy Hiatt Date: Mon, 26 Jan 2026 18:01:51 -0800 Subject: [PATCH 006/590] moq-relay: Ignore empty JWT param (#868) --- rs/moq-relay/src/connection.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index f73ce8d6dc..e42d9ccebb 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -16,7 +16,10 @@ impl Connection { Some(url) => { // Extract the path and token from the URL. let path = url.path(); - let token = url.query_pairs().find(|(k, _)| k == "jwt").map(|(_, v)| v.to_string()); + let token = url + .query_pairs() + .find(|(k, v)| k == "jwt" && !v.is_empty()) + .map(|(_, v)| v.to_string()); (path, token) } None => ("", None), From 501f3a0ea675c0119cff97774965c4010d6aa297 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Jan 2026 12:23:30 -0300 Subject: [PATCH 007/590] CMAF passthrough attempt v3 (#867) Co-authored-by: Claude Opus 4.5 --- Cargo.lock | 1 - bun.lock | 5 + deno.lock | 1 + .../src/watch/components/LatencySlider.tsx | 8 +- js/hang-ui/src/watch/context.tsx | 20 +- js/hang/package.json | 3 +- js/hang/src/catalog/audio.ts | 12 +- js/hang/src/catalog/container.ts | 31 +- js/hang/src/catalog/index.ts | 1 - js/hang/src/catalog/video.ts | 12 +- js/hang/src/container/cmaf.ts | 352 ++++++++++++++++++ js/hang/src/container/codec.ts | 155 -------- js/hang/src/container/index.ts | 3 +- js/hang/src/{frame.ts => container/legacy.ts} | 139 ++++--- js/hang/src/index.ts | 2 +- js/hang/src/publish/audio/encoder.ts | 28 +- js/hang/src/publish/video/encoder.ts | 34 +- js/hang/src/util/latency.ts | 58 +++ js/hang/src/watch/audio/source.ts | 104 +++++- js/hang/src/watch/element.ts | 19 +- js/hang/src/watch/video/source.ts | 129 ++++++- rs/hang-cli/Cargo.toml | 1 - rs/hang-cli/src/main.rs | 5 +- rs/hang-cli/src/publish.rs | 78 ++-- rs/hang/examples/video.rs | 2 + rs/hang/src/catalog/audio/mod.rs | 17 + rs/hang/src/catalog/container.rs | 28 ++ rs/hang/src/catalog/mod.rs | 2 + rs/hang/src/catalog/root.rs | 20 +- rs/hang/src/catalog/video/mod.rs | 17 + rs/hang/src/import/aac.rs | 2 + rs/hang/src/import/avc3.rs | 15 +- rs/hang/src/import/decoder.rs | 44 +-- rs/hang/src/import/fmp4.rs | 334 ++++++++++++----- rs/hang/src/import/hev1.rs | 2 + rs/hang/src/import/hls.rs | 123 +++++- rs/hang/src/import/opus.rs | 2 + 37 files changed, 1292 insertions(+), 517 deletions(-) create mode 100644 js/hang/src/container/cmaf.ts delete mode 100644 js/hang/src/container/codec.ts rename js/hang/src/{frame.ts => container/legacy.ts} (65%) create mode 100644 js/hang/src/util/latency.ts create mode 100644 rs/hang/src/catalog/container.rs diff --git a/Cargo.lock b/Cargo.lock index 2ee2b9e515..f5f5a846ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,7 +1605,6 @@ dependencies = [ "anyhow", "axum", "axum-server", - "bytes", "clap", "hang", "moq-native", diff --git a/bun.lock b/bun.lock index 99fb7edc52..86d3e6c323 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "@libav.js/variant-opus-af": "^6.8.8", "@moq/lite": "workspace:^", "@moq/signals": "workspace:^", + "@svta/cml-iso-bmff": "^1.0.0-alpha.9", "async-mutex": "^0.5.0", "comlink": "^4.4.2", "zod": "^4.1.5", @@ -493,6 +494,10 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.0-alpha.9", "", { "peerDependencies": { "@svta/cml-utils": "1.1.0" } }, "sha512-m2rrVjOHdZv8vvoKh++xCCDUU7g97HTmlu0SGVf3AXq7qKY8OrNbtQU0mbZrN2s71KbLEc7wfC/ZZIipiPmH3Q=="], + + "@svta/cml-utils": ["@svta/cml-utils@1.1.0", "", {}, "sha512-5RyHD75RYbq0clUkb/L/+JklxAq+PZRAwKZTcmqUt/ciHm79HBq0/IgrDXYvTgIRGRv8gE4GNvUWQbvRZRxZpA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], diff --git a/deno.lock b/deno.lock index 8e95c842cf..ee7053a71e 100644 --- a/deno.lock +++ b/deno.lock @@ -37,6 +37,7 @@ "dependencies": [ "npm:@kixelated/libavjs-webcodecs-polyfill@~0.5.5", "npm:@libav.js/variant-opus-af@^6.8.8", + "npm:@svta/cml-iso-bmff@^1.0.0-alpha.9", "npm:@types/audioworklet@^0.0.77", "npm:@types/web@^0.0.241", "npm:async-mutex@0.5", diff --git a/js/hang-ui/src/watch/components/LatencySlider.tsx b/js/hang-ui/src/watch/components/LatencySlider.tsx index 85db1b8def..c72149af21 100644 --- a/js/hang-ui/src/watch/components/LatencySlider.tsx +++ b/js/hang-ui/src/watch/components/LatencySlider.tsx @@ -9,13 +9,13 @@ export default function LatencySlider() { const onInputChange = (event: Event) => { const target = event.currentTarget as HTMLInputElement; const latency = parseFloat(target.value); - context.setLatencyValue(latency); + context.setBuffer(latency); }; return (
- {typeof context.latency() !== "undefined" ? `${Math.round(context.latency())}ms` : ""} + {typeof context.buffer() !== "undefined" ? `${Math.round(context.buffer())}ms` : ""}
); } diff --git a/js/hang-ui/src/watch/context.tsx b/js/hang-ui/src/watch/context.tsx index 8eaa9e8de6..68553a8570 100644 --- a/js/hang-ui/src/watch/context.tsx +++ b/js/hang-ui/src/watch/context.tsx @@ -1,6 +1,7 @@ import { type Moq, Signals } from "@moq/hang"; import type * as Catalog from "@moq/hang/catalog"; import type HangWatch from "@moq/hang/watch/element"; +import solid from "@moq/signals/solid"; import type { JSX } from "solid-js"; import { createContext, createSignal, onCleanup } from "solid-js"; @@ -27,8 +28,8 @@ type WatchUIContextValues = { togglePlayback: () => void; toggleMuted: () => void; buffering: () => boolean; - latency: () => number; - setLatencyValue: (value: number) => void; + buffer: () => number; + setBuffer: (value: number) => void; availableRenditions: () => Rendition[]; activeRendition: () => string | undefined; setActiveRendition: (name: string | undefined) => void; @@ -46,7 +47,7 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp const [isMuted, setIsMuted] = createSignal(false); const [currentVolume, setCurrentVolume] = createSignal(0); const [buffering, setBuffering] = createSignal(false); - const [latency, setLatency] = createSignal(0); + const buffer = solid(props.hangWatch.buffer); const [availableRenditions, setAvailableRenditions] = createSignal([]); const [activeRendition, setActiveRendition] = createSignal(undefined); const [isStatsPanelVisible, setIsStatsPanelVisible] = createSignal(false); @@ -72,8 +73,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp props.hangWatch.muted.update((muted) => !muted); }; - const setLatencyValue = (latency: number) => { - props.hangWatch.latency.set(latency as Moq.Time.Milli); + const setBuffer = (latency: number) => { + props.hangWatch.buffer.set(latency as Moq.Time.Milli); }; const setActiveRenditionValue = (name: string | undefined) => { @@ -93,8 +94,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp currentVolume, toggleMuted, buffering, - latency, - setLatencyValue, + buffer, + setBuffer, availableRenditions, activeRendition, setActiveRendition: setActiveRenditionValue, @@ -152,11 +153,6 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp setBuffering(shouldShow); }); - signals.effect((effect) => { - const latency = effect.get(watch.latency); - setLatency(latency); - }); - signals.effect((effect) => { const rootCatalog = effect.get(watch.broadcast.catalog); const videoCatalog = rootCatalog?.video; diff --git a/js/hang/package.json b/js/hang/package.json index c1e162c1bd..09a6856df7 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -31,9 +31,10 @@ }, "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", + "@libav.js/variant-opus-af": "^6.8.8", "@moq/lite": "workspace:^", "@moq/signals": "workspace:^", - "@libav.js/variant-opus-af": "^6.8.8", + "@svta/cml-iso-bmff": "^1.0.0-alpha.9", "async-mutex": "^0.5.0", "comlink": "^4.4.2", "zod": "^4.1.5" diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index e57710bf2e..2711d9c8ae 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ContainerSchema, DEFAULT_CONTAINER } from "./container"; +import { ContainerSchema } from "./container"; import { u53Schema } from "./integers"; // Backwards compatibility: old track schema @@ -14,9 +14,8 @@ export const AudioConfigSchema = z.object({ // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), - // Container format for timestamp encoding - // Defaults to "legacy" when not specified in catalog (backward compatibility) - container: ContainerSchema.default(DEFAULT_CONTAINER), + // The container format, used to decode the timestamp and more. + container: ContainerSchema, // The description is used for some codecs. // If provided, we can initialize the decoder based on the catalog alone. @@ -32,6 +31,11 @@ export const AudioConfigSchema = z.object({ // The bitrate of the audio in bits per second // TODO: Support up to Number.MAX_SAFE_INTEGER bitrate: u53Schema.optional(), + + // Minimum buffer size in milliseconds required for smooth playback. + // This represents the minimum time the player should buffer before starting playback. + // The player should add additional buffer on top of this value. + minBuffer: u53Schema.optional(), }); export const AudioSchema = z diff --git a/js/hang/src/catalog/container.ts b/js/hang/src/catalog/container.ts index 05b0e81db2..3d289f3181 100644 --- a/js/hang/src/catalog/container.ts +++ b/js/hang/src/catalog/container.ts @@ -1,18 +1,27 @@ import { z } from "zod"; +import { u53Schema } from "./integers"; /** - * Container format for frame timestamp encoding. + * Container format for frame timestamp encoding and frame payload structure. * - * - "legacy": Uses QUIC VarInt encoding (1-8 bytes, variable length) - * - "raw": Uses fixed u64 encoding (8 bytes, big-endian) - * - "fmp4": Fragmented MP4 container (future) + * - "legacy": Uses QUIC VarInt encoding (1-8 bytes, variable length), raw frame payloads. + * Timestamps are in microseconds. + * - "cmaf": Fragmented MP4 container - frames contain complete moof+mdat fragments. + * Timestamps are in timescale units. */ -export const ContainerSchema = z.enum(["legacy", "raw", "fmp4"]); +export const ContainerSchema = z + .discriminatedUnion("kind", [ + // The default hang container + z.object({ kind: z.literal("legacy") }), + // CMAF container with timescale for timestamp conversion + z.object({ + kind: z.literal("cmaf"), + // Time units per second + timescale: u53Schema, + // Track ID used in the moof/mdat fragments + trackId: u53Schema, + }), + ]) + .default({ kind: "legacy" }); export type Container = z.infer; - -/** - * Default container format when not specified. - * Set to legacy for backward compatibility. - */ -export const DEFAULT_CONTAINER: Container = "legacy"; diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts index cfe27be115..1c5a49cc11 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -2,7 +2,6 @@ export * from "./audio"; export * from "./capabilities"; export * from "./chat"; export * from "./container"; -export { DEFAULT_CONTAINER } from "./container"; export * from "./integers"; export * from "./location"; export * from "./preview"; diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index b8b77883ff..28b942d584 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ContainerSchema, DEFAULT_CONTAINER } from "./container"; +import { ContainerSchema } from "./container"; import { u53Schema } from "./integers"; // Backwards compatibility: old track schema @@ -13,9 +13,8 @@ export const VideoConfigSchema = z.object({ // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), - // Container format for timestamp encoding - // Defaults to "legacy" when not specified in catalog (backward compatibility) - container: ContainerSchema.default(DEFAULT_CONTAINER), + // The container format, used to decode the timestamp and more. + container: ContainerSchema, // The description is used for some codecs. // If provided, we can initialize the decoder based on the catalog alone. @@ -43,6 +42,11 @@ export const VideoConfigSchema = z.object({ // If true, the decoder will optimize for latency. // Default: true optimizeForLatency: z.boolean().optional(), + + // Minimum buffer size in milliseconds required for smooth playback. + // This represents the minimum time the player should buffer before starting playback. + // The player should add additional buffer on top of this value. + minBuffer: u53Schema.optional(), }); // Mirrors VideoDecoderConfig diff --git a/js/hang/src/container/cmaf.ts b/js/hang/src/container/cmaf.ts new file mode 100644 index 0000000000..aa174fb08c --- /dev/null +++ b/js/hang/src/container/cmaf.ts @@ -0,0 +1,352 @@ +/** + * MP4 decoding utilities for parsing fMP4 init and data segments. + * Used by WebCodecs to extract raw frames from CMAF container. + */ + +import { + type MediaHeaderBox, + type ParsedIsoBox, + readAvc1, + readHev1, + readHvc1, + readIsoBoxes, + readMdat, + readMdhd, + readMfhd, + readMp4a, + readStsd, + readTfdt, + readTfhd, + readTkhd, + readTrun, + type SampleDescriptionBox, + type TrackFragmentBaseMediaDecodeTimeBox, + type TrackFragmentHeaderBox, + type TrackRunBox, + type TrackRunSample, +} from "@svta/cml-iso-bmff"; + +// Configure readers for specific box types we need to parse +const INIT_READERS = { + avc1: readAvc1, + avc3: readAvc1, // avc3 has same structure + hvc1: readHvc1, + hev1: readHev1, + mp4a: readMp4a, + stsd: readStsd, + mdhd: readMdhd, + tkhd: readTkhd, +}; + +const DATA_READERS = { + mfhd: readMfhd, + tfhd: readTfhd, + tfdt: readTfdt, + trun: readTrun, + mdat: readMdat, +}; + +/** + * Recursively find a box by type in the box tree. + * This is more reliable than the library's findIsoBox which may not traverse all children. + */ +function findBox( + boxes: ParsedIsoBox[], + predicate: (box: ParsedIsoBox) => box is T, +): T | undefined { + for (const box of boxes) { + if (predicate(box)) { + return box; + } + // Recursively search children - boxes may have a 'boxes' property with children + // biome-ignore lint/suspicious/noExplicitAny: ISO box structure varies + const children = (box as any).boxes; + if (children && Array.isArray(children)) { + const found = findBox(children, predicate); + if (found) return found; + } + } + return undefined; +} + +/** + * Result of parsing an init segment. + */ +export interface InitSegment { + /** Codec-specific description (avcC, hvcC, esds, dOps, etc.) */ + description?: Uint8Array; + /** Time units per second */ + timescale: number; + /** Track ID from the init segment */ + trackId: number; +} + +/** + * A decoded sample from a data segment. + */ +export interface Sample { + /** Raw sample data */ + data: Uint8Array; + /** Timestamp in microseconds */ + timestamp: number; + /** Whether this is a keyframe (sync sample) */ + keyframe: boolean; +} + +// Helper to convert Uint8Array to ArrayBuffer for the library +function toArrayBuffer(data: Uint8Array): ArrayBuffer { + // Create a new ArrayBuffer and copy data to avoid SharedArrayBuffer issues + const buffer = new ArrayBuffer(data.byteLength); + new Uint8Array(buffer).set(data); + return buffer; +} + +// Type guard for finding boxes by type +function isBoxType(type: string) { + return (box: ParsedIsoBox): box is T => box.type === type; +} + +/** + * Parse an init segment (ftyp + moov) to extract codec description and timescale. + * + * @param init - The init segment data + * @returns Parsed init segment information + */ +export function decodeInitSegment(init: Uint8Array): InitSegment { + // Cast to ParsedIsoBox[] since the library's return type changes with readers + const boxes = readIsoBoxes(toArrayBuffer(init), { readers: INIT_READERS }) as ParsedIsoBox[]; + + // Find moov > trak > mdia > mdhd for timescale + const mdhd = findBox(boxes, isBoxType("mdhd")); + if (!mdhd) { + throw new Error("No mdhd box found in init segment"); + } + + // Find moov > trak > tkhd for track ID + // biome-ignore lint/suspicious/noExplicitAny: ISO box traversal + const tkhd = findBox(boxes, isBoxType("tkhd")); + const trackId = tkhd?.trackId ?? 1; + + // Find moov > trak > mdia > minf > stbl > stsd for sample description + const stsd = findBox(boxes, isBoxType("stsd")); + if (!stsd || !stsd.entries || stsd.entries.length === 0) { + throw new Error("No stsd box found in init segment"); + } + + // Extract codec-specific description from the first sample entry + const entry = stsd.entries[0]; + const description = extractDescription(entry); + + return { + description, + timescale: mdhd.timescale, + trackId, + }; +} + +/** + * Extract codec-specific description from a sample entry. + * The description is codec-specific: avcC for H.264, hvcC for H.265, esds for AAC, dOps for Opus. + */ +// biome-ignore lint/suspicious/noExplicitAny: ISO box types vary +function extractDescription(entry: any): Uint8Array | undefined { + if (!entry.boxes || !Array.isArray(entry.boxes)) { + return undefined; + } + + // Look for codec config boxes in the sample entry + for (const box of entry.boxes) { + // Handle raw Uint8Array boxes (already serialized) + if (box instanceof Uint8Array) { + // Extract the payload without the box header (8 bytes: 4 size + 4 type) + if (box.length > 8) { + // Check if this looks like a codec config box by reading the type + const typeBytes = String.fromCharCode(box[4], box[5], box[6], box[7]); + if (typeBytes === "avcC" || typeBytes === "hvcC" || typeBytes === "esds" || typeBytes === "dOps") { + return new Uint8Array(box.slice(8)); + } + } + continue; + } + + // Check for known codec config box types + const boxType = box.type; + if (boxType === "avcC" || boxType === "hvcC" || boxType === "esds" || boxType === "dOps") { + // The library stores parsed boxes with a 'view' property containing IsoBoxReadView + // which has access to the raw buffer. Extract the box payload (without header). + if (box.view) { + const view = box.view; + // IsoBoxReadView has buffer, byteOffset, and byteLength properties + // The box payload starts after the 8-byte header (size + type) + const headerSize = 8; + const payloadOffset = view.byteOffset + headerSize; + const payloadLength = box.size - headerSize; + return new Uint8Array(view.buffer, payloadOffset, payloadLength); + } + // Fallback: try data or raw properties + if (box.data instanceof Uint8Array) { + return new Uint8Array(box.data); + } + if (box.raw instanceof Uint8Array) { + return new Uint8Array(box.raw.slice(8)); + } + } + } + + return undefined; +} + +/** + * Parse a data segment (moof + mdat) to extract raw samples. + * + * @param segment - The moof + mdat data + * @param timescale - Time units per second (from init segment) + * @returns Array of decoded samples + */ +export function decodeDataSegment(segment: Uint8Array, timescale: number): Sample[] { + // Cast to ParsedIsoBox[] since the library's return type changes with readers + const boxes = readIsoBoxes(toArrayBuffer(segment), { readers: DATA_READERS }) as ParsedIsoBox[]; + + // Find moof > traf > tfdt for base media decode time + const tfdt = findBox(boxes, isBoxType("tfdt")); + const baseDecodeTime = tfdt?.baseMediaDecodeTime ?? 0; + + // Find moof > traf > tfhd for default sample values + const tfhd = findBox(boxes, isBoxType("tfhd")); + const defaultDuration = tfhd?.defaultSampleDuration ?? 0; + const defaultSize = tfhd?.defaultSampleSize ?? 0; + const defaultFlags = tfhd?.defaultSampleFlags ?? 0; + + // Find moof > traf > trun for sample info + const trun = findBox(boxes, isBoxType("trun")); + if (!trun) { + throw new Error("No trun box found in data segment"); + } + + // Find mdat for sample data + // biome-ignore lint/suspicious/noExplicitAny: mdat box type + const mdat = findBox(boxes, isBoxType("mdat")); + if (!mdat) { + throw new Error("No mdat box found in data segment"); + } + + // mdat.data contains the raw sample data + const mdatData = mdat.data as Uint8Array; + if (!mdatData) { + throw new Error("No data in mdat box"); + } + + const samples: Sample[] = []; + + // trun.dataOffset is an offset from the base data offset (typically moof start) to the first sample. + // For simple CMAF segments where moof is followed immediately by mdat, this equals moof.size + 8. + // Since mdat.data is the mdat payload (excluding the 8-byte header), we need to compute the + // offset within mdatData. For now, we assume samples start at the beginning of mdat.data + // when dataOffset is not specified or when it points to the start of mdat payload. + // TODO: For complex cases with base_data_offset in tfhd, this needs additional handling. + let dataOffset = 0; + let decodeTime = baseDecodeTime; + + for (let i = 0; i < trun.sampleCount; i++) { + const sample: TrackRunSample = trun.samples[i] ?? {}; + + const sampleSize = sample.sampleSize ?? defaultSize; + const sampleDuration = sample.sampleDuration ?? defaultDuration; + + // Validate sample size - must be positive to produce valid data + if (sampleSize <= 0) { + throw new Error(`Invalid sample size ${sampleSize} for sample ${i} in trun`); + } + + // Validate sample duration - must be positive for proper timing + if (sampleDuration <= 0) { + throw new Error(`Invalid sample duration ${sampleDuration} for sample ${i} in trun`); + } + + // Bounds check before slicing to prevent reading past mdat data + if (dataOffset + sampleSize > mdatData.length) { + throw new Error( + `Sample ${i} would overflow mdat: offset=${dataOffset}, size=${sampleSize}, mdatLength=${mdatData.length}`, + ); + } + + const sampleFlags = + i === 0 && trun.firstSampleFlags !== undefined + ? trun.firstSampleFlags + : (sample.sampleFlags ?? defaultFlags); + const compositionOffset = sample.sampleCompositionTimeOffset ?? 0; + + // Extract sample data + const data = new Uint8Array(mdatData.slice(dataOffset, dataOffset + sampleSize)); + dataOffset += sampleSize; + + // Calculate presentation timestamp in microseconds + // PTS = (decode_time + composition_offset) * 1_000_000 / timescale + const pts = decodeTime + compositionOffset; + const timestamp = Math.round((pts * 1_000_000) / timescale); + + // Check if keyframe (sample_is_non_sync_sample flag is bit 16) + // If flag is 0, treat as keyframe for safety + const keyframe = sampleFlags === 0 || (sampleFlags & 0x00010000) === 0; + + samples.push({ + data, + timestamp, + keyframe, + }); + + decodeTime += sampleDuration; + } + + return samples; +} + +/** + * Rewrite the track_id in a moof+mdat segment to 1. + * This allows us to always generate init segments with track_id=1. + * + * Modifies the buffer in place and returns it. + */ +export function rewriteTrackId(segment: Uint8Array): Uint8Array { + const tfhdOffset = findBoxOffset(segment, 0, segment.length, "tfhd"); + if (tfhdOffset === -1) { + return segment; // No tfhd found, return as-is + } + + // tfhd structure: size(4) + type(4) + version/flags(4) + track_id(4) + // track_id is at offset 12 within the box + const view = new DataView(segment.buffer, segment.byteOffset + tfhdOffset); + view.setUint32(12, 1, false); // Set track_id to 1 (big-endian) + + return segment; +} + +/** + * Find the byte offset of a box by type within a range. + * Recursively searches container boxes (moof, traf, etc.) + */ +function findBoxOffset(data: Uint8Array, start: number, end: number, targetType: string): number { + const view = new DataView(data.buffer, data.byteOffset); + let offset = start; + + while (offset + 8 <= end) { + const size = view.getUint32(offset, false); + if (size < 8 || offset + size > end) break; + + const type = String.fromCharCode(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]); + + if (type === targetType) { + return offset; + } + + // Recursively search container boxes + if (type === "moof" || type === "traf" || type === "mdia" || type === "minf" || type === "stbl") { + const found = findBoxOffset(data, offset + 8, offset + size, targetType); + if (found !== -1) return found; + } + + offset += size; + } + + return -1; +} diff --git a/js/hang/src/container/codec.ts b/js/hang/src/container/codec.ts deleted file mode 100644 index ca60754a41..0000000000 --- a/js/hang/src/container/codec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Time } from "@moq/lite"; -import type * as Catalog from "../catalog"; -import { DEFAULT_CONTAINER } from "../catalog"; - -/** - * Encodes a timestamp according to the specified container format. - * - * @param timestamp - The timestamp in microseconds - * @param container - The container format to use - * @returns The encoded timestamp as a Uint8Array - */ -export function encodeTimestamp(timestamp: Time.Micro, container: Catalog.Container = DEFAULT_CONTAINER): Uint8Array { - switch (container) { - case "legacy": - return encodeVarInt(timestamp); - case "raw": - return encodeU64(timestamp); - case "fmp4": - throw new Error("fmp4 container not yet implemented"); - } -} - -/** - * Decodes a timestamp from a buffer according to the specified container format. - * - * @param buffer - The buffer containing the encoded timestamp - * @param container - The container format to use - * @returns [timestamp in microseconds, remaining buffer after timestamp] - */ -export function decodeTimestamp( - buffer: Uint8Array, - container: Catalog.Container = DEFAULT_CONTAINER, -): [Time.Micro, Uint8Array] { - switch (container) { - case "legacy": { - const [value, remaining] = decodeVarInt(buffer); - return [value as Time.Micro, remaining]; - } - case "raw": { - const [value, remaining] = decodeU64(buffer); - return [value as Time.Micro, remaining]; - } - case "fmp4": - throw new Error("fmp4 container not yet implemented"); - } -} - -/** - * Gets the size in bytes of an encoded timestamp for the given container format. - * For variable-length formats, returns the maximum size. - * - * @param container - The container format - * @returns Size in bytes - */ -export function getTimestampSize(container: Catalog.Container = DEFAULT_CONTAINER): number { - switch (container) { - case "legacy": - return 8; // VarInt maximum size - case "raw": - return 8; // u64 fixed size - case "fmp4": - throw new Error("fmp4 container not yet implemented"); - } -} - -// ============================================================================ -// LEGACY VARINT IMPLEMENTATION -// ============================================================================ - -const MAX_U6 = 2 ** 6 - 1; -const MAX_U14 = 2 ** 14 - 1; -const MAX_U30 = 2 ** 30 - 1; -const MAX_U53 = Number.MAX_SAFE_INTEGER; - -function decodeVarInt(buf: Uint8Array): [number, Uint8Array] { - const size = 1 << ((buf[0] & 0xc0) >> 6); - - const view = new DataView(buf.buffer, buf.byteOffset, size); - const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size); - let v: number; - - if (size === 1) { - v = buf[0] & 0x3f; - } else if (size === 2) { - v = view.getUint16(0) & 0x3fff; - } else if (size === 4) { - v = view.getUint32(0) & 0x3fffffff; - } else if (size === 8) { - // NOTE: Precision loss above 2^52 - v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn); - } else { - throw new Error("impossible"); - } - - return [v, remain]; -} - -function encodeVarInt(v: number): Uint8Array { - const dst = new Uint8Array(8); - - if (v <= MAX_U6) { - dst[0] = v; - return new Uint8Array(dst.buffer, dst.byteOffset, 1); - } - - if (v <= MAX_U14) { - const view = new DataView(dst.buffer, dst.byteOffset, 2); - view.setUint16(0, v | 0x4000); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - - if (v <= MAX_U30) { - const view = new DataView(dst.buffer, dst.byteOffset, 4); - view.setUint32(0, v | 0x80000000); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - - if (v <= MAX_U53) { - const view = new DataView(dst.buffer, dst.byteOffset, 8); - view.setBigUint64(0, BigInt(v) | 0xc000000000000000n); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - - throw new Error(`overflow, value larger than 53-bits: ${v}`); -} - -// ============================================================================ -// RAW U64 IMPLEMENTATION -// ============================================================================ - -/** - * Decodes a fixed 8-byte big-endian unsigned 64-bit integer. - */ -function decodeU64(buf: Uint8Array): [number, Uint8Array] { - if (buf.byteLength < 8) { - throw new Error("Buffer too short for u64 decode"); - } - - const view = new DataView(buf.buffer, buf.byteOffset, 8); - const value = Number(view.getBigUint64(0)); - const remain = new Uint8Array(buf.buffer, buf.byteOffset + 8, buf.byteLength - 8); - - return [value, remain]; -} - -/** - * Encodes a number as a fixed 8-byte big-endian unsigned 64-bit integer. - * Much simpler than VarInt! - */ -function encodeU64(v: number): Uint8Array { - const dst = new Uint8Array(8); - const view = new DataView(dst.buffer, dst.byteOffset, 8); - view.setBigUint64(0, BigInt(v)); - return dst; -} diff --git a/js/hang/src/container/index.ts b/js/hang/src/container/index.ts index 2e87cf3233..8af56c6e24 100644 --- a/js/hang/src/container/index.ts +++ b/js/hang/src/container/index.ts @@ -1 +1,2 @@ -export * from "./codec"; +export * as Cmaf from "./cmaf"; +export * as Legacy from "./legacy"; diff --git a/js/hang/src/frame.ts b/js/hang/src/container/legacy.ts similarity index 65% rename from js/hang/src/frame.ts rename to js/hang/src/container/legacy.ts index a0fd86219a..d3ab74a1cd 100644 --- a/js/hang/src/frame.ts +++ b/js/hang/src/container/legacy.ts @@ -1,8 +1,6 @@ -import type * as Moq from "@moq/lite"; -import { Time } from "@moq/lite"; +import type { Time } from "@moq/lite"; +import * as Moq from "@moq/lite"; import { Effect, Signal } from "@moq/signals"; -import type * as Catalog from "./catalog"; -import * as Container from "./container"; export interface Source { byteLength: number; @@ -16,42 +14,13 @@ export interface Frame { group: number; } -export function encode(source: Uint8Array | Source, timestamp: Time.Micro, container?: Catalog.Container): Uint8Array { - // Encode timestamp using the specified container format - const timestampBytes = Container.encodeTimestamp(timestamp, container); - - // Allocate buffer for timestamp + payload - const payloadSize = source instanceof Uint8Array ? source.byteLength : source.byteLength; - const data = new Uint8Array(timestampBytes.byteLength + payloadSize); - - // Write timestamp header - data.set(timestampBytes, 0); - - // Write payload - if (source instanceof Uint8Array) { - data.set(source, timestampBytes.byteLength); - } else { - source.copyTo(data.subarray(timestampBytes.byteLength)); - } - - return data; -} - -// NOTE: A keyframe is always the first frame in a group, so it's not encoded on the wire. -export function decode(buffer: Uint8Array, container?: Catalog.Container): { data: Uint8Array; timestamp: Time.Micro } { - // Decode timestamp using the specified container format - const [timestamp, data] = Container.decodeTimestamp(buffer, container); - return { timestamp: timestamp as Time.Micro, data }; -} - +// A Helper class to encode frames into a track. export class Producer { #track: Moq.Track; #group?: Moq.Group; - #container?: Catalog.Container; - constructor(track: Moq.Track, container?: Catalog.Container) { + constructor(track: Moq.Track) { this.#track = track; - this.#container = container; } encode(data: Uint8Array | Source, timestamp: Time.Micro, keyframe: boolean) { @@ -62,11 +31,31 @@ export class Producer { throw new Error("must start with a keyframe"); } - this.#group?.writeFrame(encode(data, timestamp, this.#container)); + this.#group?.writeFrame(Producer.#encode(data, timestamp)); } - close() { - this.#track.close(); + static #encode(source: Uint8Array | Source, timestamp: Time.Micro): Uint8Array { + const timestampBytes = encodeVarInt(timestamp); + + // Allocate buffer for timestamp + payload + const payloadSize = source instanceof Uint8Array ? source.byteLength : source.byteLength; + const data = new Uint8Array(timestampBytes.byteLength + payloadSize); + + // Write timestamp header + data.set(timestampBytes, 0); + + // Write payload + if (source instanceof Uint8Array) { + data.set(source, timestampBytes.byteLength); + } else { + source.copyTo(data.subarray(timestampBytes.byteLength)); + } + + return data; + } + + close(err?: Error) { + this.#track.close(err); this.#group?.close(); } } @@ -74,7 +63,6 @@ export class Producer { export interface ConsumerProps { // Target latency in milliseconds (default: 0) latency?: Signal | Time.Milli; - container?: Catalog.Container; } interface Group { @@ -86,7 +74,6 @@ interface Group { export class Consumer { #track: Moq.Track; #latency: Signal; - #container?: Catalog.Container; #groups: Group[] = []; #active?: number; // the active group sequence number @@ -97,8 +84,7 @@ export class Consumer { constructor(track: Moq.Track, props?: ConsumerProps) { this.#track = track; - this.#latency = Signal.from(props?.latency ?? Time.Milli.zero); - this.#container = props?.container; + this.#latency = Signal.from(props?.latency ?? Moq.Time.Milli.zero); this.#signals.spawn(this.#run.bind(this)); this.#signals.cleanup(() => { @@ -152,7 +138,7 @@ export class Consumer { const next = await group.consumer.readFrame(); if (!next) break; - const { data, timestamp } = decode(next, this.#container); + const { data, timestamp } = Consumer.#decode(next); const frame = { data, timestamp, @@ -218,7 +204,7 @@ export class Consumer { if (min === undefined || max === undefined) return; const latency = max - min; - if (latency < Time.Micro.fromMilli(this.#latency.peek())) return; + if (latency < Moq.Time.Micro.fromMilli(this.#latency.peek())) return; if (this.#active !== undefined && first.consumer.sequence <= this.#active) { this.#groups.shift(); @@ -271,6 +257,12 @@ export class Consumer { } } + // NOTE: A keyframe is always the first frame in a group, so it's not encoded on the wire. + static #decode(buffer: Uint8Array): { data: Uint8Array; timestamp: Time.Micro } { + const [timestamp, data] = decodeVarInt(buffer); + return { timestamp: timestamp as Time.Micro, data }; + } + close(): void { this.#signals.close(); @@ -282,3 +274,62 @@ export class Consumer { this.#groups.length = 0; } } + +// TODO: Export this from @moq/lite to avoid duplication + +const MAX_U6 = 2 ** 6 - 1; +const MAX_U14 = 2 ** 14 - 1; +const MAX_U30 = 2 ** 30 - 1; +const MAX_U53 = Number.MAX_SAFE_INTEGER; + +function decodeVarInt(buf: Uint8Array): [number, Uint8Array] { + const size = 1 << ((buf[0] & 0xc0) >> 6); + + const view = new DataView(buf.buffer, buf.byteOffset, size); + const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size); + let v: number; + + if (size === 1) { + v = buf[0] & 0x3f; + } else if (size === 2) { + v = view.getUint16(0) & 0x3fff; + } else if (size === 4) { + v = view.getUint32(0) & 0x3fffffff; + } else if (size === 8) { + // NOTE: Precision loss above 2^52 + v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn); + } else { + throw new Error("impossible"); + } + + return [v, remain]; +} + +function encodeVarInt(v: number): Uint8Array { + const dst = new Uint8Array(8); + + if (v <= MAX_U6) { + dst[0] = v; + return new Uint8Array(dst.buffer, dst.byteOffset, 1); + } + + if (v <= MAX_U14) { + const view = new DataView(dst.buffer, dst.byteOffset, 2); + view.setUint16(0, v | 0x4000); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + + if (v <= MAX_U30) { + const view = new DataView(dst.buffer, dst.byteOffset, 4); + view.setUint32(0, v | 0x80000000); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + + if (v <= MAX_U53) { + const view = new DataView(dst.buffer, dst.byteOffset, 8); + view.setBigUint64(0, BigInt(v) | 0xc000000000000000n); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + + throw new Error(`overflow, value larger than 53-bits: ${v}`); +} diff --git a/js/hang/src/index.ts b/js/hang/src/index.ts index 5ea6c584eb..fb940ca2bf 100644 --- a/js/hang/src/index.ts +++ b/js/hang/src/index.ts @@ -1,7 +1,7 @@ export * as Moq from "@moq/lite"; export * as Signals from "@moq/signals"; export * as Catalog from "./catalog"; -export * as Frame from "./frame"; +export * as Container from "./container"; export * as Publish from "./publish"; export * as Support from "./support"; export * as Watch from "./watch"; diff --git a/js/hang/src/publish/audio/encoder.ts b/js/hang/src/publish/audio/encoder.ts index a19e096f00..887041a009 100644 --- a/js/hang/src/publish/audio/encoder.ts +++ b/js/hang/src/publish/audio/encoder.ts @@ -2,9 +2,8 @@ import type * as Moq from "@moq/lite"; import { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Catalog from "../../catalog"; -import { DEFAULT_CONTAINER } from "../../catalog"; import { u53 } from "../../catalog/integers"; -import * as Frame from "../../frame"; +import * as Container from "../../container"; import * as libav from "../../util/libav"; import { PRIORITY } from "../priority"; import type * as Capture from "./capture"; @@ -65,7 +64,7 @@ export class Encoder { this.muted = Signal.from(props?.muted ?? false); this.volume = Signal.from(props?.volume ?? 1); this.maxLatency = props?.maxLatency ?? (100 as Time.Milli); // Default is a group every 100ms - this.#container = props?.container ?? DEFAULT_CONTAINER; + this.#container = props?.container ?? { kind: "legacy" }; this.#signals.effect(this.#runSource.bind(this)); this.#signals.effect(this.#runConfig.bind(this)); @@ -167,37 +166,32 @@ export class Encoder { effect.set(this.active, true, false); - let group: Moq.Group = track.appendGroup(); - effect.cleanup(() => group.close()); + const producer = new Container.Legacy.Producer(track); + effect.cleanup(() => producer.close()); - let groupTimestamp: Time.Micro | undefined; + let lastKeyframe: Time.Micro | undefined; effect.spawn(async () => { // We're using an async polyfill temporarily for Safari support. await libav.polyfill(); - console.log(`[Audio Publisher] Using container format: ${this.#container}`); - const encoder = new AudioEncoder({ output: (frame) => { if (frame.type !== "key") { throw new Error("only key frames are supported"); } - if (!groupTimestamp) { - groupTimestamp = frame.timestamp as Time.Micro; - } else if (frame.timestamp - groupTimestamp >= Time.Micro.fromMilli(this.maxLatency)) { - group.close(); - group = track.appendGroup(); - groupTimestamp = frame.timestamp as Time.Micro; + let keyframe = false; + if (!lastKeyframe || lastKeyframe + Time.Micro.fromMilli(this.maxLatency) <= frame.timestamp) { + lastKeyframe = frame.timestamp as Time.Micro; + keyframe = true; } - const buffer = Frame.encode(frame, frame.timestamp as Time.Micro, this.#container); - group.writeFrame(buffer); + producer.encode(frame, frame.timestamp as Time.Micro, keyframe); }, error: (err) => { console.error("encoder error", err); - group.close(err); + producer.close(err); worklet.port.onmessage = null; }, }); diff --git a/js/hang/src/publish/video/encoder.ts b/js/hang/src/publish/video/encoder.ts index 1aed44cdac..6dfc45e399 100644 --- a/js/hang/src/publish/video/encoder.ts +++ b/js/hang/src/publish/video/encoder.ts @@ -2,8 +2,8 @@ import type * as Moq from "@moq/lite"; import { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Catalog from "../../catalog"; -import { DEFAULT_CONTAINER, u53 } from "../../catalog"; -import * as Frame from "../../frame"; +import { u53 } from "../../catalog"; +import * as Container from "../../container"; import { isFirefox } from "../../util/hacks"; import type { Source } from "./types"; @@ -64,7 +64,7 @@ export class Encoder { this.source = source; this.enabled = Signal.from(props?.enabled ?? false); this.config = Signal.from(props?.config); - this.#container = props?.container ?? DEFAULT_CONTAINER; + this.#container = props?.container ?? { kind: "legacy" }; this.#signals.effect(this.#runCatalog.bind(this)); this.#signals.effect(this.#runConfig.bind(this)); @@ -75,32 +75,24 @@ export class Encoder { const enabled = effect.get(this.enabled); if (!enabled) return; - effect.set(this.active, true, false); - - effect.spawn(async () => { - console.log(`[Video Publisher] Using container format: ${this.#container}`); + const producer = new Container.Legacy.Producer(track); + effect.cleanup(() => producer.close()); - let group: Moq.Group | undefined; - effect.cleanup(() => group?.close()); + let lastKeyframe: Time.Micro | undefined; - let groupTimestamp: Time.Micro | undefined; + effect.set(this.active, true, false); + effect.spawn(async () => { const encoder = new VideoEncoder({ output: (frame: EncodedVideoChunk) => { if (frame.type === "key") { - groupTimestamp = frame.timestamp as Time.Micro; - group?.close(); - group = track.appendGroup(); - } else if (!group) { - throw new Error("no keyframe"); + lastKeyframe = frame.timestamp as Time.Micro; } - const buffer = Frame.encode(frame, frame.timestamp as Time.Micro, this.#container); - group?.writeFrame(buffer); + producer.encode(frame, frame.timestamp as Time.Micro, frame.type === "key"); }, error: (err: Error) => { - track.close(err); - group?.close(err); + producer.close(err); }, }); @@ -123,9 +115,9 @@ export class Encoder { const interval = this.config.peek()?.keyframeInterval ?? Time.Milli.fromSecond(2 as Time.Second); // Force a keyframe if this is the first frame (no group yet), or GOP elapsed. - const keyFrame = !groupTimestamp || groupTimestamp + Time.Micro.fromMilli(interval) <= frame.timestamp; + const keyFrame = !lastKeyframe || lastKeyframe + Time.Micro.fromMilli(interval) <= frame.timestamp; if (keyFrame) { - groupTimestamp = frame.timestamp as Time.Micro; + lastKeyframe = frame.timestamp as Time.Micro; } encoder.encode(frame, { keyFrame }); diff --git a/js/hang/src/util/latency.ts b/js/hang/src/util/latency.ts new file mode 100644 index 0000000000..16cda5eb6c --- /dev/null +++ b/js/hang/src/util/latency.ts @@ -0,0 +1,58 @@ +import type * as Moq from "@moq/lite"; +import { Effect, type Getter, Signal } from "@moq/signals"; + +type ConfigWithMinBuffer = { minBuffer?: number; framerate?: number }; + +export interface LatencyProps { + buffer: Signal; + config: Getter; +} + +/** + * A helper class that computes the final latency based on the catalog's minBuffer and the user's buffer. + * If the minBuffer is not present, then we use framerate to estimate a default. + * + * Effective latency = catalog.minBuffer + buffer + */ +export class Latency { + buffer: Signal; + config: Getter; + + signals = new Effect(); + + #combined = new Signal(0 as Moq.Time.Milli); + readonly combined: Signal = this.#combined; + + constructor(props: LatencyProps) { + this.buffer = props.buffer; + this.config = props.config; + + this.signals.effect(this.#run.bind(this)); + } + + #run(effect: Effect): void { + const buffer = effect.get(this.buffer); + + // Compute the latency based on the catalog's minBuffer and the user's buffer. + const config = effect.get(this.config); + + // Use minBuffer from catalog if available, otherwise estimate from framerate + let minBuffer: number | undefined = config?.minBuffer; + if (minBuffer === undefined && config?.framerate !== undefined && config.framerate > 0) { + // Estimate minBuffer as one frame duration if framerate is available + minBuffer = 1000 / config.framerate; + } + minBuffer ??= 0; + + const latency = (minBuffer + buffer) as Moq.Time.Milli; + this.#combined.set(latency); + } + + peek(): Moq.Time.Milli { + return this.#combined.peek(); + } + + close(): void { + this.signals.close(); + } +} diff --git a/js/hang/src/watch/audio/source.ts b/js/hang/src/watch/audio/source.ts index fad996840f..d5a2597673 100644 --- a/js/hang/src/watch/audio/source.ts +++ b/js/hang/src/watch/audio/source.ts @@ -2,8 +2,9 @@ import type * as Moq from "@moq/lite"; import type { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Catalog from "../../catalog"; -import * as Frame from "../../frame"; +import * as Container from "../../container"; import * as Hex from "../../util/hex"; +import { Latency } from "../../util/latency"; import * as libav from "../../util/libav"; import type * as Render from "./render"; @@ -16,8 +17,10 @@ export type SourceProps = { // Enable to download the audio track. enabled?: boolean | Signal; - // Jitter buffer size in milliseconds (default: 100ms) - latency?: Time.Milli | Signal; + // Additional buffer in milliseconds on top of the catalog's minBuffer (default: 100ms). + // The effective latency = catalog.minBuffer + buffer + // Increase this if experiencing stuttering due to network jitter. + buffer?: Time.Milli | Signal; }; export interface AudioStats { @@ -50,8 +53,12 @@ export class Source { catalog = new Signal(undefined); config = new Signal(undefined); - // Not a signal because I'm lazy. - readonly latency: Signal; + // Additional buffer in milliseconds (on top of catalog's minBuffer). + buffer: Signal; + + // Computed latency = catalog.minBuffer + buffer + #latency: Latency; + readonly latency: Getter; // The name of the active rendition. active = new Signal(undefined); @@ -65,7 +72,14 @@ export class Source { ) { this.broadcast = broadcast; this.enabled = Signal.from(props?.enabled ?? false); - this.latency = Signal.from(props?.latency ?? (100 as Time.Milli)); // TODO Reduce this once fMP4 stuttering is fixed. + this.buffer = Signal.from(props?.buffer ?? (100 as Time.Milli)); + + // Compute effective latency from catalog.minBuffer + buffer + this.#latency = new Latency({ + buffer: this.buffer, + config: this.config, + }); + this.latency = this.#latency.combined; this.#signals.effect((effect) => { const audio = effect.get(catalog)?.audio; @@ -127,7 +141,7 @@ export class Source { type: "init", rate: sampleRate, channels: channelCount, - latency: this.latency.peek(), // TODO make it reactive + latency: this.#latency.peek(), // TODO make it reactive }; worklet.port.postMessage(init); @@ -166,12 +180,17 @@ export class Source { const sub = broadcast.subscribe(active, catalog.priority); effect.cleanup(() => sub.close()); + if (config.container.kind === "cmaf") { + this.#runCmafDecoder(effect, sub, config); + } else { + this.#runLegacyDecoder(effect, sub, config); + } + } + + #runLegacyDecoder(effect: Effect, sub: Moq.Track, config: Catalog.AudioConfig): void { // Create consumer with slightly less latency than the render worklet to avoid underflowing. - // Container defaults to "legacy" via Zod schema for backward compatibility - console.log(`[Audio Subscriber] Using container format: ${config.container}`); - const consumer = new Frame.Consumer(sub, { - latency: Math.max(this.latency.peek() - JITTER_UNDERHEAD, 0) as Time.Milli, - container: config.container, + const consumer = new Container.Legacy.Consumer(sub, { + latency: Math.max(this.#latency.peek() - JITTER_UNDERHEAD, 0) as Time.Milli, }); effect.cleanup(() => consumer.close()); @@ -210,6 +229,66 @@ export class Source { }); } + #runCmafDecoder(effect: Effect, sub: Moq.Track, config: Catalog.AudioConfig): void { + if (config.container.kind !== "cmaf") return; + + const { timescale } = config.container; + const description = config.description ? Hex.toBytes(config.description) : undefined; + + effect.spawn(async () => { + const loaded = await libav.polyfill(); + if (!loaded) return; // cancelled + + const decoder = new AudioDecoder({ + output: (data) => this.#emit(data), + error: (error) => console.error(error), + }); + effect.cleanup(() => decoder.close()); + + // Configure decoder with description from catalog + decoder.configure({ + codec: config.codec, + sampleRate: config.sampleRate, + numberOfChannels: config.numberOfChannels, + description, + }); + + // Process data segments + // TODO: Use a consumer wrapper for CMAF to support latency control + for (;;) { + const group = await sub.nextGroup(); + if (!group) break; + + effect.spawn(async () => { + try { + for (;;) { + const segment = await group.readFrame(); + if (!segment) break; + + const samples = Container.Cmaf.decodeDataSegment(segment, timescale); + + for (const sample of samples) { + this.#stats.update((stats) => ({ + bytesReceived: (stats?.bytesReceived ?? 0) + sample.data.byteLength, + })); + + const chunk = new EncodedAudioChunk({ + type: sample.keyframe ? "key" : "delta", + data: sample.data, + timestamp: sample.timestamp, + }); + + decoder.decode(chunk); + } + } + } finally { + group.close(); + } + }); + } + }); + } + #emit(sample: AudioData) { const timestamp = sample.timestamp as Time.Micro; @@ -244,6 +323,7 @@ export class Source { } close() { + this.#latency.close(); this.#signals.close(); } } diff --git a/js/hang/src/watch/element.ts b/js/hang/src/watch/element.ts index 47e45d8315..fcf06d9fa6 100644 --- a/js/hang/src/watch/element.ts +++ b/js/hang/src/watch/element.ts @@ -6,7 +6,7 @@ import { Broadcast } from "./broadcast"; import * as Video from "./video"; // TODO remove name; replaced with path -const OBSERVED = ["url", "name", "path", "paused", "volume", "muted", "reload", "latency"] as const; +const OBSERVED = ["url", "name", "path", "paused", "volume", "muted", "reload", "buffer"] as const; type Observed = (typeof OBSERVED)[number]; // Close everything when this element is garbage collected. @@ -52,8 +52,9 @@ export default class HangWatch extends HTMLElement { // TODO: Temporarily defaults to false because Cloudflare doesn't support it yet. reload = new Signal(false); - // Delay playing audio and video for up to 100ms - latency = new Signal(100 as Time.Milli); + // Additional buffer in milliseconds on top of the catalog's minBuffer (default: 100ms). + // The effective latency = catalog.minBuffer + buffer + buffer = new Signal(100 as Time.Milli); // Set when the element is connected to the DOM. #enabled = new Signal(false); @@ -81,10 +82,10 @@ export default class HangWatch extends HTMLElement { enabled: this.#enabled, reload: this.reload, audio: { - latency: this.latency, + buffer: this.buffer, }, video: { - latency: this.latency, + buffer: this.buffer, }, }); this.signals.cleanup(() => this.broadcast.close()); @@ -163,8 +164,8 @@ export default class HangWatch extends HTMLElement { }); this.signals.effect((effect) => { - const latency = Math.floor(effect.get(this.latency)); - this.setAttribute("latency", latency.toString()); + const buffer = Math.floor(effect.get(this.buffer)); + this.setAttribute("buffer", buffer.toString()); }); } @@ -199,8 +200,8 @@ export default class HangWatch extends HTMLElement { this.muted.set(newValue !== null); } else if (name === "reload") { this.reload.set(newValue !== null); - } else if (name === "latency") { - this.latency.set((newValue ? Number.parseFloat(newValue) : 100) as Time.Milli); + } else if (name === "buffer") { + this.buffer.set((newValue ? Number.parseFloat(newValue) : 100) as Time.Milli); } else { const exhaustive: never = name; throw new Error(`Invalid attribute: ${exhaustive}`); diff --git a/js/hang/src/watch/video/source.ts b/js/hang/src/watch/video/source.ts index 406a600bb8..8e0b951cc2 100644 --- a/js/hang/src/watch/video/source.ts +++ b/js/hang/src/watch/video/source.ts @@ -2,16 +2,18 @@ import type * as Moq from "@moq/lite"; import type { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Catalog from "../../catalog"; -import * as Frame from "../../frame"; +import * as Container from "../../container"; import { PRIORITY } from "../../publish/priority"; import * as Hex from "../../util/hex"; +import { Latency } from "../../util/latency"; export type SourceProps = { enabled?: boolean | Signal; - // Jitter buffer size in milliseconds (default: 100ms) - // When using b-frames, this should to be larger than the frame duration. - latency?: Time.Milli | Signal; + // Additional buffer in milliseconds on top of the catalog's minBuffer (default: 100ms). + // The effective latency = catalog.minBuffer + buffer + // Increase this if experiencing stuttering due to network jitter. + buffer?: Time.Milli | Signal; }; export type Target = { @@ -64,6 +66,10 @@ export class Source { #selected = new Signal(undefined); #selectedConfig = new Signal(undefined); + // The config of the active rendition. + #config = new Signal(undefined); + readonly config: Getter = this.#config; + // The name of the active rendition. active = new Signal(undefined); @@ -77,8 +83,12 @@ export class Source { // Expose the current frame to render as a signal frame = new Signal(undefined); - // The target latency in milliseconds. - latency: Signal; + // Additional buffer in milliseconds (on top of catalog's minBuffer). + buffer: Signal; + + // Computed latency = catalog.minBuffer + buffer + #latency: Latency; + readonly latency: Getter; // The display size of the video in pixels, ideally sourced from the catalog. display = new Signal<{ width: number; height: number } | undefined>(undefined); @@ -103,9 +113,16 @@ export class Source { props?: SourceProps, ) { this.broadcast = broadcast; - this.latency = Signal.from(props?.latency ?? (100 as Time.Milli)); + this.buffer = Signal.from(props?.buffer ?? (100 as Time.Milli)); this.enabled = Signal.from(props?.enabled ?? false); + // Compute effective latency from catalog.minBuffer + buffer + this.#latency = new Latency({ + buffer: this.buffer, + config: this.#config, + }); + this.latency = this.#latency.combined; + this.#signals.effect((effect) => { const c = effect.get(catalog)?.video; effect.set(this.catalog, c); @@ -126,6 +143,18 @@ export class Source { const supported: Record = {}; for (const [name, rendition] of Object.entries(renditions)) { + // For CMAF, we get description from init segment, so skip validation here + // and just check if the codec is supported + if (rendition.container.kind === "cmaf") { + const { supported: valid } = await VideoDecoder.isConfigSupported({ + codec: rendition.codec, + optimizeForLatency: rendition.optimizeForLatency ?? true, + }); + if (valid) supported[name] = rendition; + continue; + } + + // Legacy container: validate with description from catalog const description = rendition.description ? Hex.toBytes(rendition.description) : undefined; const { supported: valid } = await VideoDecoder.isConfigSupported({ @@ -157,6 +186,9 @@ export class Source { effect.set(this.#selected, selected); + // Store the full config for latency computation + effect.set(this.#config, supported[selected]); + // Remove the codedWidth/Height from the config to avoid a hard reload if nothing else has changed. const config = { ...supported[selected], codedWidth: undefined, codedHeight: undefined }; effect.set(this.#selectedConfig, config); @@ -195,15 +227,6 @@ export class Source { const sub = broadcast.subscribe(name, PRIORITY.video); // TODO use priority from catalog effect.cleanup(() => sub.close()); - // Create consumer that reorders groups/frames up to the provided latency. - // Container defaults to "legacy" via Zod schema for backward compatibility - console.log(`[Video Subscriber] Using container format: ${config.container}`); - const consumer = new Frame.Consumer(sub, { - latency: this.latency, - container: config.container, - }); - effect.cleanup(() => consumer.close()); - // We need a queue because VideoDecoder doesn't block on a Promise returned by output. // NOTE: We will drain this queue almost immediately, so the highWaterMark is just a safety net. const queue = new TransformStream( @@ -249,6 +272,7 @@ export class Source { }); effect.cleanup(() => decoder.close()); + // Output processing - same for both container types effect.spawn(async () => { for (;;) { const { value: frame } = await reader.read(); @@ -262,7 +286,7 @@ export class Source { this.#reference = ref; // Don't sleep so we immediately render this frame. } else { - sleep = this.#reference - ref + this.latency.peek(); + sleep = this.#reference - ref + this.#latency.peek(); } if (sleep > MIN_SYNC_WAIT_MS) { @@ -297,6 +321,21 @@ export class Source { } }); + // Input processing - depends on container type + if (config.container.kind === "cmaf") { + this.#runCmafTrack(effect, sub, config, decoder); + } else { + this.#runLegacyTrack(effect, sub, config, decoder); + } + } + + #runLegacyTrack(effect: Effect, sub: Moq.Track, config: RequiredDecoderConfig, decoder: VideoDecoder): void { + // Create consumer that reorders groups/frames up to the provided latency. + const consumer = new Container.Legacy.Consumer(sub, { + latency: this.#latency.combined, + }); + effect.cleanup(() => consumer.close()); + decoder.configure({ ...config, description: config.description ? Hex.toBytes(config.description) : undefined, @@ -328,6 +367,61 @@ export class Source { }); } + #runCmafTrack(effect: Effect, sub: Moq.Track, config: RequiredDecoderConfig, decoder: VideoDecoder): void { + if (config.container.kind !== "cmaf") return; + + const { timescale } = config.container; + const description = config.description ? Hex.toBytes(config.description) : undefined; + + // Configure decoder with description from catalog + decoder.configure({ + codec: config.codec, + description, + optimizeForLatency: config.optimizeForLatency ?? true, + // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. + flip: false, + }); + + effect.spawn(async () => { + // Process data segments + // TODO: Use a consumer wrapper for CMAF to support latency control + for (;;) { + const group = await sub.nextGroup(); + if (!group) break; + + effect.spawn(async () => { + try { + for (;;) { + const segment = await group.readFrame(); + if (!segment) break; + + const samples = Container.Cmaf.decodeDataSegment(segment, timescale); + + for (const sample of samples) { + const chunk = new EncodedVideoChunk({ + type: sample.keyframe ? "key" : "delta", + data: sample.data, + timestamp: sample.timestamp, + }); + + // Track stats + this.#stats.update((current) => ({ + frameCount: (current?.frameCount ?? 0) + 1, + timestamp: sample.timestamp, + bytesReceived: (current?.bytesReceived ?? 0) + sample.data.byteLength, + })); + + decoder.decode(chunk); + } + } + } finally { + group.close(); + } + }); + } + }); + } + #selectRendition(renditions: Record, target?: Target): string | undefined { const entries = Object.entries(renditions); if (entries.length <= 1) return entries.at(0)?.[0]; @@ -404,6 +498,7 @@ export class Source { return undefined; }); + this.#latency.close(); this.#signals.close(); } } diff --git a/rs/hang-cli/Cargo.toml b/rs/hang-cli/Cargo.toml index ca5e433fae..d3fa7e8c24 100644 --- a/rs/hang-cli/Cargo.toml +++ b/rs/hang-cli/Cargo.toml @@ -25,7 +25,6 @@ iroh = ["moq-native/iroh"] anyhow = { version = "1", features = ["backtrace"] } axum = { version = "0.8", features = ["tokio"] } axum-server = { version = "0.8", features = ["tls-rustls"] } -bytes = "1.10" clap = { version = "4", features = ["derive"] } hang = { workspace = true } moq-native = { workspace = true } diff --git a/rs/hang-cli/src/main.rs b/rs/hang-cli/src/main.rs index 968fb0e9ca..e26a177b55 100644 --- a/rs/hang-cli/src/main.rs +++ b/rs/hang-cli/src/main.rs @@ -83,14 +83,11 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); cli.log.init(); - let mut publish = Publish::new(match &cli.command { + let publish = Publish::new(match &cli.command { Command::Serve { format, .. } => format, Command::Publish { format, .. } => format, })?; - // Initialize the broadcast from stdin before starting any client/server. - publish.init().await?; - #[cfg(feature = "iroh")] let iroh = cli.iroh.bind().await?; diff --git a/rs/hang-cli/src/publish.rs b/rs/hang-cli/src/publish.rs index 56668d17e4..4e408db735 100644 --- a/rs/hang-cli/src/publish.rs +++ b/rs/hang-cli/src/publish.rs @@ -1,33 +1,35 @@ -use bytes::BytesMut; use clap::Subcommand; -use hang::{ - BroadcastProducer, - import::{Decoder, DecoderFormat}, - moq_lite::BroadcastConsumer, -}; -use tokio::io::AsyncReadExt; +use hang::{BroadcastProducer, moq_lite::BroadcastConsumer}; #[derive(Subcommand, Clone)] pub enum PublishFormat { Avc3, - Fmp4, + Fmp4 { + /// Transmit the fMP4 container directly instead of decoding it. + #[arg(long)] + passthrough: bool, + }, // NOTE: No aac support because it needs framing. Hls { /// URL or file path of an HLS playlist to ingest. #[arg(long)] playlist: String, + + /// Transmit the fMP4 segments directly instead of decoding them. + #[arg(long)] + passthrough: bool, }, } enum PublishDecoder { - Decoder(Box), + Avc3(Box), + Fmp4(Box), Hls(Box), } pub struct Publish { decoder: PublishDecoder, broadcast: BroadcastProducer, - buffer: BytesMut, } impl Publish { @@ -36,32 +38,32 @@ impl Publish { let decoder = match format { PublishFormat::Avc3 => { - let format = DecoderFormat::Avc3; - let stream = Decoder::new(broadcast.clone(), format); - PublishDecoder::Decoder(Box::new(stream)) + let avc3 = hang::import::Avc3::new(broadcast.clone()); + PublishDecoder::Avc3(Box::new(avc3)) } - PublishFormat::Fmp4 => { - let format = DecoderFormat::Fmp4; - let stream = Decoder::new(broadcast.clone(), format); - PublishDecoder::Decoder(Box::new(stream)) + PublishFormat::Fmp4 { passthrough } => { + let fmp4 = hang::import::Fmp4::new( + broadcast.clone(), + hang::import::Fmp4Config { + passthrough: *passthrough, + }, + ); + PublishDecoder::Fmp4(Box::new(fmp4)) } - PublishFormat::Hls { playlist } => { + PublishFormat::Hls { playlist, passthrough } => { let hls = hang::import::Hls::new( broadcast.clone(), hang::import::HlsConfig { playlist: playlist.clone(), client: None, + passthrough: *passthrough, }, )?; PublishDecoder::Hls(Box::new(hls)) } }; - Ok(Self { - decoder, - buffer: BytesMut::new(), - broadcast, - }) + Ok(Self { decoder, broadcast }) } pub fn consume(&self) -> BroadcastConsumer { @@ -70,33 +72,13 @@ impl Publish { } impl Publish { - pub async fn init(&mut self) -> anyhow::Result<()> { - match &mut self.decoder { - PublishDecoder::Decoder(decoder) => { - let mut input = tokio::io::stdin(); - - while !decoder.is_initialized() && input.read_buf(&mut self.buffer).await? > 0 { - decoder.decode_stream(&mut self.buffer)?; - } - } - PublishDecoder::Hls(decoder) => decoder.init().await?, - } - - Ok(()) - } - pub async fn run(mut self) -> anyhow::Result<()> { - match &mut self.decoder { - PublishDecoder::Decoder(decoder) => { - let mut input = tokio::io::stdin(); + let mut stdin = tokio::io::stdin(); - while input.read_buf(&mut self.buffer).await? > 0 { - decoder.decode_stream(&mut self.buffer)?; - } - } - PublishDecoder::Hls(decoder) => decoder.run().await?, + match &mut self.decoder { + PublishDecoder::Avc3(decoder) => decoder.decode_from(&mut stdin).await, + PublishDecoder::Fmp4(decoder) => decoder.decode_from(&mut stdin).await, + PublishDecoder::Hls(decoder) => decoder.run().await, } - - Ok(()) } } diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index d2d9e9e2c8..1b169d6052 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -66,6 +66,8 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> hang::TrackProdu display_ratio_width: None, display_ratio_height: None, optimize_for_latency: None, + container: hang::catalog::Container::Legacy, + min_buffer: None, }; // Create a map of video renditions diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index e1e95a794d..e95730d891 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -11,6 +11,8 @@ use bytes::Bytes; use serde::{Deserialize, Serialize}; use serde_with::{DisplayFromStr, hex::Hex}; +use crate::catalog::Container; + /// Information about an audio track in the catalog. /// /// This struct contains a map of renditions (different quality/codec options) @@ -60,4 +62,19 @@ pub struct AudioConfig { #[serde(default)] #[serde_as(as = "Option")] pub description: Option, + + /// Container format for frame encoding. + /// Defaults to "legacy" for backward compatibility. + #[serde(default)] + pub container: Container, + + /// Minimum buffer size in milliseconds required for smooth playback. + /// + /// This represents the minimum time the player should buffer before starting playback. + /// For HLS imports, this is typically the segment duration. + /// For fMP4 imports, this is detected from the fragment duration. + /// + /// The player should add additional jitter buffer on top of this value. + #[serde(default)] + pub min_buffer: Option, } diff --git a/rs/hang/src/catalog/container.rs b/rs/hang/src/catalog/container.rs new file mode 100644 index 0000000000..ea7a416d5b --- /dev/null +++ b/rs/hang/src/catalog/container.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// Container format for frame timestamp encoding and frame payload structure. +/// +/// - "legacy": Uses QUIC VarInt encoding (1-8 bytes, variable length), raw frame payloads. +/// Timestamps are in microseconds. +/// - "cmaf": Fragmented MP4 container - frames contain complete moof+mdat fragments. +/// Timestamps are in timescale units. +/// +/// JSON example: +/// ```json +/// { "kind": "cmaf", "timescale": 1000000, "trackId": 1 } +/// ``` +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "kind")] +pub enum Container { + #[serde(rename = "legacy")] + #[default] + Legacy, + Cmaf { + /// Time units per second + timescale: u64, + /// Track ID used in the moof/mdat fragments + #[serde(rename = "trackId")] + track_id: u32, + }, +} diff --git a/rs/hang/src/catalog/mod.rs b/rs/hang/src/catalog/mod.rs index ea48eee011..906feb3504 100644 --- a/rs/hang/src/catalog/mod.rs +++ b/rs/hang/src/catalog/mod.rs @@ -6,6 +6,7 @@ mod audio; mod chat; +mod container; mod preview; mod root; mod user; @@ -13,6 +14,7 @@ mod video; pub use audio::*; pub use chat::*; +pub use container::*; pub use preview::*; pub use root::*; pub use user::*; diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 6d7ef0c0ee..28eb3c0d15 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -160,6 +160,7 @@ impl CatalogProducer { CatalogGuard { catalog: self.current.lock().unwrap(), track: &mut self.track, + updated: false, } } @@ -186,6 +187,7 @@ impl From for CatalogProducer { pub struct CatalogGuard<'a> { catalog: MutexGuard<'a, Catalog>, track: &'a mut moq_lite::TrackProducer, + updated: bool, } impl<'a> Deref for CatalogGuard<'a> { @@ -198,12 +200,18 @@ impl<'a> Deref for CatalogGuard<'a> { impl<'a> DerefMut for CatalogGuard<'a> { fn deref_mut(&mut self) -> &mut Self::Target { + self.updated = true; &mut self.catalog } } impl Drop for CatalogGuard<'_> { fn drop(&mut self) { + // Avoid publishing if we didn't use `&mut self` at all. + if !self.updated { + return; + } + let mut group = self.track.append_group(); // TODO decide if this should return an error, or be impossible to fail @@ -272,7 +280,7 @@ impl From for CatalogConsumer { mod test { use std::collections::BTreeMap; - use crate::catalog::{AudioCodec::Opus, AudioConfig, H264, VideoConfig}; + use crate::catalog::{AudioCodec::Opus, AudioConfig, Container, H264, VideoConfig}; use super::*; @@ -286,7 +294,8 @@ mod test { "codedWidth": 1280, "codedHeight": 720, "bitrate": 6000000, - "framerate": 30.0 + "framerate": 30.0, + "container": "legacy" } }, "priority": 1 @@ -297,7 +306,8 @@ mod test { "codec": "opus", "sampleRate": 48000, "numberOfChannels": 2, - "bitrate": 128000 + "bitrate": 128000, + "container": "legacy" } }, "priority": 2 @@ -326,6 +336,8 @@ mod test { bitrate: Some(6_000_000), framerate: Some(30.0), optimize_for_latency: None, + container: Container::Legacy, + min_buffer: None, }, ); @@ -338,6 +350,8 @@ mod test { channel_count: 2, bitrate: Some(128_000), description: None, + container: Container::Legacy, + min_buffer: None, }, ); diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 750a2501e1..86bf8bfb84 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -16,6 +16,8 @@ use bytes::Bytes; use serde::{Deserialize, Serialize}; use serde_with::{DisplayFromStr, hex::Hex}; +use crate::catalog::Container; + /// Information about a video track in the catalog. /// /// This struct contains a map of renditions (different quality/codec options) @@ -109,4 +111,19 @@ pub struct VideoConfig { /// Default: true #[serde(default)] pub optimize_for_latency: Option, + + /// Container format for frame encoding. + /// Defaults to "legacy" for backward compatibility. + #[serde(default)] + pub container: Container, + + /// Minimum buffer size in milliseconds required for smooth playback. + /// + /// This represents the minimum time the player should buffer before starting playback. + /// For HLS imports, this is typically the segment duration. + /// For fMP4 imports, this is detected from the fragment duration. + /// + /// The player should add additional jitter buffer on top of this value. + #[serde(default)] + pub min_buffer: Option, } diff --git a/rs/hang/src/import/aac.rs b/rs/hang/src/import/aac.rs index 0652823749..844d458f33 100644 --- a/rs/hang/src/import/aac.rs +++ b/rs/hang/src/import/aac.rs @@ -107,6 +107,8 @@ impl Aac { channel_count, bitrate: None, description: None, + container: hang::catalog::Container::Legacy, + min_buffer: None, }; tracing::debug!(name = ?track.name, ?config, "starting track"); diff --git a/rs/hang/src/import/avc3.rs b/rs/hang/src/import/avc3.rs index 9171332edd..af9171c134 100644 --- a/rs/hang/src/import/avc3.rs +++ b/rs/hang/src/import/avc3.rs @@ -3,8 +3,9 @@ use crate::import::annexb::{NalIterator, START_CODE}; use anyhow::Context; use buf_list::BufList; -use bytes::{Buf, Bytes}; +use bytes::{Buf, Bytes, BytesMut}; use moq_lite as moq; +use tokio::io::{AsyncRead, AsyncReadExt}; /// A decoder for H.264 with inline SPS/PPS. pub struct Avc3 { @@ -62,6 +63,8 @@ impl Avc3 { display_ratio_width: None, display_ratio_height: None, optimize_for_latency: None, + container: hang::catalog::Container::Legacy, + min_buffer: None, }; if let Some(old) = &self.config @@ -112,6 +115,16 @@ impl Avc3 { Ok(()) } + /// Decode from an asynchronous reader. + pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { + let mut buffer = BytesMut::new(); + while reader.read_buf(&mut buffer).await? > 0 { + self.decode_stream(&mut buffer, None)?; + } + + Ok(()) + } + /// Decode as much data as possible from the given buffer. /// /// Unlike [Self::decode_frame], this method needs the start code for the next frame. diff --git a/rs/hang/src/import/decoder.rs b/rs/hang/src/import/decoder.rs index f9677c5627..61518aedee 100644 --- a/rs/hang/src/import/decoder.rs +++ b/rs/hang/src/import/decoder.rs @@ -2,14 +2,10 @@ use std::{fmt, str::FromStr}; use bytes::Buf; -use crate::{self as hang, Error, import::Aac, import::Opus}; - -#[cfg(feature = "h264")] -use crate::import::Avc3; -#[cfg(feature = "mp4")] -use crate::import::Fmp4; -#[cfg(feature = "h265")] -use crate::import::Hev1; +use crate::{ + self as hang, Error, + import::{self, Aac, Opus}, +}; /// The supported decoder formats. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -72,13 +68,13 @@ impl fmt::Display for DecoderFormat { enum DecoderKind { /// aka H264 with inline SPS/PPS #[cfg(feature = "h264")] - Avc3(Avc3), + Avc3(import::Avc3), // Boxed because it's a large struct and clippy complains about the size. #[cfg(feature = "mp4")] - Fmp4(Box), + Fmp4(Box), /// aka H265 with inline SPS/PPS #[cfg(feature = "h265")] - Hev1(Hev1), + Hev1(import::Hev1), Aac(Aac), Opus(Opus), } @@ -96,11 +92,11 @@ impl Decoder { pub fn new(broadcast: hang::BroadcastProducer, format: DecoderFormat) -> Self { let decoder = match format { #[cfg(feature = "h264")] - DecoderFormat::Avc3 => Avc3::new(broadcast).into(), + DecoderFormat::Avc3 => import::Avc3::new(broadcast).into(), #[cfg(feature = "mp4")] - DecoderFormat::Fmp4 => Box::new(Fmp4::new(broadcast)).into(), + DecoderFormat::Fmp4 => Box::new(import::Fmp4::new(broadcast, import::Fmp4Config::default())).into(), #[cfg(feature = "h265")] - DecoderFormat::Hev1 => Hev1::new(broadcast).into(), + DecoderFormat::Hev1 => import::Hev1::new(broadcast).into(), DecoderFormat::Aac => Aac::new(broadcast).into(), DecoderFormat::Opus => Opus::new(broadcast).into(), }; @@ -147,17 +143,15 @@ impl Decoder { pub fn decode_stream>(&mut self, buf: &mut T) -> anyhow::Result<()> { match &mut self.decoder { #[cfg(feature = "h264")] - DecoderKind::Avc3(decoder) => decoder.decode_stream(buf, None)?, + DecoderKind::Avc3(decoder) => decoder.decode_stream(buf, None), #[cfg(feature = "mp4")] - DecoderKind::Fmp4(decoder) => decoder.decode(buf)?, + DecoderKind::Fmp4(decoder) => decoder.decode(buf), #[cfg(feature = "h265")] - DecoderKind::Hev1(decoder) => decoder.decode_stream(buf, None)?, + DecoderKind::Hev1(decoder) => decoder.decode_stream(buf, None), // TODO Fix or make these more type safe. DecoderKind::Aac(_) => anyhow::bail!("AAC does not support stream decoding"), DecoderKind::Opus(_) => anyhow::bail!("Opus does not support stream decoding"), } - - Ok(()) } /// Flush the decoder at a frame boundary. @@ -177,16 +171,14 @@ impl Decoder { ) -> anyhow::Result<()> { match &mut self.decoder { #[cfg(feature = "h264")] - DecoderKind::Avc3(decoder) => decoder.decode_frame(buf, pts)?, + DecoderKind::Avc3(decoder) => decoder.decode_frame(buf, pts), #[cfg(feature = "mp4")] - DecoderKind::Fmp4(decoder) => decoder.decode(buf)?, + DecoderKind::Fmp4(decoder) => decoder.decode(buf), #[cfg(feature = "h265")] - DecoderKind::Hev1(decoder) => decoder.decode_frame(buf, pts)?, - DecoderKind::Aac(decoder) => decoder.decode(buf, pts)?, - DecoderKind::Opus(decoder) => decoder.decode(buf, pts)?, + DecoderKind::Hev1(decoder) => decoder.decode_frame(buf, pts), + DecoderKind::Aac(decoder) => decoder.decode(buf, pts), + DecoderKind::Opus(decoder) => decoder.decode(buf, pts), } - - Ok(()) } /// Check if the decoder has read enough data to be initialized. diff --git a/rs/hang/src/import/fmp4.rs b/rs/hang/src/import/fmp4.rs index bfbb889e3d..4be9e9f731 100644 --- a/rs/hang/src/import/fmp4.rs +++ b/rs/hang/src/import/fmp4.rs @@ -1,10 +1,20 @@ -use crate::catalog::{AAC, AV1, AudioCodec, AudioConfig, CatalogProducer, H264, H265, VP9, VideoCodec, VideoConfig}; +use crate::catalog::{AAC, AV1, AudioCodec, AudioConfig, Container, H264, H265, VP9, VideoCodec, VideoConfig}; use crate::{self as hang, Timestamp}; use anyhow::Context; use bytes::{Buf, Bytes, BytesMut}; use moq_lite as moq; use mp4_atom::{Any, Atom, DecodeMaybe, Mdat, Moof, Moov, Trak}; use std::collections::HashMap; +use tokio::io::{AsyncRead, AsyncReadExt}; + +/// Configuration for the fMP4 importer. +#[derive(Clone, Default)] +pub struct Fmp4Config { + /// When true, transport fMP4 fragments directly (passthrough mode) + /// + /// This requires a player that can decode the fragments directly. + pub passthrough: bool, +} /// Converts fMP4/CMAF files into hang broadcast streams. /// @@ -25,18 +35,10 @@ use std::collections::HashMap; /// - Opus pub struct Fmp4 { // The broadcast being produced - // This `hang` variant includes a catalog. broadcast: hang::BroadcastProducer, - // A clone of the broadcast's catalog for mutable access. - // This is the same underlying catalog (via Arc), just a separate binding. - catalog: CatalogProducer, - // A lookup to tracks in the broadcast - tracks: HashMap, - - // The timestamp of the last keyframe for each track - last_keyframe: HashMap, + tracks: HashMap, // The moov atom at the start of the file. moov: Option, @@ -44,6 +46,40 @@ pub struct Fmp4 { // The latest moof header moof: Option, moof_size: usize, + + /// Configuration for the fMP4 importer. + config: Fmp4Config, + + // -- PASSTHROUGH ONLY -- + moof_raw: Option, +} + +struct Fmp4Track { + producer: hang::TrackProducer, + + // The current group being written, only used for passthrough mode. + group: Option, + + // The minimum buffer required for the track. + min_buffer: Option, + + // The last timestamp seen for this track. + last_timestamp: Option, + + // The minimum duration between frames for this track. + min_duration: Option, +} + +impl Fmp4Track { + fn new(producer: hang::TrackProducer) -> Self { + Self { + producer, + group: None, + min_buffer: None, + last_timestamp: None, + min_duration: None, + } + } } impl Fmp4 { @@ -51,19 +87,29 @@ impl Fmp4 { /// /// The broadcast will be populated with tracks as they're discovered in the /// fMP4 file. The catalog from the `hang::BroadcastProducer` is used automatically. - pub fn new(broadcast: hang::BroadcastProducer) -> Self { - let catalog = broadcast.catalog.clone(); + pub fn new(broadcast: hang::BroadcastProducer, config: Fmp4Config) -> Self { Self { - broadcast, - catalog, tracks: HashMap::default(), - last_keyframe: HashMap::default(), moov: None, moof: None, moof_size: 0, + broadcast, + config, + moof_raw: None, } } + /// Decode from an asynchronous reader. + pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { + let mut buffer = BytesMut::new(); + while reader.read_buf(&mut buffer).await? > 0 { + self.decode(&mut buffer)?; + } + + Ok(()) + } + + /// Decode a buffer of bytes. pub fn decode>(&mut self, buf: &mut T) -> anyhow::Result<()> { let mut cursor = std::io::Cursor::new(buf); let mut position = 0; @@ -71,35 +117,34 @@ impl Fmp4 { while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? { // Process the parsed atom. let size = cursor.position() as usize - position; - position = cursor.position() as usize; + + // The raw bytes of the atom we just parsed (not copied). + let raw = &cursor.get_ref().as_ref()[position..position + size]; match atom { - Any::Ftyp(_) | Any::Styp(_) => { - // Skip - } + Any::Ftyp(_) | Any::Styp(_) => {} Any::Moov(moov) => { - // Create the broadcast. self.init(moov)?; } Any::Moof(moof) => { - if self.moof.is_some() { - // Two moof boxes in a row. - anyhow::bail!("duplicate moof box"); - } - - self.moof = Some(moof); + anyhow::ensure!(self.moof.is_none(), "duplicate moof box"); + self.moof.replace(moof); self.moof_size = size; + + if self.config.passthrough { + self.moof_raw.replace(Bytes::copy_from_slice(raw)); + } } Any::Mdat(mdat) => { - // Extract the samples from the mdat atom. - let header_size = size - mdat.data.len(); - self.extract(mdat, header_size)?; + self.extract(mdat, raw)?; } _ => { - // Skip unknown atoms - tracing::warn!(?atom, "skipping") + // Skip unknown atoms (e.g., sidx, which is optional and used for segment indexing) + // These are safe to ignore and don't affect playback } } + + position = cursor.position() as usize; } // Advance the buffer by the amount of data that was processed. @@ -113,7 +158,13 @@ impl Fmp4 { } fn init(&mut self, moov: Moov) -> anyhow::Result<()> { - let mut catalog = self.catalog.lock(); + // Make a clone to avoid the borrow checker. + let mut catalog = self.broadcast.catalog.clone(); + let mut catalog = catalog.lock(); + + // Track which specific tracks were created in this init call + let mut created_video_tracks = Vec::new(); + let mut created_audio_tracks = Vec::new(); for trak in &moov.trak { let track_id = trak.tkhd.track_id; @@ -121,44 +172,46 @@ impl Fmp4 { let track = match handler.as_ref() { b"vide" => { - let config = Self::init_video(trak)?; + let config = self.init_video(trak)?; let track = moq::Track { name: self.broadcast.track_name("video"), priority: 1, }; - tracing::debug!(name = ?track.name, ?config, "starting track"); - - let video = catalog.insert_video(track.name.clone(), config); + let video = catalog.insert_video(track.name.clone(), config.clone()); video.priority = 1; + // Record this track name + created_video_tracks.push(track.name.clone()); + let track = track.produce(); self.broadcast.insert_track(track.consumer); - track.producer + hang::TrackProducer::new(track.producer) } b"soun" => { - let config = Self::init_audio(trak)?; + let config = self.init_audio(trak)?; let track = moq::Track { name: self.broadcast.track_name("audio"), priority: 2, }; - tracing::debug!(name = ?track.name, ?config, "starting track"); - - let audio = catalog.insert_audio(track.name.clone(), config); + let audio = catalog.insert_audio(track.name.clone(), config.clone()); audio.priority = 2; + // Record this track name + created_audio_tracks.push(track.name.clone()); + let track = track.produce(); self.broadcast.insert_track(track.consumer); - track.producer + hang::TrackProducer::new(track.producer) } b"sbtl" => anyhow::bail!("subtitle tracks are not supported"), handler => anyhow::bail!("unknown track type: {:?}", handler), }; - self.tracks.insert(track_id, track.into()); + self.tracks.insert(track_id, Fmp4Track::new(track)); } self.moov = Some(moov); @@ -166,7 +219,19 @@ impl Fmp4 { Ok(()) } - fn init_video(trak: &Trak) -> anyhow::Result { + fn container(&self, trak: &Trak) -> Container { + if self.config.passthrough { + Container::Cmaf { + timescale: trak.mdia.mdhd.timescale as u64, + track_id: trak.tkhd.track_id, + } + } else { + Container::Legacy + } + } + + fn init_video(&mut self, trak: &Trak) -> anyhow::Result { + let container = self.container(trak); let stsd = &trak.mdia.minf.stbl.stsd; let codec = match stsd.codecs.len() { @@ -199,10 +264,12 @@ impl Fmp4 { display_ratio_width: None, display_ratio_height: None, optimize_for_latency: None, + container, + min_buffer: None, } } - mp4_atom::Codec::Hev1(hev1) => Self::init_h265(true, &hev1.hvcc, &hev1.visual)?, - mp4_atom::Codec::Hvc1(hvc1) => Self::init_h265(false, &hvc1.hvcc, &hvc1.visual)?, + mp4_atom::Codec::Hev1(hev1) => self.init_h265(true, &hev1.hvcc, &hev1.visual, container)?, + mp4_atom::Codec::Hvc1(hvc1) => self.init_h265(false, &hvc1.hvcc, &hvc1.visual, container)?, mp4_atom::Codec::Vp08(vp08) => VideoConfig { codec: VideoCodec::VP8, description: Default::default(), @@ -214,6 +281,8 @@ impl Fmp4 { display_ratio_width: None, display_ratio_height: None, optimize_for_latency: None, + container, + min_buffer: None, }, mp4_atom::Codec::Vp09(vp09) => { // https://github.com/gpac/mp4box.js/blob/325741b592d910297bf609bc7c400fc76101077b/src/box-codecs.js#L238 @@ -240,6 +309,8 @@ impl Fmp4 { optimize_for_latency: None, bitrate: None, framerate: None, + container, + min_buffer: None, } } mp4_atom::Codec::Av01(av01) => { @@ -272,6 +343,8 @@ impl Fmp4 { optimize_for_latency: None, bitrate: None, framerate: None, + container, + min_buffer: None, } } mp4_atom::Codec::Unknown(unknown) => anyhow::bail!("unknown codec: {:?}", unknown), @@ -282,7 +355,13 @@ impl Fmp4 { } // There's two almost identical hvcc atoms in the wild. - fn init_h265(in_band: bool, hvcc: &mp4_atom::Hvcc, visual: &mp4_atom::Visual) -> anyhow::Result { + fn init_h265( + &mut self, + in_band: bool, + hvcc: &mp4_atom::Hvcc, + visual: &mp4_atom::Visual, + container: Container, + ) -> anyhow::Result { let mut description = BytesMut::new(); hvcc.encode_body(&mut description)?; @@ -306,10 +385,13 @@ impl Fmp4 { display_ratio_width: None, display_ratio_height: None, optimize_for_latency: None, + container, + min_buffer: None, }) } - fn init_audio(trak: &Trak) -> anyhow::Result { + fn init_audio(&mut self, trak: &Trak) -> anyhow::Result { + let container = self.container(trak); let stsd = &trak.mdia.minf.stbl.stsd; let codec = match stsd.codecs.len() { @@ -338,6 +420,8 @@ impl Fmp4 { channel_count: mp4a.audio.channel_count as _, bitrate: Some(bitrate.into()), description: None, // TODO? + container, + min_buffer: None, } } mp4_atom::Codec::Opus(opus) => { @@ -347,6 +431,8 @@ impl Fmp4 { channel_count: opus.audio.channel_count as _, bitrate: None, description: None, // TODO? + container, + min_buffer: None, } } mp4_atom::Codec::Unknown(unknown) => anyhow::bail!("unknown codec: {:?}", unknown), @@ -357,15 +443,11 @@ impl Fmp4 { } // Extract all frames out of an mdat atom. - fn extract(&mut self, mdat: Mdat, header_size: usize) -> anyhow::Result<()> { - let mdat = Bytes::from(mdat.data); + fn extract(&mut self, mdat: Mdat, mdat_raw: &[u8]) -> anyhow::Result<()> { let moov = self.moov.as_ref().context("missing moov box")?; let moof = self.moof.take().context("missing moof box")?; - - // Keep track of the minimum and maximum timestamp so we can scold the user. - // Ideally these should both be the same value. - let mut min_timestamp = None; - let mut max_timestamp = None; + let moof_size = self.moof_size; + let header_size = mdat_raw.len() - mdat.data.len(); // Loop over all of the traf boxes in the moof. for traf in &moof.traf { @@ -397,6 +479,13 @@ impl Fmp4 { if traf.trun.is_empty() { anyhow::bail!("missing trun box"); } + + // Keep track of the minimum and maximum timestamp for this track to compute the min_buffer. + // Ideally these should both be the same value (a single frame lul). + let mut min_timestamp = None; + let mut max_timestamp = None; + let mut contains_keyframe = false; + for trun in &traf.trun { let tfhd = &traf.tfhd; @@ -405,11 +494,17 @@ impl Fmp4 { // This is relative to the start of the MOOF, not the MDAT. // Note: The trun data offset can be negative, but... that's not supported here. let data_offset: usize = data_offset.try_into().context("invalid data offset")?; - if data_offset < self.moof_size { - anyhow::bail!("invalid data offset"); - } + + // Use checked arithmetic to prevent underflow + let relative_offset = data_offset + .checked_sub(moof_size) + .and_then(|v| v.checked_sub(header_size)) + .context("invalid data offset: underflow")?; + // Reset offset if the TRUN has a data offset - offset = base_offset + data_offset - self.moof_size - header_size; + offset = base_offset + .checked_add(relative_offset) + .context("invalid data offset: overflow")?; } for entry in &trun.entries { @@ -427,7 +522,7 @@ impl Fmp4 { let pts = (dts as i64 + entry.cts.unwrap_or_default() as i64) as u64; let timestamp = hang::Timestamp::from_scale(pts, timescale)?; - if offset + size > mdat.len() { + if offset + size > mdat.data.len() { anyhow::bail!("invalid data offset"); } @@ -436,55 +531,98 @@ impl Fmp4 { let keyframe = (flags >> 24) & 0x3 == 0x2; // kSampleDependsOnNoOther let non_sync = (flags >> 16) & 0x1 == 0x1; // kSampleIsNonSyncSample - if keyframe && !non_sync { - for audio in moov.trak.iter().filter(|t| t.mdia.hdlr.handler == b"soun".into()) { - // Force an audio keyframe on video keyframes - self.last_keyframe.remove(&audio.tkhd.track_id); - } - - true - } else { - false - } + keyframe && !non_sync } else { - match self.last_keyframe.get(&track_id) { - // Force an audio keyframe at least every 10 seconds, but ideally at video keyframes - Some(prev) => timestamp - *prev > Timestamp::from_secs(10).unwrap(), - None => true, - } + // Audio frames are always keyframes. + true }; - if keyframe { - self.last_keyframe.insert(track_id, timestamp); - } + contains_keyframe |= keyframe; - let payload = mdat.slice(offset..(offset + size)); + if !self.config.passthrough { + // TODO Avoid a copy if mp4-atom uses Bytes? + let payload = Bytes::copy_from_slice(&mdat.data[offset..(offset + size)]); - let frame = hang::Frame { - timestamp, - keyframe, - payload: payload.into(), - }; - track.write(frame)?; - - dts += duration as u64; - offset += size; + let frame = hang::Frame { + timestamp, + keyframe, + payload: payload.into(), + }; + track.producer.write(frame)?; + } - if timestamp >= max_timestamp.unwrap_or_default() { + if timestamp >= max_timestamp.unwrap_or(Timestamp::ZERO) { max_timestamp = Some(timestamp); } - if timestamp <= min_timestamp.unwrap_or_default() { + if timestamp <= min_timestamp.unwrap_or(Timestamp::MAX) { min_timestamp = Some(timestamp); } + + if let Some(last_timestamp) = track.last_timestamp + && let Ok(duration) = timestamp.checked_sub(last_timestamp) + && duration < track.min_duration.unwrap_or(Timestamp::MAX) + { + track.min_duration = Some(duration); + } + + track.last_timestamp = Some(timestamp); + + dts += duration as u64; + offset += size; } } - } - if let (Some(min), Some(max)) = (min_timestamp, max_timestamp) { - let diff = max - min; + // If we're doing passthrough mode, then we write one giant fragment instead of individual frames. + if self.config.passthrough { + let mut group = if contains_keyframe { + if let Some(group) = track.group.take() { + group.close(); + } + + track.producer.inner.append_group() + } else { + track.group.take().context("no keyframe at start")? + }; + + let moof_raw = self.moof_raw.as_ref().context("missing moof box")?; + + // To avoid an extra allocation, we use the chunked API to write the moof and mdat atoms separately. + let mut frame = group.create_frame(moq_lite::Frame { + size: moof_raw.len() as u64 + mdat_raw.len() as u64, + }); - if diff > Timestamp::from_millis(1).unwrap() { - tracing::warn!("fMP4 introduced {:?} of latency", diff); + frame.write_chunk(moof_raw.clone()); + frame.write_chunk(Bytes::copy_from_slice(mdat_raw)); + frame.close(); + + track.group = Some(group); + } + + if let (Some(min), Some(max), Some(min_duration)) = (min_timestamp, max_timestamp, track.min_duration) { + // We report the minimum buffer required as the difference between the min and max frames. + // We also add the duration between frames to account for the frame rate. + // ex. for 2s fragments, this should be exactly 2s if we did everything correctly. + let min_buffer = max - min + min_duration; + + if min_buffer < track.min_buffer.unwrap_or(Timestamp::MAX) { + track.min_buffer = Some(min_buffer); + + // Update the catalog with the new min_buffer + let mut catalog = self.broadcast.catalog.lock(); + + // We're lazy and don't keep track if this track is for audio or video, so just try to update both. + if let Some(video) = catalog.video.as_mut() + && let Some(config) = video.renditions.get_mut(&track.producer.info.name) + { + config.min_buffer = Some(min_buffer.convert()?); + } + + if let Some(audio) = catalog.audio.as_mut() + && let Some(config) = audio.renditions.get_mut(&track.producer.info.name) + { + config.min_buffer = Some(min_buffer.convert()?); + } + } } } @@ -497,11 +635,9 @@ impl Drop for Fmp4 { let mut catalog = self.broadcast.catalog.lock(); for track in self.tracks.values() { - tracing::debug!(name = ?track.info.name, "ending track"); - // We're too lazy to keep track of if this track is for audio or video, so we just remove both. - catalog.remove_video(&track.info.name); - catalog.remove_audio(&track.info.name); + catalog.remove_video(&track.producer.info.name); + catalog.remove_audio(&track.producer.info.name); } } } diff --git a/rs/hang/src/import/hev1.rs b/rs/hang/src/import/hev1.rs index 1062a2c5c5..4d203889b6 100644 --- a/rs/hang/src/import/hev1.rs +++ b/rs/hang/src/import/hev1.rs @@ -62,6 +62,8 @@ impl Hev1 { display_ratio_width: vui_data.display_ratio_width, display_ratio_height: vui_data.display_ratio_height, optimize_for_latency: None, + container: hang::catalog::Container::Legacy, + min_buffer: None, }; if let Some(old) = &self.config diff --git a/rs/hang/src/import/hls.rs b/rs/hang/src/import/hls.rs index 40e73025a5..284bdf39ab 100644 --- a/rs/hang/src/import/hls.rs +++ b/rs/hang/src/import/hls.rs @@ -20,7 +20,7 @@ use tracing::{debug, info, warn}; use url::Url; use crate::BroadcastProducer; -use crate::import::Fmp4; +use crate::import::{Fmp4, Fmp4Config}; /// Configuration for the single-rendition HLS ingest loop. #[derive(Clone)] @@ -31,11 +31,20 @@ pub struct HlsConfig { /// An optional HTTP client to use for fetching the playlist and segments. /// If not provided, a default client will be created. pub client: Option, + + /// Enable passthrough mode for CMAF fragment transport. + /// When enabled, complete fMP4 fragments (moof+mdat) are transported directly + /// instead of being decomposed into individual samples. + pub passthrough: bool, } impl HlsConfig { pub fn new(playlist: String) -> Self { - Self { playlist, client: None } + Self { + playlist, + client: None, + passthrough: false, + } } /// Parse the playlist string into a URL. @@ -86,6 +95,7 @@ pub struct Hls { video: Vec, /// Optional audio track shared across variants. audio: Option, + passthrough: bool, } #[derive(Debug, Clone, Copy)] @@ -120,9 +130,11 @@ impl Hls { .build() .unwrap() }); + let passthrough = cfg.passthrough; Ok(Self { broadcast, video_importers: Vec::new(), + passthrough, audio_importer: None, client, base_url, @@ -150,9 +162,10 @@ impl Hls { let outcome = self.step().await?; let delay = self.refresh_delay(outcome.target_duration, outcome.wrote_segments); - debug!( - wrote = outcome.wrote_segments, - delay = ?delay, + info!( + wrote_segments = outcome.wrote_segments, + target_duration = ?outcome.target_duration, + delay_secs = delay.as_secs_f32(), "HLS ingest step complete" ); @@ -165,6 +178,7 @@ impl Hls { self.ensure_tracks().await?; let mut buffered = 0usize; + const MAX_INIT_SEGMENTS: usize = 3; // Only process a few segments during init to avoid getting ahead of live stream // Prime all discovered video variants. // @@ -174,7 +188,7 @@ impl Hls { for (index, mut track) in video_tracks.into_iter().enumerate() { let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; let count = self - .consume_segments(TrackKind::Video(index), &mut track, &playlist) + .consume_segments(TrackKind::Video(index), &mut track, &playlist, Some(MAX_INIT_SEGMENTS)) .await?; buffered += count; self.video.push(track); @@ -183,7 +197,9 @@ impl Hls { // Prime the shared audio track, if any. if let Some(mut track) = self.audio.take() { let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - let count = self.consume_segments(TrackKind::Audio, &mut track, &playlist).await?; + let count = self + .consume_segments(TrackKind::Audio, &mut track, &playlist, Some(MAX_INIT_SEGMENTS)) + .await?; buffered += count; self.audio = Some(track); } @@ -211,7 +227,7 @@ impl Hls { target_duration = Some(playlist.target_duration); } let count = self - .consume_segments(TrackKind::Video(index), &mut track, &playlist) + .consume_segments(TrackKind::Video(index), &mut track, &playlist, None) .await?; wrote += count; self.video.push(track); @@ -223,7 +239,9 @@ impl Hls { if target_duration.is_none() { target_duration = Some(playlist.target_duration); } - let count = self.consume_segments(TrackKind::Audio, &mut track, &playlist).await?; + let count = self + .consume_segments(TrackKind::Audio, &mut track, &playlist, None) + .await?; wrote += count; self.audio = Some(track); } @@ -307,22 +325,64 @@ impl Hls { kind: TrackKind, track: &mut TrackState, playlist: &MediaPlaylist, + limit: Option, ) -> anyhow::Result { self.ensure_init_segment(kind, track, playlist).await?; - // Skip segments we've already seen - let skip = track.next_sequence.unwrap_or(0).saturating_sub(playlist.media_sequence) as usize; - let base_seq = playlist.media_sequence + skip as u64; - for (i, segment) in playlist.segments[skip..].iter().enumerate() { - self.push_segment(kind, track, segment, base_seq + i as u64).await?; - } - let consumed = playlist.segments.len() - skip; + let next_seq = track.next_sequence.unwrap_or(0); + let playlist_seq = playlist.media_sequence; + let total_segments = playlist.segments.len(); + let last_playlist_seq = playlist_seq + total_segments as u64; + + let skip = if next_seq > last_playlist_seq { + warn!( + ?kind, + next_sequence = next_seq, + playlist_sequence = playlist_seq, + last_playlist_sequence = last_playlist_seq, + "imported ahead of playlist, waiting for new segments" + ); + total_segments + } else if next_seq < playlist_seq { + warn!( + ?kind, + next_sequence = next_seq, + playlist_sequence = playlist_seq, + "next_sequence behind playlist, resetting to start of playlist" + ); + track.next_sequence = None; + 0 + } else { + (next_seq - playlist_seq) as usize + }; + + let available = total_segments.saturating_sub(skip); + let to_process = match limit { + Some(max) => available.min(max), + None => available, + }; - if consumed == 0 { + info!( + ?kind, + playlist_sequence = playlist_seq, + next_sequence = next_seq, + skip = skip, + total_segments = total_segments, + to_process = to_process, + "consuming HLS segments" + ); + + if to_process > 0 { + let base_seq = playlist_seq + skip as u64; + for (i, segment) in playlist.segments[skip..skip + to_process].iter().enumerate() { + self.push_segment(kind, track, segment, base_seq + i as u64).await?; + } + info!(?kind, consumed = to_process, "consumed HLS segments"); + } else { debug!(?kind, "no fresh HLS segments available"); } - Ok(consumed) + Ok(to_process) } async fn ensure_init_segment( @@ -369,11 +429,28 @@ impl Hls { let url = resolve_uri(&track.playlist, &segment.uri)?; let mut bytes = self.fetch_bytes(url).await?; + // Ensure the importer is initialized before processing fragments + // Use track.init_ready to avoid borrowing issues + if !track.init_ready { + // Try to ensure init segment is processed + let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; + self.ensure_init_segment(kind, track, &playlist).await?; + } + + // Get importer after ensuring init segment let importer = match kind { TrackKind::Video(index) => self.ensure_video_importer_for(index), TrackKind::Audio => self.ensure_audio_importer(), }; + // Final check after ensuring init segment + if !importer.is_initialized() { + return Err(anyhow::anyhow!( + "importer not initialized for {:?} after ensure_init_segment - init segment processing failed", + kind + )); + } + importer.decode(&mut bytes).context("failed to parse media segment")?; track.next_sequence = Some(sequence + 1); @@ -403,7 +480,12 @@ impl Hls { /// independent while still contributing to the same shared catalog. fn ensure_video_importer_for(&mut self, index: usize) -> &mut Fmp4 { while self.video_importers.len() <= index { - let importer = Fmp4::new(self.broadcast.clone()); + let importer = Fmp4::new( + self.broadcast.clone(), + Fmp4Config { + passthrough: self.passthrough, + }, + ); self.video_importers.push(importer); } @@ -412,8 +494,9 @@ impl Hls { /// Create or retrieve the fMP4 importer for the audio rendition. fn ensure_audio_importer(&mut self) -> &mut Fmp4 { + let passthrough = self.passthrough; self.audio_importer - .get_or_insert_with(|| Fmp4::new(self.broadcast.clone())) + .get_or_insert_with(|| Fmp4::new(self.broadcast.clone(), Fmp4Config { passthrough })) } #[cfg(test)] diff --git a/rs/hang/src/import/opus.rs b/rs/hang/src/import/opus.rs index ed5282ab7c..99583d1ea7 100644 --- a/rs/hang/src/import/opus.rs +++ b/rs/hang/src/import/opus.rs @@ -53,6 +53,8 @@ impl Opus { channel_count, bitrate: None, description: None, + container: hang::catalog::Container::Legacy, + min_buffer: None, }; tracing::debug!(name = ?track.name, ?config, "starting track"); From b8e3af0edc52cecf8e9043482dd7e84a2ca6af42 Mon Sep 17 00:00:00 2001 From: Pietro Zanella Date: Wed, 28 Jan 2026 11:46:29 +0100 Subject: [PATCH 008/590] feat: add hang-ui types (#866) --- js/hang-ui/package.json | 2 +- js/hang-ui/src/publish/index.tsx | 6 ++++++ js/hang-ui/src/watch/index.tsx | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/js/hang-ui/package.json b/js/hang-ui/package.json index baed1db0a8..0c0dbbacb8 100644 --- a/js/hang-ui/package.json +++ b/js/hang-ui/package.json @@ -32,8 +32,8 @@ "rimraf": "^6.0.1", "solid-element": "^1.9.1", "solid-js": "^1.9.10", - "unplugin-solid": "^1.0.0", "typescript": "^5.9.2", + "unplugin-solid": "^1.0.0", "vite": "^7.3.1", "vite-plugin-solid": "^2.11.10", "vitest": "^3.2.4" diff --git a/js/hang-ui/src/publish/index.tsx b/js/hang-ui/src/publish/index.tsx index af70b6b4e5..509ab33a80 100644 --- a/js/hang-ui/src/publish/index.tsx +++ b/js/hang-ui/src/publish/index.tsx @@ -19,3 +19,9 @@ customElement("hang-publish-ui", (_, { element }) => { ); }); + +declare global { + interface HTMLElementTagNameMap { + "hang-publish-ui": HTMLElement; + } +} diff --git a/js/hang-ui/src/watch/index.tsx b/js/hang-ui/src/watch/index.tsx index cb85313dc5..c33937e005 100644 --- a/js/hang-ui/src/watch/index.tsx +++ b/js/hang-ui/src/watch/index.tsx @@ -18,3 +18,9 @@ customElement("hang-watch-ui", (_, { element }) => { ); }); + +declare global { + interface HTMLElementTagNameMap { + "hang-watch-ui": HTMLElement; + } +} From 56edbf6f207aa76a5efb92526c93815476a324b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 08:52:07 -0300 Subject: [PATCH 009/590] Close audio groups immediately. (#870) --- rs/hang/src/import/aac.rs | 8 ++- rs/hang/src/import/fmp4.rs | 120 ++++++++++++++++++++++++++----------- rs/hang/src/import/opus.rs | 8 ++- rs/hang/src/model/frame.rs | 23 +++++++ rs/hang/src/model/track.rs | 14 +---- 5 files changed, 118 insertions(+), 55 deletions(-) diff --git a/rs/hang/src/import/aac.rs b/rs/hang/src/import/aac.rs index 844d458f33..745b4c9bd4 100644 --- a/rs/hang/src/import/aac.rs +++ b/rs/hang/src/import/aac.rs @@ -7,7 +7,7 @@ use moq_lite as moq; /// AAC decoder, initialized via AudioSpecificConfig (variable length from ESDS box). pub struct Aac { broadcast: hang::BroadcastProducer, - track: Option, + track: Option, zero: Option, } @@ -120,7 +120,7 @@ impl Aac { let audio = catalog.insert_audio(track.producer.info.name.clone(), config); audio.priority = 2; - self.track = Some(track.producer.into()); + self.track = Some(track.producer); Ok(()) } @@ -141,7 +141,9 @@ impl Aac { payload, }; - track.write(frame)?; + let mut group = track.append_group(); + frame.encode(&mut group)?; + group.close(); Ok(()) } diff --git a/rs/hang/src/import/fmp4.rs b/rs/hang/src/import/fmp4.rs index 4be9e9f731..9868972605 100644 --- a/rs/hang/src/import/fmp4.rs +++ b/rs/hang/src/import/fmp4.rs @@ -54,8 +54,16 @@ pub struct Fmp4 { moof_raw: Option, } +#[derive(PartialEq, Debug)] +enum TrackKind { + Video, + Audio, +} + struct Fmp4Track { - producer: hang::TrackProducer, + kind: TrackKind, + + producer: moq_lite::TrackProducer, // The current group being written, only used for passthrough mode. group: Option, @@ -71,8 +79,9 @@ struct Fmp4Track { } impl Fmp4Track { - fn new(producer: hang::TrackProducer) -> Self { + fn new(kind: TrackKind, producer: moq_lite::TrackProducer) -> Self { Self { + kind, producer, group: None, min_buffer: None, @@ -170,7 +179,7 @@ impl Fmp4 { let track_id = trak.tkhd.track_id; let handler = &trak.mdia.hdlr.handler; - let track = match handler.as_ref() { + let (kind, track) = match handler.as_ref() { b"vide" => { let config = self.init_video(trak)?; @@ -185,9 +194,7 @@ impl Fmp4 { // Record this track name created_video_tracks.push(track.name.clone()); - let track = track.produce(); - self.broadcast.insert_track(track.consumer); - hang::TrackProducer::new(track.producer) + (TrackKind::Video, track) } b"soun" => { let config = self.init_audio(trak)?; @@ -203,15 +210,15 @@ impl Fmp4 { // Record this track name created_audio_tracks.push(track.name.clone()); - let track = track.produce(); - self.broadcast.insert_track(track.consumer); - hang::TrackProducer::new(track.producer) + (TrackKind::Audio, track) } b"sbtl" => anyhow::bail!("subtitle tracks are not supported"), handler => anyhow::bail!("unknown track type: {:?}", handler), }; - self.tracks.insert(track_id, Fmp4Track::new(track)); + let track = self.broadcast.create_track(track); + + self.tracks.insert(track_id, Fmp4Track::new(kind, track)); } self.moov = Some(moov); @@ -526,21 +533,25 @@ impl Fmp4 { anyhow::bail!("invalid data offset"); } - let keyframe = if trak.mdia.hdlr.handler == b"vide".into() { - // https://chromium.googlesource.com/chromium/src/media/+/master/formats/mp4/track_run_iterator.cc#177 - let keyframe = (flags >> 24) & 0x3 == 0x2; // kSampleDependsOnNoOther - let non_sync = (flags >> 16) & 0x1 == 0x1; // kSampleIsNonSyncSample - - keyframe && !non_sync - } else { - // Audio frames are always keyframes. - true + let keyframe = match track.kind { + TrackKind::Video => { + // https://chromium.googlesource.com/chromium/src/media/+/master/formats/mp4/track_run_iterator.cc#177 + let keyframe = (flags >> 24) & 0x3 == 0x2; // kSampleDependsOnNoOther + let non_sync = (flags >> 16) & 0x1 == 0x1; // kSampleIsNonSyncSample + + keyframe && !non_sync + } + TrackKind::Audio => { + // Audio frames are always keyframes. + // TODO: Optionally bundle audio frames into groups to + true + } }; contains_keyframe |= keyframe; if !self.config.passthrough { - // TODO Avoid a copy if mp4-atom uses Bytes? + // TODO Avoid a copy if mp4-atom switches to using Bytes? let payload = Bytes::copy_from_slice(&mdat.data[offset..(offset + size)]); let frame = hang::Frame { @@ -548,7 +559,32 @@ impl Fmp4 { keyframe, payload: payload.into(), }; - track.producer.write(frame)?; + + // NOTE: We inline some of the hang::TrackProducer logic so we get more control over the group creation. + // This is completely optional; you can use hang::TrackProducer if you want. + let mut group = match track.kind { + // If this is a video keyframe, we create a new group. + TrackKind::Video if keyframe => { + if let Some(group) = track.group.take() { + // Close the previous group if it exists. + group.close(); + } + track.producer.append_group() + } + // If this is a video non-keyframe, we use the previous group. + TrackKind::Video => track.group.take().context("no keyframe at start")?, + TrackKind::Audio => { + // For audio, we send the entire fragment as a single group. + // This is an optimization to avoid a burst of tiny groups, possibly hitting MAX_STREAMS, when it doesn't really matter. + // ex. 2s of audio: 1 group instead of 90 groups. + // Technically, individual groups are better for skipping, but it's a moot point if fMP4 is introducing so much latency. + track.group.take().unwrap_or_else(|| track.producer.append_group()) + } + }; + + // Encode the frame and update the group. + frame.encode(&mut group)?; + track.group = Some(group); } if timestamp >= max_timestamp.unwrap_or(Timestamp::ZERO) { @@ -579,7 +615,7 @@ impl Fmp4 { group.close(); } - track.producer.inner.append_group() + track.producer.append_group() } else { track.group.take().context("no keyframe at start")? }; @@ -596,6 +632,11 @@ impl Fmp4 { frame.close(); track.group = Some(group); + } else if track.kind == TrackKind::Audio { + // Close the audio group if it exists. + if let Some(group) = track.group.take() { + group.close(); + } } if let (Some(min), Some(max), Some(min_duration)) = (min_timestamp, max_timestamp, track.min_duration) { @@ -610,17 +651,23 @@ impl Fmp4 { // Update the catalog with the new min_buffer let mut catalog = self.broadcast.catalog.lock(); - // We're lazy and don't keep track if this track is for audio or video, so just try to update both. - if let Some(video) = catalog.video.as_mut() - && let Some(config) = video.renditions.get_mut(&track.producer.info.name) - { - config.min_buffer = Some(min_buffer.convert()?); - } - - if let Some(audio) = catalog.audio.as_mut() - && let Some(config) = audio.renditions.get_mut(&track.producer.info.name) - { - config.min_buffer = Some(min_buffer.convert()?); + match track.kind { + TrackKind::Video => { + let video = catalog.video.as_mut().context("missing video")?; + let config = video + .renditions + .get_mut(&track.producer.info.name) + .context("missing video config")?; + config.min_buffer = Some(min_buffer.convert()?); + } + TrackKind::Audio => { + let audio = catalog.audio.as_mut().context("missing audio")?; + let config = audio + .renditions + .get_mut(&track.producer.info.name) + .context("missing audio config")?; + config.min_buffer = Some(min_buffer.convert()?); + } } } } @@ -635,9 +682,10 @@ impl Drop for Fmp4 { let mut catalog = self.broadcast.catalog.lock(); for track in self.tracks.values() { - // We're too lazy to keep track of if this track is for audio or video, so we just remove both. - catalog.remove_video(&track.producer.info.name); - catalog.remove_audio(&track.producer.info.name); + match track.kind { + TrackKind::Video => catalog.remove_video(&track.producer.info.name), + TrackKind::Audio => catalog.remove_audio(&track.producer.info.name), + } } } } diff --git a/rs/hang/src/import/opus.rs b/rs/hang/src/import/opus.rs index 99583d1ea7..ee0ea90d03 100644 --- a/rs/hang/src/import/opus.rs +++ b/rs/hang/src/import/opus.rs @@ -7,7 +7,7 @@ use moq_lite as moq; /// Opus decoder, initialized via a OpusHead. Does not support Ogg. pub struct Opus { broadcast: hang::BroadcastProducer, - track: Option, + track: Option, zero: Option, } @@ -66,7 +66,7 @@ impl Opus { let audio = catalog.insert_audio(track.producer.info.name.clone(), config); audio.priority = 2; - self.track = Some(track.producer.into()); + self.track = Some(track.producer); Ok(()) } @@ -87,7 +87,9 @@ impl Opus { payload, }; - track.write(frame)?; + let mut group = track.append_group(); + frame.encode(&mut group)?; + group.close(); Ok(()) } diff --git a/rs/hang/src/model/frame.rs b/rs/hang/src/model/frame.rs index 6432dc05cc..2a770306b7 100644 --- a/rs/hang/src/model/frame.rs +++ b/rs/hang/src/model/frame.rs @@ -1,6 +1,10 @@ +use bytes::{Buf, BytesMut}; use derive_more::Debug; pub use buf_list::BufList; +use moq_lite::{coding::Encode, lite}; + +use crate::Error; pub type Timestamp = moq_lite::Timescale<1_000_000>; @@ -32,3 +36,22 @@ pub struct Frame { #[debug("{} bytes", payload.num_bytes())] pub payload: BufList, } + +impl Frame { + /// Encode the frame to the given group. + pub fn encode(&self, group: &mut moq_lite::GroupProducer) -> Result<(), Error> { + let mut header = BytesMut::new(); + self.timestamp.encode(&mut header, lite::Version::Draft02); + + let size = header.len() + self.payload.remaining(); + + let mut chunked = group.create_frame(size.into()); + chunked.write_chunk(header.freeze()); + for chunk in &self.payload { + chunked.write_chunk(chunk.clone()); + } + chunked.close(); + + Ok(()) + } +} diff --git a/rs/hang/src/model/track.rs b/rs/hang/src/model/track.rs index 3fae96be0f..8e9e103ecd 100644 --- a/rs/hang/src/model/track.rs +++ b/rs/hang/src/model/track.rs @@ -5,8 +5,6 @@ use crate::Error; use crate::model::{Frame, GroupConsumer, Timestamp}; use futures::{StreamExt, stream::FuturesUnordered}; -use moq_lite::{coding::*, lite}; - /// A producer for media tracks. /// /// This wraps a `moq_lite::TrackProducer` and adds hang-specific functionality @@ -47,9 +45,6 @@ impl TrackProducer { pub fn write(&mut self, frame: Frame) -> Result<(), Error> { tracing::trace!(?frame, "write frame"); - let mut header = BytesMut::new(); - frame.timestamp.encode(&mut header, lite::Version::Draft02); - if frame.keyframe { if let Some(group) = self.group.take() { group.close(); @@ -73,14 +68,7 @@ impl TrackProducer { None => return Err(Error::MissingKeyframe), }; - let size = header.len() + frame.payload.remaining(); - - let mut chunked = group.create_frame(size.into()); - chunked.write_chunk(header.freeze()); - for chunk in frame.payload { - chunked.write_chunk(chunk); - } - chunked.close(); + frame.encode(&mut group)?; self.group.replace(group); From c7ebc4f2554083b6c7e31a8128c8087411dd75fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 12:13:46 -0300 Subject: [PATCH 010/590] Improve video rendering and sync (#871) --- .../stats/__tests__/providers/buffer.test.ts | 286 ------------------ .../components/stats/__tests__/utils.ts | 10 - .../components/stats/providers/buffer.ts | 25 +- .../src/shared/components/stats/types.ts | 2 - js/hang-ui/src/watch/context.tsx | 4 +- js/hang/src/watch/audio/source.ts | 35 +-- js/hang/src/watch/broadcast.ts | 9 +- js/hang/src/watch/element.ts | 5 +- js/hang/src/watch/sync.ts | 113 +++++++ js/hang/src/watch/video/source.ts | 164 ++++------ 10 files changed, 183 insertions(+), 470 deletions(-) delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts create mode 100644 js/hang/src/watch/sync.ts diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts deleted file mode 100644 index 8c3b25dd7b..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { Getter } from "@moq/signals"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BufferProvider } from "../../providers/buffer"; -import type { - BufferStatus, - ProviderContext, - ProviderProps, - SyncStatus, - VideoResolution, - VideoSource, - VideoStats, -} from "../../types"; - -describe("BufferProvider", () => { - let provider: BufferProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - - /** - * Helper to create a complete VideoSource mock with all required properties - */ - const createVideoSourceMock = (overrides?: { - display?: Partial>; - syncStatus?: Partial>; - bufferStatus?: Partial>; - latency?: Partial>; - stats?: Partial>; - }): VideoSource => { - const unsubscribe = vi.fn(); - const changedCallbacks: Map void> = new Map(); - - const createSignalMock = (key: string, peek: () => T) => { - const callbacks: Array<() => void> = []; - return { - peek, - changed: vi.fn((fn: () => void) => { - callbacks.push(fn); - // Store the callback so we can trigger it later if needed - changedCallbacks.set(key, () => { - callbacks.forEach((cb) => { - cb(); - }); - }); - return unsubscribe; - }), - subscribe: vi.fn(() => unsubscribe), - }; - }; - - const displaySignal = { - ...createSignalMock("display", () => ({ width: 1920, height: 1080 })), - ...overrides?.display, - }; - const syncStatusSignal = { - ...createSignalMock("syncStatus", () => ({ state: "ready" as const })), - ...overrides?.syncStatus, - }; - const bufferStatusSignal = { - ...createSignalMock("bufferStatus", () => ({ state: "filled" as const })), - ...overrides?.bufferStatus, - }; - const latencySignal = { - ...createSignalMock("latency", () => 100), - ...overrides?.latency, - }; - const statsSignal = { - ...createSignalMock("stats", () => ({ frameCount: 0, timestamp: 0, bytesReceived: 0 }) as VideoStats), - ...overrides?.stats, - }; - - return { - source: { - display: displaySignal, - syncStatus: syncStatusSignal, - bufferStatus: bufferStatusSignal, - latency: latencySignal, - stats: statsSignal, - }, - }; - }; - - beforeEach(() => { - setDisplayData = vi.fn(); - context = { setDisplayData }; - }); - - it("should display N/A when video source is not available", () => { - const props: ProviderProps = {}; - provider = new BufferProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should calculate buffer percentage from sync status", async () => { - const unsubscribe = vi.fn(); - const video = createVideoSourceMock({ - syncStatus: { - peek: () => ({ - state: "wait" as const, - bufferDuration: 500, - }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - bufferStatus: { - peek: () => ({ state: "empty" as const }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - latency: { - peek: () => 1000, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - }); - - const props: ProviderProps = { video }; - provider = new BufferProvider(props); - provider.setup(context); - - // Wait for the effect to run - await Promise.resolve(); - - expect(setDisplayData).toHaveBeenCalledWith("50%\n1000ms"); - }); - - it("should display 100% when buffer is filled", async () => { - const unsubscribe = vi.fn(); - const video = createVideoSourceMock({ - syncStatus: { - peek: () => undefined, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - bufferStatus: { - peek: () => ({ state: "filled" as const }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - latency: { - peek: () => 500, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - }); - - const props: ProviderProps = { video }; - provider = new BufferProvider(props); - provider.setup(context); - - // Wait for the effect to run - await Promise.resolve(); - - expect(setDisplayData).toHaveBeenCalledWith("100%\n500ms"); - }); - - it("should display 0% when buffer is empty", async () => { - const unsubscribe = vi.fn(); - const video = createVideoSourceMock({ - syncStatus: { - peek: () => undefined, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - bufferStatus: { - peek: () => ({ state: "empty" as const }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - latency: { - peek: () => 1000, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - }); - - const props: ProviderProps = { video }; - provider = new BufferProvider(props); - provider.setup(context); - - // Wait for the effect to run - await Promise.resolve(); - - expect(setDisplayData).toHaveBeenCalledWith("0%\n1000ms"); - }); - - it("should cap buffer percentage at 100%", async () => { - const unsubscribe = vi.fn(); - const video = createVideoSourceMock({ - syncStatus: { - peek: () => ({ - state: "wait" as const, - bufferDuration: 2000, - }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - bufferStatus: { - peek: () => ({ state: "empty" as const }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - latency: { - peek: () => 1000, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - }); - - const props: ProviderProps = { video }; - provider = new BufferProvider(props); - provider.setup(context); - - // Wait for the effect to run - await Promise.resolve(); - - expect(setDisplayData).toHaveBeenCalledWith("100%\n1000ms"); - }); - - it("should display N/A when latency is not available", async () => { - const unsubscribe = vi.fn(); - const video = createVideoSourceMock({ - syncStatus: { - peek: () => ({ - state: "wait" as const, - bufferDuration: 500, - }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - bufferStatus: { - peek: () => ({ state: "empty" as const }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - latency: { - peek: () => undefined, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - }); - - const props: ProviderProps = { video }; - provider = new BufferProvider(props); - provider.setup(context); - - // Wait for the effect to run - await Promise.resolve(); - - expect(setDisplayData).toHaveBeenCalledWith("0%\nN/A"); - }); - - it("should calculate percentage correctly with decimal values", async () => { - const unsubscribe = vi.fn(); - const video = createVideoSourceMock({ - syncStatus: { - peek: () => ({ - state: "wait" as const, - bufferDuration: 333, - }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - bufferStatus: { - peek: () => ({ state: "empty" as const }), - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - latency: { - peek: () => 1000, - changed: vi.fn(() => unsubscribe), - subscribe: vi.fn(() => unsubscribe), - }, - }); - - const props: ProviderProps = { video }; - provider = new BufferProvider(props); - provider.setup(context); - - // Wait for the effect to run - await Promise.resolve(); - - expect(setDisplayData).toHaveBeenCalledWith("33%\n1000ms"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts b/js/hang-ui/src/shared/components/stats/__tests__/utils.ts index 89b6321f9c..1df4024a21 100644 --- a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts +++ b/js/hang-ui/src/shared/components/stats/__tests__/utils.ts @@ -68,21 +68,11 @@ export const createMockProviderProps = (options?: MockProviderPropsOptions): Pro subscribe: vi.fn(() => vi.fn()), changed: vi.fn(() => vi.fn()), }, - syncStatus: { - peek: () => ({ state: "ready" as const }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, bufferStatus: { peek: () => ({ state: "filled" as const }), subscribe: vi.fn(() => vi.fn()), changed: vi.fn(() => vi.fn()), }, - latency: { - peek: () => 100, - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, stats: { peek: () => ({ frameCount: 60, diff --git a/js/hang-ui/src/shared/components/stats/providers/buffer.ts b/js/hang-ui/src/shared/components/stats/providers/buffer.ts index b1f29499e4..3d41e83b9f 100644 --- a/js/hang-ui/src/shared/components/stats/providers/buffer.ts +++ b/js/hang-ui/src/shared/components/stats/providers/buffer.ts @@ -1,19 +1,14 @@ -import type { Getter } from "@moq/signals"; -import type { BufferStatus, ProviderContext, SyncStatus } from "../types"; +import type { ProviderContext } from "../types"; import { BaseProvider } from "./base"; /** * Provider for buffer metrics (fill percentage, latency) */ export class BufferProvider extends BaseProvider { - /** Display context for updating metrics */ - private context: ProviderContext | undefined; - /** * Initialize buffer provider with signal subscriptions */ setup(context: ProviderContext): void { - this.context = context; const video = this.props.video; if (!video) { @@ -21,22 +16,6 @@ export class BufferProvider extends BaseProvider { return; } - this.signals.effect((effect) => { - const syncStatus = effect.get(video.source.syncStatus as Getter); - const bufferStatus = effect.get(video.source.bufferStatus as Getter); - const latency = effect.get(video.source.latency as Getter); - - const isLatencyValid = latency !== null && latency !== undefined && latency > 0; - const bufferPercentage = - syncStatus?.state === "wait" && syncStatus?.bufferDuration !== undefined && isLatencyValid - ? Math.min(100, Math.round((syncStatus.bufferDuration / latency) * 100)) - : bufferStatus?.state === "filled" - ? 100 - : 0; - - const parts = [`${bufferPercentage}%`, isLatencyValid ? `${latency}ms` : "N/A"]; - - this.context?.setDisplayData(parts.join("\n")); - }); + context.setDisplayData("TODO"); } } diff --git a/js/hang-ui/src/shared/components/stats/types.ts b/js/hang-ui/src/shared/components/stats/types.ts index bb73c4730f..3b6c5295dd 100644 --- a/js/hang-ui/src/shared/components/stats/types.ts +++ b/js/hang-ui/src/shared/components/stats/types.ts @@ -82,9 +82,7 @@ export type VideoStats = { export interface VideoSource { source: { display: Signal; - syncStatus: Signal; bufferStatus: Signal; - latency: Signal; stats: Signal; }; } diff --git a/js/hang-ui/src/watch/context.tsx b/js/hang-ui/src/watch/context.tsx index 68553a8570..1975bea6e0 100644 --- a/js/hang-ui/src/watch/context.tsx +++ b/js/hang-ui/src/watch/context.tsx @@ -146,10 +146,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp }); signals.effect((effect) => { - const syncStatus = effect.get(watch.video.source.syncStatus); const bufferStatus = effect.get(watch.video.source.bufferStatus); - const shouldShow = syncStatus.state === "wait" || bufferStatus.state === "empty"; - + const shouldShow = bufferStatus.state === "empty"; setBuffering(shouldShow); }); diff --git a/js/hang/src/watch/audio/source.ts b/js/hang/src/watch/audio/source.ts index d5a2597673..f85f982f2a 100644 --- a/js/hang/src/watch/audio/source.ts +++ b/js/hang/src/watch/audio/source.ts @@ -4,29 +4,19 @@ import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Catalog from "../../catalog"; import * as Container from "../../container"; import * as Hex from "../../util/hex"; -import { Latency } from "../../util/latency"; import * as libav from "../../util/libav"; import type * as Render from "./render"; -// We want some extra overhead to avoid starving the render worklet. -// The default Opus frame duration is 20ms. -// TODO: Put it in the catalog so we don't have to guess. -const JITTER_UNDERHEAD = 25 as Time.Milli; - export type SourceProps = { // Enable to download the audio track. enabled?: boolean | Signal; - - // Additional buffer in milliseconds on top of the catalog's minBuffer (default: 100ms). - // The effective latency = catalog.minBuffer + buffer - // Increase this if experiencing stuttering due to network jitter. - buffer?: Time.Milli | Signal; }; export interface AudioStats { bytesReceived: number; } +import type { Sync } from "../sync"; // Unfortunately, we need to use a Vite-exclusive import for now. import RenderWorklet from "./render-worklet.ts?worker&url"; @@ -53,12 +43,7 @@ export class Source { catalog = new Signal(undefined); config = new Signal(undefined); - // Additional buffer in milliseconds (on top of catalog's minBuffer). - buffer: Signal; - - // Computed latency = catalog.minBuffer + buffer - #latency: Latency; - readonly latency: Getter; + sync: Sync; // The name of the active rendition. active = new Signal(undefined); @@ -68,18 +53,12 @@ export class Source { constructor( broadcast: Getter, catalog: Getter, + sync: Sync, props?: SourceProps, ) { this.broadcast = broadcast; this.enabled = Signal.from(props?.enabled ?? false); - this.buffer = Signal.from(props?.buffer ?? (100 as Time.Milli)); - - // Compute effective latency from catalog.minBuffer + buffer - this.#latency = new Latency({ - buffer: this.buffer, - config: this.config, - }); - this.latency = this.#latency.combined; + this.sync = sync; this.#signals.effect((effect) => { const audio = effect.get(catalog)?.audio; @@ -141,7 +120,7 @@ export class Source { type: "init", rate: sampleRate, channels: channelCount, - latency: this.#latency.peek(), // TODO make it reactive + latency: this.sync.latency.peek(), // TODO make it reactive }; worklet.port.postMessage(init); @@ -189,8 +168,9 @@ export class Source { #runLegacyDecoder(effect: Effect, sub: Moq.Track, config: Catalog.AudioConfig): void { // Create consumer with slightly less latency than the render worklet to avoid underflowing. + // TODO include JITTER_UNDERHEAD const consumer = new Container.Legacy.Consumer(sub, { - latency: Math.max(this.#latency.peek() - JITTER_UNDERHEAD, 0) as Time.Milli, + latency: this.sync.latency, }); effect.cleanup(() => consumer.close()); @@ -323,7 +303,6 @@ export class Source { } close() { - this.#latency.close(); this.#signals.close(); } } diff --git a/js/hang/src/watch/broadcast.ts b/js/hang/src/watch/broadcast.ts index c8fe30a1ac..dde9ab491b 100644 --- a/js/hang/src/watch/broadcast.ts +++ b/js/hang/src/watch/broadcast.ts @@ -6,6 +6,7 @@ import * as Audio from "./audio"; import { Chat, type ChatProps } from "./chat"; import * as Location from "./location"; import { Preview, type PreviewProps } from "./preview"; +import { Sync, type SyncProps } from "./sync"; import * as User from "./user"; import * as Video from "./video"; @@ -28,6 +29,7 @@ export interface BroadcastProps { chat?: ChatProps; preview?: PreviewProps; user?: User.Props; + sync?: SyncProps; } // A broadcast that (optionally) reloads automatically when live/offline. @@ -45,6 +47,7 @@ export class Broadcast { chat: Chat; preview: Preview; user: User.Info; + sync: Sync; #broadcast = new Signal(undefined); @@ -62,8 +65,9 @@ export class Broadcast { this.path = Signal.from(props?.path); this.enabled = Signal.from(props?.enabled ?? false); this.reload = Signal.from(props?.reload ?? true); - this.audio = new Audio.Source(this.#broadcast, this.#catalog, props?.audio); - this.video = new Video.Source(this.#broadcast, this.#catalog, props?.video); + this.sync = new Sync(props?.sync); + this.audio = new Audio.Source(this.#broadcast, this.#catalog, this.sync, props?.audio); + this.video = new Video.Source(this.#broadcast, this.#catalog, this.sync, props?.video); this.location = new Location.Root(this.#broadcast, this.#catalog, props?.location); this.chat = new Chat(this.#broadcast, this.#catalog, props?.chat); this.preview = new Preview(this.#broadcast, this.#catalog, props?.preview); @@ -165,5 +169,6 @@ export class Broadcast { this.chat.close(); this.preview.close(); this.user.close(); + this.sync.close(); } } diff --git a/js/hang/src/watch/element.ts b/js/hang/src/watch/element.ts index fcf06d9fa6..6e6b64208a 100644 --- a/js/hang/src/watch/element.ts +++ b/js/hang/src/watch/element.ts @@ -81,10 +81,7 @@ export default class HangWatch extends HTMLElement { path: this.path, enabled: this.#enabled, reload: this.reload, - audio: { - buffer: this.buffer, - }, - video: { + sync: { buffer: this.buffer, }, }); diff --git a/js/hang/src/watch/sync.ts b/js/hang/src/watch/sync.ts new file mode 100644 index 0000000000..d0ad15b1bb --- /dev/null +++ b/js/hang/src/watch/sync.ts @@ -0,0 +1,113 @@ +import type { Time } from "@moq/lite"; +import { Effect, Signal } from "@moq/signals"; +import type * as Catalog from "../catalog"; + +export interface SyncProps { + buffer?: Time.Milli | Signal; + audio?: Catalog.AudioConfig | Signal; + video?: Catalog.VideoConfig | Signal; +} + +export class Sync { + // The earliest time we've received a frame, relative to its timestamp. + // This will keep being updated as we catch up to the live playhead then will be relatively static. + // TODO Update this when RTT changes + #reference?: Time.Milli; + + audio: Signal; + video: Signal; + + buffer: Signal; + + #latency = new Signal(0 as Time.Milli); + readonly latency: Signal = this.#latency; + + // A ghetto way to learn when the reference/latency changes. + // There's probably a way to use Effect, but lets keep it simple for now. + #update: Promise; + #resolve!: () => void; + + signals = new Effect(); + + constructor(props?: SyncProps) { + this.buffer = Signal.from(props?.buffer ?? (100 as Time.Milli)); + this.audio = Signal.from(props?.audio); + this.video = Signal.from(props?.video); + + this.#update = new Promise((resolve) => { + this.#resolve = resolve; + }); + + this.signals.effect(this.#runLatency.bind(this)); + } + + #runLatency(effect: Effect): void { + const buffer = effect.get(this.buffer); + + // Compute the latency based on the catalog's minBuffer and the user's buffer. + const video = effect.get(this.video); + + // Use minBuffer from catalog if available, otherwise estimate from framerate + let videoBuffer: number | undefined = video?.minBuffer; + if (videoBuffer === undefined && video?.framerate !== undefined && video.framerate > 0) { + // Estimate minBuffer as one frame duration if framerate is available + videoBuffer = 1000 / video.framerate; + } + videoBuffer ??= 0; + + const audio = effect.get(this.audio); + // TODO if there's no explicit buffer, estimate the audio buffer based on the sample rate and codec? + const audioBuffer = audio?.minBuffer ?? 0; + + const latency = (Math.max(videoBuffer, audioBuffer) + buffer) as Time.Milli; + this.#latency.set(latency); + + this.#resolve(); + + this.#update = new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + // Update the reference if this is the earliest frame we've seen, relative to its timestamp. + update(timestamp: Time.Milli): void { + const ref = (performance.now() - timestamp) as Time.Milli; + + if (this.#reference && ref >= this.#reference) { + return; + } + this.#reference = ref; + this.#resolve(); + + this.#update = new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + // Sleep until it's time to render this frame. + // + // Returns the amount of time we tried to sleep. + async wait(timestamp: Time.Milli): Promise { + if (!this.#reference) { + throw new Error("reference not set; call update() first"); + } + + for (;;) { + // Sleep until it's time to decode the next frame. + // NOTE: This function runs in parallel for each frame. + const now = performance.now(); + const ref = (now - timestamp) as Time.Milli; + + const sleep = this.#reference - ref + this.#latency.peek(); + if (sleep <= 0) return; + const wait = new Promise((resolve) => setTimeout(resolve, sleep)).then(() => true); + + const ok = await Promise.race([this.#update, wait]); + if (ok) return; + } + } + + close() { + this.signals.close(); + } +} diff --git a/js/hang/src/watch/video/source.ts b/js/hang/src/watch/video/source.ts index 8e0b951cc2..71fb81cca3 100644 --- a/js/hang/src/watch/video/source.ts +++ b/js/hang/src/watch/video/source.ts @@ -1,19 +1,14 @@ import type * as Moq from "@moq/lite"; -import type { Time } from "@moq/lite"; +import { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Catalog from "../../catalog"; import * as Container from "../../container"; import { PRIORITY } from "../../publish/priority"; import * as Hex from "../../util/hex"; -import { Latency } from "../../util/latency"; +import type { Sync } from "../sync"; export type SourceProps = { enabled?: boolean | Signal; - - // Additional buffer in milliseconds on top of the catalog's minBuffer (default: 100ms). - // The effective latency = catalog.minBuffer + buffer - // Increase this if experiencing stuttering due to network jitter. - buffer?: Time.Milli | Signal; }; export type Target = { @@ -33,25 +28,12 @@ type RequiredDecoderConfig = Omit; @@ -83,12 +65,12 @@ export class Source { // Expose the current frame to render as a signal frame = new Signal(undefined); - // Additional buffer in milliseconds (on top of catalog's minBuffer). - buffer: Signal; + // The timestamp of the current frame. + #timestamp = new Signal(undefined); + readonly timestamp: Getter = this.#timestamp; - // Computed latency = catalog.minBuffer + buffer - #latency: Latency; - readonly latency: Getter; + // Additional buffer in milliseconds (on top of catalog's minBuffer). + sync: Sync; // The display size of the video in pixels, ideally sourced from the catalog. display = new Signal<{ width: number; height: number } | undefined>(undefined); @@ -96,11 +78,7 @@ export class Source { // Whether to flip the video horizontally. flip = new Signal(undefined); - // Used to convert PTS to wall time. - #reference: DOMHighResTimeStamp | undefined; - bufferStatus = new Signal({ state: "empty" }); - syncStatus = new Signal({ state: "ready" }); #stats = new Signal(undefined); readonly stats: Getter = this.#stats; @@ -110,18 +88,12 @@ export class Source { constructor( broadcast: Signal, catalog: Signal, + sync: Sync, props?: SourceProps, ) { this.broadcast = broadcast; - this.buffer = Signal.from(props?.buffer ?? (100 as Time.Milli)); this.enabled = Signal.from(props?.enabled ?? false); - - // Compute effective latency from catalog.minBuffer + buffer - this.#latency = new Latency({ - buffer: this.buffer, - config: this.#config, - }); - this.latency = this.#latency.combined; + this.sync = sync; this.#signals.effect((effect) => { const c = effect.get(catalog)?.video; @@ -227,83 +199,43 @@ export class Source { const sub = broadcast.subscribe(name, PRIORITY.video); // TODO use priority from catalog effect.cleanup(() => sub.close()); - // We need a queue because VideoDecoder doesn't block on a Promise returned by output. - // NOTE: We will drain this queue almost immediately, so the highWaterMark is just a safety net. - const queue = new TransformStream( - undefined, - { highWaterMark: MAX_BFRAMES }, - { highWaterMark: MAX_BFRAMES }, - ); - - const writer = queue.writable.getWriter(); - effect.cleanup(() => writer.close()); - - const reader = queue.readable.getReader(); - effect.cleanup(async () => { - // Drain any remaining frames in the queue to prevent memory leaks - try { - let result = await reader.read(); - while (!result.done) { - result.value?.close(); - result = await reader.read(); - } - } catch (error) { - console.error("Error during frame draining:", error); - } finally { - await reader.cancel(); + effect.cleanup(() => { + if (this.#active === effect) { + this.#timestamp.set(undefined); } }); const decoder = new VideoDecoder({ output: async (frame: VideoFrame) => { - // Insert into a queue so we can perform ordered sleeps. - // If this were to block, I believe WritableStream is still ordered. try { - await writer.write(frame); - } catch { - frame.close(); - } - }, - // TODO bubble up error - error: (error) => { - console.error(error); - effect.close(); - }, - }); - effect.cleanup(() => decoder.close()); + const timestamp = Time.Milli.fromMicro(frame.timestamp as Time.Micro); - // Output processing - same for both container types - effect.spawn(async () => { - for (;;) { - const { value: frame } = await reader.read(); - if (!frame) break; - - // Sleep until it's time to decode the next frame. - const ref = performance.now() - frame.timestamp / 1000; - - let sleep = 0; - if (!this.#reference || ref < this.#reference) { - this.#reference = ref; - // Don't sleep so we immediately render this frame. - } else { - sleep = this.#reference - ref + this.#latency.peek(); - } + if (timestamp < (this.#timestamp.peek() ?? 0)) { + // Late frame, don't render it. + return; + } - if (sleep > MIN_SYNC_WAIT_MS) { - this.syncStatus.set({ state: "wait", bufferDuration: sleep }); - } + if (this.frame.peek() === undefined) { + // Render something while we wait for the sync to catch up. + this.frame.set(frame.clone()); + } - if (sleep > 0) { - // NOTE: WebCodecs doesn't block on output promises (I think?), so these sleeps will occur concurrently. - // TODO: This cause the `syncStatus` to be racey especially - await new Promise((resolve) => setTimeout(resolve, sleep)); - } + const wait = this.sync.wait(timestamp).then(() => true); + const ok = await Promise.race([wait, effect.cancel]); + if (!ok) return; - if (sleep > MIN_SYNC_WAIT_MS) { - // Include how long we slept if it was above the threshold. - this.syncStatus.set({ state: "ready", bufferDuration: sleep }); - } else { - this.syncStatus.set({ state: "ready" }); + if (timestamp < (this.#timestamp.peek() ?? 0)) { + // Late frame, don't render it. + // NOTE: This can happen when the ref is updated, such as on playback start. + return; + } + + this.#timestamp.set(timestamp); + + this.frame.update((prev) => { + prev?.close(); + return frame.clone(); // avoid closing the frame here + }); // If the track switch was pending, complete it now. if (this.#pending === effect) { @@ -312,14 +244,17 @@ export class Source { this.#pending = undefined; effect.set(this.active, name); } + } finally { + frame.close(); } - - this.frame.update((prev) => { - prev?.close(); - return frame; - }); - } + }, + // TODO bubble up error + error: (error) => { + console.error(error); + effect.close(); + }, }); + effect.cleanup(() => decoder.close()); // Input processing - depends on container type if (config.container.kind === "cmaf") { @@ -332,7 +267,7 @@ export class Source { #runLegacyTrack(effect: Effect, sub: Moq.Track, config: RequiredDecoderConfig, decoder: VideoDecoder): void { // Create consumer that reorders groups/frames up to the provided latency. const consumer = new Container.Legacy.Consumer(sub, { - latency: this.#latency.combined, + latency: this.sync.latency, }); effect.cleanup(() => consumer.close()); @@ -349,6 +284,9 @@ export class Source { const next = await Promise.race([consumer.decode(), effect.cancel]); if (!next) break; + // Mark that we received this frame right now. + this.sync.update(Time.Milli.fromMicro(next.timestamp as Time.Micro)); + const chunk = new EncodedVideoChunk({ type: next.keyframe ? "key" : "delta", data: next.data, @@ -404,6 +342,9 @@ export class Source { timestamp: sample.timestamp, }); + // Mark that we received this frame right now. + this.sync.update(Time.Milli.fromMicro(sample.timestamp as Time.Micro)); + // Track stats this.#stats.update((current) => ({ frameCount: (current?.frameCount ?? 0) + 1, @@ -498,7 +439,6 @@ export class Source { return undefined; }); - this.#latency.close(); this.#signals.close(); } } From bf759bd7795036182dfc549175b673ae0e15008e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 13:30:37 -0300 Subject: [PATCH 011/590] moq-relay: Skip jwt query param when no token configured (#873) Co-authored-by: Claude Opus 4.5 --- rs/moq-relay/src/cluster.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 09650902a8..ce9498ae87 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -231,7 +231,10 @@ impl Cluster { #[tracing::instrument("remote", skip_all, err, fields(%node))] async fn run_remote(mut self, node: &str, token: String, origin: BroadcastConsumer) -> anyhow::Result<()> { - let url = Url::parse(&format!("https://{node}/?jwt={token}"))?; + let url = match token.is_empty() { + true => Url::parse(&format!("https://{node}/"))?, + false => Url::parse(&format!("https://{node}/?jwt={token}"))?, + }; let mut backoff = 1; loop { From 61d76cf4294ce15ea8fabdcb9065cbf6e0ad83ae Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 13:31:22 -0300 Subject: [PATCH 012/590] moq-lite: Close session on drop (#872) Co-authored-by: Claude Opus 4.5 --- rs/moq-lite/src/session.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rs/moq-lite/src/session.rs b/rs/moq-lite/src/session.rs index 4f15e851b0..648b693718 100644 --- a/rs/moq-lite/src/session.rs +++ b/rs/moq-lite/src/session.rs @@ -43,6 +43,12 @@ impl Session { } } +impl Drop for Session { + fn drop(&mut self) { + self.session.close(Error::Cancel.to_code(), "dropped"); + } +} + // We use a wrapper type that is dyn-compatible to remove the generic bounds from Session. trait SessionInner: Send + Sync { fn close(&self, code: u32, reason: &str); From c7f47ffa450e3e1b5c18b50958b930cbdf8dcfd8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 15:01:52 -0300 Subject: [PATCH 013/590] moq-lite: Remove Produce struct and simplify API (#875) Co-authored-by: Claude Opus 4.5 --- rs/hang-cli/src/client.rs | 4 +- rs/hang-cli/src/server.rs | 4 +- rs/hang/examples/video.rs | 11 +- rs/hang/src/catalog/root.rs | 9 +- rs/hang/src/import/aac.rs | 6 +- rs/hang/src/import/avc3.rs | 4 +- rs/hang/src/import/hev1.rs | 4 +- rs/hang/src/import/hls.rs | 2 +- rs/hang/src/import/opus.rs | 6 +- rs/hang/src/model/broadcast.rs | 4 +- rs/moq-clock/src/main.rs | 10 +- rs/moq-lite/src/ietf/publisher.rs | 2 +- rs/moq-lite/src/ietf/subscriber.rs | 10 +- rs/moq-lite/src/lite/publisher.rs | 2 +- rs/moq-lite/src/lite/subscriber.rs | 6 +- rs/moq-lite/src/model/broadcast.rs | 50 ++-- rs/moq-lite/src/model/frame.rs | 10 +- rs/moq-lite/src/model/group.rs | 14 +- rs/moq-lite/src/model/mod.rs | 2 - rs/moq-lite/src/model/origin.rs | 396 +++++++++++++---------------- rs/moq-lite/src/model/produce.rs | 10 - rs/moq-lite/src/model/track.rs | 14 +- rs/moq-native/examples/chat.rs | 9 +- rs/moq-relay/src/cluster.rs | 31 ++- 24 files changed, 280 insertions(+), 340 deletions(-) delete mode 100644 rs/moq-lite/src/model/produce.rs diff --git a/rs/hang-cli/src/client.rs b/rs/hang-cli/src/client.rs index fa14f569e8..b815a1e465 100644 --- a/rs/hang-cli/src/client.rs +++ b/rs/hang-cli/src/client.rs @@ -6,12 +6,12 @@ use url::Url; pub async fn run_client(client: moq_native::Client, url: Url, name: String, publish: Publish) -> anyhow::Result<()> { // Create an origin producer to publish to the broadcast. let origin = moq_lite::Origin::produce(); - origin.producer.publish_broadcast(&name, publish.consume()); + origin.publish_broadcast(&name, publish.consume()); tracing::info!(%url, %name, "connecting"); // Establish the connection, not providing a subscriber. - let session = client.with_publish(origin.consumer).connect(url).await?; + let session = client.with_publish(origin.consume()).connect(url).await?; #[cfg(unix)] // Notify systemd that we're ready. diff --git a/rs/hang-cli/src/server.rs b/rs/hang-cli/src/server.rs index 02ce9def7f..0324595130 100644 --- a/rs/hang-cli/src/server.rs +++ b/rs/hang-cli/src/server.rs @@ -40,10 +40,10 @@ async fn run_session( ) -> anyhow::Result<()> { // Create an origin producer to publish to the broadcast. let origin = moq_lite::Origin::produce(); - origin.producer.publish_broadcast(&name, consumer); + origin.publish_broadcast(&name, consumer); // Blindly accept the session (WebTransport or QUIC), regardless of the URL. - let session = session.with_publish(origin.consumer).accept().await?; + let session = session.with_publish(origin.consume()).accept().await?; tracing::info!(id, "accepted session"); diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index 1b169d6052..d566407a4a 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -8,13 +8,14 @@ async fn main() -> anyhow::Result<()> { // Create an origin that we can publish to and the session can consume from. let origin = moq_lite::Origin::produce(); + let consumer = origin.consume(); // Run the broadcast production and the session in parallel. // This is a simple example of how you can concurrently run multiple tasks. // tokio::spawn works too. tokio::select! { - res = run_broadcast(origin.producer) => res, - res = run_session(origin.consumer) => res, + res = run_broadcast(origin) => res, + res = run_session(consumer) => res, } } @@ -93,7 +94,7 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> hang::TrackProdu .produce(); // Publish the catalog track to the broadcast. - broadcast.insert_track(catalog.consumer.track); + broadcast.insert_track(catalog.track.consume()); // Actually create the media track now. let track = broadcast.create_track(video_track); @@ -106,11 +107,11 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> hang::TrackProdu async fn run_broadcast(origin: moq_lite::OriginProducer) -> anyhow::Result<()> { // Create and publish a broadcast to the origin. let mut broadcast = moq_lite::Broadcast::produce(); - let mut track = create_track(&mut broadcast.producer); + let mut track = create_track(&mut broadcast); // NOTE: The path is empty because we're using the URL to scope the broadcast. // OPTIONAL: We publish after inserting the tracks just to avoid a nearly impossible race condition. - origin.publish_broadcast("", broadcast.consumer); + origin.publish_broadcast("", broadcast.consume()); // Not real frames of course. track.write(hang::Frame { diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 28eb3c0d15..5c97e4aa76 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize}; use crate::Result; use crate::catalog::{Audio, AudioConfig, Chat, User, Video, VideoConfig}; -use moq_lite::Produce; /// A catalog track, created by a broadcaster to describe the tracks available in a broadcast. #[serde_with::serde_as] @@ -83,13 +82,9 @@ impl Catalog { } /// Produce a catalog track that describes the available media tracks. - pub fn produce(self) -> Produce { + pub fn produce(self) -> CatalogProducer { let track = Catalog::default_track().produce(); - - Produce { - producer: CatalogProducer::new(track.producer, self), - consumer: track.consumer.into(), - } + CatalogProducer::new(track, self) } pub fn default_track() -> moq_lite::Track { diff --git a/rs/hang/src/import/aac.rs b/rs/hang/src/import/aac.rs index 745b4c9bd4..8d012966d9 100644 --- a/rs/hang/src/import/aac.rs +++ b/rs/hang/src/import/aac.rs @@ -114,13 +114,13 @@ impl Aac { tracing::debug!(name = ?track.name, ?config, "starting track"); let track = track.produce(); - self.broadcast.insert_track(track.consumer); + self.broadcast.insert_track(track.consume()); let mut catalog = self.broadcast.catalog.lock(); - let audio = catalog.insert_audio(track.producer.info.name.clone(), config); + let audio = catalog.insert_audio(track.info.name.clone(), config); audio.priority = 2; - self.track = Some(track.producer); + self.track = Some(track); Ok(()) } diff --git a/rs/hang/src/import/avc3.rs b/rs/hang/src/import/avc3.rs index af9171c134..5b0d19ef19 100644 --- a/rs/hang/src/import/avc3.rs +++ b/rs/hang/src/import/avc3.rs @@ -92,10 +92,10 @@ impl Avc3 { } let track = track.produce(); - self.broadcast.insert_track(track.consumer); + self.broadcast.insert_track(track.consume()); self.config = Some(config); - self.track = Some(track.producer.into()); + self.track = Some(track.into()); Ok(()) } diff --git a/rs/hang/src/import/hev1.rs b/rs/hang/src/import/hev1.rs index 4d203889b6..6a0471a44a 100644 --- a/rs/hang/src/import/hev1.rs +++ b/rs/hang/src/import/hev1.rs @@ -91,10 +91,10 @@ impl Hev1 { } let track = track.produce(); - self.broadcast.insert_track(track.consumer); + self.broadcast.insert_track(track.consume()); self.config = Some(config); - self.track = Some(track.producer.into()); + self.track = Some(track.into()); Ok(()) } diff --git a/rs/hang/src/import/hls.rs b/rs/hang/src/import/hls.rs index 284bdf39ab..1779ed3763 100644 --- a/rs/hang/src/import/hls.rs +++ b/rs/hang/src/import/hls.rs @@ -626,7 +626,7 @@ mod tests { #[test] fn hls_ingest_starts_without_importers() { - let broadcast = moq_lite::Broadcast::produce().producer.into(); + let broadcast = moq_lite::Broadcast::produce().into(); let url = "https://example.com/master.m3u8".to_string(); let cfg = HlsConfig::new(url); let hls = Hls::new(broadcast, cfg).unwrap(); diff --git a/rs/hang/src/import/opus.rs b/rs/hang/src/import/opus.rs index ee0ea90d03..d01ba0a117 100644 --- a/rs/hang/src/import/opus.rs +++ b/rs/hang/src/import/opus.rs @@ -60,13 +60,13 @@ impl Opus { tracing::debug!(name = ?track.name, ?config, "starting track"); let track = track.produce(); - self.broadcast.insert_track(track.consumer); + self.broadcast.insert_track(track.consume()); let mut catalog = self.broadcast.catalog.lock(); - let audio = catalog.insert_audio(track.producer.info.name.clone(), config); + let audio = catalog.insert_audio(track.info.name.clone(), config); audio.priority = 2; - self.track = Some(track.producer); + self.track = Some(track); Ok(()) } diff --git a/rs/hang/src/model/broadcast.rs b/rs/hang/src/model/broadcast.rs index ca6e671c75..c2407587d3 100644 --- a/rs/hang/src/model/broadcast.rs +++ b/rs/hang/src/model/broadcast.rs @@ -19,11 +19,11 @@ pub struct BroadcastProducer { impl BroadcastProducer { pub fn new(mut inner: moq_lite::BroadcastProducer) -> Self { let catalog = Catalog::default().produce(); - inner.insert_track(catalog.consumer.track); + inner.insert_track(catalog.track.consume()); Self { inner, - catalog: catalog.producer, + catalog, track_id: Default::default(), } } diff --git a/rs/moq-clock/src/main.rs b/rs/moq-clock/src/main.rs index a1c14eb614..fb8583045b 100644 --- a/rs/moq-clock/src/main.rs +++ b/rs/moq-clock/src/main.rs @@ -63,12 +63,12 @@ async fn main() -> anyhow::Result<()> { match config.role { Command::Publish => { let mut broadcast = moq_lite::Broadcast::produce(); - let track = broadcast.producer.create_track(track); + let track = broadcast.create_track(track); let clock = clock::Publisher::new(track); - origin.producer.publish_broadcast(&config.broadcast, broadcast.consumer); + origin.publish_broadcast(&config.broadcast, broadcast.consume()); - let session = client.with_publish(origin.consumer).connect(config.url).await?; + let session = client.with_publish(origin.consume()).connect(config.url).await?; tokio::select! { res = session.closed() => res.context("session closed"), @@ -76,7 +76,7 @@ async fn main() -> anyhow::Result<()> { } } Command::Subscribe => { - let session = client.with_consume(origin.producer).connect(config.url).await?; + let session = client.with_consume(origin.clone()).connect(config.url).await?; // NOTE: We could just call `session.consume_broadcast(&config.broadcast)` instead, // However that won't work with IETF MoQ and the current OriginConsumer API the moment. @@ -86,7 +86,7 @@ async fn main() -> anyhow::Result<()> { let path: moq_lite::Path<'_> = config.broadcast.into(); let mut origin = origin - .consumer + .consume() .consume_only(&[path]) .context("not allowed to consume broadcast")?; diff --git a/rs/moq-lite/src/ietf/publisher.rs b/rs/moq-lite/src/ietf/publisher.rs index 9c0224cc9c..e3e08c79e1 100644 --- a/rs/moq-lite/src/ietf/publisher.rs +++ b/rs/moq-lite/src/ietf/publisher.rs @@ -26,7 +26,7 @@ pub(super) struct Publisher { impl Publisher { pub fn new(session: S, origin: Option, control: Control, version: Version) -> Self { // Default to a dummy origin that is immediately closed. - let origin = origin.unwrap_or_else(|| Origin::produce().consumer); + let origin = origin.unwrap_or_else(|| Origin::produce().consume()); Self { session, origin, diff --git a/rs/moq-lite/src/ietf/subscriber.rs b/rs/moq-lite/src/ietf/subscriber.rs index b8ace558da..3d729a6478 100644 --- a/rs/moq-lite/src/ietf/subscriber.rs +++ b/rs/moq-lite/src/ietf/subscriber.rs @@ -88,12 +88,12 @@ impl Subscriber { } Entry::Vacant(entry) => { let broadcast = Broadcast::produce(); - origin.publish_broadcast(path.clone(), broadcast.consumer); + origin.publish_broadcast(path.clone(), broadcast.consume()); entry.insert(BroadcastState { - producer: broadcast.producer.clone(), + producer: broadcast.clone(), count: 1, }); - broadcast.producer + broadcast } }; @@ -451,7 +451,7 @@ impl Subscriber { match state.subscribes.entry(request_id) { Entry::Vacant(entry) => { entry.insert(TrackState { - producer: track.producer, + producer: track.clone(), alias: Some(msg.track_alias), }); } @@ -466,7 +466,7 @@ impl Subscriber { // NOTE: This is debated in the IETF draft, but is significantly easier to implement. let mut broadcast = self.start_announce(msg.track_namespace.to_owned())?; - let exists = broadcast.insert_track(track.consumer); + let exists = broadcast.insert_track(track.consume()); if exists { tracing::warn!(track = %msg.track_name, "track already exists, replacing it"); } diff --git a/rs/moq-lite/src/lite/publisher.rs b/rs/moq-lite/src/lite/publisher.rs index d32b3aca82..c90ef99f57 100644 --- a/rs/moq-lite/src/lite/publisher.rs +++ b/rs/moq-lite/src/lite/publisher.rs @@ -22,7 +22,7 @@ pub(super) struct Publisher { impl Publisher { pub fn new(session: S, origin: Option, version: Version) -> Self { // Default to a dummy origin that is immediately closed. - let origin = origin.unwrap_or_else(|| Origin::produce().consumer); + let origin = origin.unwrap_or_else(|| Origin::produce().consume()); Self { session, origin, diff --git a/rs/moq-lite/src/lite/subscriber.rs b/rs/moq-lite/src/lite/subscriber.rs index 9054ca9bde..41bbd3007a 100644 --- a/rs/moq-lite/src/lite/subscriber.rs +++ b/rs/moq-lite/src/lite/subscriber.rs @@ -134,16 +134,16 @@ impl Subscriber { // Make sure the peer doesn't double announce. match producers.entry(path.to_owned()) { Entry::Occupied(_) => return Err(Error::Duplicate), - Entry::Vacant(entry) => entry.insert(broadcast.producer.clone()), + Entry::Vacant(entry) => entry.insert(broadcast.clone()), }; // Run the broadcast in the background until all consumers are dropped. self.origin .as_mut() .unwrap() - .publish_broadcast(path.clone(), broadcast.consumer); + .publish_broadcast(path.clone(), broadcast.consume()); - web_async::spawn(self.clone().run_broadcast(path, broadcast.producer)); + web_async::spawn(self.clone().run_broadcast(path, broadcast)); Ok(()) } diff --git a/rs/moq-lite/src/model/broadcast.rs b/rs/moq-lite/src/model/broadcast.rs index c8affe410d..e8eb622874 100644 --- a/rs/moq-lite/src/model/broadcast.rs +++ b/rs/moq-lite/src/model/broadcast.rs @@ -7,7 +7,7 @@ use std::{ }, }; -use crate::{Error, Produce, TrackConsumer, TrackProducer}; +use crate::{Error, TrackConsumer, TrackProducer}; use tokio::sync::watch; use web_async::Lock; @@ -32,10 +32,8 @@ pub struct Broadcast { } impl Broadcast { - pub fn produce() -> Produce { - let producer = BroadcastProducer::new(); - let consumer = producer.consume(); - Produce { producer, consumer } + pub fn produce() -> BroadcastProducer { + BroadcastProducer::new() } } @@ -76,9 +74,9 @@ impl BroadcastProducer { /// Produce a new track and insert it into the broadcast. pub fn create_track(&mut self, track: Track) -> TrackProducer { - let track = track.clone().produce(); - self.insert_track(track.consumer); - track.producer + let track = track.produce(); + self.insert_track(track.consume()); + track } /// Insert a track into the lookup, returning true if it was unique. @@ -205,9 +203,8 @@ impl BroadcastConsumer { } // Otherwise we have never seen this track before and need to create a new producer. - let track = track.clone().produce(); - let producer = track.producer; - let consumer = track.consumer; + let producer = track.clone().produce(); + let consumer = producer.consume(); // Insert the producer into the lookup so we will deduplicate requests. // This is not a subscriber so it doesn't count towards "used" subscribers. @@ -271,22 +268,22 @@ mod test { let mut track1 = Track::new("track1").produce(); // Make sure we can insert before a consumer is created. - producer.insert_track(track1.consumer); - track1.producer.append_group(); + producer.insert_track(track1.consume()); + track1.append_group(); let consumer = producer.consume(); - let mut track1_sub = consumer.subscribe_track(&track1.producer.info); + let mut track1_sub = consumer.subscribe_track(&track1.info); track1_sub.assert_group(); let mut track2 = Track::new("track2").produce(); - producer.insert_track(track2.consumer); + producer.insert_track(track2.consume()); let consumer2 = producer.consume(); - let mut track2_consumer = consumer2.subscribe_track(&track2.producer.info); + let mut track2_consumer = consumer2.subscribe_track(&track2.info); track2_consumer.assert_no_group(); - track2.producer.append_group(); + track2.append_group(); track2_consumer.assert_group(); } @@ -334,10 +331,10 @@ mod test { // Create a new track and insert it into the broadcast. let mut track1 = Track::new("track1").produce(); - track1.producer.append_group(); - producer.insert_track(track1.consumer); + track1.append_group(); + producer.insert_track(track1.consume()); - let mut track1c = consumer.subscribe_track(&track1.producer.info); + let mut track1c = consumer.subscribe_track(&track1.info); let track2 = consumer.subscribe_track(&Track::new("track2")); drop(producer); @@ -352,7 +349,7 @@ mod test { track1c.assert_not_closed(); // TODO: We should probably cascade the closed state. - drop(track1.producer); + drop(track1); track1c.assert_closed(); } @@ -408,12 +405,13 @@ mod test { #[tokio::test] async fn requested_unused() { let mut broadcast = Broadcast::produce(); + let consumer = broadcast.consume(); // Subscribe to a track that doesn't exist - this creates a request - let consumer1 = broadcast.consumer.subscribe_track(&Track::new("unknown_track")); + let consumer1 = consumer.subscribe_track(&Track::new("unknown_track")); // Get the requested track producer - let producer1 = broadcast.producer.assert_request(); + let producer1 = broadcast.assert_request(); // The track producer should NOT be unused yet because there's a consumer assert!( @@ -422,7 +420,7 @@ mod test { ); // Making a new consumer will keep the producer alive - let consumer2 = broadcast.consumer.subscribe_track(&Track::new("unknown_track")); + let consumer2 = consumer.subscribe_track(&Track::new("unknown_track")); consumer2.assert_is_clone(&consumer1); // Drop the consumer subscription @@ -449,8 +447,8 @@ mod test { tokio::time::sleep(std::time::Duration::from_millis(1)).await; // Now the cleanup task should have run and we can subscribe again to the unknown track. - let consumer3 = broadcast.consumer.subscribe_track(&Track::new("unknown_track")); - let producer2 = broadcast.producer.assert_request(); + let consumer3 = consumer.subscribe_track(&Track::new("unknown_track")); + let producer2 = broadcast.assert_request(); // Drop the consumer, now the producer should be unused drop(consumer3); diff --git a/rs/moq-lite/src/model/frame.rs b/rs/moq-lite/src/model/frame.rs index 23c2b29f9b..140f3e61cf 100644 --- a/rs/moq-lite/src/model/frame.rs +++ b/rs/moq-lite/src/model/frame.rs @@ -3,7 +3,7 @@ use std::future::Future; use bytes::{Bytes, BytesMut}; use tokio::sync::watch; -use crate::{Error, Produce, Result}; +use crate::{Error, Result}; /// A chunk of data with an upfront size. #[derive(Clone, Debug)] @@ -13,11 +13,9 @@ pub struct Frame { } impl Frame { - /// Create a new producer and consumer for the frame. - pub fn produce(self) -> Produce { - let producer = FrameProducer::new(self); - let consumer = producer.consume(); - Produce { producer, consumer } + /// Create a new producer for the frame. + pub fn produce(self) -> FrameProducer { + FrameProducer::new(self) } } diff --git a/rs/moq-lite/src/model/group.rs b/rs/moq-lite/src/model/group.rs index 1bd440769d..62aa301b84 100644 --- a/rs/moq-lite/src/model/group.rs +++ b/rs/moq-lite/src/model/group.rs @@ -12,7 +12,7 @@ use std::future::Future; use bytes::Bytes; use tokio::sync::watch; -use crate::{Error, Produce, Result}; +use crate::{Error, Result}; use super::{Frame, FrameConsumer, FrameProducer}; @@ -26,10 +26,8 @@ pub struct Group { } impl Group { - pub fn produce(self) -> Produce { - let producer = GroupProducer::new(self); - let consumer = producer.consume(); - Produce { producer, consumer } + pub fn produce(self) -> GroupProducer { + GroupProducer::new(self) } } @@ -106,9 +104,9 @@ impl GroupProducer { /// Create a frame with an upfront size pub fn create_frame(&mut self, info: Frame) -> FrameProducer { - let frame = Frame::produce(info); - self.append_frame(frame.consumer); - frame.producer + let frame = info.produce(); + self.append_frame(frame.consume()); + frame } /// Append a frame to the group. diff --git a/rs/moq-lite/src/model/mod.rs b/rs/moq-lite/src/model/mod.rs index c3bd0f5fcf..460a300dfd 100644 --- a/rs/moq-lite/src/model/mod.rs +++ b/rs/moq-lite/src/model/mod.rs @@ -2,7 +2,6 @@ mod broadcast; mod frame; mod group; mod origin; -mod produce; mod time; mod track; @@ -10,6 +9,5 @@ pub use broadcast::*; pub use frame::*; pub use group::*; pub use origin::*; -pub use produce::*; pub use time::*; pub use track::*; diff --git a/rs/moq-lite/src/model/origin.rs b/rs/moq-lite/src/model/origin.rs index f0dff3613c..630f46f99f 100644 --- a/rs/moq-lite/src/model/origin.rs +++ b/rs/moq-lite/src/model/origin.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc; use web_async::Lock; use super::BroadcastConsumer; -use crate::{AsPath, Broadcast, BroadcastProducer, Path, PathOwned, Produce}; +use crate::{AsPath, Broadcast, BroadcastProducer, Path, PathOwned}; static NEXT_CONSUMER_ID: AtomicU64 = AtomicU64::new(0); @@ -334,10 +334,8 @@ pub type OriginAnnounce = (PathOwned, Option); pub struct Origin {} impl Origin { - pub fn produce() -> Produce { - let producer = OriginProducer::default(); - let consumer = producer.consume(); - Produce { producer, consumer } + pub fn produce() -> OriginProducer { + OriginProducer::default() } } @@ -359,8 +357,7 @@ impl OriginProducer { /// Returns [None] if the broadcast is not allowed to be published. pub fn create_broadcast(&self, path: impl AsPath) -> Option { let broadcast = Broadcast::produce(); - self.publish_broadcast(path, broadcast.consumer) - .then_some(broadcast.producer) + self.publish_broadcast(path, broadcast.consume()).then_some(broadcast) } /// Publish a broadcast, announcing it to all consumers. @@ -611,32 +608,32 @@ mod tests { let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); - let mut consumer1 = origin.consumer; + let mut consumer1 = origin.consume(); // Make a new consumer that should get it. consumer1.assert_next_wait(); // Publish the first broadcast. - origin.producer.publish_broadcast("test1", broadcast1.consumer); + origin.publish_broadcast("test1", broadcast1.consume()); - consumer1.assert_next("test1", &broadcast1.producer.consume()); + consumer1.assert_next("test1", &broadcast1.consume()); consumer1.assert_next_wait(); // Make a new consumer that should get the existing broadcast. // But we don't consume it yet. - let mut consumer2 = origin.producer.consume(); + let mut consumer2 = origin.consume(); // Publish the second broadcast. - origin.producer.publish_broadcast("test2", broadcast2.consumer); + origin.publish_broadcast("test2", broadcast2.consume()); - consumer1.assert_next("test2", &broadcast2.producer.consume()); + consumer1.assert_next("test2", &broadcast2.consume()); consumer1.assert_next_wait(); - consumer2.assert_next("test1", &broadcast1.producer.consume()); - consumer2.assert_next("test2", &broadcast2.producer.consume()); + consumer2.assert_next("test1", &broadcast1.consume()); + consumer2.assert_next("test2", &broadcast2.consume()); consumer2.assert_next_wait(); // Close the first broadcast. - drop(broadcast1.producer); + drop(broadcast1); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; @@ -648,12 +645,12 @@ mod tests { consumer2.assert_next_wait(); // And a new consumer only gets the last broadcast. - let mut consumer3 = origin.producer.consume(); - consumer3.assert_next("test2", &broadcast2.producer.consume()); + let mut consumer3 = origin.consume(); + consumer3.assert_next("test2", &broadcast2.consume()); consumer3.assert_next_wait(); // Close the other producer and make sure it cleans up - drop(broadcast2.producer); + drop(broadcast2); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; @@ -671,173 +668,176 @@ mod tests { #[tokio::test] async fn test_duplicate() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); - let consumer1 = broadcast1.consumer; - let consumer2 = broadcast2.consumer; - let consumer3 = broadcast3.consumer; + let consumer1 = broadcast1.consume(); + let consumer2 = broadcast2.consume(); + let consumer3 = broadcast3.consume(); - origin.producer.publish_broadcast("test", consumer1.clone()); - origin.producer.publish_broadcast("test", consumer2.clone()); - origin.producer.publish_broadcast("test", consumer3.clone()); + origin.publish_broadcast("test", consumer1.clone()); + origin.publish_broadcast("test", consumer2.clone()); + origin.publish_broadcast("test", consumer3.clone()); - assert!(origin.consumer.consume_broadcast("test").is_some()); + assert!(consumer.consume_broadcast("test").is_some()); - origin.consumer.assert_next("test", &consumer1); - origin.consumer.assert_next_none("test"); - origin.consumer.assert_next("test", &consumer2); - origin.consumer.assert_next_none("test"); - origin.consumer.assert_next("test", &consumer3); + consumer.assert_next("test", &consumer1); + consumer.assert_next_none("test"); + consumer.assert_next("test", &consumer2); + consumer.assert_next_none("test"); + consumer.assert_next("test", &consumer3); // Drop the backup, nothing should change. - drop(broadcast2.producer); + drop(broadcast2); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(origin.consumer.consume_broadcast("test").is_some()); - origin.consumer.assert_next_wait(); + assert!(consumer.consume_broadcast("test").is_some()); + consumer.assert_next_wait(); // Drop the active, we should reannounce. - drop(broadcast3.producer); + drop(broadcast3); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(origin.consumer.consume_broadcast("test").is_some()); - origin.consumer.assert_next_none("test"); - origin.consumer.assert_next("test", &consumer1); + assert!(consumer.consume_broadcast("test").is_some()); + consumer.assert_next_none("test"); + consumer.assert_next("test", &consumer1); // Drop the final broadcast, we should unannounce. - drop(broadcast1.producer); + drop(broadcast1); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(origin.consumer.consume_broadcast("test").is_none()); + assert!(consumer.consume_broadcast("test").is_none()); - origin.consumer.assert_next_none("test"); - origin.consumer.assert_next_wait(); + consumer.assert_next_none("test"); + consumer.assert_next_wait(); } #[tokio::test] async fn test_duplicate_reverse() { let origin = Origin::produce(); + let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); - origin.producer.publish_broadcast("test", broadcast1.consumer); - origin.producer.publish_broadcast("test", broadcast2.consumer); - assert!(origin.consumer.consume_broadcast("test").is_some()); + origin.publish_broadcast("test", broadcast1.consume()); + origin.publish_broadcast("test", broadcast2.consume()); + assert!(consumer.consume_broadcast("test").is_some()); // This is harder, dropping the new broadcast first. - drop(broadcast2.producer); + drop(broadcast2); // Wait for the cleanup async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(origin.consumer.consume_broadcast("test").is_some()); + assert!(consumer.consume_broadcast("test").is_some()); - drop(broadcast1.producer); + drop(broadcast1); // Wait for the cleanup async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(origin.consumer.consume_broadcast("test").is_none()); + assert!(consumer.consume_broadcast("test").is_none()); } #[tokio::test] async fn test_double_publish() { let origin = Origin::produce(); + let consumer = origin.consume(); let broadcast = Broadcast::produce(); // Ensure it doesn't crash. - origin.producer.publish_broadcast("test", broadcast.producer.consume()); - origin.producer.publish_broadcast("test", broadcast.producer.consume()); + origin.publish_broadcast("test", broadcast.consume()); + origin.publish_broadcast("test", broadcast.consume()); - assert!(origin.consumer.consume_broadcast("test").is_some()); + assert!(consumer.consume_broadcast("test").is_some()); - drop(broadcast.producer); + drop(broadcast); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(origin.consumer.consume_broadcast("test").is_none()); + assert!(consumer.consume_broadcast("test").is_none()); } // There was a tokio bug where only the first 127 broadcasts would be received instantly. #[tokio::test] #[should_panic] async fn test_128() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); for i in 0..256 { - origin - .producer - .publish_broadcast(format!("test{i}"), broadcast.consumer.clone()); + origin.publish_broadcast(format!("test{i}"), broadcast.consume()); } for i in 0..256 { - origin.consumer.assert_next(format!("test{i}"), &broadcast.consumer); + consumer.assert_next(format!("test{i}"), &broadcast.consume()); } } #[tokio::test] async fn test_128_fix() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); for i in 0..256 { - origin - .producer - .publish_broadcast(format!("test{i}"), broadcast.consumer.clone()); + origin.publish_broadcast(format!("test{i}"), broadcast.consume()); } for i in 0..256 { // try_next does not have the same issue because it's synchronous. - origin.consumer.assert_try_next(format!("test{i}"), &broadcast.consumer); + consumer.assert_try_next(format!("test{i}"), &broadcast.consume()); } } #[tokio::test] async fn test_with_root_basic() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); // Create a producer with root "/foo" - let foo_producer = origin.producer.with_root("foo").expect("should create root"); + let foo_producer = origin.with_root("foo").expect("should create root"); assert_eq!(foo_producer.root().as_str(), "foo"); // When publishing to "bar/baz", it should actually publish to "foo/bar/baz" - assert!(foo_producer.publish_broadcast("bar/baz", broadcast.consumer.clone())); + assert!(foo_producer.publish_broadcast("bar/baz", broadcast.consume())); // The original consumer should see the full path - origin.consumer.assert_next("foo/bar/baz", &broadcast.consumer); + consumer.assert_next("foo/bar/baz", &broadcast.consume()); // A consumer created from the rooted producer should see the stripped path let mut foo_consumer = foo_producer.consume(); - foo_consumer.assert_next("bar/baz", &broadcast.consumer); + foo_consumer.assert_next("bar/baz", &broadcast.consume()); } #[tokio::test] async fn test_with_root_nested() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); // Create nested roots - let foo_producer = origin.producer.with_root("foo").expect("should create foo root"); + let foo_producer = origin.with_root("foo").expect("should create foo root"); let foo_bar_producer = foo_producer.with_root("bar").expect("should create bar root"); assert_eq!(foo_bar_producer.root().as_str(), "foo/bar"); // Publishing to "baz" should actually publish to "foo/bar/baz" - assert!(foo_bar_producer.publish_broadcast("baz", broadcast.consumer.clone())); + assert!(foo_bar_producer.publish_broadcast("baz", broadcast.consume())); // The original consumer sees the full path - origin.consumer.assert_next("foo/bar/baz", &broadcast.consumer); + consumer.assert_next("foo/bar/baz", &broadcast.consume()); // Consumer from foo_bar_producer sees just "baz" let mut foo_bar_consumer = foo_bar_producer.consume(); - foo_bar_consumer.assert_next("baz", &broadcast.consumer); + foo_bar_consumer.assert_next("baz", &broadcast.consume()); } #[tokio::test] @@ -847,19 +847,18 @@ mod tests { // Create a producer that can only publish to "allowed" paths let limited_producer = origin - .producer .publish_only(&["allowed/path1".into(), "allowed/path2".into()]) .expect("should create limited producer"); // Should be able to publish to allowed paths - assert!(limited_producer.publish_broadcast("allowed/path1", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("allowed/path1/nested", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("allowed/path2", broadcast.consumer.clone())); + assert!(limited_producer.publish_broadcast("allowed/path1", broadcast.consume())); + assert!(limited_producer.publish_broadcast("allowed/path1/nested", broadcast.consume())); + assert!(limited_producer.publish_broadcast("allowed/path2", broadcast.consume())); // Should not be able to publish to disallowed paths - assert!(!limited_producer.publish_broadcast("notallowed", broadcast.consumer.clone())); - assert!(!limited_producer.publish_broadcast("allowed", broadcast.consumer.clone())); // Parent of allowed path - assert!(!limited_producer.publish_broadcast("other/path", broadcast.consumer.clone())); + assert!(!limited_producer.publish_broadcast("notallowed", broadcast.consume())); + assert!(!limited_producer.publish_broadcast("allowed", broadcast.consume())); // Parent of allowed path + assert!(!limited_producer.publish_broadcast("other/path", broadcast.consume())); } #[tokio::test] @@ -867,79 +866,68 @@ mod tests { let origin = Origin::produce(); // Creating a producer with no allowed paths should return None - assert!(origin.producer.publish_only(&[]).is_none()); + assert!(origin.publish_only(&[]).is_none()); } #[tokio::test] async fn test_consume_only_filters() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); // Publish to different paths - origin - .producer - .publish_broadcast("allowed", broadcast1.consumer.clone()); - origin - .producer - .publish_broadcast("allowed/nested", broadcast2.consumer.clone()); - origin - .producer - .publish_broadcast("notallowed", broadcast3.consumer.clone()); + origin.publish_broadcast("allowed", broadcast1.consume()); + origin.publish_broadcast("allowed/nested", broadcast2.consume()); + origin.publish_broadcast("notallowed", broadcast3.consume()); // Create a consumer that only sees "allowed" paths - let mut limited_consumer = origin - .consumer + let mut limited_consumer = consumer .consume_only(&["allowed".into()]) .expect("should create limited consumer"); // Should only receive broadcasts under "allowed" - limited_consumer.assert_next("allowed", &broadcast1.consumer); - limited_consumer.assert_next("allowed/nested", &broadcast2.consumer); + limited_consumer.assert_next("allowed", &broadcast1.consume()); + limited_consumer.assert_next("allowed/nested", &broadcast2.consume()); limited_consumer.assert_next_wait(); // Should not see "notallowed" // Original consumer should see all - origin.consumer.assert_next("allowed", &broadcast1.consumer); - origin.consumer.assert_next("allowed/nested", &broadcast2.consumer); - origin.consumer.assert_next("notallowed", &broadcast3.consumer); + consumer.assert_next("allowed", &broadcast1.consume()); + consumer.assert_next("allowed/nested", &broadcast2.consume()); + consumer.assert_next("notallowed", &broadcast3.consume()); } #[tokio::test] async fn test_consume_only_multiple_prefixes() { let origin = Origin::produce(); + let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); - origin - .producer - .publish_broadcast("foo/test", broadcast1.consumer.clone()); - origin - .producer - .publish_broadcast("bar/test", broadcast2.consumer.clone()); - origin - .producer - .publish_broadcast("baz/test", broadcast3.consumer.clone()); + origin.publish_broadcast("foo/test", broadcast1.consume()); + origin.publish_broadcast("bar/test", broadcast2.consume()); + origin.publish_broadcast("baz/test", broadcast3.consume()); // Consumer that only sees "foo" and "bar" paths - let mut limited_consumer = origin - .consumer + let mut limited_consumer = consumer .consume_only(&["foo".into(), "bar".into()]) .expect("should create limited consumer"); - limited_consumer.assert_next("foo/test", &broadcast1.consumer); - limited_consumer.assert_next("bar/test", &broadcast2.consumer); + limited_consumer.assert_next("foo/test", &broadcast1.consume()); + limited_consumer.assert_next("bar/test", &broadcast2.consume()); limited_consumer.assert_next_wait(); // Should not see "baz/test" } #[tokio::test] async fn test_with_root_and_publish_only() { - let mut origin = Origin::produce(); + let origin = Origin::produce(); + let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); // User connects to /foo root - let foo_producer = origin.producer.with_root("foo").expect("should create foo root"); + let foo_producer = origin.with_root("foo").expect("should create foo root"); // Limit them to publish only to "bar" and "goop/pee" within /foo let limited_producer = foo_producer @@ -947,21 +935,21 @@ mod tests { .expect("should create limited producer"); // Should be able to publish to foo/bar and foo/goop/pee (but user sees as bar and goop/pee) - assert!(limited_producer.publish_broadcast("bar", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("bar/nested", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("goop/pee", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("goop/pee/nested", broadcast.consumer.clone())); + assert!(limited_producer.publish_broadcast("bar", broadcast.consume())); + assert!(limited_producer.publish_broadcast("bar/nested", broadcast.consume())); + assert!(limited_producer.publish_broadcast("goop/pee", broadcast.consume())); + assert!(limited_producer.publish_broadcast("goop/pee/nested", broadcast.consume())); // Should not be able to publish outside allowed paths - assert!(!limited_producer.publish_broadcast("baz", broadcast.consumer.clone())); - assert!(!limited_producer.publish_broadcast("goop", broadcast.consumer.clone())); // Parent of allowed - assert!(!limited_producer.publish_broadcast("goop/other", broadcast.consumer.clone())); + assert!(!limited_producer.publish_broadcast("baz", broadcast.consume())); + assert!(!limited_producer.publish_broadcast("goop", broadcast.consume())); // Parent of allowed + assert!(!limited_producer.publish_broadcast("goop/other", broadcast.consume())); // Original consumer sees full paths - origin.consumer.assert_next("foo/bar", &broadcast.consumer); - origin.consumer.assert_next("foo/bar/nested", &broadcast.consumer); - origin.consumer.assert_next("foo/goop/pee", &broadcast.consumer); - origin.consumer.assert_next("foo/goop/pee/nested", &broadcast.consumer); + consumer.assert_next("foo/bar", &broadcast.consume()); + consumer.assert_next("foo/bar/nested", &broadcast.consume()); + consumer.assert_next("foo/goop/pee", &broadcast.consume()); + consumer.assert_next("foo/goop/pee/nested", &broadcast.consume()); } #[tokio::test] @@ -972,18 +960,12 @@ mod tests { let broadcast3 = Broadcast::produce(); // Publish broadcasts - origin - .producer - .publish_broadcast("foo/bar/test", broadcast1.consumer.clone()); - origin - .producer - .publish_broadcast("foo/goop/pee/test", broadcast2.consumer.clone()); - origin - .producer - .publish_broadcast("foo/other/test", broadcast3.consumer.clone()); + origin.publish_broadcast("foo/bar/test", broadcast1.consume()); + origin.publish_broadcast("foo/goop/pee/test", broadcast2.consume()); + origin.publish_broadcast("foo/other/test", broadcast3.consume()); // User connects to /foo root - let foo_producer = origin.producer.with_root("foo").expect("should create foo root"); + let foo_producer = origin.with_root("foo").expect("should create foo root"); // Create consumer limited to "bar" and "goop/pee" within /foo let mut limited_consumer = foo_producer @@ -991,8 +973,8 @@ mod tests { .expect("should create limited consumer"); // Should only see allowed paths (without foo prefix) - limited_consumer.assert_next("bar/test", &broadcast1.consumer); - limited_consumer.assert_next("goop/pee/test", &broadcast2.consumer); + limited_consumer.assert_next("bar/test", &broadcast1.consume()); + limited_consumer.assert_next("goop/pee/test", &broadcast2.consume()); limited_consumer.assert_next_wait(); // Should not see "other/test" } @@ -1002,7 +984,6 @@ mod tests { // First limit the producer to specific paths let limited_producer = origin - .producer .publish_only(&["allowed".into()]) .expect("should create limited producer"); @@ -1022,11 +1003,11 @@ mod tests { let broadcast = Broadcast::produce(); // Producer with root access (empty string means wildcard) - let root_producer = origin.producer.clone(); + let root_producer = origin.clone(); // Should be able to publish anywhere - assert!(root_producer.publish_broadcast("any/path", broadcast.consumer.clone())); - assert!(root_producer.publish_broadcast("other/path", broadcast.consumer.clone())); + assert!(root_producer.publish_broadcast("any/path", broadcast.consume())); + assert!(root_producer.publish_broadcast("other/path", broadcast.consume())); // Can create any root let foo_producer = root_producer.with_root("foo").expect("should create any root"); @@ -1036,33 +1017,29 @@ mod tests { #[tokio::test] async fn test_consume_broadcast_with_permissions() { let origin = Origin::produce(); + let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); - origin - .producer - .publish_broadcast("allowed/test", broadcast1.consumer.clone()); - origin - .producer - .publish_broadcast("notallowed/test", broadcast2.consumer.clone()); + origin.publish_broadcast("allowed/test", broadcast1.consume()); + origin.publish_broadcast("notallowed/test", broadcast2.consume()); // Create limited consumer - let limited_consumer = origin - .consumer + let limited_consumer = consumer .consume_only(&["allowed".into()]) .expect("should create limited consumer"); // Should be able to get allowed broadcast let result = limited_consumer.consume_broadcast("allowed/test"); assert!(result.is_some()); - assert!(result.unwrap().is_clone(&broadcast1.consumer)); + assert!(result.unwrap().is_clone(&broadcast1.consume())); // Should not be able to get disallowed broadcast assert!(limited_consumer.consume_broadcast("notallowed/test").is_none()); // Original consumer can get both - assert!(origin.consumer.consume_broadcast("allowed/test").is_some()); - assert!(origin.consumer.consume_broadcast("notallowed/test").is_some()); + assert!(consumer.consume_broadcast("allowed/test").is_some()); + assert!(consumer.consume_broadcast("notallowed/test").is_some()); } #[tokio::test] @@ -1072,64 +1049,55 @@ mod tests { // Create producer limited to "a/b/c" let limited_producer = origin - .producer .publish_only(&["a/b/c".into()]) .expect("should create limited producer"); // Should be able to publish to exact path and nested paths - assert!(limited_producer.publish_broadcast("a/b/c", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("a/b/c/d", broadcast.consumer.clone())); - assert!(limited_producer.publish_broadcast("a/b/c/d/e", broadcast.consumer.clone())); + assert!(limited_producer.publish_broadcast("a/b/c", broadcast.consume())); + assert!(limited_producer.publish_broadcast("a/b/c/d", broadcast.consume())); + assert!(limited_producer.publish_broadcast("a/b/c/d/e", broadcast.consume())); // Should not be able to publish to parent or sibling paths - assert!(!limited_producer.publish_broadcast("a", broadcast.consumer.clone())); - assert!(!limited_producer.publish_broadcast("a/b", broadcast.consumer.clone())); - assert!(!limited_producer.publish_broadcast("a/b/other", broadcast.consumer.clone())); + assert!(!limited_producer.publish_broadcast("a", broadcast.consume())); + assert!(!limited_producer.publish_broadcast("a/b", broadcast.consume())); + assert!(!limited_producer.publish_broadcast("a/b/other", broadcast.consume())); } #[tokio::test] async fn test_multiple_consumers_with_different_permissions() { let origin = Origin::produce(); + let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); // Publish to different paths - origin - .producer - .publish_broadcast("foo/test", broadcast1.consumer.clone()); - origin - .producer - .publish_broadcast("bar/test", broadcast2.consumer.clone()); - origin - .producer - .publish_broadcast("baz/test", broadcast3.consumer.clone()); + origin.publish_broadcast("foo/test", broadcast1.consume()); + origin.publish_broadcast("bar/test", broadcast2.consume()); + origin.publish_broadcast("baz/test", broadcast3.consume()); // Create consumers with different permissions - let mut foo_consumer = origin - .consumer + let mut foo_consumer = consumer .consume_only(&["foo".into()]) .expect("should create foo consumer"); - let mut bar_consumer = origin - .consumer + let mut bar_consumer = consumer .consume_only(&["bar".into()]) .expect("should create bar consumer"); - let mut foobar_consumer = origin - .consumer + let mut foobar_consumer = consumer .consume_only(&["foo".into(), "bar".into()]) .expect("should create foobar consumer"); // Each consumer should only see their allowed paths - foo_consumer.assert_next("foo/test", &broadcast1.consumer); + foo_consumer.assert_next("foo/test", &broadcast1.consume()); foo_consumer.assert_next_wait(); - bar_consumer.assert_next("bar/test", &broadcast2.consumer); + bar_consumer.assert_next("bar/test", &broadcast2.consume()); bar_consumer.assert_next_wait(); - foobar_consumer.assert_next("foo/test", &broadcast1.consumer); - foobar_consumer.assert_next("bar/test", &broadcast2.consumer); + foobar_consumer.assert_next("foo/test", &broadcast1.consume()); + foobar_consumer.assert_next("bar/test", &broadcast2.consume()); foobar_consumer.assert_next_wait(); } @@ -1140,14 +1108,14 @@ mod tests { let broadcast2 = Broadcast::produce(); // User with root "demo" allowed to subscribe to "worm-node" and "foobar" - let demo_producer = origin.producer.with_root("demo").expect("should create demo root"); + let demo_producer = origin.with_root("demo").expect("should create demo root"); let limited_producer = demo_producer .publish_only(&["worm-node".into(), "foobar".into()]) .expect("should create limited producer"); // Publish some broadcasts - assert!(limited_producer.publish_broadcast("worm-node/test", broadcast1.consumer.clone())); - assert!(limited_producer.publish_broadcast("foobar/test", broadcast2.consumer.clone())); + assert!(limited_producer.publish_broadcast("worm-node/test", broadcast1.consume())); + assert!(limited_producer.publish_broadcast("foobar/test", broadcast2.consume())); // consume_only with empty prefix should keep the exact same "worm-node" and "foobar" nodes let mut consumer = limited_producer @@ -1155,8 +1123,8 @@ mod tests { .expect("should create consumer with empty prefix"); // Should still see both broadcasts - consumer.assert_next("worm-node/test", &broadcast1.consumer); - consumer.assert_next("foobar/test", &broadcast2.consumer); + consumer.assert_next("worm-node/test", &broadcast1.consume()); + consumer.assert_next("foobar/test", &broadcast2.consume()); consumer.assert_next_wait(); } @@ -1168,15 +1136,15 @@ mod tests { let broadcast3 = Broadcast::produce(); // User with root "demo" allowed to subscribe to "worm-node" and "foobar" - let demo_producer = origin.producer.with_root("demo").expect("should create demo root"); + let demo_producer = origin.with_root("demo").expect("should create demo root"); let limited_producer = demo_producer .publish_only(&["worm-node".into(), "foobar".into()]) .expect("should create limited producer"); // Publish broadcasts at different levels - assert!(limited_producer.publish_broadcast("worm-node", broadcast1.consumer.clone())); - assert!(limited_producer.publish_broadcast("worm-node/foo", broadcast2.consumer.clone())); - assert!(limited_producer.publish_broadcast("foobar/bar", broadcast3.consumer.clone())); + assert!(limited_producer.publish_broadcast("worm-node", broadcast1.consume())); + assert!(limited_producer.publish_broadcast("worm-node/foo", broadcast2.consume())); + assert!(limited_producer.publish_broadcast("foobar/bar", broadcast3.consume())); // Test 1: consume_only("worm-node") should result in a single "" node with contents of "worm-node" ONLY let mut worm_consumer = limited_producer @@ -1184,8 +1152,8 @@ mod tests { .expect("should create worm-node consumer"); // Should see worm-node content with paths stripped to "" - worm_consumer.assert_next("worm-node", &broadcast1.consumer); - worm_consumer.assert_next("worm-node/foo", &broadcast2.consumer); + worm_consumer.assert_next("worm-node", &broadcast1.consume()); + worm_consumer.assert_next("worm-node/foo", &broadcast2.consume()); worm_consumer.assert_next_wait(); // Should NOT see foobar content // Test 2: consume_only("worm-node/foo") should result in a "" node with contents of "worm-node/foo" @@ -1193,7 +1161,7 @@ mod tests { .consume_only(&["worm-node/foo".into()]) .expect("should create worm-node/foo consumer"); - foo_consumer.assert_next("worm-node/foo", &broadcast2.consumer); + foo_consumer.assert_next("worm-node/foo", &broadcast2.consume()); foo_consumer.assert_next_wait(); // Should NOT see other content } @@ -1206,14 +1174,13 @@ mod tests { // Producer with multiple allowed roots let limited_producer = origin - .producer .publish_only(&["app1".into(), "app2".into(), "shared".into()]) .expect("should create limited producer"); // Publish to each root - assert!(limited_producer.publish_broadcast("app1/data", broadcast1.consumer.clone())); - assert!(limited_producer.publish_broadcast("app2/config", broadcast2.consumer.clone())); - assert!(limited_producer.publish_broadcast("shared/resource", broadcast3.consumer.clone())); + assert!(limited_producer.publish_broadcast("app1/data", broadcast1.consume())); + assert!(limited_producer.publish_broadcast("app2/config", broadcast2.consume())); + assert!(limited_producer.publish_broadcast("shared/resource", broadcast3.consume())); // consume_only with empty prefix should maintain all roots let mut consumer = limited_producer @@ -1221,9 +1188,9 @@ mod tests { .expect("should create consumer with empty prefix"); // Should see all broadcasts from all roots - consumer.assert_next("app1/data", &broadcast1.consumer); - consumer.assert_next("app2/config", &broadcast2.consumer); - consumer.assert_next("shared/resource", &broadcast3.consumer); + consumer.assert_next("app1/data", &broadcast1.consume()); + consumer.assert_next("app2/config", &broadcast2.consume()); + consumer.assert_next("shared/resource", &broadcast3.consume()); consumer.assert_next_wait(); } @@ -1234,7 +1201,6 @@ mod tests { // Producer with specific allowed paths let limited_producer = origin - .producer .publish_only(&["services/api".into(), "services/web".into()]) .expect("should create limited producer"); @@ -1244,10 +1210,10 @@ mod tests { .expect("should create producer with empty prefix"); // Should still have the same publishing restrictions - assert!(same_producer.publish_broadcast("services/api", broadcast.consumer.clone())); - assert!(same_producer.publish_broadcast("services/web", broadcast.consumer.clone())); - assert!(!same_producer.publish_broadcast("services/db", broadcast.consumer.clone())); - assert!(!same_producer.publish_broadcast("other", broadcast.consumer.clone())); + assert!(same_producer.publish_broadcast("services/api", broadcast.consume())); + assert!(same_producer.publish_broadcast("services/web", broadcast.consume())); + assert!(!same_producer.publish_broadcast("services/db", broadcast.consume())); + assert!(!same_producer.publish_broadcast("other", broadcast.consume())); } #[tokio::test] @@ -1259,21 +1225,20 @@ mod tests { // Producer with broad permission let limited_producer = origin - .producer .publish_only(&["org".into()]) .expect("should create limited producer"); // Publish at various depths - assert!(limited_producer.publish_broadcast("org/team1/project1", broadcast1.consumer.clone())); - assert!(limited_producer.publish_broadcast("org/team1/project2", broadcast2.consumer.clone())); - assert!(limited_producer.publish_broadcast("org/team2/project1", broadcast3.consumer.clone())); + assert!(limited_producer.publish_broadcast("org/team1/project1", broadcast1.consume())); + assert!(limited_producer.publish_broadcast("org/team1/project2", broadcast2.consume())); + assert!(limited_producer.publish_broadcast("org/team2/project1", broadcast3.consume())); // Narrow down to team1 only let mut team1_consumer = limited_producer .consume_only(&["org/team2".into()]) .expect("should create team1 consumer"); - team1_consumer.assert_next("org/team2/project1", &broadcast3.consumer); + team1_consumer.assert_next("org/team2/project1", &broadcast3.consume()); team1_consumer.assert_next_wait(); // Should NOT see team1 content // Further narrow down to team1/project1 @@ -1282,7 +1247,7 @@ mod tests { .expect("should create project1 consumer"); // Should only see project1 content at root - project1_consumer.assert_next("org/team1/project1", &broadcast1.consumer); + project1_consumer.assert_next("org/team1/project1", &broadcast1.consume()); project1_consumer.assert_next_wait(); } @@ -1292,7 +1257,6 @@ mod tests { // Producer with specific allowed paths let limited_producer = origin - .producer .publish_only(&["allowed/path".into()]) .expect("should create limited producer"); @@ -1310,14 +1274,14 @@ mod tests { let broadcast2 = Broadcast::produce(); // Setup: user with root "demo" allowed to subscribe to specific paths - let demo_producer = origin.producer.with_root("demo").expect("should create demo root"); + let demo_producer = origin.with_root("demo").expect("should create demo root"); let user_producer = demo_producer .publish_only(&["worm-node".into(), "foobar".into()]) .expect("should create user producer"); // Publish some data - assert!(user_producer.publish_broadcast("worm-node/data", broadcast1.consumer.clone())); - assert!(user_producer.publish_broadcast("foobar", broadcast2.consumer.clone())); + assert!(user_producer.publish_broadcast("worm-node/data", broadcast1.consume())); + assert!(user_producer.publish_broadcast("foobar", broadcast2.consume())); // Key test: consume_only with "" should maintain access to allowed roots let mut consumer = user_producer @@ -1325,8 +1289,8 @@ mod tests { .expect("consume_only with empty prefix should not fail when user has specific permissions"); // Should still receive broadcasts from allowed paths - consumer.assert_next("worm-node/data", &broadcast1.consumer); - consumer.assert_next("foobar", &broadcast2.consumer); + consumer.assert_next("worm-node/data", &broadcast1.consume()); + consumer.assert_next("foobar", &broadcast2.consume()); consumer.assert_next_wait(); // Also test that we can still narrow the scope @@ -1334,7 +1298,7 @@ mod tests { .consume_only(&["worm-node".into()]) .expect("should be able to narrow scope to worm-node"); - narrow_consumer.assert_next("worm-node/data", &broadcast1.consumer); + narrow_consumer.assert_next("worm-node/data", &broadcast1.consume()); narrow_consumer.assert_next_wait(); // Should not see foobar } } diff --git a/rs/moq-lite/src/model/produce.rs b/rs/moq-lite/src/model/produce.rs deleted file mode 100644 index 0c2520649a..0000000000 --- a/rs/moq-lite/src/model/produce.rs +++ /dev/null @@ -1,10 +0,0 @@ -/// A named tuple of a producer and consumer for convenience. -/// -/// The producer and consumer may each be cloned as many times as you want. -/// However when the number of references reaches zero, the other will receive a signal to close. -/// A new consumer may be created at any time by calling the producer's `consume()` method. -#[derive(Clone)] -pub struct Produce { - pub producer: P, - pub consumer: C, -} diff --git a/rs/moq-lite/src/model/track.rs b/rs/moq-lite/src/model/track.rs index b90078474f..8ba18bc2ec 100644 --- a/rs/moq-lite/src/model/track.rs +++ b/rs/moq-lite/src/model/track.rs @@ -14,7 +14,7 @@ use tokio::sync::watch; -use crate::{Error, Produce, Result}; +use crate::{Error, Result}; use super::{Group, GroupConsumer, GroupProducer}; @@ -35,10 +35,8 @@ impl Track { } } - pub fn produce(self) -> Produce { - let producer = TrackProducer::new(self); - let consumer = producer.consume(); - Produce { producer, consumer } + pub fn produce(self) -> TrackProducer { + TrackProducer::new(self) } } @@ -86,7 +84,7 @@ impl TrackProducer { /// If the sequence number is not the latest, this method will return None. pub fn create_group(&mut self, info: Group) -> Option { let group = info.produce(); - self.insert_group(group.consumer).then_some(group.producer) + self.insert_group(group.consume()).then_some(group) } /// Create a new group with the next sequence number. @@ -98,8 +96,8 @@ impl TrackProducer { let sequence = state.latest.as_ref().map_or(0, |group| group.info.sequence + 1); let group = Group { sequence }.produce(); - state.latest = Some(group.consumer); - producer = Some(group.producer); + state.latest = Some(group.consume()); + producer = Some(group); true }); diff --git a/rs/moq-native/examples/chat.rs b/rs/moq-native/examples/chat.rs index b73f4d7a35..1bd1cb9a90 100644 --- a/rs/moq-native/examples/chat.rs +++ b/rs/moq-native/examples/chat.rs @@ -7,13 +7,14 @@ async fn main() -> anyhow::Result<()> { // Create an origin that we can publish to and the session can consume from. let origin = moq_lite::Origin::produce(); + let consumer = origin.consume(); // Run the broadcast production and the session in parallel. // This is a simple example of how you can concurrently run multiple tasks. // tokio::spawn works too. tokio::select! { - res = run_broadcast(origin.producer) => res, - res = run_session(origin.consumer) => res, + res = run_broadcast(origin) => res, + res = run_session(consumer) => res, } } @@ -41,7 +42,7 @@ async fn run_broadcast(origin: moq_lite::OriginProducer) -> anyhow::Result<()> { // Create a track that we'll insert into the broadcast. // A track is a series of groups representing a live stream. - let mut track = broadcast.producer.create_track(moq_lite::Track { + let mut track = broadcast.create_track(moq_lite::Track { name: "chat".to_string(), priority: 0, }); @@ -49,7 +50,7 @@ async fn run_broadcast(origin: moq_lite::OriginProducer) -> anyhow::Result<()> { // NOTE: The path is empty because we're using the URL to scope the broadcast. // If you put "alice" here, it would be published as "anon/chat-example/alice". // OPTIONAL: We publish after inserting the track just to avoid a nearly impossible race condition. - origin.publish_broadcast("", broadcast.consumer); + origin.publish_broadcast("", broadcast.consume()); // Create a group. // Each group is independent and the newest group(s) will be prioritized. diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index ce9498ae87..e749ef495c 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -57,16 +57,16 @@ pub struct Cluster { client: moq_native::Client, // Advertises ourselves as an origin to other nodes. - noop: moq_lite::Produce, + noop: BroadcastProducer, // Broadcasts announced by local clients (users). - pub primary: Arc>, + pub primary: Arc, // Broadcasts announced by remote servers (cluster). - pub secondary: Arc>, + pub secondary: Arc, // Broadcasts announced by local clients and remote servers. - pub combined: Arc>, + pub combined: Arc, } impl Cluster { @@ -92,7 +92,7 @@ impl Cluster { }; // Scope the origin to our root. - let subscribe_origin = subscribe_origin.producer.with_root(&token.root)?; + let subscribe_origin = subscribe_origin.with_root(&token.root)?; subscribe_origin.consume_only(&token.subscribe) } @@ -104,15 +104,15 @@ impl Cluster { false => &self.primary, }; - let publish_origin = publish_origin.producer.with_root(&token.root)?; + let publish_origin = publish_origin.with_root(&token.root)?; publish_origin.publish_only(&token.publish) } pub fn get(&self, broadcast: &str) -> Option { self.primary - .consumer + .consume() .consume_broadcast(broadcast) - .or_else(|| self.secondary.consumer.consume_broadcast(broadcast)) + .or_else(|| self.secondary.consume().consume_broadcast(broadcast)) } pub async fn run(self) -> anyhow::Result<()> { @@ -133,14 +133,13 @@ impl Cluster { // Use with_root to automatically strip the prefix from announced paths. let origins = self .secondary - .producer .with_root(&self.config.prefix) .context("no authorized origins")?; // Announce ourselves as an origin to the root node. if let Some(myself) = self.config.node.as_ref() { tracing::info!(%myself, "announcing as leaf"); - origins.publish_broadcast(myself, self.noop.consumer.clone()); + origins.publish_broadcast(myself, self.noop.consume()); } // If the token is provided, read it from the disk and use it in the query parameter. @@ -150,7 +149,7 @@ impl Cluster { None => "".to_string(), }; - let noop = self.noop.consumer.clone(); + let noop = self.noop.consume(); // Despite returning a Result, we should NEVER return an Ok tokio::select! { @@ -171,8 +170,8 @@ impl Cluster { // Shovel broadcasts from the primary and secondary origins into the combined origin. async fn run_combined(self) -> anyhow::Result<()> { - let mut primary = self.primary.consumer.consume(); - let mut secondary = self.secondary.consumer.consume(); + let mut primary = self.primary.consume(); + let mut secondary = self.secondary.consume(); loop { let (name, broadcast) = tokio::select! { @@ -183,7 +182,7 @@ impl Cluster { }; if let Some(broadcast) = broadcast { - self.combined.producer.publish_broadcast(&name, broadcast); + self.combined.publish_broadcast(&name, broadcast); } } } @@ -268,8 +267,8 @@ impl Cluster { let session = self .client .clone() - .with_publish(self.primary.consumer.consume()) - .with_consume(self.secondary.producer.clone()) + .with_publish(self.primary.consume()) + .with_consume((*self.secondary).clone()) .connect(url.clone()) .await .context("failed to connect to remote")?; From 0a1a2a30de217bb9c42eb03875e00a1babdc8056 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 17:32:01 -0300 Subject: [PATCH 014/590] Switch the dev config to use IPv4. (#874) --- dev/leaf.toml | 5 ++--- dev/prod.toml | 6 +++--- dev/relay.toml | 5 ++--- dev/root.toml | 5 ++--- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/dev/leaf.toml b/dev/leaf.toml index f623d71c1c..2a38e0c88d 100644 --- a/dev/leaf.toml +++ b/dev/leaf.toml @@ -5,8 +5,7 @@ level = "debug" [server] # Listen for QUIC connections on UDP:4444 -# Sometimes IPv6 causes issues; try 127.0.0.1:4444 instead. -listen = "[::]:4444" +listen = "0.0.0.0:4444" # Generate a self-signed certificate for the server. # You should use a real certificate in production. @@ -14,7 +13,7 @@ tls.generate = ["localhost"] [web.http] # Listen for HTTP and WebSocket (TCP) connections on the given address. -listen = "[::]:4444" +listen = "0.0.0.0:4444" # This clustering scheme is very very simple for now. # diff --git a/dev/prod.toml b/dev/prod.toml index 40805ca639..dbe2cf9837 100644 --- a/dev/prod.toml +++ b/dev/prod.toml @@ -4,7 +4,7 @@ [server] # Listen for QUIC connections on UDP:443 # This is the default port for WebTransport -listen = "[::]:443" +listen = "0.0.0.0:443" [server.tls] # You'll need to provide a real certificate and key in production. @@ -14,11 +14,11 @@ key = "key.pem" #[web.http] # Optionally listen for HTTP and WebSocket connections on port 80. -# listen = "[::]:80" +# listen = "0.0.0.0:80" [web.https] # Optionally listen for TCP/HTTPS/WSS connections on port 443. -listen = "[::]:443" +listen = "0.0.0.0:443" # You'll need to provide a real certificate and key in production. # This can be the exact same certificate and key as the QUIC endpoint. diff --git a/dev/relay.toml b/dev/relay.toml index c954f9ee16..d60af2bee9 100644 --- a/dev/relay.toml +++ b/dev/relay.toml @@ -8,8 +8,7 @@ level = "debug" [server] # Listen for QUIC connections on UDP:4443 -# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead. -listen = "[::]:4443" +listen = "0.0.0.0:4443" # Generate a self-signed certificate for the given hostnames. # This is used for local development, in conjunction with a fingerprint, or with TLS verification disabled. @@ -19,7 +18,7 @@ tls.generate = ["localhost"] # Listen for HTTP and WebSocket connections on the given TCP address. # This is unfortunately required to serve certificate.sha256 for local development. # However, as a bonus, we can serve tracks via both HTTP and WebSocket fallbacks. -listen = "[::]:4443" +listen = "0.0.0.0:4443" # See root.toml and leaf.toml for auth and clustering examples. [auth] diff --git a/dev/root.toml b/dev/root.toml index 8b8140de17..c3d94bfec4 100644 --- a/dev/root.toml +++ b/dev/root.toml @@ -5,8 +5,7 @@ level = "debug" [server] # Listen for QUIC connections on UDP:4443 -# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead. -listen = "[::]:4443" +listen = "0.0.0.0:4443" # Generate a self-signed certificate for the given hostnames. # This is used for local development, in conjunction with a fingerprint, or with TLS verification disabled. @@ -15,7 +14,7 @@ tls.generate = ["localhost"] [web.http] # Listen for HTTP and WebSocket (TCP) connections on the given address. # Defaults to disabled if not provided. -listen = "[::]:4443" +listen = "0.0.0.0:4443" # In production, we would use a real certificate from something like Let's Encrypt. # Multiple certificates are supported; the first one that matches the SNI will be used. From 80fa03c9bf85acc1f82ab806f658cd04eb58e84a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Jan 2026 18:16:14 -0300 Subject: [PATCH 015/590] Tweak a few small things the AI merge missed. (#876) --- js/hang/src/watch/video/source.ts | 1 - rs/hang/examples/video.rs | 3 +- rs/hang/src/import/aac.rs | 3 +- rs/hang/src/import/avc3.rs | 3 +- rs/hang/src/import/hev1.rs | 3 +- rs/hang/src/import/opus.rs | 3 +- rs/moq-clock/src/main.rs | 1 - rs/moq-lite/src/model/broadcast.rs | 2 +- rs/moq-lite/src/model/frame.rs | 2 +- rs/moq-lite/src/model/group.rs | 2 +- rs/moq-lite/src/model/origin.rs | 60 +++++++++++++++++------------- rs/moq-lite/src/model/track.rs | 2 +- rs/moq-native/examples/chat.rs | 3 +- rs/moq-relay/src/cluster.rs | 19 +++++----- 14 files changed, 54 insertions(+), 53 deletions(-) diff --git a/js/hang/src/watch/video/source.ts b/js/hang/src/watch/video/source.ts index 71fb81cca3..f1f61f8045 100644 --- a/js/hang/src/watch/video/source.ts +++ b/js/hang/src/watch/video/source.ts @@ -209,7 +209,6 @@ export class Source { output: async (frame: VideoFrame) => { try { const timestamp = Time.Milli.fromMicro(frame.timestamp as Time.Micro); - if (timestamp < (this.#timestamp.peek() ?? 0)) { // Late frame, don't render it. return; diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index d566407a4a..920435deb3 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -8,14 +8,13 @@ async fn main() -> anyhow::Result<()> { // Create an origin that we can publish to and the session can consume from. let origin = moq_lite::Origin::produce(); - let consumer = origin.consume(); // Run the broadcast production and the session in parallel. // This is a simple example of how you can concurrently run multiple tasks. // tokio::spawn works too. tokio::select! { + res = run_session(origin.consume()) => res, res = run_broadcast(origin) => res, - res = run_session(consumer) => res, } } diff --git a/rs/hang/src/import/aac.rs b/rs/hang/src/import/aac.rs index 8d012966d9..240e1e970f 100644 --- a/rs/hang/src/import/aac.rs +++ b/rs/hang/src/import/aac.rs @@ -113,8 +113,7 @@ impl Aac { tracing::debug!(name = ?track.name, ?config, "starting track"); - let track = track.produce(); - self.broadcast.insert_track(track.consume()); + let track = self.broadcast.create_track(track); let mut catalog = self.broadcast.catalog.lock(); let audio = catalog.insert_audio(track.info.name.clone(), config); diff --git a/rs/hang/src/import/avc3.rs b/rs/hang/src/import/avc3.rs index 5b0d19ef19..a4bdf6fe4d 100644 --- a/rs/hang/src/import/avc3.rs +++ b/rs/hang/src/import/avc3.rs @@ -91,8 +91,7 @@ impl Avc3 { video.priority = 2; } - let track = track.produce(); - self.broadcast.insert_track(track.consume()); + let track = self.broadcast.create_track(track); self.config = Some(config); self.track = Some(track.into()); diff --git a/rs/hang/src/import/hev1.rs b/rs/hang/src/import/hev1.rs index 6a0471a44a..571c3d51b6 100644 --- a/rs/hang/src/import/hev1.rs +++ b/rs/hang/src/import/hev1.rs @@ -90,8 +90,7 @@ impl Hev1 { video.priority = 2; } - let track = track.produce(); - self.broadcast.insert_track(track.consume()); + let track = self.broadcast.create_track(track); self.config = Some(config); self.track = Some(track.into()); diff --git a/rs/hang/src/import/opus.rs b/rs/hang/src/import/opus.rs index d01ba0a117..cfbf17159c 100644 --- a/rs/hang/src/import/opus.rs +++ b/rs/hang/src/import/opus.rs @@ -59,8 +59,7 @@ impl Opus { tracing::debug!(name = ?track.name, ?config, "starting track"); - let track = track.produce(); - self.broadcast.insert_track(track.consume()); + let track = self.broadcast.create_track(track); let mut catalog = self.broadcast.catalog.lock(); let audio = catalog.insert_audio(track.info.name.clone(), config); diff --git a/rs/moq-clock/src/main.rs b/rs/moq-clock/src/main.rs index fb8583045b..4797ae9c67 100644 --- a/rs/moq-clock/src/main.rs +++ b/rs/moq-clock/src/main.rs @@ -86,7 +86,6 @@ async fn main() -> anyhow::Result<()> { let path: moq_lite::Path<'_> = config.broadcast.into(); let mut origin = origin - .consume() .consume_only(&[path]) .context("not allowed to consume broadcast")?; diff --git a/rs/moq-lite/src/model/broadcast.rs b/rs/moq-lite/src/model/broadcast.rs index e8eb622874..15affe5229 100644 --- a/rs/moq-lite/src/model/broadcast.rs +++ b/rs/moq-lite/src/model/broadcast.rs @@ -55,7 +55,7 @@ impl Default for BroadcastProducer { } impl BroadcastProducer { - fn new() -> Self { + pub fn new() -> Self { Self { state: Lock::new(State { published: HashMap::new(), diff --git a/rs/moq-lite/src/model/frame.rs b/rs/moq-lite/src/model/frame.rs index 140f3e61cf..7d0cf8835b 100644 --- a/rs/moq-lite/src/model/frame.rs +++ b/rs/moq-lite/src/model/frame.rs @@ -66,7 +66,7 @@ pub struct FrameProducer { } impl FrameProducer { - fn new(info: Frame) -> Self { + pub fn new(info: Frame) -> Self { Self { info, state: Default::default(), diff --git a/rs/moq-lite/src/model/group.rs b/rs/moq-lite/src/model/group.rs index 62aa301b84..43ab738a77 100644 --- a/rs/moq-lite/src/model/group.rs +++ b/rs/moq-lite/src/model/group.rs @@ -81,7 +81,7 @@ pub struct GroupProducer { } impl GroupProducer { - fn new(info: Group) -> Self { + pub fn new(info: Group) -> Self { Self { info, state: Default::default(), diff --git a/rs/moq-lite/src/model/origin.rs b/rs/moq-lite/src/model/origin.rs index 630f46f99f..c8df8a2b40 100644 --- a/rs/moq-lite/src/model/origin.rs +++ b/rs/moq-lite/src/model/origin.rs @@ -335,7 +335,7 @@ pub struct Origin {} impl Origin { pub fn produce() -> OriginProducer { - OriginProducer::default() + OriginProducer::new() } } @@ -351,6 +351,10 @@ pub struct OriginProducer { } impl OriginProducer { + pub fn new() -> Self { + Self::default() + } + /// Create and publish a new broadcast, returning the producer. /// /// This is a helper method when you only want to publish a broadcast to a single origin. @@ -413,6 +417,16 @@ impl OriginProducer { Some(OriginConsumer::new(self.root.clone(), self.nodes.select(prefixes)?)) } + /// Subscribe to a specific broadcast. + /// + /// Returns None if the broadcast is not found. + pub fn consume_broadcast(&self, path: impl AsPath) -> Option { + let path = path.as_path(); + let (root, rest) = self.nodes.get(&path)?; + let state = root.lock(); + state.consume_broadcast(&rest) + } + /// Returns a new OriginProducer that automatically strips out the provided prefix. /// /// Returns None if the provided root is not authorized; when publish_only was already used without a wildcard. @@ -669,7 +683,6 @@ mod tests { #[tokio::test] async fn test_duplicate() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); @@ -683,6 +696,7 @@ mod tests { origin.publish_broadcast("test", consumer2.clone()); origin.publish_broadcast("test", consumer3.clone()); + let mut consumer = origin.consume(); assert!(consumer.consume_broadcast("test").is_some()); consumer.assert_next("test", &consumer1); @@ -724,58 +738,56 @@ mod tests { #[tokio::test] async fn test_duplicate_reverse() { let origin = Origin::produce(); - let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); origin.publish_broadcast("test", broadcast1.consume()); origin.publish_broadcast("test", broadcast2.consume()); - assert!(consumer.consume_broadcast("test").is_some()); + assert!(origin.consume_broadcast("test").is_some()); // This is harder, dropping the new broadcast first. drop(broadcast2); // Wait for the cleanup async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(consumer.consume_broadcast("test").is_some()); + assert!(origin.consume_broadcast("test").is_some()); drop(broadcast1); // Wait for the cleanup async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(consumer.consume_broadcast("test").is_none()); + assert!(origin.consume_broadcast("test").is_none()); } #[tokio::test] async fn test_double_publish() { let origin = Origin::produce(); - let consumer = origin.consume(); let broadcast = Broadcast::produce(); // Ensure it doesn't crash. origin.publish_broadcast("test", broadcast.consume()); origin.publish_broadcast("test", broadcast.consume()); - assert!(consumer.consume_broadcast("test").is_some()); + assert!(origin.consume_broadcast("test").is_some()); drop(broadcast); // Wait for the async task to run. tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; - assert!(consumer.consume_broadcast("test").is_none()); + assert!(origin.consume_broadcast("test").is_none()); } // There was a tokio bug where only the first 127 broadcasts would be received instantly. #[tokio::test] #[should_panic] async fn test_128() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); for i in 0..256 { origin.publish_broadcast(format!("test{i}"), broadcast.consume()); } + let mut consumer = origin.consume(); for i in 0..256 { consumer.assert_next(format!("test{i}"), &broadcast.consume()); } @@ -784,13 +796,13 @@ mod tests { #[tokio::test] async fn test_128_fix() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); for i in 0..256 { origin.publish_broadcast(format!("test{i}"), broadcast.consume()); } + let mut consumer = origin.consume(); for i in 0..256 { // try_next does not have the same issue because it's synchronous. consumer.assert_try_next(format!("test{i}"), &broadcast.consume()); @@ -800,7 +812,6 @@ mod tests { #[tokio::test] async fn test_with_root_basic() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); // Create a producer with root "/foo" @@ -810,6 +821,7 @@ mod tests { // When publishing to "bar/baz", it should actually publish to "foo/bar/baz" assert!(foo_producer.publish_broadcast("bar/baz", broadcast.consume())); + let mut consumer = origin.consume(); // The original consumer should see the full path consumer.assert_next("foo/bar/baz", &broadcast.consume()); @@ -821,7 +833,6 @@ mod tests { #[tokio::test] async fn test_with_root_nested() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); // Create nested roots @@ -832,6 +843,7 @@ mod tests { // Publishing to "baz" should actually publish to "foo/bar/baz" assert!(foo_bar_producer.publish_broadcast("baz", broadcast.consume())); + let mut consumer = origin.consume(); // The original consumer sees the full path consumer.assert_next("foo/bar/baz", &broadcast.consume()); @@ -872,7 +884,6 @@ mod tests { #[tokio::test] async fn test_consume_only_filters() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); @@ -883,7 +894,7 @@ mod tests { origin.publish_broadcast("notallowed", broadcast3.consume()); // Create a consumer that only sees "allowed" paths - let mut limited_consumer = consumer + let mut limited_consumer = origin .consume_only(&["allowed".into()]) .expect("should create limited consumer"); @@ -892,7 +903,8 @@ mod tests { limited_consumer.assert_next("allowed/nested", &broadcast2.consume()); limited_consumer.assert_next_wait(); // Should not see "notallowed" - // Original consumer should see all + // Unscoped consumer should see all + let mut consumer = origin.consume(); consumer.assert_next("allowed", &broadcast1.consume()); consumer.assert_next("allowed/nested", &broadcast2.consume()); consumer.assert_next("notallowed", &broadcast3.consume()); @@ -901,7 +913,6 @@ mod tests { #[tokio::test] async fn test_consume_only_multiple_prefixes() { let origin = Origin::produce(); - let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); @@ -911,7 +922,7 @@ mod tests { origin.publish_broadcast("baz/test", broadcast3.consume()); // Consumer that only sees "foo" and "bar" paths - let mut limited_consumer = consumer + let mut limited_consumer = origin .consume_only(&["foo".into(), "bar".into()]) .expect("should create limited consumer"); @@ -923,7 +934,6 @@ mod tests { #[tokio::test] async fn test_with_root_and_publish_only() { let origin = Origin::produce(); - let mut consumer = origin.consume(); let broadcast = Broadcast::produce(); // User connects to /foo root @@ -946,6 +956,7 @@ mod tests { assert!(!limited_producer.publish_broadcast("goop/other", broadcast.consume())); // Original consumer sees full paths + let mut consumer = origin.consume(); consumer.assert_next("foo/bar", &broadcast.consume()); consumer.assert_next("foo/bar/nested", &broadcast.consume()); consumer.assert_next("foo/goop/pee", &broadcast.consume()); @@ -1017,7 +1028,6 @@ mod tests { #[tokio::test] async fn test_consume_broadcast_with_permissions() { let origin = Origin::produce(); - let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); @@ -1025,7 +1035,7 @@ mod tests { origin.publish_broadcast("notallowed/test", broadcast2.consume()); // Create limited consumer - let limited_consumer = consumer + let limited_consumer = origin .consume_only(&["allowed".into()]) .expect("should create limited consumer"); @@ -1038,6 +1048,7 @@ mod tests { assert!(limited_consumer.consume_broadcast("notallowed/test").is_none()); // Original consumer can get both + let consumer = origin.consume(); assert!(consumer.consume_broadcast("allowed/test").is_some()); assert!(consumer.consume_broadcast("notallowed/test").is_some()); } @@ -1066,7 +1077,6 @@ mod tests { #[tokio::test] async fn test_multiple_consumers_with_different_permissions() { let origin = Origin::produce(); - let consumer = origin.consume(); let broadcast1 = Broadcast::produce(); let broadcast2 = Broadcast::produce(); let broadcast3 = Broadcast::produce(); @@ -1077,15 +1087,15 @@ mod tests { origin.publish_broadcast("baz/test", broadcast3.consume()); // Create consumers with different permissions - let mut foo_consumer = consumer + let mut foo_consumer = origin .consume_only(&["foo".into()]) .expect("should create foo consumer"); - let mut bar_consumer = consumer + let mut bar_consumer = origin .consume_only(&["bar".into()]) .expect("should create bar consumer"); - let mut foobar_consumer = consumer + let mut foobar_consumer = origin .consume_only(&["foo".into(), "bar".into()]) .expect("should create foobar consumer"); diff --git a/rs/moq-lite/src/model/track.rs b/rs/moq-lite/src/model/track.rs index 8ba18bc2ec..06f69508e3 100644 --- a/rs/moq-lite/src/model/track.rs +++ b/rs/moq-lite/src/model/track.rs @@ -54,7 +54,7 @@ pub struct TrackProducer { } impl TrackProducer { - fn new(info: Track) -> Self { + pub fn new(info: Track) -> Self { Self { info, state: Default::default(), diff --git a/rs/moq-native/examples/chat.rs b/rs/moq-native/examples/chat.rs index 1bd1cb9a90..b3f1e2ec3f 100644 --- a/rs/moq-native/examples/chat.rs +++ b/rs/moq-native/examples/chat.rs @@ -7,14 +7,13 @@ async fn main() -> anyhow::Result<()> { // Create an origin that we can publish to and the session can consume from. let origin = moq_lite::Origin::produce(); - let consumer = origin.consume(); // Run the broadcast production and the session in parallel. // This is a simple example of how you can concurrently run multiple tasks. // tokio::spawn works too. tokio::select! { + res = run_session(origin.consume()) => res, res = run_broadcast(origin) => res, - res = run_session(consumer) => res, } } diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index e749ef495c..ade5ffa9e3 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, path::PathBuf}; use anyhow::Context; use moq_lite::{Broadcast, BroadcastConsumer, BroadcastProducer, Origin, OriginConsumer, OriginProducer}; @@ -60,13 +60,13 @@ pub struct Cluster { noop: BroadcastProducer, // Broadcasts announced by local clients (users). - pub primary: Arc, + pub primary: OriginProducer, // Broadcasts announced by remote servers (cluster). - pub secondary: Arc, + pub secondary: OriginProducer, // Broadcasts announced by local clients and remote servers. - pub combined: Arc, + pub combined: OriginProducer, } impl Cluster { @@ -75,9 +75,9 @@ impl Cluster { config, client, noop: Broadcast::produce(), - primary: Arc::new(Origin::produce()), - secondary: Arc::new(Origin::produce()), - combined: Arc::new(Origin::produce()), + primary: Origin::produce(), + secondary: Origin::produce(), + combined: Origin::produce(), } } @@ -110,9 +110,8 @@ impl Cluster { pub fn get(&self, broadcast: &str) -> Option { self.primary - .consume() .consume_broadcast(broadcast) - .or_else(|| self.secondary.consume().consume_broadcast(broadcast)) + .or_else(|| self.secondary.consume_broadcast(broadcast)) } pub async fn run(self) -> anyhow::Result<()> { @@ -268,7 +267,7 @@ impl Cluster { .client .clone() .with_publish(self.primary.consume()) - .with_consume((*self.secondary).clone()) + .with_consume(self.secondary.clone()) .connect(url.clone()) .await .context("failed to connect to remote")?; From 129a9fe9aad018dcb40cd29e41aa43970d9e1efd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Jan 2026 13:29:25 -0300 Subject: [PATCH 016/590] Add some more comments. (#878) --- js/hang/src/watch/audio/source.ts | 1 + js/hang/src/watch/video/source.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/js/hang/src/watch/audio/source.ts b/js/hang/src/watch/audio/source.ts index f85f982f2a..210a0b73b4 100644 --- a/js/hang/src/watch/audio/source.ts +++ b/js/hang/src/watch/audio/source.ts @@ -43,6 +43,7 @@ export class Source { catalog = new Signal(undefined); config = new Signal(undefined); + // Used to target a latency and synchronize playback of video with audio. sync: Sync; // The name of the active rendition. diff --git a/js/hang/src/watch/video/source.ts b/js/hang/src/watch/video/source.ts index f1f61f8045..bbd6eebd9b 100644 --- a/js/hang/src/watch/video/source.ts +++ b/js/hang/src/watch/video/source.ts @@ -69,7 +69,7 @@ export class Source { #timestamp = new Signal(undefined); readonly timestamp: Getter = this.#timestamp; - // Additional buffer in milliseconds (on top of catalog's minBuffer). + // Used to target a latency and synchronize playback of video with audio. sync: Sync; // The display size of the video in pixels, ideally sourced from the catalog. @@ -206,6 +206,10 @@ export class Source { }); const decoder = new VideoDecoder({ + // NOTE: This function runs in parallel for each frame. + // This is an easy way to support b-frames without needing to reorder them, but it introduces some race conditions. + // VideoDecoder has some backpressure to avoid too many open VideoFrame objects, so we do need to close them. + // At the time of writing, Firefox only allows 2? open VideoFrames at a time. output: async (frame: VideoFrame) => { try { const timestamp = Time.Milli.fromMicro(frame.timestamp as Time.Micro); @@ -214,12 +218,16 @@ export class Source { return; } + // If there's no frame, then use the current one so we're rendering at least something. if (this.frame.peek() === undefined) { - // Render something while we wait for the sync to catch up. + // NOTE: We don't set timestamp here to avoid dropping "old" frames. this.frame.set(frame.clone()); } + // Wait until we should render this timestamp given the target latency. + // TODO We should have an async way to check if we should drop this frame, to reduce backpressure. const wait = this.sync.wait(timestamp).then(() => true); + const ok = await Promise.race([wait, effect.cancel]); if (!ok) return; @@ -229,6 +237,7 @@ export class Source { return; } + // Update the timestamp of the current frame. this.#timestamp.set(timestamp); this.frame.update((prev) => { @@ -284,6 +293,7 @@ export class Source { if (!next) break; // Mark that we received this frame right now. + // This is used to detect the live playhead position, so we can have exactly x milliseconds of buffer. this.sync.update(Time.Milli.fromMicro(next.timestamp as Time.Micro)); const chunk = new EncodedVideoChunk({ @@ -342,6 +352,7 @@ export class Source { }); // Mark that we received this frame right now. + // This is used to detect the live playhead position, so we can have exactly x milliseconds of buffer. this.sync.update(Time.Milli.fromMicro(sample.timestamp as Time.Micro)); // Track stats From 449b1c61b3be7603a7304e09c7b8462e93b3d049 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Jan 2026 14:51:03 -0300 Subject: [PATCH 017/590] Add support for multiple groups, and fetching them (#877) --- rs/hang/examples/video.rs | 2 +- rs/hang/src/model/broadcast.rs | 2 +- rs/moq-lite/src/ietf/subscriber.rs | 2 +- rs/moq-lite/src/lite/publisher.rs | 69 ++----------- rs/moq-lite/src/model/broadcast.rs | 58 +++++------ rs/moq-lite/src/model/group.rs | 20 ++++ rs/moq-lite/src/model/track.rs | 109 ++++++++++++++++----- rs/moq-relay/src/auth.rs | 2 +- rs/moq-relay/src/web.rs | 151 +++++++++++++++++++++++------ 9 files changed, 264 insertions(+), 151 deletions(-) diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index 920435deb3..eecabbedc5 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -93,7 +93,7 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> hang::TrackProdu .produce(); // Publish the catalog track to the broadcast. - broadcast.insert_track(catalog.track.consume()); + broadcast.insert_track(catalog.track.clone()); // Actually create the media track now. let track = broadcast.create_track(video_track); diff --git a/rs/hang/src/model/broadcast.rs b/rs/hang/src/model/broadcast.rs index c2407587d3..86838cea51 100644 --- a/rs/hang/src/model/broadcast.rs +++ b/rs/hang/src/model/broadcast.rs @@ -19,7 +19,7 @@ pub struct BroadcastProducer { impl BroadcastProducer { pub fn new(mut inner: moq_lite::BroadcastProducer) -> Self { let catalog = Catalog::default().produce(); - inner.insert_track(catalog.track.consume()); + inner.insert_track(catalog.track.clone()); Self { inner, diff --git a/rs/moq-lite/src/ietf/subscriber.rs b/rs/moq-lite/src/ietf/subscriber.rs index 3d729a6478..5354aa1699 100644 --- a/rs/moq-lite/src/ietf/subscriber.rs +++ b/rs/moq-lite/src/ietf/subscriber.rs @@ -466,7 +466,7 @@ impl Subscriber { // NOTE: This is debated in the IETF draft, but is significantly easier to implement. let mut broadcast = self.start_announce(msg.track_namespace.to_owned())?; - let exists = broadcast.insert_track(track.consume()); + let exists = broadcast.insert_track(track); if exists { tracing::warn!(track = %msg.track_name, "track already exists, replacing it"); } diff --git a/rs/moq-lite/src/lite/publisher.rs b/rs/moq-lite/src/lite/publisher.rs index c90ef99f57..4f0c90c65d 100644 --- a/rs/moq-lite/src/lite/publisher.rs +++ b/rs/moq-lite/src/lite/publisher.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use futures::{FutureExt, StreamExt, stream::FuturesUnordered}; use web_async::FuturesExt; use crate::{ @@ -215,49 +216,21 @@ impl Publisher { priority: PriorityQueue, version: Version, ) -> Result<(), Error> { - // TODO use a BTreeMap serve the latest N groups by sequence. - // Until then, we'll implement N=2 manually. - // Also, this is more complicated because we can't use tokio because of WASM. - // We need to drop futures in order to cancel them and keep polling them with select! - let mut old_group = None; - let mut new_group = None; - - // Annoying that we can't use a tuple here as we need the compiler to infer the type. - // Otherwise we'd have to pick Send or !Send... - let mut old_sequence = None; - let mut new_sequence = None; - - // Keep reading groups from the track, some of which may arrive out of order. + let mut tasks = FuturesUnordered::new(); + loop { let group = tokio::select! { - biased; + // Poll all active group futures; never matches but keeps them running. + true = async { + while tasks.next().await.is_some() {} + false + } => unreachable!(), Some(group) = track.next_group().transpose() => group, - Some(_) = async { Some(old_group.as_mut()?.await) } => { - old_group = None; - old_sequence = None; - continue; - }, - Some(_) = async { Some(new_group.as_mut()?.await) } => { - new_group = old_group; - new_sequence = old_sequence; - old_group = None; - old_sequence = None; - continue; - }, else => return Ok(()), }?; let sequence = group.info.sequence; - let latest = new_sequence.as_ref().unwrap_or(&0); - - tracing::debug!(subscribe = %subscribe.id, track = %track.info.name, sequence, latest, "serving group"); - - // If this group is older than the oldest group we're serving, skip it. - // We always serve at most two groups, but maybe we should serve only sequence >= MAX-1. - if sequence < *old_sequence.as_ref().unwrap_or(&0) { - tracing::debug!(subscribe = %subscribe.id, track = %track.info.name, old = %sequence, %latest, "skipping group"); - continue; - } + tracing::debug!(subscribe = %subscribe.id, track = %track.info.name, sequence, "serving group"); let msg = lite::Group { subscribe: subscribe.id, @@ -265,29 +238,7 @@ impl Publisher { }; let priority = priority.insert(track.info.priority, sequence); - - // Spawn a task to serve this group, ignoring any errors because they don't really matter. - // TODO add some logging at least. - let handle = Box::pin(Self::serve_group(session.clone(), msg, priority, group, version)); - - // Terminate the old group if it's still running. - if let Some(old_sequence) = old_sequence.take() { - tracing::debug!(subscribe = %subscribe.id, track = %track.info.name, old = %old_sequence, %latest, "aborting group"); - old_group.take(); // Drop the future to cancel it. - } - - assert!(old_group.is_none()); - - if sequence >= *latest { - old_group = new_group; - old_sequence = new_sequence; - - new_group = Some(handle); - new_sequence = Some(sequence); - } else { - old_group = Some(handle); - old_sequence = Some(sequence); - } + tasks.push(Self::serve_group(session.clone(), msg, priority, group, version).map(|_| ())); } } diff --git a/rs/moq-lite/src/model/broadcast.rs b/rs/moq-lite/src/model/broadcast.rs index 15affe5229..d57a190319 100644 --- a/rs/moq-lite/src/model/broadcast.rs +++ b/rs/moq-lite/src/model/broadcast.rs @@ -16,11 +16,11 @@ use super::Track; struct State { // When explicitly publishing, we hold a reference to the consumer. // This prevents the track from being marked as "unused". - published: HashMap, + consumers: HashMap, // When requesting, we hold a reference to the producer for dynamic tracks. // The track will be marked as "unused" when the last consumer is dropped. - requested: HashMap, + producers: HashMap, } /// A collection of media tracks that can be published and subscribed to. @@ -58,8 +58,8 @@ impl BroadcastProducer { pub fn new() -> Self { Self { state: Lock::new(State { - published: HashMap::new(), - requested: HashMap::new(), + consumers: HashMap::new(), + producers: HashMap::new(), }), closed: Default::default(), requested: async_channel::unbounded(), @@ -74,24 +74,24 @@ impl BroadcastProducer { /// Produce a new track and insert it into the broadcast. pub fn create_track(&mut self, track: Track) -> TrackProducer { - let track = track.produce(); - self.insert_track(track.consume()); + let track = TrackProducer::new(track); + self.insert_track(track.clone()); track } /// Insert a track into the lookup, returning true if it was unique. - pub fn insert_track(&mut self, track: TrackConsumer) -> bool { + /// + /// NOTE: You probably want to [TrackProducer::clone] to keep publishing to the track. + pub fn insert_track(&mut self, track: TrackProducer) -> bool { let mut state = self.state.lock(); - let unique = state.published.insert(track.info.name.clone(), track.clone()).is_none(); - let removed = state.requested.remove(&track.info.name).is_some(); - - unique && !removed + state.consumers.insert(track.info.name.clone(), track.consume()); + state.producers.insert(track.info.name.clone(), track).is_none() } /// Remove a track from the lookup. pub fn remove_track(&mut self, name: &str) -> bool { let mut state = self.state.lock(); - state.published.remove(name).is_some() || state.requested.remove(name).is_some() + state.consumers.remove(name).is_some() || state.producers.remove(name).is_some() } pub fn consume(&self) -> BroadcastConsumer { @@ -150,8 +150,8 @@ impl Drop for BroadcastProducer { let mut state = self.state.lock(); // Cleanup any published tracks. - state.published.clear(); - state.requested.clear(); + state.consumers.clear(); + state.producers.clear(); } } @@ -192,13 +192,7 @@ impl BroadcastConsumer { pub fn subscribe_track(&self, track: &Track) -> TrackConsumer { let mut state = self.state.lock(); - // Return any explictly published track. - if let Some(consumer) = state.published.get(&track.name).cloned() { - return consumer; - } - - // Return any requested tracks. - if let Some(producer) = state.requested.get(&track.name) { + if let Some(producer) = state.producers.get(&track.name) { return producer.consume(); } @@ -219,13 +213,13 @@ impl BroadcastConsumer { } // Insert the producer into the lookup so we will deduplicate requests. - state.requested.insert(producer.info.name.clone(), producer.clone()); + state.producers.insert(producer.info.name.clone(), producer.clone()); // Remove the track from the lookup when it's unused. let state = self.state.clone(); web_async::spawn(async move { producer.unused().await; - state.lock().requested.remove(&producer.info.name); + state.lock().producers.remove(&producer.info.name); }); consumer @@ -268,19 +262,19 @@ mod test { let mut track1 = Track::new("track1").produce(); // Make sure we can insert before a consumer is created. - producer.insert_track(track1.consume()); + producer.insert_track(track1.clone()); track1.append_group(); let consumer = producer.consume(); - let mut track1_sub = consumer.subscribe_track(&track1.info); + let mut track1_sub = consumer.subscribe_track(&Track::new("track1")); track1_sub.assert_group(); let mut track2 = Track::new("track2").produce(); - producer.insert_track(track2.consume()); + producer.insert_track(track2.clone()); let consumer2 = producer.consume(); - let mut track2_consumer = consumer2.subscribe_track(&track2.info); + let mut track2_consumer = consumer2.subscribe_track(&Track::new("track2")); track2_consumer.assert_no_group(); track2.append_group(); @@ -330,9 +324,8 @@ mod test { consumer.assert_not_closed(); // Create a new track and insert it into the broadcast. - let mut track1 = Track::new("track1").produce(); + let mut track1 = producer.create_track(Track::new("track1")); track1.append_group(); - producer.insert_track(track1.consume()); let mut track1c = consumer.subscribe_track(&track1.info); let track2 = consumer.subscribe_track(&Track::new("track2")); @@ -405,10 +398,9 @@ mod test { #[tokio::test] async fn requested_unused() { let mut broadcast = Broadcast::produce(); - let consumer = broadcast.consume(); // Subscribe to a track that doesn't exist - this creates a request - let consumer1 = consumer.subscribe_track(&Track::new("unknown_track")); + let consumer1 = broadcast.consume().subscribe_track(&Track::new("unknown_track")); // Get the requested track producer let producer1 = broadcast.assert_request(); @@ -420,7 +412,7 @@ mod test { ); // Making a new consumer will keep the producer alive - let consumer2 = consumer.subscribe_track(&Track::new("unknown_track")); + let consumer2 = broadcast.consume().subscribe_track(&Track::new("unknown_track")); consumer2.assert_is_clone(&consumer1); // Drop the consumer subscription @@ -447,7 +439,7 @@ mod test { tokio::time::sleep(std::time::Duration::from_millis(1)).await; // Now the cleanup task should have run and we can subscribe again to the unknown track. - let consumer3 = consumer.subscribe_track(&Track::new("unknown_track")); + let consumer3 = broadcast.consume().subscribe_track(&Track::new("unknown_track")); let producer2 = broadcast.assert_request(); // Drop the consumer, now the producer should be unused diff --git a/rs/moq-lite/src/model/group.rs b/rs/moq-lite/src/model/group.rs index 43ab738a77..de82266c02 100644 --- a/rs/moq-lite/src/model/group.rs +++ b/rs/moq-lite/src/model/group.rs @@ -215,4 +215,24 @@ impl GroupConsumer { } } } + + pub async fn get_frame(&self, index: usize) -> Result> { + let mut state = self.state.clone(); + let Ok(state) = state + .wait_for(|state| index < state.frames.len() || state.closed.is_some()) + .await + else { + return Err(Error::Cancel); + }; + + if let Some(frame) = state.frames.get(index).cloned() { + return Ok(Some(frame)); + } + + match &state.closed { + Some(Ok(_)) => Ok(None), + Some(Err(err)) => Err(err.clone()), + _ => unreachable!(), + } + } } diff --git a/rs/moq-lite/src/model/track.rs b/rs/moq-lite/src/model/track.rs index 06f69508e3..7f0d715e45 100644 --- a/rs/moq-lite/src/model/track.rs +++ b/rs/moq-lite/src/model/track.rs @@ -18,7 +18,9 @@ use crate::{Error, Result}; use super::{Group, GroupConsumer, GroupProducer}; -use std::{cmp::Ordering, future::Future}; +use std::{collections::VecDeque, future::Future}; + +const MAX_CACHE: std::time::Duration = std::time::Duration::from_secs(30); #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -42,8 +44,28 @@ impl Track { #[derive(Default)] struct TrackState { - latest: Option, + groups: VecDeque<(tokio::time::Instant, GroupConsumer)>, closed: Option>, + offset: usize, + + max_sequence: Option, + + // The largest sequence number that has been dropped. + drop_sequence: Option, +} + +impl TrackState { + fn trim(&mut self, now: tokio::time::Instant) { + while let Some((timestamp, _)) = self.groups.front() { + if now.saturating_duration_since(*timestamp) > MAX_CACHE { + let (_, group) = self.groups.pop_front().unwrap(); + self.drop_sequence = Some(self.drop_sequence.unwrap_or(0).max(group.info.sequence)); + self.offset += 1; + } else { + break; + } + } + } } /// A producer for a track, used to create new groups. @@ -65,16 +87,10 @@ impl TrackProducer { pub fn insert_group(&mut self, group: GroupConsumer) -> bool { self.state.send_if_modified(|state| { assert!(state.closed.is_none()); - - if let Some(latest) = &state.latest { - match group.info.cmp(&latest.info) { - Ordering::Less => return false, - Ordering::Equal => return false, - Ordering::Greater => (), - } - } - - state.latest = Some(group.clone()); + let now = tokio::time::Instant::now(); + state.trim(now); + state.groups.push_back((now, group.clone())); + state.max_sequence = Some(state.max_sequence.unwrap_or(0).max(group.info.sequence)); true }) } @@ -94,11 +110,15 @@ impl TrackProducer { self.state.send_if_modified(|state| { assert!(state.closed.is_none()); - let sequence = state.latest.as_ref().map_or(0, |group| group.info.sequence + 1); + let now = tokio::time::Instant::now(); + state.trim(now); + + let sequence = state.max_sequence.map_or(0, |sequence| sequence + 1); let group = Group { sequence }.produce(); - state.latest = Some(group.consume()); - producer = Some(group); + state.groups.push_back((now, group.consume())); + state.max_sequence = Some(sequence); + producer = Some(group); true }); @@ -122,10 +142,12 @@ impl TrackProducer { /// Create a new consumer for the track. pub fn consume(&self) -> TrackConsumer { + let state = self.state.borrow(); TrackConsumer { info: self.info.clone(), state: self.state.subscribe(), - prev: None, + // Start at the latest group + index: state.offset + state.groups.len().saturating_sub(1), } } @@ -154,7 +176,7 @@ impl From for TrackProducer { pub struct TrackConsumer { pub info: Track, state: watch::Receiver, - prev: Option, // The previous sequence number + index: usize, } impl TrackConsumer { @@ -166,24 +188,61 @@ impl TrackConsumer { let Ok(state) = self .state .wait_for(|state| { - state.latest.as_ref().map(|group| group.info.sequence) > self.prev || state.closed.is_some() + let index = self.index.saturating_sub(state.offset); + state.groups.get(index).is_some() || state.closed.is_some() }) .await else { return Err(Error::Cancel); }; + let index = self.index.saturating_sub(state.offset); + if let Some(group) = state.groups.get(index) { + self.index = state.offset + index + 1; + return Ok(Some(group.1.clone())); + } + match &state.closed { - Some(Ok(_)) => return Ok(None), - Some(Err(err)) => return Err(err.clone()), - _ => {} + Some(Ok(_)) => Ok(None), + Some(Err(err)) => Err(err.clone()), + _ => unreachable!(), } + } + + /// Block until the group is available. + /// + /// NOTE: This can block indefinitely if the requested group is dropped. + pub async fn get_group(&self, sequence: u64) -> Result> { + let mut state = self.state.clone(); - // If there's a new latest group, return it. - let group = state.latest.clone().unwrap(); - self.prev = Some(group.info.sequence); + let Ok(state) = state + .wait_for(|state| { + if state.closed.is_some() { + return true; + } - Ok(Some(group)) + if let Some(drop_sequence) = state.drop_sequence + && drop_sequence >= sequence + { + return true; + } + + state.groups.iter().any(|(_, group)| group.info.sequence == sequence) + }) + .await + else { + return Err(Error::Cancel); + }; + + if let Some((_, group)) = state.groups.iter().find(|(_, group)| group.info.sequence == sequence) { + return Ok(Some(group.clone())); + } + + match &state.closed { + Some(Ok(_)) => Ok(None), // end of stream + Some(Err(err)) => Err(err.clone()), + None => Ok(None), // Dropped + } } /// Block until the track is closed. diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index b011c97a8c..cb4c7f7018 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -68,7 +68,7 @@ pub struct AuthToken { pub cluster: bool, } -const REFRESH_ERROR_INTERVAL: Duration = Duration::from_mins(5); +const REFRESH_ERROR_INTERVAL: Duration = Duration::from_secs(300); #[derive(Clone)] pub struct Auth { diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 620fa9bbb4..81534f97fd 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -22,18 +22,12 @@ use axum::{ use bytes::Bytes; use clap::Parser; use moq_lite::{OriginConsumer, OriginProducer}; -use serde::{Deserialize, Serialize}; use std::future::Future; use tower_http::cors::{Any, CorsLayer}; use crate::{Auth, Cluster}; -#[derive(Debug, Deserialize)] -struct Params { - jwt: Option, -} - -#[derive(Parser, Clone, Debug, Deserialize, Serialize, Default)] +#[derive(Parser, Clone, Debug, serde::Deserialize, serde::Serialize, Default)] #[serde(deny_unknown_fields, default)] pub struct WebConfig { #[command(flatten)] @@ -166,10 +160,70 @@ async fn serve_fingerprint(State(state): State>) -> String { .clone() } +#[derive(Debug, serde::Deserialize)] +struct AuthParams { + jwt: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct FetchParams { + #[serde(flatten)] + auth: AuthParams, + + #[serde(default)] + group: FetchGroup, + + #[serde(default)] + frame: FetchFrame, +} + +#[derive(Debug, Default)] +enum FetchGroup { + // Return the group at the given sequence number. + Num(u64), + + // Return the latest group. + #[default] + Latest, +} + +impl<'de> serde::Deserialize<'de> for FetchGroup { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if let Ok(num) = s.parse::() { + Ok(FetchGroup::Num(num)) + } else if s == "latest" { + Ok(FetchGroup::Latest) + } else { + Err(serde::de::Error::custom(format!("invalid group value: {s}"))) + } + } +} + +#[derive(Debug, Default)] +enum FetchFrame { + Num(usize), + #[default] + Chunked, +} + +impl<'de> serde::Deserialize<'de> for FetchFrame { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if let Ok(num) = s.parse::() { + Ok(FetchFrame::Num(num)) + } else if s == "chunked" { + Ok(FetchFrame::Chunked) + } else { + Err(serde::de::Error::custom(format!("invalid frame value: {s}"))) + } + } +} + async fn serve_ws( ws: WebSocketUpgrade, Path(path): Path, - Query(params): Query, + Query(params): Query, State(state): State>, ) -> axum::response::Result { let ws = ws.protocols(["webtransport"]); @@ -229,7 +283,7 @@ where /// Serve the announced broadcasts for a given prefix. async fn serve_announced( path: Option>, - Query(params): Query, + Query(params): Query, State(state): State>, ) -> axum::response::Result { let prefix = match path { @@ -253,10 +307,10 @@ async fn serve_announced( Ok(broadcasts.iter().map(|p| p.to_string()).collect::>().join("\n")) } -/// Serve the latest group for a given track +/// Serve the given group for a given track async fn serve_fetch( Path(path): Path, - Query(params): Query, + Query(params): Query, State(state): State>, ) -> axum::response::Result { // The path containts a broadcast/track @@ -269,7 +323,7 @@ async fn serve_fetch( } let broadcast = path.join("/"); - let token = state.auth.verify(&broadcast, params.jwt.as_deref())?; + let token = state.auth.verify(&broadcast, params.auth.jwt.as_deref())?; let Some(origin) = state.cluster.subscriber(&token) else { return Err(StatusCode::UNAUTHORIZED.into()); @@ -286,39 +340,76 @@ async fn serve_fetch( let broadcast = origin.consume_broadcast("").ok_or(StatusCode::NOT_FOUND)?; let mut track = broadcast.subscribe_track(&track); - let Ok(group) = track.next_group().await else { - return Err(StatusCode::INTERNAL_SERVER_ERROR.into()); - }; - let Some(group) = group else { - return Err(StatusCode::NOT_FOUND.into()); - }; + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30); + + let result = tokio::time::timeout_at(deadline, async { + let group = match params.group { + FetchGroup::Latest => track.next_group().await, + FetchGroup::Num(sequence) => track.get_group(sequence).await, + }; + + let group = match group { + Ok(Some(group)) => group, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + tracing::info!(track = %track.info.name, group = %group.info.sequence, "serving group"); + + match params.frame { + FetchFrame::Num(index) => match group.get_frame(index).await { + Ok(Some(frame)) => Ok(ServeGroup { + group: None, + frame: Some(frame), + deadline, + }), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + FetchFrame::Chunked => Ok(ServeGroup { + group: Some(group), + frame: None, + deadline, + }), + } + }) + .await; - Ok(ServeGroup::new(group)) + match result { + Ok(Ok(serve)) => Ok(serve), + Ok(Err(status)) => Err(status.into()), + Err(_) => Err(StatusCode::GATEWAY_TIMEOUT.into()), + } } struct ServeGroup { - group: moq_lite::GroupConsumer, + group: Option, frame: Option, + deadline: tokio::time::Instant, } impl ServeGroup { - fn new(group: moq_lite::GroupConsumer) -> Self { - Self { group, frame: None } - } - async fn next(&mut self) -> moq_lite::Result> { - loop { + while self.group.is_some() || self.frame.is_some() { if let Some(frame) = self.frame.as_mut() { - let data = frame.read_all().await?; + let data = tokio::time::timeout_at(self.deadline, frame.read_all()) + .await + .map_err(|_| moq_lite::Error::Timeout)?; self.frame.take(); - return Ok(Some(data)); + return Ok(Some(data?)); } - self.frame = self.group.next_frame().await?; - if self.frame.is_none() { - return Ok(None); + if let Some(group) = self.group.as_mut() { + self.frame = tokio::time::timeout_at(self.deadline, group.next_frame()) + .await + .map_err(|_| moq_lite::Error::Timeout)??; + if self.frame.is_none() { + self.group.take(); + } } } + + Ok(None) } } From 527ee95ce1849924509dd76d2bea622500220994 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Jan 2026 17:08:37 -0300 Subject: [PATCH 018/590] Export Varint module from @moq/lite (#881) Co-authored-by: Claude --- js/hang/src/container/legacy.ts | 63 +--------------- js/lite/src/index.ts | 1 + js/lite/src/stream.ts | 81 +++----------------- js/lite/src/varint.test.ts | 129 ++++++++++++++++++++++++++++++++ js/lite/src/varint.ts | 114 ++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 133 deletions(-) create mode 100644 js/lite/src/varint.test.ts create mode 100644 js/lite/src/varint.ts diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index d3ab74a1cd..5e1b4f4b0d 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -35,7 +35,7 @@ export class Producer { } static #encode(source: Uint8Array | Source, timestamp: Time.Micro): Uint8Array { - const timestampBytes = encodeVarInt(timestamp); + const timestampBytes = Moq.Varint.encode(timestamp); // Allocate buffer for timestamp + payload const payloadSize = source instanceof Uint8Array ? source.byteLength : source.byteLength; @@ -259,7 +259,7 @@ export class Consumer { // NOTE: A keyframe is always the first frame in a group, so it's not encoded on the wire. static #decode(buffer: Uint8Array): { data: Uint8Array; timestamp: Time.Micro } { - const [timestamp, data] = decodeVarInt(buffer); + const [timestamp, data] = Moq.Varint.decode(buffer); return { timestamp: timestamp as Time.Micro, data }; } @@ -274,62 +274,3 @@ export class Consumer { this.#groups.length = 0; } } - -// TODO: Export this from @moq/lite to avoid duplication - -const MAX_U6 = 2 ** 6 - 1; -const MAX_U14 = 2 ** 14 - 1; -const MAX_U30 = 2 ** 30 - 1; -const MAX_U53 = Number.MAX_SAFE_INTEGER; - -function decodeVarInt(buf: Uint8Array): [number, Uint8Array] { - const size = 1 << ((buf[0] & 0xc0) >> 6); - - const view = new DataView(buf.buffer, buf.byteOffset, size); - const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size); - let v: number; - - if (size === 1) { - v = buf[0] & 0x3f; - } else if (size === 2) { - v = view.getUint16(0) & 0x3fff; - } else if (size === 4) { - v = view.getUint32(0) & 0x3fffffff; - } else if (size === 8) { - // NOTE: Precision loss above 2^52 - v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn); - } else { - throw new Error("impossible"); - } - - return [v, remain]; -} - -function encodeVarInt(v: number): Uint8Array { - const dst = new Uint8Array(8); - - if (v <= MAX_U6) { - dst[0] = v; - return new Uint8Array(dst.buffer, dst.byteOffset, 1); - } - - if (v <= MAX_U14) { - const view = new DataView(dst.buffer, dst.byteOffset, 2); - view.setUint16(0, v | 0x4000); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - - if (v <= MAX_U30) { - const view = new DataView(dst.buffer, dst.byteOffset, 4); - view.setUint32(0, v | 0x80000000); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - - if (v <= MAX_U53) { - const view = new DataView(dst.buffer, dst.byteOffset, 8); - view.setBigUint64(0, BigInt(v) | 0xc000000000000000n); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - } - - throw new Error(`overflow, value larger than 53-bits: ${v}`); -} diff --git a/js/lite/src/index.ts b/js/lite/src/index.ts index cf64a47056..02470b6083 100644 --- a/js/lite/src/index.ts +++ b/js/lite/src/index.ts @@ -5,3 +5,4 @@ export * from "./group.ts"; export * as Path from "./path.ts"; export * as Time from "./time.ts"; export * from "./track.ts"; +export * as Varint from "./varint.ts"; diff --git a/js/lite/src/stream.ts b/js/lite/src/stream.ts index 9e5d053e59..7edb12b9a0 100644 --- a/js/lite/src/stream.ts +++ b/js/lite/src/stream.ts @@ -1,12 +1,6 @@ -const MAX_U6 = 2 ** 6 - 1; -const MAX_U14 = 2 ** 14 - 1; -const MAX_U30 = 2 ** 30 - 1; -const MAX_U31 = 2 ** 31 - 1; -const MAX_U53 = Number.MAX_SAFE_INTEGER; - -// TODO: Figure out why webpack is converting this to Math.pow -//const MAX_U62: bigint = 2n ** 62n - 1n; +import * as Varint from "./varint.ts"; +const MAX_U31 = 2 ** 31 - 1; const MAX_READ_SIZE = 1024 * 1024 * 64; // don't allocate more than 64MB for a message export class Stream { @@ -161,7 +155,7 @@ export class Reader { // Returns a Number using 53-bits, the max Javascript can use for integer math async u53(): Promise { const v = await this.u62(); - if (v > MAX_U53) { + if (v > Varint.MAX_U53) { throw new Error("value larger than 53-bits; use v62 instead"); } @@ -251,27 +245,14 @@ export class Writer { } async u53(v: number) { - if (v < 0) { - throw new Error(`underflow, value is negative: ${v.toString()}`); - } - if (v > MAX_U53) { + if (v > Varint.MAX_U53) { throw new Error(`overflow, value larger than 53-bits: ${v.toString()}`); } - - await this.write(setVint53(this.#scratch, v)); + await this.write(Varint.encodeTo(this.#scratch, v)); } async u62(v: bigint) { - if (v < 0) { - throw new Error(`underflow, value is negative: ${v.toString()}`); - } - /* - if (v >= MAX_U62) { - throw new Error(`overflow, value larger than 62-bits: ${v}`); - } - */ - - await this.write(setVint62(this.#scratch, v)); + await this.write(Varint.encodeTo(this.#scratch, v)); } async write(v: Uint8Array) { @@ -302,68 +283,24 @@ export class Writer { } } -export function setUint8(dst: ArrayBuffer, v: number): Uint8Array { +function setUint8(dst: ArrayBuffer, v: number): Uint8Array { const buffer = new Uint8Array(dst, 0, 1); buffer[0] = v; return buffer; } -export function setUint16(dst: ArrayBuffer, v: number): Uint8Array { +function setUint16(dst: ArrayBuffer, v: number): Uint8Array { const view = new DataView(dst, 0, 2); view.setUint16(0, v); return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); } -export function setInt32(dst: ArrayBuffer, v: number): Uint8Array { +function setInt32(dst: ArrayBuffer, v: number): Uint8Array { const view = new DataView(dst, 0, 4); view.setInt32(0, v); return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); } -export function setUint32(dst: ArrayBuffer, v: number): Uint8Array { - const view = new DataView(dst, 0, 4); - view.setUint32(0, v); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); -} - -export function setVint53(dst: ArrayBuffer, v: number): Uint8Array { - if (v <= MAX_U6) { - return setUint8(dst, v); - } - if (v <= MAX_U14) { - return setUint16(dst, v | 0x4000); - } - if (v <= MAX_U30) { - return setUint32(dst, v | 0x80000000); - } - if (v <= MAX_U53) { - return setUint64(dst, BigInt(v) | 0xc000000000000000n); - } - throw new Error(`overflow, value larger than 53-bits: ${v.toString()}`); -} - -export function setVint62(dst: ArrayBuffer, v: bigint): Uint8Array { - if (v < MAX_U6) { - return setUint8(dst, Number(v)); - } - if (v < MAX_U14) { - return setUint16(dst, Number(v) | 0x4000); - } - if (v <= MAX_U30) { - return setUint32(dst, Number(v) | 0x80000000); - } - //if (v <= MAX_U62) { - return setUint64(dst, BigInt(v) | 0xc000000000000000n); - //} - //throw new Error(`overflow, value larger than 62-bits: ${v}`); -} - -export function setUint64(dst: ArrayBuffer, v: bigint): Uint8Array { - const view = new DataView(dst, 0, 8); - view.setBigUint64(0, v); - return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); -} - // Returns the next stream from the connection export class Readers { #reader: ReadableStreamDefaultReader>; diff --git a/js/lite/src/varint.test.ts b/js/lite/src/varint.test.ts new file mode 100644 index 0000000000..a3f8a5173b --- /dev/null +++ b/js/lite/src/varint.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert"; +import test from "node:test"; +import * as Varint from "./varint.ts"; + +test("Varint encode/decode roundtrip - 1 byte values (0-63)", () => { + const testValues = [0, 1, 32, 63]; + + for (const value of testValues) { + const encoded = Varint.encode(value); + assert.strictEqual(encoded.byteLength, 1, `${value} should encode to 1 byte`); + + const [decoded, remaining] = Varint.decode(encoded); + assert.strictEqual(decoded, value, `${value} should decode correctly`); + assert.strictEqual(remaining.byteLength, 0, "remaining should be empty"); + } +}); + +test("Varint encode/decode roundtrip - 2 byte values (64-16383)", () => { + const testValues = [64, 100, 1000, 16383]; + + for (const value of testValues) { + const encoded = Varint.encode(value); + assert.strictEqual(encoded.byteLength, 2, `${value} should encode to 2 bytes`); + + const [decoded, remaining] = Varint.decode(encoded); + assert.strictEqual(decoded, value, `${value} should decode correctly`); + assert.strictEqual(remaining.byteLength, 0, "remaining should be empty"); + } +}); + +test("Varint encode/decode roundtrip - 4 byte values (16384-1073741823)", () => { + const testValues = [16384, 100000, 1073741823]; + + for (const value of testValues) { + const encoded = Varint.encode(value); + assert.strictEqual(encoded.byteLength, 4, `${value} should encode to 4 bytes`); + + const [decoded, remaining] = Varint.decode(encoded); + assert.strictEqual(decoded, value, `${value} should decode correctly`); + assert.strictEqual(remaining.byteLength, 0, "remaining should be empty"); + } +}); + +test("Varint encode/decode roundtrip - 8 byte values (1073741824+)", () => { + const testValues = [1073741824, Number.MAX_SAFE_INTEGER]; + + for (const value of testValues) { + const encoded = Varint.encode(value); + assert.strictEqual(encoded.byteLength, 8, `${value} should encode to 8 bytes`); + + const [decoded, remaining] = Varint.decode(encoded); + assert.strictEqual(decoded, value, `${value} should decode correctly`); + assert.strictEqual(remaining.byteLength, 0, "remaining should be empty"); + } +}); + +test("Varint size calculation", () => { + assert.strictEqual(Varint.size(0), 1); + assert.strictEqual(Varint.size(63), 1); + assert.strictEqual(Varint.size(64), 2); + assert.strictEqual(Varint.size(16383), 2); + assert.strictEqual(Varint.size(16384), 4); + assert.strictEqual(Varint.size(1073741823), 4); + assert.strictEqual(Varint.size(1073741824), 8); + assert.strictEqual(Varint.size(Number.MAX_SAFE_INTEGER), 8); +}); + +test("Varint decode returns remaining buffer", () => { + // Encode a value and append extra data + const encoded = Varint.encode(42); + const extra = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const combined = new Uint8Array(encoded.byteLength + extra.byteLength); + combined.set(encoded, 0); + combined.set(extra, encoded.byteLength); + + const [decoded, remaining] = Varint.decode(combined); + assert.strictEqual(decoded, 42); + assert.deepEqual(remaining, extra); +}); + +test("Varint decode handles buffer at non-zero offset", () => { + // Create a buffer with padding before the varint + const padding = new Uint8Array([0xff, 0xff]); + const encoded = Varint.encode(1000); // 2-byte varint + const combined = new Uint8Array(padding.byteLength + encoded.byteLength); + combined.set(padding, 0); + combined.set(encoded, padding.byteLength); + + // Create a subarray starting after the padding + const subarray = combined.subarray(padding.byteLength); + + const [decoded, remaining] = Varint.decode(subarray); + assert.strictEqual(decoded, 1000); + assert.strictEqual(remaining.byteLength, 0); +}); + +test("Varint encode rejects negative values", () => { + assert.throws(() => Varint.encode(-1), /underflow/); +}); + +test("Varint decode throws on empty buffer", () => { + assert.throws(() => Varint.decode(new Uint8Array(0)), /buffer is empty/); +}); + +test("Varint decode throws on truncated buffer", () => { + // Create a 2-byte varint header but only provide 1 byte + const truncated = new Uint8Array([0x40]); // 0x40 = 2-byte marker with value 0 + assert.throws(() => Varint.decode(truncated), /buffer too short/); +}); + +test("Varint boundary values", () => { + // Test exact boundary values + const boundaries = [ + { value: 63, expectedSize: 1 }, + { value: 64, expectedSize: 2 }, + { value: 16383, expectedSize: 2 }, + { value: 16384, expectedSize: 4 }, + { value: 1073741823, expectedSize: 4 }, + { value: 1073741824, expectedSize: 8 }, + ]; + + for (const { value, expectedSize } of boundaries) { + const encoded = Varint.encode(value); + assert.strictEqual(encoded.byteLength, expectedSize, `${value} should encode to ${expectedSize} bytes`); + + const [decoded] = Varint.decode(encoded); + assert.strictEqual(decoded, value, `${value} should roundtrip correctly`); + } +}); diff --git a/js/lite/src/varint.ts b/js/lite/src/varint.ts new file mode 100644 index 0000000000..241427c3c2 --- /dev/null +++ b/js/lite/src/varint.ts @@ -0,0 +1,114 @@ +// QUIC variable-length integer encoding/decoding +// https://www.rfc-editor.org/rfc/rfc9000#section-16 + +export const MAX_U6 = 2 ** 6 - 1; +export const MAX_U14 = 2 ** 14 - 1; +export const MAX_U30 = 2 ** 30 - 1; +export const MAX_U53 = Number.MAX_SAFE_INTEGER; + +/** + * Returns the number of bytes needed to encode a value as a varint. + */ +export function size(v: number): number { + if (v <= MAX_U6) return 1; + if (v <= MAX_U14) return 2; + if (v <= MAX_U30) return 4; + if (v <= MAX_U53) return 8; + throw new Error(`overflow, value larger than 53-bits: ${v}`); +} + +// Helper functions for writing to an ArrayBuffer +function setUint8(dst: ArrayBuffer, v: number): Uint8Array { + const buffer = new Uint8Array(dst, 0, 1); + buffer[0] = v; + return buffer; +} + +function setUint16(dst: ArrayBuffer, v: number): Uint8Array { + const view = new DataView(dst, 0, 2); + view.setUint16(0, v); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +function setUint32(dst: ArrayBuffer, v: number): Uint8Array { + const view = new DataView(dst, 0, 4); + view.setUint32(0, v); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +function setUint64(dst: ArrayBuffer, v: bigint): Uint8Array { + const view = new DataView(dst, 0, 8); + view.setBigUint64(0, v); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +const MAX_U62 = 2n ** 62n - 1n; + +/** + * Encodes a number or bigint into a scratch buffer. + * Used by stream.ts to avoid allocations. + */ +export function encodeTo(dst: ArrayBuffer, v: number | bigint): Uint8Array { + const b = BigInt(v); + if (b < 0n) { + throw new Error(`underflow, value is negative: ${v}`); + } + if (b > MAX_U62) { + throw new Error(`overflow, value larger than 62-bits: ${v}`); + } + const n = Number(b); + if (n <= MAX_U6) { + return setUint8(dst, n); + } + if (n <= MAX_U14) { + return setUint16(dst, n | 0x4000); + } + if (n <= MAX_U30) { + return setUint32(dst, n | 0x80000000); + } + return setUint64(dst, b | 0xc000000000000000n); +} + +/** + * Encodes a number as a QUIC variable-length integer. + * Returns a new Uint8Array containing the encoded bytes. + */ +export function encode(v: number): Uint8Array { + return encodeTo(new ArrayBuffer(8), v); +} + +/** + * Decodes a QUIC variable-length integer from a buffer. + * Returns a tuple of [value, remaining buffer]. + */ +export function decode(buf: Uint8Array): [number, Uint8Array] { + if (buf.length === 0) { + throw new Error("buffer is empty"); + } + + const size = 1 << ((buf[0] & 0xc0) >> 6); + + if (buf.length < size) { + throw new Error(`buffer too short: need ${size} bytes, have ${buf.length}`); + } + + const view = new DataView(buf.buffer, buf.byteOffset, size); + const remain = buf.subarray(size); + + let v: number; + + if (size === 1) { + v = buf[0] & 0x3f; + } else if (size === 2) { + v = view.getUint16(0) & 0x3fff; + } else if (size === 4) { + v = view.getUint32(0) & 0x3fffffff; + } else if (size === 8) { + // NOTE: Precision loss above 2^53, but we're using number type + v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn); + } else { + throw new Error("impossible"); + } + + return [v, remain]; +} From 007ab5109d687a29940d7d7a588eb21ddd526058 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Jan 2026 18:36:51 -0300 Subject: [PATCH 019/590] Remove stats component test files (#882) Co-authored-by: Claude --- .../components/stats/__tests__/Stats.test.tsx | 225 ----------- .../__tests__/components/StatsItem.test.tsx | 363 ------------------ .../__tests__/components/StatsPanel.test.tsx | 81 ---- .../stats/__tests__/providers/audio.test.ts | 255 ------------ .../stats/__tests__/providers/base.test.ts | 59 --- .../stats/__tests__/providers/network.test.ts | 309 --------------- .../__tests__/providers/registry.test.ts | 60 --- .../stats/__tests__/providers/video.test.ts | 181 --------- .../components/stats/__tests__/utils.ts | 89 ----- 9 files changed, 1622 deletions(-) delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts delete mode 100644 js/hang-ui/src/shared/components/stats/__tests__/utils.ts diff --git a/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx deleted file mode 100644 index a9269f3ac1..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { createContext, createSignal } from "solid-js"; -import { render } from "solid-js/web"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Stats } from ".."; -import type { ProviderProps } from "../types"; -import { createMockProviderProps } from "./utils"; - -describe("Stats Component", () => { - let container: HTMLDivElement; - let dispose: (() => void) | undefined; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - - // Mock fetch to prevent network requests for Icon SVG files - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - }); - }); - - afterEach(() => { - dispose?.(); - dispose = undefined; - vi.restoreAllMocks(); - }); - - it("renders stats container", () => { - const mockProps = createMockProviderProps(); - const TestContext = createContext(mockProps); - - dispose = render( - () => ( - - context={TestContext} getElement={() => mockProps} /> - - ), - container, - ); - - const stats = container.querySelector(".stats"); - expect(stats).toBeTruthy(); - }); - - it("waits for audio and video before rendering", async () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - - dispose = render(() => { - const [mediaElement, setMediaElement] = createSignal(undefined); - - // Set media element after initial render - setTimeout(() => setMediaElement(createMockProviderProps()), 100); - - return ( - - context={TestContext} getElement={() => mediaElement()} /> - - ); - }, container); - - // Initially no StatsPanel should be rendered - let panel = container.querySelector(".stats__panel"); - expect(panel).toBeFalsy(); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - // Now panel should be rendered - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("only renders when both audio and video are available", async () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - const mockWithoutVideo = createMockProviderProps({ video: false }); - - dispose = render(() => { - const [mediaElement, setMediaElement] = createSignal | undefined>(mockWithoutVideo); - - // Set full props after initial render - setTimeout(() => setMediaElement(createMockProviderProps()), 100); - - return ( - - - context={TestContext} - getElement={() => mediaElement() as ProviderProps | undefined} - /> - - ); - }, container); - - let panel = container.querySelector(".stats__panel"); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("works with different context types", () => { - interface CustomContext { - hangWatch: () => ProviderProps | undefined; - } - - const mockProps = createMockProviderProps(); - const contextValue: CustomContext = { - hangWatch: () => mockProps, - }; - const CustomTestContext = createContext(contextValue); - - dispose = render( - () => ( - - context={CustomTestContext} getElement={(ctx) => ctx?.hangWatch()} /> - - ), - container, - ); - - const stats = container.querySelector(".stats"); - expect(stats).toBeTruthy(); - - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("provides context value to child components", () => { - const mockProps = createMockProviderProps(); - const TestContext = createContext(mockProps); - - dispose = render( - () => ( - - context={TestContext} getElement={() => mockProps} /> - - ), - container, - ); - - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("handles undefined context gracefully", () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - - dispose = render( - () => ( - - context={TestContext} getElement={() => undefined} /> - - ), - container, - ); - - // Should not render panel when element is undefined - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeFalsy(); - }); - - it("updates when context changes", async () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - - dispose = render(() => { - const [element, setElement] = createSignal(undefined); - - // Set element after initial render - setTimeout(() => setElement(createMockProviderProps()), 100); - - return ( - - context={TestContext} getElement={() => element()} /> - - ); - }, container); - - let panel = container.querySelector(".stats__panel"); - expect(panel).toBeFalsy(); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("rerenders when getElement function returns different values", async () => { - const TestContext = createContext("test"); - const element1 = createMockProviderProps(); - const element2 = createMockProviderProps(); - - dispose = render(() => { - const [key, setKey] = createSignal<"element1" | "element2">("element1"); - - const getElement = (_ctx: string) => { - return key() === "element1" ? element1 : element2; - }; - - // Change key after initial render - setTimeout(() => setKey("element2"), 100); - - return ( - - context={TestContext} getElement={getElement} /> - - ); - }, container); - - let panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx deleted file mode 100644 index e97d3ae3f8..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { createSignal } from "solid-js"; -import { render } from "solid-js/web"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as Icon from "../../../icon/icon"; -import { StatsItem } from "../../components/StatsItem"; -import type { BaseProvider } from "../../providers/base"; -import * as registry from "../../providers/registry"; -import type { ProviderContext, ProviderProps } from "../../types"; -import { createMockProviderProps } from "../utils"; - -vi.mock("../../providers/registry", () => ({ - getStatsInformationProvider: vi.fn(), -})); - -describe("StatsItem", () => { - let container: HTMLDivElement; - let dispose: (() => void) | undefined; - let mockAudioVideo: ProviderProps; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - mockAudioVideo = createMockProviderProps(); - - // Mock fetch to prevent network requests for Icon SVG files - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - }); - }); - - afterEach(() => { - dispose?.(); - dispose = undefined; - document.body.removeChild(container); - vi.clearAllMocks(); - }); - - it("renders with correct base structure", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const item = container.querySelector(".stats__item"); - expect(item).toBeTruthy(); - expect(item?.classList.contains("stats__item--network")).toBe(true); - }); - - it("renders icon wrapper with SVG content", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render(() => { - const testSvg = ( - - ); - - return ( - - ); - }, container); - - const iconWrapper = container.querySelector(".stats__icon-wrapper"); - expect(iconWrapper).toBeTruthy(); - - const icon = container.querySelector("svg[data-testid='test-icon']"); - expect(icon).toBeTruthy(); - }); - - it("renders item detail with icon text", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const iconText = container.querySelector(".stats__item-title"); - expect(iconText?.textContent).toBe("audio"); - }); - - it("displays N/A when no provider is available", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const dataDisplay = container.querySelector(".stats__item-data"); - expect(dataDisplay?.textContent).toBe("N/A"); - }); - - it("initializes provider with audio and video props", () => { - const mockProvider: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(MockProviderClass).toHaveBeenCalledWith(mockAudioVideo); - }); - - it("calls provider setup with setDisplayData callback", () => { - const mockProvider: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(mockProvider.setup).toHaveBeenCalled(); - - const setupCall = vi.mocked(mockProvider.setup).mock.calls[0][0] as ProviderContext; - expect(setupCall.setDisplayData).toBeDefined(); - expect(typeof setupCall.setDisplayData).toBe("function"); - }); - - it("updates display data when provider calls setDisplayData", () => { - let capturedSetDisplayData: ((data: string) => void) | undefined; - - const mockProvider: Pick = { - setup: vi.fn((context: ProviderContext) => { - capturedSetDisplayData = context.setDisplayData; - }), - cleanup: vi.fn(), - }; - - const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(capturedSetDisplayData).toBeDefined(); - - capturedSetDisplayData?.("42 kbps"); - - const dataDisplay = container.querySelector(".stats__item-data"); - expect(dataDisplay?.textContent).toBe("42 kbps"); - }); - - it("renders correct class for each icon type", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - const statsProvider = [ - { name: "network", Icon: Icon.Network }, - { name: "video", Icon: Icon.Video }, - { name: "audio", Icon: Icon.Audio }, - { name: "buffer", Icon: Icon.Buffer }, - ] as const; - - statsProvider.forEach((provider) => { - const testContainer = document.createElement("div"); - document.body.appendChild(testContainer); - - const testDispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - testContainer, - ); - - const item = testContainer.querySelector(".stats__item"); - expect(item?.classList.contains(`stats__item--${provider.name}`)).toBe(true); - - testDispose(); - document.body.removeChild(testContainer); - }); - }); - - it("cleans up previous provider before initializing new one", async () => { - const mockProvider1: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const mockProvider2: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const MockProviderClass1 = vi.fn(() => mockProvider1) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - const MockProviderClass2 = vi.fn(() => mockProvider2) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - - let _callCount = 0; - vi.mocked(registry.getStatsInformationProvider).mockImplementation(() => { - if (_callCount === 0) { - _callCount++; - return MockProviderClass1; - } - return MockProviderClass2; - }); - - dispose = render(() => { - const [statProvider, setStatProvider] = createSignal<"network" | "video">("network"); - - // Switch provider after initial render - setTimeout(() => { - _callCount = 0; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass2); - setStatProvider("video"); - }, 0); - - return ( - : } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ); - }, container); - - expect(mockProvider1.setup).toHaveBeenCalled(); - - // Wait for setTimeout - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(mockProvider1.cleanup).toHaveBeenCalled(); - expect(mockProvider2.setup).toHaveBeenCalled(); - }); - - it("maintains correct DOM hierarchy", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const item = container.querySelector(".stats__item"); - expect(item?.children.length).toBe(2); - - const iconWrapper = item?.querySelector(".stats__icon-wrapper"); - expect(iconWrapper).toBeTruthy(); - expect(iconWrapper?.children.length).toBe(1); - - const detail = item?.querySelector(".stats__item-detail"); - expect(detail).toBeTruthy(); - expect(detail?.children.length).toBe(2); - - expect(detail?.querySelector(".stats__item-title")).toBeTruthy(); - expect(detail?.querySelector(".stats__item-data")).toBeTruthy(); - }); - - it("calls getStatsInformationProvider with correct statProvider", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(registry.getStatsInformationProvider).toHaveBeenCalledWith("buffer"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx deleted file mode 100644 index 09a44c71a7..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { render } from "solid-js/web"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StatsPanel, statsDetailItems } from "../../components/StatsPanel"; -import type { ProviderProps } from "../../types"; -import { createMockProviderProps } from "../utils"; - -describe("StatsPanel", () => { - let container: HTMLDivElement; - let dispose: (() => void) | undefined; - let mockAudioVideo: ProviderProps; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - mockAudioVideo = createMockProviderProps(); - - // Mock fetch to prevent network requests for Icon SVG files - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - }); - }); - - afterEach(() => { - dispose?.(); - dispose = undefined; - document.body.removeChild(container); - vi.restoreAllMocks(); - }); - - it("renders with correct base class", () => { - dispose = render(() => , container); - - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("renders all metric items", () => { - dispose = render(() => , container); - - const items = container.querySelectorAll(".stats__item"); - expect(items.length).toBe(statsDetailItems.length); - }); - - it("renders items with correct icon types", () => { - const expectedIcons = ["network", "video", "audio", "buffer"]; - dispose = render(() => , container); - - const items = container.querySelectorAll(".stats__item"); - items.forEach((item, index) => { - expect(item.classList.contains(`stats__item--${expectedIcons[index]}`)).toBe(true); - }); - }); - - it("renders each item with icon wrapper", () => { - dispose = render(() => , container); - - const wrappers = container.querySelectorAll(".stats__icon-wrapper"); - expect(wrappers.length).toBe(4); - }); - - it("renders each item with detail section", () => { - dispose = render(() => , container); - - const details = container.querySelectorAll(".stats__item-detail"); - expect(details.length).toBe(4); - }); - - it("maintains correct DOM structure", () => { - dispose = render(() => , container); - - const panel = container.querySelector(".stats__panel"); - const items = panel?.querySelectorAll(".stats__item"); - - expect(panel?.children.length).toBe(4); - items?.forEach((item) => { - expect(item.querySelector(".stats__icon-wrapper")).toBeTruthy(); - expect(item.querySelector(".stats__item-detail")).toBeTruthy(); - }); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts deleted file mode 100644 index 081f542efe..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AudioProvider } from "../../providers/audio"; -import type { AudioConfig, AudioSource, AudioStats, ProviderContext, ProviderProps } from "../../types"; -import { createMockProviderProps } from "../utils"; - -declare global { - var __advanceTime: (ms: number) => void; -} - -describe("AudioProvider", () => { - let provider: AudioProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - let intervalCallback: ((interval: number) => void) | null = null; - let originalWindow: typeof window; - let originalPerformance: typeof performance; - let mockClearInterval: ReturnType; - - beforeEach(() => { - originalWindow = global.window; - originalPerformance = global.performance as unknown as Performance; - setDisplayData = vi.fn(); - context = { setDisplayData }; - intervalCallback = null; - - // Mock window functions - const mockSetInterval = vi.fn((callback: () => void) => { - intervalCallback = callback as unknown as (interval: number) => void; - return 1; - }); - mockClearInterval = vi.fn(); - - global.window = { - setInterval: mockSetInterval, - clearInterval: mockClearInterval, - } as unknown as typeof window; - - // Mock performance.now() - let mockTime = 0; - global.performance = { - now: vi.fn(() => mockTime), - } as unknown as Performance; - - // Helper to advance mock time - global.__advanceTime = (ms: number) => { - mockTime += ms; - }; - }); - - afterEach(() => { - provider?.cleanup(); - global.window = originalWindow; - global.performance = originalPerformance as unknown as Performance; - }); - - it("should display N/A when audio source is not available", () => { - const props: ProviderProps = {}; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should display audio config with stats placeholder on first call", () => { - const audioConfig: AudioConfig = { - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }; - - const stats: AudioStats = { bytesReceived: 0 }; - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => stats, - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("48.0kHz\n2ch\nN/A\nopus"); - }); - - it("should calculate bitrate from bytesReceived delta", () => { - const audioConfig: AudioConfig = { - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }; - - const peekFn = vi.fn(() => ({ bytesReceived: 0 }) as AudioStats); - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: peekFn, - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - - // First call - start with bytes already flowing - peekFn.mockReturnValue({ bytesReceived: 5000 } as AudioStats); - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("48.0kHz\n2ch\nN/A\nopus"); - - // Simulate bytesReceived increasing: 6250 bytes delta = 200 kbps at 250ms interval - // (6250 bytes * 8 bits/byte * 4) / 1000 = 200 kbps - peekFn.mockReturnValue({ bytesReceived: 11250 } as AudioStats); - global.__advanceTime(250); - intervalCallback?.(250); - expect(setDisplayData).toHaveBeenNthCalledWith(2, "48.0kHz\n2ch\n200kbps\nopus"); - - // Increase bytes more: delta 3125 = 100 kbps - peekFn.mockReturnValue({ bytesReceived: 14375 } as AudioStats); - global.__advanceTime(250); - intervalCallback?.(250); - expect(setDisplayData).toHaveBeenNthCalledWith(3, "48.0kHz\n2ch\n100kbps\nopus"); - }); - - it("should display N/A when active or config is missing", () => { - const audio: AudioSource = { - source: { - active: { - peek: () => undefined, - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => undefined, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ bytesReceived: 0 }), - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should handle mono audio", () => { - const audioConfig: AudioConfig = { - sampleRate: 44100, - numberOfChannels: 1, - bitrate: 128000, - codec: "opus", - }; - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ bytesReceived: 0 }), - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("44.1kHz\n1ch\nN/A\nopus"); - }); - - it("should format Mbps for high bitrates", () => { - const audioConfig: AudioConfig = { - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }; - - const peekFn = vi.fn(() => ({ bytesReceived: 0 }) as AudioStats); - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: peekFn, - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - - // First call - display audio config with stats placeholder on first call - peekFn.mockReturnValue({ bytesReceived: 48000 } as AudioStats); - provider.setup(context); - // Simulate 5 Mbps: 156250 bytes delta = 5000000 bits/s = 5 Mbps - peekFn.mockReturnValue({ bytesReceived: 204250 } as AudioStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "48.0kHz\n2ch\n5.0Mbps\nopus"); - }); - - it("should cleanup interval on dispose", () => { - const props: ProviderProps = createMockProviderProps({ video: false }); - provider = new AudioProvider(props); - provider.setup(context); - - provider.cleanup(); - - expect(mockClearInterval).toHaveBeenCalledTimes(1); - expect(mockClearInterval).toHaveBeenCalledWith(1); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts deleted file mode 100644 index c70a27f27c..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BaseProvider } from "../../providers/base"; -import type { ProviderContext, ProviderProps } from "../../types"; - -class TestProvider extends BaseProvider { - public setupCalled = false; - public setupContext: ProviderContext | undefined; - public cleanupCalled = false; - - setup(context: ProviderContext): void { - this.setupCalled = true; - this.setupContext = context; - } - - cleanup(): void { - this.cleanupCalled = true; - super.cleanup(); - } -} - -describe("BaseProvider", () => { - let provider: TestProvider; - let context: ProviderContext; - - beforeEach(() => { - const props: ProviderProps = {}; - provider = new TestProvider(props); - context = { - setDisplayData: vi.fn(), - }; - }); - - it("should initialize with props", () => { - const props: ProviderProps = { audio: undefined, video: undefined }; - const testProvider = new TestProvider(props); - expect(testProvider).toBeDefined(); - }); - - it("should call setup method", () => { - provider.setup(context); - expect(provider.setupCalled).toBe(true); - expect(provider.setupContext).toEqual(context); - }); - - it("should cleanup", () => { - provider.setup(context); - expect(provider.cleanupCalled).toBe(false); - provider.cleanup(); - expect(provider.cleanupCalled).toBe(true); - expect(provider.setupCalled).toBe(true); - }); - - it("should have setup and cleanup methods", () => { - expect(provider.setup).toBeDefined(); - expect(provider.cleanup).toBeDefined(); - expect(typeof provider.setup).toBe("function"); - expect(typeof provider.cleanup).toBe("function"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts deleted file mode 100644 index 1aed84d59c..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { NetworkProvider } from "../../providers/network"; -import type { ProviderContext, ProviderProps } from "../../types"; - -interface MockConnection { - effectiveType?: "slow-2g" | "2g" | "3g" | "4g"; - downlinkMax?: number; - downlink?: number; - rtt?: number; - saveData?: boolean; - addEventListener?: (type: string, listener: () => void) => void; - removeEventListener?: (type: string, listener: () => void) => void; -} - -const mockNavigator: { onLine: boolean; connection?: MockConnection } = { - onLine: true, - connection: undefined, -}; - -describe("NetworkProvider", () => { - let provider: NetworkProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - - beforeEach(() => { - setDisplayData = vi.fn(); - context = { setDisplayData }; - - Object.defineProperty(window, "navigator", { - value: mockNavigator, - writable: true, - configurable: true, - }); - - vi.useFakeTimers(); - }); - - afterEach(() => { - provider?.cleanup(); - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it("should display N/A initially when no connection info", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.connection = undefined; - mockNavigator.onLine = true; - - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should display offline status when browser is offline", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = false; - mockNavigator.connection = { - effectiveType: "4g" as const, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("offline"); - }); - - it("should display effective connection type", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G"); - }); - - it("should map all effective connection types", () => { - const effectiveTypes: Array<["slow-2g", string] | ["2g", string] | ["3g", string] | ["4g", string]> = [ - ["slow-2g", "Slow-2G"], - ["2g", "2G"], - ["3g", "3G"], - ["4g", "4G"], - ]; - - for (const [type, expected] of effectiveTypes) { - setDisplayData.mockClear(); - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { effectiveType: type }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith(expected); - provider.cleanup(); - } - }); - - it("should display bandwidth in Gbps", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 5000, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n5.0Gbps"); - }); - - it("should display bandwidth in Mbps", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 50, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n50.0Mbps"); - }); - - it("should display bandwidth in Kbps", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "3g" as const, - downlink: 0.5, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("3G\n500Kbps"); - }); - - it("should display latency in milliseconds", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - rtt: 50, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n50ms"); - }); - - it("should display save-data status when enabled", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - saveData: true, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\nSave-Data"); - }); - - it("should combine all network metrics", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 50, - rtt: 45, - saveData: false, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n50.0Mbps\n45ms"); - }); - - it("should update display on online event", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }; - - provider.setup(context); - expect(setDisplayData).toHaveBeenLastCalledWith("4G"); - - setDisplayData.mockClear(); - mockNavigator.onLine = false; - - window.dispatchEvent(new Event("offline")); - - expect(setDisplayData).toHaveBeenCalledWith("offline"); - }); - - it("should update display on offline event", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { effectiveType: "4g" as const }; - - provider.setup(context); - expect(setDisplayData).toHaveBeenLastCalledWith("4G"); - - setDisplayData.mockClear(); - mockNavigator.onLine = false; - - window.dispatchEvent(new Event("offline")); - - expect(setDisplayData).toHaveBeenCalledWith("offline"); - }); - - it("should ignore zero or negative bandwidth", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 0, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G"); - }); - - it("should ignore zero or negative latency", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - rtt: 0, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G"); - }); - - it("should cleanup event listeners", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { effectiveType: "4g" as const }; - - provider.setup(context); - - const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); - provider.cleanup(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith("online", expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith("offline", expect.any(Function)); - }); - - it("should update periodically", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 50, - }; - - provider.setup(context); - setDisplayData.mockClear(); - - vi.advanceTimersByTime(100); - - expect(setDisplayData).toHaveBeenCalled(); - }); - - it("should prefer mozilla and webkit connection fallbacks", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - - const mozConnection = { - effectiveType: "3g" as const, - }; - - Object.defineProperty(window, "navigator", { - value: { - onLine: true, - connection: undefined, - mozConnection, - }, - writable: true, - configurable: true, - }); - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("3G"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts deleted file mode 100644 index 180c997f31..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { AudioProvider } from "../../providers/audio"; -import { BufferProvider } from "../../providers/buffer"; -import { NetworkProvider } from "../../providers/network"; -import { getStatsInformationProvider, providers } from "../../providers/registry"; -import { VideoProvider } from "../../providers/video"; -import type { KnownStatsProviders } from "../../types"; - -describe("Registry", () => { - it("should have all required providers registered", () => { - const expectedProviders: KnownStatsProviders[] = ["network", "video", "audio", "buffer"]; - - for (const statProvider of expectedProviders) { - expect(providers[statProvider]).toBeDefined(); - } - }); - - it("should map video icon to VideoProvider", () => { - expect(providers.video).toBe(VideoProvider); - }); - - it("should map audio icon to AudioProvider", () => { - expect(providers.audio).toBe(AudioProvider); - }); - - it("should map buffer icon to BufferProvider", () => { - expect(providers.buffer).toBe(BufferProvider); - }); - - it("should map network icon to NetworkProvider", () => { - expect(providers.network).toBe(NetworkProvider); - }); - - it("should return correct provider class with getStatsInformationProvider", () => { - expect(getStatsInformationProvider("video")).toBe(VideoProvider); - expect(getStatsInformationProvider("audio")).toBe(AudioProvider); - expect(getStatsInformationProvider("buffer")).toBe(BufferProvider); - expect(getStatsInformationProvider("network")).toBe(NetworkProvider); - }); - - it("should return undefined for unknown icon", () => { - expect(getStatsInformationProvider("unknown" as KnownStatsProviders)).toBeUndefined(); - }); - - it("should instantiate providers correctly", () => { - const providersList: KnownStatsProviders[] = ["network", "video", "audio", "buffer"]; - - for (const statProvider of providersList) { - const ProviderClass = getStatsInformationProvider(statProvider); - expect(ProviderClass).toBeDefined(); - - if (ProviderClass) { - const instance = new ProviderClass({}); - expect(instance).toBeDefined(); - expect(instance.setup).toBeDefined(); - expect(instance.cleanup).toBeDefined(); - } - } - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts deleted file mode 100644 index ed8252a68a..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { VideoProvider } from "../../providers/video"; -import type { ProviderContext, ProviderProps, VideoSource, VideoStats } from "../../types"; -import { createMockProviderProps } from "../utils"; - -declare global { - var __advanceTime: (ms: number) => void; -} - -describe("VideoProvider", () => { - let provider: VideoProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - let intervalCallback: ((interval: number) => void) | null = null; - - beforeEach(() => { - setDisplayData = vi.fn(); - context = { setDisplayData }; - intervalCallback = null; - - // Mock window functions - const mockSetInterval = vi.fn((callback: (interval: number) => void) => { - intervalCallback = callback; - return 1 as unknown as NodeJS.Timeout; - }); - - const mockClearInterval = vi.fn(); - - global.window = { - setInterval: mockSetInterval, - clearInterval: mockClearInterval, - } as unknown as typeof window; - - // Mock performance.now() - let mockTime = 0; - global.performance = { - now: vi.fn(() => mockTime), - } as unknown as Performance; - - // Helper to advance mock time - global.__advanceTime = (ms: number) => { - mockTime += ms; - }; - }); - - afterEach(() => { - provider?.cleanup(); - }); - - it("should display N/A when video source is not available", () => { - const props: ProviderProps = {}; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should setup interval for display updates", () => { - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalled(); - }); - - it("should display video resolution with stats placeholder on first call", () => { - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("1920x1080\nN/A\nN/A"); - }); - - it("should calculate FPS from frame count and timestamp delta", () => { - const peekFn = vi.fn(); - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.stats.peek = peekFn; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - - // First call - use non-zero timestamp so next call can calculate FPS - peekFn.mockReturnValue({ frameCount: 100, timestamp: 1000000, bytesReceived: 50000 } as VideoStats); - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("1920x1080\nN/A\nN/A"); - - // Second call: 6 frames in 250ms at 24fps = exactly 24 frames per second - // frameCount delta = 106 - 100 = 6 - // timestamp delta = 250000 microseconds - // FPS = 6 / 0.25 = 24.0 fps - // bytesReceived delta = 100000 - 50000 = 50000 bytes - // bitrate = 50000 * 8 * 4 = 1600000 bits/s = 1.6Mbps - peekFn.mockReturnValue({ frameCount: 106, timestamp: 1250000, bytesReceived: 100000 } as VideoStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "1920x1080\n@24.0 fps\n1.6Mbps"); - }); - - it("should calculate bitrate from bytesReceived delta", () => { - const peekFn = vi.fn(); - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.display.peek = () => ({ width: 1280, height: 720 }); - video.source.stats.peek = peekFn; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - - // First call - use non-zero initial values - peekFn.mockReturnValue({ frameCount: 0, timestamp: 1000000, bytesReceived: 100000 } as VideoStats); - provider.setup(context); - - // Second call: 5 Mbps = 156250 bytes delta at 250ms - // (156250 * 8 * 4) / 1_000_000 = 5.0 Mbps - peekFn.mockReturnValue({ frameCount: 6, timestamp: 1250000, bytesReceived: 256250 } as VideoStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "1280x720\n@24.0 fps\n5.0Mbps"); - }); - - it("should display N/A for FPS and bitrate on first call", () => { - const props: ProviderProps = {}; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should display only resolution when stats are not available", () => { - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.display.peek = () => ({ width: 1280, height: 720 }); - video.source.stats.peek = () => undefined; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("1280x720\nN/A\nN/A"); - }); - - it("should format kbps for lower bitrates", () => { - const peekFn = vi.fn(); - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.stats.peek = peekFn; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - - // First call - use non-zero initial timestamp - peekFn.mockReturnValue({ frameCount: 0, timestamp: 1000000, bytesReceived: 100000 } as VideoStats); - provider.setup(context); - - // 256 kbps = 8000 bytes at 250ms - // (8000 * 8 * 4) / 1000 = 256 kbps - peekFn.mockReturnValue({ frameCount: 6, timestamp: 1250000, bytesReceived: 108000 } as VideoStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "1920x1080\n@24.0 fps\n256kbps"); - }); - - it("should cleanup interval on dispose", () => { - const props: ProviderProps = {}; - provider = new VideoProvider(props); - provider.setup(context); - - provider.cleanup(); - - expect(provider).toBeDefined(); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts b/js/hang-ui/src/shared/components/stats/__tests__/utils.ts deleted file mode 100644 index 1df4024a21..0000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { vi } from "vitest"; -import type { ProviderProps } from "../types"; - -export interface MockProviderPropsOptions { - audio?: boolean; - video?: boolean; -} - -/** - * Creates mock ProviderProps with configurable audio and video sources. - * Each signal includes a peek() method and a subscribe() method with vi.fn(). - * - * @param options - Configuration options - * @returns ProviderProps with mocked audio and/or video sources - * - * @example - * ```ts - * // Create mock with both audio and video - * const props = createMockProviderProps(); - * - * // Create mock with audio only - * const audioOnly = createMockProviderProps({ video: false }); - * - * // Create mock with video only - * const videoOnly = createMockProviderProps({ audio: false }); - * ``` - */ -export const createMockProviderProps = (options?: MockProviderPropsOptions): ProviderProps => { - const { audio = true, video = true } = options ?? {}; - - return { - ...(audio && { - audio: { - source: { - active: { - peek: () => "audio-active", - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - config: { - peek: () => ({ - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ - bytesReceived: 1024, - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - }, - }, - }), - ...(video && { - video: { - source: { - display: { - peek: () => ({ - width: 1920, - height: 1080, - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - bufferStatus: { - peek: () => ({ state: "filled" as const }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ - frameCount: 60, - timestamp: Date.now(), - bytesReceived: 2048, - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - }, - }, - }), - }; -}; From 2c986754e700bfecea378a10a190c92a7b17872a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Jan 2026 22:18:15 -0300 Subject: [PATCH 020/590] Remove hang-meet element (#883) Co-authored-by: Claude --- js/hang-demo/src/index.html | 1 - js/hang-demo/src/meet.html | 72 ------------ js/hang-demo/src/meet.ts | 7 -- js/hang-demo/src/publish.html | 1 - js/hang-demo/src/support.html | 1 - js/hang-demo/vite.config.ts | 1 - js/hang/README.md | 21 ---- js/hang/package.json | 5 +- js/hang/src/meet/element.ts | 199 ---------------------------------- js/hang/src/meet/index.ts | 1 - js/hang/src/meet/room.ts | 175 ------------------------------ 11 files changed, 1 insertion(+), 483 deletions(-) delete mode 100644 js/hang-demo/src/meet.html delete mode 100644 js/hang-demo/src/meet.ts delete mode 100644 js/hang/src/meet/element.ts delete mode 100644 js/hang/src/meet/index.ts delete mode 100644 js/hang/src/meet/room.ts diff --git a/js/hang-demo/src/index.html b/js/hang-demo/src/index.html index adbc0caf71..bcaab49af5 100644 --- a/js/hang-demo/src/index.html +++ b/js/hang-demo/src/index.html @@ -41,7 +41,6 @@

Other demos:

diff --git a/js/hang-demo/src/meet.html b/js/hang-demo/src/meet.html deleted file mode 100644 index 58d2612474..0000000000 --- a/js/hang-demo/src/meet.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - MoQ Demo - - - - - - - - - - - - - - - - - -

Other demos:

- - -

Tips:

-

- Broadcasts are discovered/announced via the relay server; no need for a separate signaling server. - You could download your own broadcast... or save some bandwidth and perform a local preview. - You still get a signal informing you when other viewers can see your broadcast. -

-

- A "room" of broadcasts is little more than a shared prefix. - If you publish to demo/foo and demo/bar, then using demo will render both. - You can provide the path of the broadcast via the path attribute. -

-
-

- I hightly recommend using the Javascript API instead of the Web Component, even if the API is still evolving. - There's just too much business logic involved in determining how to render broadcasts. - This demo uses the most basic grid layout with near-zero styling. - It's ugly on purpose. - There's a million different ways to render a collection of broadcasts and it's up to your application to decide. -

-

- My application renders to a shared canvas instead. - If you're interested in the details, it's similar to the VideoRenderer class. - You should instead use this library to discover broadcasts, download them, and perhaps render them. -

- - - - - diff --git a/js/hang-demo/src/meet.ts b/js/hang-demo/src/meet.ts deleted file mode 100644 index b7b23062d7..0000000000 --- a/js/hang-demo/src/meet.ts +++ /dev/null @@ -1,7 +0,0 @@ -import "./highlight"; -import "@moq/hang-ui/publish"; -import HangMeet from "@moq/hang/meet/element"; -import HangPublish from "@moq/hang/publish/element"; -import HangSupport from "@moq/hang/support/element"; - -export { HangMeet, HangSupport, HangPublish }; diff --git a/js/hang-demo/src/publish.html b/js/hang-demo/src/publish.html index 2d74b141ac..6ca52ad926 100644 --- a/js/hang-demo/src/publish.html +++ b/js/hang-demo/src/publish.html @@ -36,7 +36,6 @@

Other demos:

diff --git a/js/hang-demo/src/support.html b/js/hang-demo/src/support.html index f31267ad3d..e1095404f4 100644 --- a/js/hang-demo/src/support.html +++ b/js/hang-demo/src/support.html @@ -18,7 +18,6 @@

Other demos:

Tips:

diff --git a/js/hang-demo/vite.config.ts b/js/hang-demo/vite.config.ts index a64e72788d..466cc76444 100644 --- a/js/hang-demo/vite.config.ts +++ b/js/hang-demo/vite.config.ts @@ -13,7 +13,6 @@ export default defineConfig({ watch: "index.html", publish: "publish.html", support: "support.html", - meet: "meet.html", }, }, }, diff --git a/js/hang/README.md b/js/hang/README.md index 15eba0506e..61ae82a5c0 100644 --- a/js/hang/README.md +++ b/js/hang/README.md @@ -152,27 +152,6 @@ Publishes a microphone/camera or screen as a hang broadcast. ``` -### `` - -Downloads multiple hang broadcasts and renders them in a grid. -Very crude and best as an example; use the JS API instead. - -```html - - - - -``` - -This will discover any broadcasts that start with `room123/` and render them. -You can also specify a `` child element to publish your own broadcast, using a local preview instead of downloading it. - ### `` A simple element that displays browser support. diff --git a/js/hang/package.json b/js/hang/package.json index 09a6856df7..fb1b671776 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -11,8 +11,6 @@ "./publish/element": "./src/publish/element.ts", "./watch": "./src/watch/index.ts", "./watch/element": "./src/watch/element.ts", - "./meet": "./src/meet/index.ts", - "./meet/element": "./src/meet/element.ts", "./catalog": "./src/catalog/index.ts", "./support": "./src/support/index.ts", "./support/element": "./src/support/element.ts" @@ -20,8 +18,7 @@ "sideEffects": [ "./src/publish/element.ts", "./src/watch/element.ts", - "./src/support/element.ts", - "./src/meet/element.ts" + "./src/support/element.ts" ], "scripts": { "build": "rimraf dist && tsc -b && bun ../scripts/package.ts", diff --git a/js/hang/src/meet/element.ts b/js/hang/src/meet/element.ts deleted file mode 100644 index b1572fecc3..0000000000 --- a/js/hang/src/meet/element.ts +++ /dev/null @@ -1,199 +0,0 @@ -import * as Moq from "@moq/lite"; -import { Effect, Signal } from "@moq/signals"; -import * as DOM from "@moq/signals/dom"; -import { type Publish, Watch } from ".."; -import HangPublish from "../publish/element"; -import { Room } from "./room"; - -const OBSERVED = ["url", "name", "path"] as const; -type Observed = (typeof OBSERVED)[number]; - -// This is primarily to avoid a console.warn that we didn't close() before GC. -// There's no destructor for web components so this is the best we can do. -const cleanup = new FinalizationRegistry((signals) => signals.close()); - -// NOTE: This element is more of an example of how to use the library. -// You likely want your own layout, rendering, controls, etc. -// This element instead creates a crude NxN grid of broadcasts. -export default class HangMeet extends HTMLElement { - static observedAttributes = OBSERVED; - - url = new Signal(undefined); - path = new Signal(undefined); - - connection: Moq.Connection.Reload; - room: Room; - - #enabled = new Signal(false); - - // Save a reference to the
diff --git a/js/hang-ui/src/watch/context.tsx b/js/hang-ui/src/watch/context.tsx index a81f171288..76fe7a43f3 100644 --- a/js/hang-ui/src/watch/context.tsx +++ b/js/hang-ui/src/watch/context.tsx @@ -18,7 +18,7 @@ export type Rendition = { height?: number; }; -type WatchUIContextValues = { +export type WatchUIContextValues = { hangWatch: HangWatch; watchStatus: () => WatchStatus; isPlaying: () => boolean; @@ -28,8 +28,8 @@ type WatchUIContextValues = { togglePlayback: () => void; toggleMuted: () => void; buffering: () => boolean; - jitter: () => number; - setJitter: (value: number) => void; + jitter: () => Moq.Time.Milli; + setJitter: (value: Moq.Time.Milli) => void; availableRenditions: () => Rendition[]; activeRendition: () => string | undefined; setActiveRendition: (name: string | undefined) => void; @@ -37,7 +37,7 @@ type WatchUIContextValues = { setIsStatsPanelVisible: (visible: boolean) => void; isFullscreen: () => boolean; toggleFullscreen: () => void; - timestamp: () => number; + timestamp: () => Moq.Time.Milli | undefined; videoBuffered: () => BufferedRanges; audioBuffered: () => BufferedRanges; }; @@ -76,8 +76,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp props.hangWatch.audio.muted.update((muted) => !muted); }; - const setJitter = (latency: number) => { - props.hangWatch.jitter.set(latency as Moq.Time.Milli); + const setJitter = (latency: Moq.Time.Milli) => { + props.hangWatch.jitter.set(latency); }; const setActiveRenditionValue = (name: string | undefined) => { @@ -88,7 +88,7 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp }; // Use solid helper for the new signals - const timestamp = solid(props.hangWatch.timestamp); + const timestamp = solid(props.hangWatch.video.timestamp); const videoBuffered = solid(props.hangWatch.video.buffered); const audioBuffered = solid(props.hangWatch.audio.buffered); @@ -157,8 +157,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp }); signals.effect((effect) => { - const buffering = effect.get(watch.buffering); - setBuffering(buffering); + const stalled = effect.get(watch.video.stalled); + setBuffering(stalled); }); signals.effect((effect) => { diff --git a/js/hang-ui/src/watch/hooks/use-watch-ui.ts b/js/hang-ui/src/watch/hooks/use-watch-ui.ts index 9a52540623..b0e3d0d947 100644 --- a/js/hang-ui/src/watch/hooks/use-watch-ui.ts +++ b/js/hang-ui/src/watch/hooks/use-watch-ui.ts @@ -1,7 +1,7 @@ import { useContext } from "solid-js"; -import { WatchUIContext } from "../context"; +import { WatchUIContext, type WatchUIContextValues } from "../context"; -export default function useWatchUIContext() { +export default function useWatchUIContext(): WatchUIContextValues { const context = useContext(WatchUIContext); if (!context) { diff --git a/js/hang-ui/src/watch/styles/index.css b/js/hang-ui/src/watch/styles/index.css index cab710734e..995c63553e 100644 --- a/js/hang-ui/src/watch/styles/index.css +++ b/js/hang-ui/src/watch/styles/index.css @@ -3,6 +3,13 @@ @import "../../shared/components/button/button.css"; @import "../../shared/components/stats/styles/index.css"; +/* Color variables for buffer states */ +:root { + --buffer-green: #4ade80; + --buffer-yellow: #facc15; + --buffer-red: #f87171; +} + .watchVideoContainer { display: block; position: relative; @@ -202,3 +209,123 @@ background: #1a1a1a; color: #fff; } + +/* Buffer Control styles (BEM) */ +.buffer__container { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 12px; +} + +.buffer__visualization { + position: relative; + width: 100%; + height: 52px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + cursor: pointer; + user-select: none; + padding-left: 48px; + box-sizing: border-box; +} + +.buffer__playhead { + position: absolute; + left: 48px; + top: 0; + bottom: 0; + width: 2px; + background: #fff; + z-index: 3; +} + +.buffer__track { + position: absolute; + left: 48px; + right: 0; + height: 20px; + display: flex; + align-items: center; +} + +.buffer__track--video { + top: 4px; +} + +.buffer__track--audio { + top: 28px; +} + +.buffer__track-label { + position: absolute; + left: -46px; + width: 40px; + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + text-align: right; + padding-right: 4px; + pointer-events: none; + box-sizing: border-box; +} + +.buffer__range { + position: absolute; + top: 2px; + height: calc(100% - 4px); + border-radius: 2px; + min-width: 2px; + opacity: 0.85; + display: flex; + align-items: center; + justify-content: flex-end; + overflow: hidden; +} + +.buffer__overflow-label { + font-size: 9px; + color: rgba(0, 0, 0, 0.7); + padding-right: 4px; + font-weight: 500; +} + +.buffer__target-area { + position: absolute; + top: 0; + bottom: 0; + left: 48px; + right: 0; + pointer-events: none; +} + +.buffer__target-line { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background: var(--color-orange-400, #fb923c); + z-index: 2; + cursor: ew-resize; + border-radius: 1px; + pointer-events: auto; +} + +.buffer__target-label { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + font-weight: 500; + color: var(--color-orange-400, #fb923c); + white-space: nowrap; + margin-bottom: 2px; + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.buffer__visualization:hover .buffer__target-label, +.buffer__visualization--dragging .buffer__target-label { + opacity: 1; +} diff --git a/js/hang/src/container/cmaf/decode.ts b/js/hang/src/container/cmaf/decode.ts index a42e19674b..edca90ecf9 100644 --- a/js/hang/src/container/cmaf/decode.ts +++ b/js/hang/src/container/cmaf/decode.ts @@ -3,6 +3,7 @@ * Used by WebCodecs to extract raw frames from CMAF container. */ +import type { Time } from "@moq/lite"; import { type MediaHeaderBox, type ParsedIsoBox, @@ -196,6 +197,25 @@ function extractDescription(entry: any): Uint8Array | undefined { return undefined; } +/** + * Extract just the base media decode time from a data segment (moof + mdat). + * This is a lighter-weight function when you only need the timestamp. + * + * @param segment - The moof + mdat data + * @param timescale - Time units per second (from init segment) + * @returns The base media decode time in microseconds + */ +export function decodeTimestamp(segment: Uint8Array, timescale: number): Time.Micro { + const boxes = readIsoBoxes(toArrayBuffer(segment), { readers: DATA_READERS }) as ParsedIsoBox[]; + + // Find moof > traf > tfdt for base media decode time + const tfdt = findBox(boxes, isBoxType("tfdt")); + const baseDecodeTime = tfdt?.baseMediaDecodeTime ?? 0; + + // Convert to microseconds + return ((baseDecodeTime * 1_000_000) / timescale) as Time.Micro; +} + /** * Parse a data segment (moof + mdat) to extract raw samples. * diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index 5e1b4f4b0d..6111ade9e9 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -1,6 +1,6 @@ import type { Time } from "@moq/lite"; import * as Moq from "@moq/lite"; -import { Effect, Signal } from "@moq/signals"; +import { Effect, type Getter, Signal } from "@moq/signals"; export interface Source { byteLength: number; @@ -11,7 +11,6 @@ export interface Frame { data: Uint8Array; timestamp: Time.Micro; keyframe: boolean; - group: number; } // A Helper class to encode frames into a track. @@ -65,6 +64,13 @@ export interface ConsumerProps { latency?: Signal | Time.Milli; } +export interface BufferedRange { + start: Time.Milli; + end: Time.Milli; +} + +export type BufferedRanges = BufferedRange[]; + interface Group { consumer: Moq.Group; frames: Frame[]; // decode order @@ -80,6 +86,9 @@ export class Consumer { // Wake up the consumer when a new frame is available. #notify?: () => void; + #buffered = new Signal([]); + readonly buffered: Getter = this.#buffered; + #signals = new Effect(); constructor(track: Moq.Track, props?: ConsumerProps) { @@ -143,7 +152,6 @@ export class Consumer { data, timestamp, keyframe, - group: group.consumer.sequence, }; keyframe = false; @@ -154,6 +162,8 @@ export class Consumer { group.latest = timestamp; } + this.#updateBuffered(); + if (group.consumer.sequence === this.#active) { this.#notify?.(); this.#notify = undefined; @@ -209,22 +219,23 @@ export class Consumer { if (this.#active !== undefined && first.consumer.sequence <= this.#active) { this.#groups.shift(); - console.warn(`skipping slow group: ${first.consumer.sequence} < ${this.#groups[0]?.consumer.sequence}`); + this.#active = this.#groups[0]?.consumer.sequence; + console.warn(`skipping slow group: ${first.consumer.sequence} < ${this.#active}`); first.consumer.close(); first.frames.length = 0; } - // Advance to the next known group. - // NOTE: Can't be undefined, because we checked above. - this.#active = this.#groups[0]?.consumer.sequence; + this.#updateBuffered(); // Wake up any consumers waiting for a new frame. this.#notify?.(); this.#notify = undefined; } - async decode(): Promise { + // Returns the next frame in order, along with the group number. + // If frame is undefined, the group is done. + async next(): Promise<{ frame: Frame | undefined; group: number } | undefined> { for (;;) { if ( this.#groups.length > 0 && @@ -232,12 +243,19 @@ export class Consumer { this.#groups[0].consumer.sequence <= this.#active ) { const frame = this.#groups[0].frames.shift(); - if (frame) return frame; + if (frame) { + this.#updateBuffered(); + return { frame, group: this.#groups[0].consumer.sequence }; + } // Check if the group is done and then remove it. if (this.#active > this.#groups[0].consumer.sequence) { - this.#groups.shift(); - continue; + const group = this.#groups.shift(); + if (group) { + this.#updateBuffered(); + // Always true + return { frame: undefined, group: group.consumer.sequence }; + } } } @@ -263,6 +281,30 @@ export class Consumer { return { timestamp: timestamp as Time.Micro, data }; } + #updateBuffered(): void { + // Compute buffered ranges from all groups + // Each contiguous sequence of groups forms a buffered range + const ranges: BufferedRanges = []; + + for (const group of this.#groups) { + const first = group.frames.at(0); + if (!first || !group.latest) continue; + + const start = Moq.Time.Milli.fromMicro(first.timestamp); + const end = Moq.Time.Milli.fromMicro(group.latest); + + // Try to merge with the last range if contiguous + const last = ranges.at(-1); + if (last && last.end >= start) { + last.end = Math.max(last.end, end) as Time.Milli; + } else { + ranges.push({ start, end }); + } + } + + this.#buffered.set(ranges); + } + close(): void { this.#signals.close(); diff --git a/js/hang/src/watch/audio/decoder.ts b/js/hang/src/watch/audio/decoder.ts index 9bfb44977a..c06aa9f782 100644 --- a/js/hang/src/watch/audio/decoder.ts +++ b/js/hang/src/watch/audio/decoder.ts @@ -1,5 +1,5 @@ import type * as Moq from "@moq/lite"; -import type { Time } from "@moq/lite"; +import { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import * as Catalog from "../../catalog"; import * as Container from "../../container"; @@ -7,6 +7,7 @@ import * as Hex from "../../util/hex"; import * as libav from "../../util/libav"; import type { BufferedRanges } from "../backend"; import type * as Render from "./render"; +import type { ToMain } from "./render"; // Unfortunately, we need to use a Vite-exclusive import for now. import RenderWorklet from "./render-worklet.ts?worker&url"; import type { Source } from "./source"; @@ -40,7 +41,18 @@ export class Decoder { #stats = new Signal(undefined); readonly stats: Getter = this.#stats; - // Empty stub for WebCodecs (no traditional buffering) + // Current playback timestamp from worklet + #timestamp = new Signal(undefined); + readonly timestamp: Getter = this.#timestamp; + + // Whether the audio buffer is stalled (waiting to fill) + #stalled = new Signal(true); + readonly stalled: Getter = this.#stalled; + + // Decode buffer: audio sent to worklet but not yet played + #decodeBuffered = new Signal([]); + + // Combined buffered ranges (network jitter + decode buffer) #buffered = new Signal([]); readonly buffered: Getter = this.#buffered; @@ -54,6 +66,7 @@ export class Decoder { this.#signals.effect(this.#runWorklet.bind(this)); this.#signals.effect(this.#runEnabled.bind(this)); + this.#signals.effect(this.#runLatency.bind(this)); this.#signals.effect(this.#runDecoder.bind(this)); } @@ -99,10 +112,20 @@ export class Decoder { type: "init", rate: sampleRate, channels: channelCount, - latency: this.source.sync.latency.peek(), // TODO make it reactive + latency: this.source.sync.latency.peek(), // Updated reactively via #runLatency }; worklet.port.postMessage(init); + // Listen for state updates from worklet + worklet.port.onmessage = (event: MessageEvent) => { + if (event.data.type === "state") { + const timestamp = Time.Milli.fromMicro(event.data.timestamp); + this.#timestamp.set(timestamp); + this.#stalled.set(event.data.stalled); + this.#trimDecodeBuffered(timestamp); + } + }; + effect.set(this.#worklet, worklet); }); } @@ -117,6 +140,19 @@ export class Decoder { // NOTE: You should disconnect/reconnect the worklet to save power when disabled. } + #runLatency(effect: Effect): void { + const worklet = effect.get(this.#worklet); + if (!worklet) return; + + const latency = effect.get(this.source.sync.latency); + + const msg: Render.Latency = { + type: "latency", + latency, + }; + worklet.port.postMessage(msg); + } + #runDecoder(effect: Effect): void { const enabled = effect.get(this.enabled); if (!enabled) return; @@ -151,6 +187,13 @@ export class Decoder { }); effect.cleanup(() => consumer.close()); + // Combine network jitter buffer with decode buffer + effect.effect((inner) => { + const network = inner.get(consumer.buffered); + const decode = inner.get(this.#decodeBuffered); + this.#buffered.update(() => mergeBufferedRanges(network, decode)); + }); + effect.spawn(async () => { const loaded = await libav.polyfill(); if (!loaded) return; // cancelled @@ -168,8 +211,11 @@ export class Decoder { }); for (;;) { - const frame = await consumer.decode(); - if (!frame) break; + const next = await consumer.next(); + if (!next) break; + + const { frame } = next; + if (!frame) continue; // Skip over group done notifications. this.#stats.update((stats) => ({ bytesReceived: (stats?.bytesReceived ?? 0) + frame.data.byteLength, @@ -192,6 +238,13 @@ export class Decoder { const { timescale } = config.container; const description = config.description ? Hex.toBytes(config.description) : undefined; + // For CMAF, just use decode buffer (no network jitter buffer yet) + // TODO: Add CMAF consumer wrapper for latency control + effect.effect((inner) => { + const decode = inner.get(this.#decodeBuffered); + this.#buffered.update(() => decode); + }); + effect.spawn(async () => { const loaded = await libav.polyfill(); if (!loaded) return; // cancelled @@ -248,6 +301,7 @@ export class Decoder { #emit(sample: AudioData) { const timestamp = sample.timestamp as Time.Micro; + const timestampMilli = Time.Milli.fromMicro(timestamp); const worklet = this.#worklet.peek(); if (!worklet) { @@ -256,6 +310,14 @@ export class Decoder { return; } + // Calculate end time from sample duration + const durationMicro = ((sample.numberOfFrames / sample.sampleRate) * 1_000_000) as Time.Micro; + const durationMilli = Time.Milli.fromMicro(durationMicro); + const end = (timestampMilli + durationMilli) as Time.Milli; + + // Add to decode buffer + this.#addDecodeBuffered(timestampMilli, end); + const channelData: Float32Array[] = []; for (let channel = 0; channel < sample.numberOfChannels; channel++) { const data = new Float32Array(sample.numberOfFrames); @@ -279,6 +341,36 @@ export class Decoder { sample.close(); } + #addDecodeBuffered(start: Time.Milli, end: Time.Milli): void { + if (start > end) return; + + this.#decodeBuffered.mutate((current) => { + for (const range of current) { + // Extend range if new sample overlaps or is adjacent (1ms tolerance for float precision) + if (start <= range.end + 1 && end >= range.start) { + range.start = Math.min(range.start, start) as Time.Milli; + range.end = Math.max(range.end, end) as Time.Milli; + return; + } + } + + current.push({ start, end }); + current.sort((a, b) => a.start - b.start); + }); + } + + #trimDecodeBuffered(timestamp: Time.Milli): void { + this.#decodeBuffered.mutate((current) => { + while (current.length > 0) { + if (current[0].end >= timestamp) { + current[0].start = Math.max(current[0].start, timestamp) as Time.Milli; + break; + } + current.shift(); + } + }); + } + close() { this.#signals.close(); } @@ -292,3 +384,24 @@ async function supported(config: Catalog.AudioConfig): Promise { }); return res.supported ?? false; } + +// Merge two sets of buffered ranges into one sorted list +function mergeBufferedRanges(a: BufferedRanges, b: BufferedRanges): BufferedRanges { + if (a.length === 0) return b; + if (b.length === 0) return a; + + const result: BufferedRanges = []; + const all = [...a, ...b].sort((x, y) => x.start - y.start); + + for (const range of all) { + const last = result.at(-1); + if (last && last.end >= range.start) { + // Merge overlapping ranges + last.end = Math.max(last.end, range.end) as Time.Milli; + } else { + result.push({ ...range }); + } + } + + return result; +} diff --git a/js/hang/src/watch/audio/mse.ts b/js/hang/src/watch/audio/mse.ts index 81be9b8b17..d6f3e2094d 100644 --- a/js/hang/src/watch/audio/mse.ts +++ b/js/hang/src/watch/audio/mse.ts @@ -1,4 +1,4 @@ -import type * as Moq from "@moq/lite"; +import * as Moq from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import * as Catalog from "../../catalog"; import * as Container from "../../container"; @@ -103,6 +103,8 @@ export class Mse implements Backend { ): void { if (config.container.kind !== "cmaf") throw new Error("unreachable"); + const timescale = config.container.timescale; + effect.spawn(async () => { // Generate init segment from catalog config (uses track_id from container) const initSegment = Container.Cmaf.createAudioInitSegment(config); @@ -114,6 +116,10 @@ export class Mse implements Backend { const frame = await sub.readFrame(); if (!frame) return; + // Extract the timestamp from the CMAF segment and mark when we received it. + const timestamp = Container.Cmaf.decodeTimestamp(frame, timescale); + this.source.sync.received(Moq.Time.Milli.fromMicro(timestamp)); + await this.#appendBuffer(sourceBuffer, frame); // Seek to the start of the buffer if we're behind it (for startup). @@ -147,15 +153,32 @@ export class Mse implements Backend { let duration: Moq.Time.Micro | undefined; // Buffer one frame so we can compute accurate duration from the next frame's timestamp - let pending = await consumer.decode(); - if (!pending) return; + let pending: Container.Legacy.Frame; + for (;;) { + const next = await consumer.next(); + if (!next) return; + if (!next.frame) continue; // Skip over group done notifications. + + pending = next.frame; + + // Mark that we received this frame for latency calculation. + this.source.sync.received(Moq.Time.Milli.fromMicro(pending.timestamp as Moq.Time.Micro)); + + break; + } for (;;) { - const next = await consumer.decode(); + const next = await consumer.next(); + if (next && !next.frame) continue; // Skip over group done notifications. + + const frame = next?.frame; // Compute duration from next frame's timestamp, or use last known duration if stream ended - if (next) { - duration = (next.timestamp - pending.timestamp) as Moq.Time.Micro; + if (frame) { + duration = (frame.timestamp - pending.timestamp) as Moq.Time.Micro; + + // Mark that we received this frame for latency calculation. + this.source.sync.received(Moq.Time.Milli.fromMicro(frame.timestamp as Moq.Time.Micro)); } // Wrap raw frame in moof+mdat @@ -174,8 +197,8 @@ export class Mse implements Backend { element.currentTime = element.buffered.start(0); } - if (!next) return; - pending = next; + if (!frame) return; + pending = frame; } }); } diff --git a/js/hang/src/watch/audio/render-worklet.ts b/js/hang/src/watch/audio/render-worklet.ts index 6b3684fd36..59b1fb8427 100644 --- a/js/hang/src/watch/audio/render-worklet.ts +++ b/js/hang/src/watch/audio/render-worklet.ts @@ -1,9 +1,10 @@ -import type { Message } from "./render"; +import type { Message, State } from "./render"; import { AudioRingBuffer } from "./ring-buffer"; class Render extends AudioWorkletProcessor { #buffer?: AudioRingBuffer; #underflow = 0; + #stateCounter = 0; constructor() { super(); @@ -17,6 +18,9 @@ class Render extends AudioWorkletProcessor { } else if (type === "data") { if (!this.#buffer) throw new Error("buffer not initialized"); this.#buffer.write(event.data.timestamp, event.data.data); + } else if (type === "latency") { + if (!this.#buffer) throw new Error("buffer not initialized"); + this.#buffer.resize(event.data.latency); } else { const exhaustive: never = type; throw new Error(`unknown message type: ${exhaustive}`); @@ -35,6 +39,18 @@ class Render extends AudioWorkletProcessor { this.#underflow = 0; } + // Send state update every ~5 frames (~60/sec) to avoid excessive DOM updates + this.#stateCounter++; + if (this.#buffer && this.#stateCounter >= 5) { + this.#stateCounter = 0; + const state: State = { + type: "state", + timestamp: this.#buffer.timestamp, + stalled: this.#buffer.stalled, + }; + this.port.postMessage(state); + } + return true; } } diff --git a/js/hang/src/watch/audio/render.ts b/js/hang/src/watch/audio/render.ts index e5f9d90607..9b1f838f7d 100644 --- a/js/hang/src/watch/audio/render.ts +++ b/js/hang/src/watch/audio/render.ts @@ -1,6 +1,14 @@ import type { Time } from "@moq/lite"; -export type Message = Init | Data; +export type Message = Init | Data | Latency; + +export type ToMain = State; + +export interface State { + type: "state"; + timestamp: Time.Micro; + stalled: boolean; +} export interface Data { type: "data"; @@ -14,3 +22,8 @@ export interface Init { channels: number; latency: Time.Milli; } + +export interface Latency { + type: "latency"; + latency: Time.Milli; +} diff --git a/js/hang/src/watch/audio/ring-buffer.test.ts b/js/hang/src/watch/audio/ring-buffer.test.ts index 6a4e3f5bee..8e8537c5c0 100644 --- a/js/hang/src/watch/audio/ring-buffer.test.ts +++ b/js/hang/src/watch/audio/ring-buffer.test.ts @@ -83,9 +83,9 @@ describe("writing data", () => { // Should have filled the gap with zeros expect(buffer.length).toBe(30); // 10 + 10 (gap) + 10 - // Exit refill mode by filling buffer + // Exit stalled mode by filling buffer write(buffer, 30 as Time.Milli, 70, { channels: 2, value: 0.0 }); - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Read and verify the gap was filled with zeros const output = read(buffer, 30, 2); @@ -111,9 +111,9 @@ describe("writing data", () => { it("should handle late-arriving samples (out-of-order writes)", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); - // Fill buffer to exit refill mode + // Fill buffer to exit stalled mode write(buffer, 0 as Time.Milli, 100, { channels: 1, value: 0.0 }); - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Read 50 samples to advance read pointer to 50 read(buffer, 50, 1); @@ -148,9 +148,9 @@ describe("writing data", () => { it("should discard samples that are too old", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); - // Exit refill mode by filling buffer + // Exit stalled mode by filling buffer write(buffer, 0 as Time.Milli, 100, { channels: 2, value: 0.0 }); - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Read 60 samples, readIndex now at 60 read(buffer, 60, 2); @@ -181,10 +181,10 @@ describe("reading data", () => { it("should read available data", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); - // Exit refill mode by filling the buffer + // Exit stalled mode by filling the buffer write(buffer, 0 as Time.Milli, 100, { channels: 2, value: 0.0 }); - // Buffer should now be out of refill mode - expect(buffer.refilling).toBe(false); + // Buffer should now be out of stalled mode + expect(buffer.stalled).toBe(false); // Read some samples to make room (readIndex at 80) read(buffer, 80, 2); @@ -219,9 +219,9 @@ describe("reading data", () => { it("should handle partial reads", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); - // Exit refill mode by filling the buffer + // Exit stalled mode by filling the buffer write(buffer, 0 as Time.Milli, 100, { channels: 2, value: 0.0 }); - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Read some to make room (readIndex at 80) read(buffer, 80, 2); @@ -251,27 +251,27 @@ describe("reading data", () => { }); }); -describe("refill behavior", () => { - it("should start in refill mode", () => { +describe("stall behavior", () => { + it("should start in stalled mode", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); - expect(buffer.refilling).toBe(true); + expect(buffer.stalled).toBe(true); - // Should not output anything in refill mode + // Should not output anything in stalled mode write(buffer, 0 as Time.Milli, 50, { channels: 2, value: 1.0 }); const output = read(buffer, 10, 2); expect(output[0].length).toBe(0); }); - it("should exit refill mode when buffer is full", () => { + it("should exit stalled mode when buffer is full", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); // Fill the buffer completely write(buffer, 0 as Time.Milli, 100, { channels: 2, value: 1.0 }); // Write more data to trigger overflow handling - write(buffer, 10 as Time.Milli, 50, { channels: 2, value: 2.0 }); // This should exit refill mode + write(buffer, 10 as Time.Milli, 50, { channels: 2, value: 2.0 }); // This should exit stalled mode - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Now we should be able to read const output = read(buffer, 10, 2); @@ -285,7 +285,7 @@ describe("ring buffer wrapping", () => { // Fill the buffer write(buffer, 0 as Time.Milli, 100, { channels: 1, value: 1.0 }); - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Read 50 samples to make room (readIndex at 50) const output1 = read(buffer, 50, 1); @@ -321,9 +321,9 @@ describe("multi-channel handling", () => { it("should handle stereo data correctly", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); - // Exit refill mode by filling buffer + // Exit stalled mode by filling buffer write(buffer, 0 as Time.Milli, 100, { channels: 2, value: 0.5 }); - expect(buffer.refilling).toBe(false); + expect(buffer.stalled).toBe(false); // Read some to make room read(buffer, 80, 2); @@ -373,9 +373,9 @@ describe("edge cases", () => { it("should handle fractional timestamps", () => { const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); - // Exit refill mode first + // Exit stalled mode first write(buffer, 0 as Time.Milli, 100, { channels: 2, value: 0.0 }); - write(buffer, 10 as Time.Milli, 10, { channels: 2, value: 0.0 }); // This exits refill mode + write(buffer, 10 as Time.Milli, 10, { channels: 2, value: 0.0 }); // This exits stalled mode read(buffer, 110, 2); // Write with fractional timestamp that rounds @@ -386,3 +386,165 @@ describe("edge cases", () => { expect(output[0].length).toBeGreaterThan(0); }); }); + +describe("resize", () => { + it("should resize to a larger buffer", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + expect(buffer.capacity).toBe(100); + + // Write 50 samples + write(buffer, 0 as Time.Milli, 50, { channels: 1, value: 1.0 }); + expect(buffer.length).toBe(50); + + // Resize to larger buffer (200ms = 200 samples) + buffer.resize(200 as Time.Milli); + + expect(buffer.capacity).toBe(200); + expect(buffer.length).toBe(50); // Samples preserved + expect(buffer.stalled).toBe(true); // Should trigger stall + }); + + it("should resize to a smaller buffer and keep the most recent samples", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + expect(buffer.capacity).toBe(100); + + // Write 80 samples: first 40 with value 1.0, next 40 with value 2.0 + write(buffer, 0 as Time.Milli, 40, { channels: 1, value: 1.0 }); + write(buffer, 40 as Time.Milli, 40, { channels: 1, value: 2.0 }); + expect(buffer.length).toBe(80); + + // Resize to smaller buffer (50ms = 50 samples) + // Should keep the most recent 50 samples (samples 30-79) + buffer.resize(50 as Time.Milli); + + expect(buffer.capacity).toBe(50); + expect(buffer.length).toBe(50); // Truncated to new capacity + expect(buffer.stalled).toBe(true); // Should trigger stall + }); + + it("should be a no-op when capacity is unchanged", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + + // Exit stalled mode + write(buffer, 0 as Time.Milli, 100, { channels: 1, value: 1.0 }); + expect(buffer.stalled).toBe(false); + + // Resize to same capacity + buffer.resize(100 as Time.Milli); + + // Should still not be stalled (no-op) + expect(buffer.stalled).toBe(false); + expect(buffer.capacity).toBe(100); + }); + + it("should throw on zero latency", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + expect(() => buffer.resize(0 as Time.Milli)).toThrow(/empty buffer/); + }); + + it("should handle resize with stereo data", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 2, latency: 100 as Time.Milli }); + + // Write stereo data with different values per channel + const data = [new Float32Array(60), new Float32Array(60)]; + for (let i = 0; i < 60; i++) { + data[0][i] = 1.0; // Left channel + data[1][i] = 2.0; // Right channel + } + buffer.write(0 as Time.Micro, data); + expect(buffer.length).toBe(60); + + // Resize to smaller buffer + buffer.resize(50 as Time.Milli); + expect(buffer.capacity).toBe(50); + expect(buffer.length).toBe(50); + expect(buffer.stalled).toBe(true); + }); + + it("should handle resize when buffer is empty", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + expect(buffer.length).toBe(0); + + // Resize empty buffer + buffer.resize(200 as Time.Milli); + + expect(buffer.capacity).toBe(200); + expect(buffer.length).toBe(0); + expect(buffer.stalled).toBe(true); + }); + + it("should handle resize after partial read", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + + // Fill and exit stalled mode + write(buffer, 0 as Time.Milli, 100, { channels: 1, value: 1.0 }); + expect(buffer.stalled).toBe(false); + + // Read 60 samples (readIndex at 60, writeIndex at 100) + read(buffer, 60, 1); + expect(buffer.length).toBe(40); + + // Write more data + write(buffer, 100 as Time.Milli, 30, { channels: 1, value: 2.0 }); + expect(buffer.length).toBe(70); // 130 - 60 + + // Resize to 50 samples - should keep most recent 50 (samples 80-129) + buffer.resize(50 as Time.Milli); + expect(buffer.capacity).toBe(50); + expect(buffer.length).toBe(50); + expect(buffer.stalled).toBe(true); + }); + + it("should exit stall and read new data after resize", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 100 as Time.Milli }); + + // Write some initial data + write(buffer, 0 as Time.Milli, 50, { channels: 1, value: 1.0 }); + + // Resize to smaller buffer + buffer.resize(50 as Time.Milli); + expect(buffer.stalled).toBe(true); + + // Write new data to fill the buffer and exit stall + // The overflow will discard preserved samples and advance readIndex + write(buffer, 50 as Time.Milli, 50, { channels: 1, value: 2.0 }); + expect(buffer.stalled).toBe(false); + + // Read should return the new data (value 2.0) + const output = read(buffer, 50, 1); + expect(output[0].length).toBe(50); + for (let i = 0; i < 50; i++) { + expect(output[0][i]).toBe(2.0); + } + }); + + it("should preserve samples correctly when resizing larger then filling", () => { + const buffer = new AudioRingBuffer({ rate: 1000, channels: 1, latency: 50 as Time.Milli }); + expect(buffer.capacity).toBe(50); + + // Write 30 samples + write(buffer, 0 as Time.Milli, 30, { channels: 1, value: 1.0 }); + expect(buffer.length).toBe(30); + + // Resize to larger buffer (100ms = 100 samples) + buffer.resize(100 as Time.Milli); + expect(buffer.capacity).toBe(100); + expect(buffer.length).toBe(30); // All samples preserved + expect(buffer.stalled).toBe(true); + + // Write 70 more samples to fill the buffer and exit stall + write(buffer, 30 as Time.Milli, 70, { channels: 1, value: 2.0 }); + expect(buffer.stalled).toBe(false); + expect(buffer.length).toBe(100); + + // Read all - should have 30 samples of 1.0 then 70 of 2.0 + const output = read(buffer, 100, 1); + expect(output[0].length).toBe(100); + for (let i = 0; i < 30; i++) { + expect(output[0][i]).toBe(1.0); + } + for (let i = 30; i < 100; i++) { + expect(output[0][i]).toBe(2.0); + } + }); +}); diff --git a/js/hang/src/watch/audio/ring-buffer.ts b/js/hang/src/watch/audio/ring-buffer.ts index 5bb7995461..d10a84eb3d 100644 --- a/js/hang/src/watch/audio/ring-buffer.ts +++ b/js/hang/src/watch/audio/ring-buffer.ts @@ -7,7 +7,7 @@ export class AudioRingBuffer { readonly rate: number; readonly channels: number; - #refill = true; + #stalled = true; constructor(props: { rate: number; channels: number; latency: Time.Milli }) { if (props.channels <= 0) throw new Error("invalid channels"); @@ -26,8 +26,12 @@ export class AudioRingBuffer { } } - get refilling(): boolean { - return this.#refill; + get stalled(): boolean { + return this.#stalled; + } + + get timestamp(): Time.Micro { + return Time.Micro.fromSecond((this.#readIndex / this.rate) as Time.Second); } get length(): number { @@ -38,6 +42,38 @@ export class AudioRingBuffer { return this.#buffer[0]?.length; } + resize(latency: Time.Milli): void { + const newCapacity = Math.ceil(this.rate * Time.Second.fromMilli(latency)); + if (newCapacity === this.capacity) return; + if (newCapacity === 0) throw new Error("empty buffer"); + + const newBuffer: Float32Array[] = []; + for (let i = 0; i < this.channels; i++) { + newBuffer[i] = new Float32Array(newCapacity); + } + + // Copy existing data, preserving the most recent samples + const samplesToKeep = Math.min(this.length, newCapacity); + if (samplesToKeep > 0) { + // Copy the most recent samples (closest to writeIndex) + const copyStart = this.#writeIndex - samplesToKeep; + for (let channel = 0; channel < this.channels; channel++) { + const src = this.#buffer[channel]; + const dst = newBuffer[channel]; + for (let i = 0; i < samplesToKeep; i++) { + const srcPos = (copyStart + i) % src.length; + const dstPos = i % dst.length; + dst[dstPos] = src[srcPos]; + } + } + } + + // Update state for the new buffer and trigger stall to refill + this.#buffer = newBuffer; + this.#readIndex = this.#writeIndex - samplesToKeep; + this.#stalled = true; + } + write(timestamp: Time.Micro, data: Float32Array[]): void { if (data.length !== this.channels) throw new Error("wrong number of channels"); @@ -62,8 +98,8 @@ export class AudioRingBuffer { // Check if we need to discard old samples to prevent overflow const overflow = end - this.#readIndex - this.#buffer[0].length; if (overflow >= 0) { - // Discard old samples and exit refill mode - this.#refill = false; + // Discard old samples and exit stalled mode + this.#stalled = false; this.#readIndex += overflow; } @@ -105,7 +141,7 @@ export class AudioRingBuffer { read(output: Float32Array[]): number { if (output.length !== this.channels) throw new Error("wrong number of channels"); - if (this.#refill) return 0; + if (this.#stalled) return 0; const samples = Math.min(this.#writeIndex - this.#readIndex, output[0].length); if (samples === 0) return 0; diff --git a/js/hang/src/watch/backend.ts b/js/hang/src/watch/backend.ts index b5fb59cb40..04e54a8889 100644 --- a/js/hang/src/watch/backend.ts +++ b/js/hang/src/watch/backend.ts @@ -1,5 +1,5 @@ -import type * as Moq from "@moq/lite"; -import { Effect, type Getter, Signal } from "@moq/signals"; +import * as Moq from "@moq/lite"; +import { Effect, Signal } from "@moq/signals"; import * as Audio from "./audio"; import type { Broadcast } from "./broadcast"; import { Muxer } from "./mse"; @@ -8,16 +8,20 @@ import * as Video from "./video"; // Serializable representation of TimeRanges export interface BufferedRange { - start: number; // seconds - end: number; // seconds + start: Moq.Time.Milli; + end: Moq.Time.Milli; } export type BufferedRanges = BufferedRange[]; // Helper to convert DOM TimeRanges export function timeRangesToArray(ranges: TimeRanges): BufferedRanges { const result: BufferedRange[] = []; + for (let i = 0; i < ranges.length; i++) { - result.push({ start: ranges.start(i), end: ranges.end(i) }); + const start = Moq.Time.Milli.fromSecond(ranges.start(i) as Moq.Time.Second); + const end = Moq.Time.Milli.fromSecond(ranges.end(i) as Moq.Time.Second); + + result.push({ start, end }); } return result; } @@ -26,12 +30,6 @@ export interface Backend { // Whether audio/video playback is paused. paused: Signal; - // Whether the video is currently buffering, false when paused. - buffering: Getter; - - // Current playback position in seconds. - timestamp: Getter; - // Video specific signals. video?: Video.Backend; @@ -60,9 +58,15 @@ class VideoBackend implements Video.Backend { // The stats of the video. stats = new Signal(undefined); - // Buffered time ranges (for MSE backend). + // We're currently stalled waiting for the next frame + stalled = new Signal(false); + + // Buffered time ranges buffered = new Signal([]); + // The timestamp of the current frame + timestamp = new Signal(Moq.Time.Milli.zero); + constructor(source: Video.Source) { this.source = source; } @@ -81,7 +85,7 @@ class AudioBackend implements Audio.Backend { // The stats of the audio. stats = new Signal(undefined); - // Buffered time ranges (for MSE backend). + // Buffered time ranges buffered = new Signal([]); constructor(source: Audio.Source) { @@ -107,12 +111,6 @@ export class MultiBackend implements Backend { // Used to sync audio and video playback at a target delay. #sync: Sync; - #buffering = new Signal(false); - readonly buffering: Getter = this.#buffering; - - #timestamp = new Signal(0); - readonly timestamp: Getter = this.#timestamp; - signals = new Effect(); constructor(props?: MultiBackendProps) { @@ -169,17 +167,11 @@ export class MultiBackend implements Backend { // Proxy the read only signals to the backend. effect.proxy(this.video.stats, videoSource.stats); effect.proxy(this.video.buffered, videoSource.buffered); + effect.proxy(this.video.stalled, videoSource.stalled); + effect.proxy(this.video.timestamp, videoSource.timestamp); effect.proxy(this.audio.stats, audioSource.stats); effect.proxy(this.audio.buffered, audioSource.buffered); - - // Derive timestamp from video stats (in lock-step with frame signal) - effect.effect((e) => { - const stats = e.get(videoSource.stats); - if (stats) { - this.#timestamp.set(stats.timestamp / 1_000_000); // microseconds to seconds - } - }); } #runMse(effect: Effect, element: HTMLVideoElement): void { @@ -203,10 +195,14 @@ export class MultiBackend implements Backend { // Proxy the read only signals to the backend. effect.proxy(this.video.stats, video.stats); effect.proxy(this.video.buffered, video.buffered); + effect.proxy(this.video.stalled, video.stalled); + effect.proxy(this.video.timestamp, video.timestamp); effect.proxy(this.audio.stats, audio.stats); effect.proxy(this.audio.buffered, audio.buffered); + } - effect.proxy(this.#timestamp, mse.timestamp); + close(): void { + this.signals.close(); } } diff --git a/js/hang/src/watch/element.ts b/js/hang/src/watch/element.ts index 50b1fa451e..7834b706fd 100644 --- a/js/hang/src/watch/element.ts +++ b/js/hang/src/watch/element.ts @@ -1,6 +1,6 @@ import type { Time } from "@moq/lite"; import * as Moq from "@moq/lite"; -import { Effect, type Getter, Signal } from "@moq/signals"; +import { Effect, Signal } from "@moq/signals"; import type * as Audio from "./audio"; import { type Backend, MultiBackend } from "./backend"; import { Broadcast } from "./broadcast"; @@ -58,7 +58,7 @@ export default class HangWatch extends HTMLElement implements Backend { this.#backend = new MultiBackend({ broadcast: this.broadcast, }); - this.signals.cleanup(() => this.#backend.signals.close()); + this.signals.cleanup(() => this.#backend.close()); // Watch to see if the canvas element is added or removed. const setElement = () => { @@ -189,14 +189,6 @@ export default class HangWatch extends HTMLElement implements Backend { get video(): Video.Backend { return this.#backend.video; } - - get buffering(): Getter { - return this.#backend.buffering; - } - - get timestamp(): Getter { - return this.#backend.timestamp; - } } customElements.define("hang-watch", HangWatch); diff --git a/js/hang/src/watch/mse.ts b/js/hang/src/watch/mse.ts index 127ad0f474..e28f34f479 100644 --- a/js/hang/src/watch/mse.ts +++ b/js/hang/src/watch/mse.ts @@ -1,3 +1,4 @@ +import { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import type { Sync } from "./sync"; @@ -20,12 +21,6 @@ export class Muxer { #mediaSource = new Signal(undefined); readonly mediaSource: Getter = this.#mediaSource; - #buffering = new Signal(false); - readonly buffering: Getter = this.#buffering; - - #timestamp = new Signal(0); - readonly timestamp: Getter = this.#timestamp; - #signals = new Effect(); constructor(sync: Sync, props?: MuxerProps) { @@ -36,9 +31,8 @@ export class Muxer { this.#signals.effect(this.#runMediaSource.bind(this)); this.#signals.effect(this.#runSkip.bind(this)); this.#signals.effect(this.#runTrim.bind(this)); - this.#signals.effect(this.#runBuffering.bind(this)); this.#signals.effect(this.#runPaused.bind(this)); - this.#signals.effect(this.#runTimestamp.bind(this)); + this.#signals.effect(this.#runSync.bind(this)); } #runMediaSource(effect: Effect): void { @@ -73,7 +67,8 @@ export class Muxer { if (paused) return; // Use the computed latency (catalog jitter + user jitter) - const latency = effect.get(this.#sync.latency); + // Convert to seconds since DOM APIs use seconds + const latency = Time.Milli.toSecond(effect.get(this.#sync.latency)); effect.interval(() => { // Skip over gaps based on the effective latency. @@ -81,12 +76,13 @@ export class Muxer { if (buffered.length === 0) return; const last = buffered.end(buffered.length - 1); - const diff = last - element.currentTime; + const target = last - latency; + const seek = target - element.currentTime; - // Seek to maintain the target latency from the buffer end. - if (diff > latency && diff > 0.1) { - console.warn("skipping ahead", diff, "seconds"); - element.currentTime = last - latency; + // Seek forward if we're too far behind, or backward if we're too far ahead (>100ms) + if (seek > 0.1 || seek < -0.1) { + console.warn("seeking", seek > 0 ? "forward" : "backward", Math.abs(seek).toFixed(3), "seconds"); + element.currentTime = target; } }, 100); } @@ -113,20 +109,6 @@ export class Muxer { }, 1000); } - #runBuffering(effect: Effect): void { - const element = effect.get(this.element); - if (!element) return; - - const update = () => { - this.#buffering.set(element.readyState <= HTMLMediaElement.HAVE_CURRENT_DATA); - }; - - // TODO Are these the correct events to use? - effect.event(element, "waiting", update); - effect.event(element, "playing", update); - effect.event(element, "seeking", update); - } - #runPaused(effect: Effect): void { const element = effect.get(this.element); if (!element) return; @@ -137,31 +119,31 @@ export class Muxer { } else if (!paused && element.paused) { element.play().catch((e) => { console.error("[MSE] MediaElement play error:", e); - this.paused.set(true); }); } } - #runTimestamp(effect: Effect): void { + // Seek to the target position based on the reference and latency. + #runSync(effect: Effect): void { const element = effect.get(this.element); if (!element) return; - // Use requestVideoFrameCallback if available (frame-accurate) - if ("requestVideoFrameCallback" in element) { - const video = element as HTMLVideoElement; - let handle: number; - const onFrame = () => { - this.#timestamp.set(video.currentTime); - handle = video.requestVideoFrameCallback(onFrame); - }; - handle = video.requestVideoFrameCallback(onFrame); - effect.cleanup(() => video.cancelVideoFrameCallback(handle)); - } else { - // Fallback to timeupdate event - effect.event(element, "timeupdate", () => { - this.#timestamp.set(element.currentTime); - }); - } + // Don't seek when paused, otherwise we'll keep jerking around. + const paused = effect.get(this.paused); + if (paused) return; + + const reference = effect.get(this.#sync.reference); + if (reference === undefined) return; + + const latency = effect.get(this.#sync.latency); + + // Compute the target currentTime based on reference and latency. + // reference = performance.now() - frameTimestamp (in ms) when we received the earliest frame + // So the target media timestamp (in ms) at time `now` is: now - reference - latency + const target = (performance.now() - reference - latency) as Time.Milli; + + // Seek to the target position. + element.currentTime = Time.Milli.toSecond(target); } close(): void { diff --git a/js/hang/src/watch/sync.ts b/js/hang/src/watch/sync.ts index f62713e40f..572ee0ce17 100644 --- a/js/hang/src/watch/sync.ts +++ b/js/hang/src/watch/sync.ts @@ -11,7 +11,8 @@ export class Sync { // The earliest time we've received a frame, relative to its timestamp. // This will keep being updated as we catch up to the live playhead then will be relatively static. // TODO Update this when RTT changes - #reference?: Time.Milli; + #reference = new Signal(undefined); + readonly reference: Signal = this.#reference; // The minimum buffer size, to account for network jitter. jitter: Signal; @@ -61,11 +62,12 @@ export class Sync { // Update the reference if this is the earliest frame we've seen, relative to its timestamp. received(timestamp: Time.Milli): void { const ref = (performance.now() - timestamp) as Time.Milli; + const current = this.#reference.peek(); - if (this.#reference && ref >= this.#reference) { + if (current !== undefined && ref >= current) { return; } - this.#reference = ref; + this.#reference.set(ref); this.#resolve(); this.#update = new Promise((resolve) => { @@ -75,7 +77,8 @@ export class Sync { // Sleep until it's time to render this frame. async wait(timestamp: Time.Milli): Promise { - if (!this.#reference) { + const reference = this.#reference.peek(); + if (reference === undefined) { throw new Error("reference not set; call update() first"); } @@ -85,7 +88,10 @@ export class Sync { const now = performance.now(); const ref = (now - timestamp) as Time.Milli; - const sleep = this.#reference - ref + this.#latency.peek(); + const currentRef = this.#reference.peek(); + if (currentRef === undefined) return; + + const sleep = currentRef - ref + this.#latency.peek(); if (sleep <= 0) return; const wait = new Promise((resolve) => setTimeout(resolve, sleep)).then(() => true); diff --git a/js/hang/src/watch/video/backend.ts b/js/hang/src/watch/video/backend.ts index 47bbe0fa43..3f37b5b317 100644 --- a/js/hang/src/watch/video/backend.ts +++ b/js/hang/src/watch/video/backend.ts @@ -1,3 +1,4 @@ +import type * as Moq from "@moq/lite"; import type { Getter } from "@moq/signals"; import type { BufferedRanges } from "../backend"; import type { Source } from "./source"; @@ -10,12 +11,17 @@ export interface Backend { // The stats of the video. stats: Getter; + // Whether the video is currently buffering + stalled: Getter; + // Buffered time ranges (for MSE backend). buffered: Getter; + + // The timestamp of the current frame. + timestamp: Getter; } export interface Stats { frameCount: number; - timestamp: number; bytesReceived: number; } diff --git a/js/hang/src/watch/video/decoder.ts b/js/hang/src/watch/video/decoder.ts index f89482920d..84f26405cc 100644 --- a/js/hang/src/watch/video/decoder.ts +++ b/js/hang/src/watch/video/decoder.ts @@ -10,6 +10,7 @@ import type { Source } from "./source"; // The amount of time to wait before considering the video to be buffering. const BUFFERING = 500 as Time.Milli; +const SWITCH = 100 as Time.Milli; export type DecoderProps = { enabled?: boolean | Signal; @@ -25,8 +26,7 @@ export class Decoder implements Backend { source: Source; // The current track running, held so we can cancel it when the new track is ready. - #pending?: Effect; - #active?: Effect; + #active = new Signal(undefined); // Expose the current frame to render as a signal #frame = new Signal(undefined); @@ -40,13 +40,13 @@ export class Decoder implements Backend { #display = new Signal<{ width: number; height: number } | undefined>(undefined); readonly display: Getter<{ width: number; height: number } | undefined> = this.#display; - #buffering = new Signal(false); - readonly buffering: Getter = this.#buffering; + #stalled = new Signal(false); + readonly stalled: Getter = this.#stalled; #stats = new Signal(undefined); readonly stats: Getter = this.#stats; - // Empty stub for WebCodecs (no traditional buffering) + // Combined buffered ranges (network jitter + decode buffer) #buffered = new Signal([]); readonly buffered: Getter = this.#buffered; @@ -59,90 +59,187 @@ export class Decoder implements Backend { this.source.supported.set(supported); // super hacky this.#signals.effect(this.#runPending.bind(this)); + this.#signals.effect(this.#runActive.bind(this)); this.#signals.effect(this.#runDisplay.bind(this)); this.#signals.effect(this.#runBuffering.bind(this)); } #runPending(effect: Effect): void { - const broadcast = effect.get(this.source.broadcast); - const enabled = effect.get(this.enabled); - const track = effect.get(this.source.track); - const config = effect.get(this.source.config); - const active = broadcast ? effect.get(broadcast.active) : undefined; - - if (!active || !config || !track || !enabled) { - // Stop the active track. - this.#active?.close(); - this.#active = undefined; - - this.#frame.update((prev) => { - prev?.close(); - return undefined; + const values = effect.getAll([this.enabled, this.source.broadcast, this.source.track, this.source.config]); + if (!values) return; + const [_, source, track, config] = values; + + const broadcast = effect.get(source.active); + if (!broadcast) return; + + // Start a new pending effect. + let pending: DecoderTrack | undefined = new DecoderTrack({ + source: this.source, + broadcast, + track, + config, + stats: this.#stats, + }); + + effect.cleanup(() => pending?.close()); + + effect.effect((effect) => { + if (!pending) return; + + const active = effect.get(this.#active); + if (active) { + const pendingTimestamp = effect.get(pending.timestamp); + const activeTimestamp = effect.get(active.timestamp); + + // Switch to the new track if it's ready and we've caught up enough. + if (!pendingTimestamp) return; + if (activeTimestamp && activeTimestamp > pendingTimestamp + SWITCH) return; + } + + // Upgrade the pending track to active. + // #runActive will be in charge of it now. + this.#active.set(pending); + pending = undefined; + }); + } + + #runActive(effect: Effect): void { + const active = effect.get(this.#active); + if (!active) return; + + effect.cleanup(() => active.close()); + + effect.proxy(this.#frame, active.frame); + effect.proxy(this.#timestamp, active.timestamp); + effect.proxy(this.#buffered, active.buffered); + } + + #runDisplay(effect: Effect): void { + const catalog = effect.get(this.source.catalog); + if (!catalog) return; + + const display = catalog.display; + if (display) { + effect.set(this.#display, { + width: display.width, + height: display.height, }); + return; + } + + const frame = effect.get(this.frame); + if (!frame) return; + effect.set(this.#display, { + width: frame.displayWidth, + height: frame.displayHeight, + }); + } + + #runBuffering(effect: Effect): void { + const enabled = effect.get(this.enabled); + if (!enabled) return; + + const frame = effect.get(this.frame); + if (!frame) { + this.#stalled.set(true); return; } - // Start a new pending effect. - this.#pending = new Effect(); + this.#stalled.set(false); + + effect.timer(() => { + this.#stalled.set(true); + }, BUFFERING); + } + + close() { + this.#frame.update((prev) => { + prev?.close(); + return undefined; + }); + + this.#signals.close(); + } +} + +interface DecoderTrackProps { + source: Source; + broadcast: Moq.Broadcast; + track: string; + config: Catalog.VideoConfig; + + stats: Signal; +} + +class DecoderTrack { + source: Source; + broadcast: Moq.Broadcast; + track: string; + config: RequiredDecoderConfig; + stats: Signal; + + timestamp = new Signal(undefined); + frame = new Signal(undefined); + + // Network jitter + decode buffer. + buffered = new Signal([]); + + // Decoded frames waiting to be rendered. + #buffered = new Signal([]); - // NOTE: If the track catches up in time, it'll remove itself from #pending. - // We use #pending here on purpose so we only close it when it hasn't caught up yet. - effect.cleanup(() => this.#pending?.close()); + signals = new Effect(); + constructor(props: DecoderTrackProps) { // Remove the codedWidth/Height from the config to avoid a hard reload if nothing else has changed. - const { codedWidth: _, codedHeight: __, ...minConfig } = config; + const { codedWidth: _, codedHeight: __, ...requiredConfig } = props.config; - this.#runTrack(this.#pending, active, track, minConfig); + this.source = props.source; + this.broadcast = props.broadcast; + this.track = props.track; + this.config = requiredConfig; + this.stats = props.stats; + + this.signals.effect(this.#run.bind(this)); } - #runTrack(effect: Effect, broadcast: Moq.Broadcast, name: string, config: RequiredDecoderConfig): void { - const sub = broadcast.subscribe(name, Catalog.PRIORITY.video); + #run(effect: Effect): void { + const sub = this.broadcast.subscribe(this.track, Catalog.PRIORITY.video); effect.cleanup(() => sub.close()); - effect.cleanup(() => { - if (this.#active === effect) { - this.#timestamp.set(undefined); - } - }); - const decoder = new VideoDecoder({ output: async (frame: VideoFrame) => { try { const timestamp = Time.Milli.fromMicro(frame.timestamp as Time.Micro); - if (timestamp < (this.#timestamp.peek() ?? 0)) { + if (timestamp < (this.timestamp.peek() ?? 0)) { // Late frame, don't render it. return; } - if (this.#frame.peek() === undefined) { + if (this.frame.peek() === undefined) { // Render something while we wait for the sync to catch up. - this.#frame.set(frame.clone()); + this.frame.set(frame.clone()); } const wait = this.source.sync.wait(timestamp).then(() => true); const ok = await Promise.race([wait, effect.cancel]); if (!ok) return; - if (timestamp < (this.#timestamp.peek() ?? 0)) { + if (timestamp < (this.timestamp.peek() ?? 0)) { // Late frame, don't render it. // NOTE: This can happen when the ref is updated, such as on playback start. return; } - this.#timestamp.set(timestamp); + this.timestamp.set(timestamp); + + // Trim the decode buffer as frames are rendered + this.#trimBuffered(timestamp); - this.#frame.update((prev) => { + this.frame.update((prev) => { prev?.close(); return frame.clone(); // avoid closing the frame here }); - - // If the track switch was pending, complete it now. - if (this.#pending === effect) { - this.#active?.close(); - this.#active = effect; - this.#pending = undefined; - } } finally { frame.close(); } @@ -156,69 +253,106 @@ export class Decoder implements Backend { effect.cleanup(() => decoder.close()); // Input processing - depends on container type - if (config.container.kind === "cmaf") { - this.#runCmafTrack(effect, sub, config, decoder); + if (this.config.container.kind === "cmaf") { + this.#runCmaf(effect, sub, decoder); } else { - this.#runLegacyTrack(effect, sub, config, decoder); + this.#runLegacy(effect, sub, decoder); } } - #runLegacyTrack(effect: Effect, sub: Moq.Track, config: RequiredDecoderConfig, decoder: VideoDecoder): void { + #runLegacy(effect: Effect, sub: Moq.Track, decoder: VideoDecoder): void { // Create consumer that reorders groups/frames up to the provided latency. const consumer = new Container.Legacy.Consumer(sub, { latency: this.source.sync.latency, }); effect.cleanup(() => consumer.close()); + // Combine network jitter buffer with decode buffer + effect.effect((inner) => { + const network = inner.get(consumer.buffered); + const decode = inner.get(this.#buffered); + this.buffered.update(() => mergeBufferedRanges(network, decode)); + }); + decoder.configure({ - ...config, - description: config.description ? Hex.toBytes(config.description) : undefined, - optimizeForLatency: config.optimizeForLatency ?? true, + ...this.config, + description: this.config.description ? Hex.toBytes(this.config.description) : undefined, + optimizeForLatency: this.config.optimizeForLatency ?? true, // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. flip: false, }); + let previous: { timestamp: Time.Micro; group: number; final: boolean } | undefined; + effect.spawn(async () => { for (;;) { - const next = await Promise.race([consumer.decode(), effect.cancel]); + const next = await Promise.race([consumer.next(), effect.cancel]); if (!next) break; + const { frame, group } = next; + + if (!frame) { + if (previous) { + previous.final = true; + } + // The group is done + continue; + } + // Mark that we received this frame right now. - this.source.sync.received(Time.Milli.fromMicro(next.timestamp as Time.Micro)); + this.source.sync.received(Time.Milli.fromMicro(frame.timestamp as Time.Micro)); const chunk = new EncodedVideoChunk({ - type: next.keyframe ? "key" : "delta", - data: next.data, - timestamp: next.timestamp, + type: frame.keyframe ? "key" : "delta", + data: frame.data, + timestamp: frame.timestamp, }); // Track both frame count and bytes received for stats in the UI - this.#stats.update((current) => ({ + this.stats.update((current) => ({ frameCount: (current?.frameCount ?? 0) + 1, - timestamp: next.timestamp, - bytesReceived: (current?.bytesReceived ?? 0) + next.data.byteLength, + bytesReceived: (current?.bytesReceived ?? 0) + frame.data.byteLength, })); + // Track decode buffer: frames sent to decoder but not yet rendered + if (previous?.group === group || (previous?.final && previous.group + 1 === group)) { + const start = Time.Milli.fromMicro(previous.timestamp); + const end = Time.Milli.fromMicro(frame.timestamp); + this.#addBuffered(start, end); + } + + previous = { + timestamp: frame.timestamp, + group, + final: false, + }; + decoder.decode(chunk); } }); } - #runCmafTrack(effect: Effect, sub: Moq.Track, config: RequiredDecoderConfig, decoder: VideoDecoder): void { - if (config.container.kind !== "cmaf") return; + #runCmaf(effect: Effect, sub: Moq.Track, decoder: VideoDecoder): void { + if (this.config.container.kind !== "cmaf") return; - const { timescale } = config.container; - const description = config.description ? Hex.toBytes(config.description) : undefined; + const { timescale } = this.config.container; + const description = this.config.description ? Hex.toBytes(this.config.description) : undefined; // Configure decoder with description from catalog decoder.configure({ - codec: config.codec, + codec: this.config.codec, description, - optimizeForLatency: config.optimizeForLatency ?? true, + optimizeForLatency: this.config.optimizeForLatency ?? true, // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. flip: false, }); + // Use decode buffer directly (no network jitter buffer for CMAF yet) + effect.effect((inner) => { + const decode = inner.get(this.#buffered); + this.buffered.update(() => decode); + }); + effect.spawn(async () => { // Process data segments // TODO: Use a consumer wrapper for CMAF to support latency control @@ -227,6 +361,8 @@ export class Decoder implements Backend { if (!group) break; effect.spawn(async () => { + let previous: Time.Micro | undefined; + try { for (;;) { const segment = await Promise.race([group.readFrame(), effect.cancel]); @@ -245,12 +381,19 @@ export class Decoder implements Backend { this.source.sync.received(Time.Milli.fromMicro(sample.timestamp as Time.Micro)); // Track stats - this.#stats.update((current) => ({ + this.stats.update((current) => ({ frameCount: (current?.frameCount ?? 0) + 1, - timestamp: sample.timestamp, bytesReceived: (current?.bytesReceived ?? 0) + sample.data.byteLength, })); + // Track decode buffer + if (previous !== undefined) { + const start = Time.Milli.fromMicro(previous); + const end = Time.Milli.fromMicro(sample.timestamp as Time.Micro); + this.#addBuffered(start, end); + } + previous = sample.timestamp as Time.Micro; + decoder.decode(chunk); } } @@ -262,53 +405,67 @@ export class Decoder implements Backend { }); } - #runDisplay(effect: Effect): void { - const catalog = effect.get(this.source.catalog); - if (!catalog) return; - - const display = catalog.display; - if (display) { - effect.set(this.#display, { - width: display.width, - height: display.height, - }); - return; - } - - const frame = effect.get(this.frame); - if (!frame) return; + // Add a range to the decode buffer (decoded, waiting to render) + #addBuffered(start: Time.Milli, end: Time.Milli): void { + if (start > end) return; + + this.#buffered.mutate((current) => { + for (const range of current) { + // Check if there's any overlap, then merge + if (range.start <= end && range.end >= start) { + range.start = Math.min(range.start, start) as Time.Milli; + range.end = Math.max(range.end, end) as Time.Milli; + return; + } + } - effect.set(this.#display, { - width: frame.displayWidth, - height: frame.displayHeight, + current.push({ start, end }); + current.sort((a, b) => a.start - b.start); }); } - #runBuffering(effect: Effect): void { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - const frame = effect.get(this.frame); - if (!frame) { - this.#buffering.set(true); - return; - } - - this.#buffering.set(false); - - effect.timer(() => { - this.#buffering.set(true); - }, BUFFERING); + // Trim the decode buffer up to the rendered timestamp + #trimBuffered(timestamp: Time.Milli): void { + this.#buffered.mutate((current) => { + while (current.length > 0) { + if (current[0].end >= timestamp) { + current[0].start = Math.max(current[0].start, timestamp) as Time.Milli; + break; + } + current.shift(); + } + }); } - close() { - this.#frame.update((prev) => { + close(): void { + this.signals.close(); + + this.frame.update((prev) => { prev?.close(); return undefined; }); + } +} - this.#signals.close(); +// Merge two sets of buffered ranges into one sorted list +function mergeBufferedRanges(a: BufferedRanges, b: BufferedRanges): BufferedRanges { + if (a.length === 0) return b; + if (b.length === 0) return a; + + const result: BufferedRanges = []; + const all = [...a, ...b].sort((x, y) => x.start - y.start); + + for (const range of all) { + const last = result.at(-1); + if (last && last.end >= range.start) { + // Merge overlapping ranges + last.end = Math.max(last.end, range.end) as Time.Milli; + } else { + result.push({ ...range }); + } } + + return result; } async function supported(config: Catalog.VideoConfig): Promise { diff --git a/js/hang/src/watch/video/mse.ts b/js/hang/src/watch/video/mse.ts index ba990cf5fa..6b1388b39f 100644 --- a/js/hang/src/watch/video/mse.ts +++ b/js/hang/src/watch/video/mse.ts @@ -1,4 +1,4 @@ -import type * as Moq from "@moq/lite"; +import * as Moq from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; import * as Catalog from "../../catalog"; import * as Container from "../../container"; @@ -22,6 +22,12 @@ export class Mse implements Backend { #buffered = new Signal([]); readonly buffered: Getter = this.#buffered; + #stalled = new Signal(false); + readonly stalled: Getter = this.#stalled; + + #timestamp = new Signal(Moq.Time.Milli.zero); + readonly timestamp: Getter = this.#timestamp; + signals = new Effect(); constructor(muxer: Muxer, source: Source) { @@ -30,6 +36,8 @@ export class Mse implements Backend { this.source.supported.set(supported); // super hacky this.signals.effect(this.#runMedia.bind(this)); + this.signals.effect(this.#runStalled.bind(this)); + this.signals.effect(this.#runTimestamp.bind(this)); } #runMedia(effect: Effect): void { @@ -99,6 +107,8 @@ export class Mse implements Backend { const data = active.subscribe(track, Catalog.PRIORITY.video); effect.cleanup(() => data.close()); + const timescale = config.container.timescale; + effect.spawn(async () => { // Generate init segment from catalog config (uses track_id from container) const initSegment = Container.Cmaf.createVideoInitSegment(config); @@ -110,6 +120,10 @@ export class Mse implements Backend { const frame = await data.readFrame(); if (!frame) return; + // Extract the timestamp from the CMAF segment and mark when we received it. + const timestamp = Container.Cmaf.decodeTimestamp(frame, timescale); + this.source.sync.received(Moq.Time.Milli.fromMicro(timestamp)); + await this.#appendBuffer(sourceBuffer, frame); // Seek to the start of the buffer if we're behind it (for startup). @@ -147,15 +161,31 @@ export class Mse implements Backend { let duration: Moq.Time.Micro | undefined; // Buffer one frame so we can compute accurate duration from the next frame's timestamp - let pending = await consumer.decode(); - if (!pending) return; + let pending: Container.Legacy.Frame; + for (;;) { + const next = await consumer.next(); + if (!next) return; + if (!next.frame) continue; // Skip over group done notifications. + + pending = next.frame; + + // Mark that we received this frame right now. + this.source.sync.received(Moq.Time.Milli.fromMicro(pending.timestamp as Moq.Time.Micro)); + + break; + } for (;;) { - const next = await consumer.decode(); + const next = await consumer.next(); + if (next && !next.frame) continue; // Skip over group done notifications. + const frame = next?.frame; // Compute duration from next frame's timestamp, or use last known duration if stream ended - if (next) { - duration = (next.timestamp - pending.timestamp) as Moq.Time.Micro; + if (frame) { + duration = (frame.timestamp - pending.timestamp) as Moq.Time.Micro; + + // Mark that we received this frame right now for latency calculation. + this.source.sync.received(Moq.Time.Milli.fromMicro(frame.timestamp as Moq.Time.Micro)); } // Wrap raw frame in moof+mdat @@ -174,12 +204,55 @@ export class Mse implements Backend { element.currentTime = element.buffered.start(0); } - if (!next) return; - pending = next; + if (!frame) return; + pending = frame; } }); } + #runStalled(effect: Effect): void { + const element = effect.get(this.muxer.element); + if (!element) return; + + const update = () => { + this.#stalled.set(element.readyState <= HTMLMediaElement.HAVE_CURRENT_DATA); + }; + + // Set initial state + update(); + + // TODO Are these the correct events to use? + effect.event(element, "waiting", update); + effect.event(element, "playing", update); + effect.event(element, "seeking", update); + } + + #runTimestamp(effect: Effect): void { + const element = effect.get(this.muxer.element); + if (!element) return; + + // Use requestVideoFrameCallback if available (frame-accurate) + if ("requestVideoFrameCallback" in element) { + const video = element as HTMLVideoElement; + + let handle: number; + const onFrame = () => { + const timestamp = Moq.Time.Milli.fromSecond(video.currentTime as Moq.Time.Second); + this.#timestamp.set(timestamp); + handle = video.requestVideoFrameCallback(onFrame); + }; + handle = video.requestVideoFrameCallback(onFrame); + + effect.cleanup(() => video.cancelVideoFrameCallback(handle)); + } else { + // Fallback to timeupdate event + effect.event(element, "timeupdate", () => { + const timestamp = Moq.Time.Milli.fromSecond(element.currentTime as Moq.Time.Second); + this.#timestamp.set(timestamp); + }); + } + } + close(): void { this.source.close(); this.signals.close(); diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index 4efcad230d..cc7bf2a875 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -23,6 +23,7 @@ export interface Getter { export interface Setter { set(value: T | ((prev: T) => T)): void; + update(fn: (prev: T) => T): void; } export class Signal implements Getter, Setter { @@ -66,7 +67,14 @@ export class Signal implements Getter, Setter { // Don't even queue a microtask if the value is the EXACT same. // We don't use dequal here because we don't want to run it twice, only when it matters. - if (notify === undefined && old === this.#value) return; + if (notify === undefined && old === this.#value) { + if (DEV && value !== null && (typeof value === "object" || typeof value === "function")) { + console.warn( + "Signal.set() called with the same object reference. Changes won't propagate. Use update() or mutate() instead.", + ); + } + return; + } // If there are no subscribers, don't queue a microtask. if (this.#subscribers.size === 0 && this.#changed.size === 0) return; @@ -574,6 +582,6 @@ export class Effect { } proxy(dst: Setter, src: Getter): void { - this.subscribe(src, (value) => dst.set(value)); + this.subscribe(src, (value) => dst.update(() => value)); } } From 637ab956951561b314977818bd2999395f73856a Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:56:55 -0800 Subject: [PATCH 040/590] chore: release (#869) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 198 ++++++++++++++++++---------------- Cargo.toml | 4 +- rs/hang-cli/CHANGELOG.md | 7 ++ rs/hang-cli/Cargo.toml | 2 +- rs/hang/CHANGELOG.md | 11 ++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 6 ++ rs/libmoq/Cargo.toml | 2 +- rs/moq-clock/CHANGELOG.md | 7 ++ rs/moq-clock/Cargo.toml | 2 +- rs/moq-lite/CHANGELOG.md | 9 ++ rs/moq-lite/Cargo.toml | 2 +- rs/moq-native/CHANGELOG.md | 7 ++ rs/moq-native/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 9 ++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-token-cli/CHANGELOG.md | 6 ++ rs/moq-token-cli/Cargo.toml | 2 +- rs/moq-token/CHANGELOG.md | 6 ++ rs/moq-token/Cargo.toml | 2 +- 20 files changed, 183 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4493ec1f3e..32cccdfba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" dependencies = [ "rustversion", ] @@ -524,9 +524,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.54" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -592,9 +592,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", "clap_derive", @@ -602,9 +602,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ "anstream", "anstyle", @@ -614,9 +614,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -1132,13 +1132,13 @@ dependencies = [ [[package]] name = "ed25519" -version = "3.0.0-rc.2" +version = "3.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "594435fe09e345ee388e4e8422072ff7dfeca8729389fbd997b3f5504c44cd47" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" dependencies = [ - "pkcs8 0.11.0-rc.9", + "pkcs8 0.11.0-rc.10", "serde", - "signature 3.0.0-rc.8", + "signature 3.0.0-rc.10", ] [[package]] @@ -1152,7 +1152,7 @@ dependencies = [ "rand_core 0.9.5", "serde", "sha2 0.11.0-rc.2", - "signature 3.0.0-rc.8", + "signature 3.0.0-rc.10", "subtle", "zeroize", ] @@ -1290,9 +1290,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -1302,9 +1302,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1571,7 +1571,7 @@ checksum = "253b313319f7109de64e480ffb606f89475cd758bae82e096e00c5d95341d30e" [[package]] name = "hang" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "buf-list", @@ -1600,7 +1600,7 @@ dependencies = [ [[package]] name = "hang-cli" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "axum", @@ -1821,9 +1821,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b41fb3dc24fe72c2e3a4685eed55917c2fb228851257f4a8f2d985da9443c3e5" +checksum = "e1b229d73f5803b562cc26e4da0396c8610a4ee209f4fac8fa4f8d709166dc45" dependencies = [ "typenum", "zeroize", @@ -1866,7 +1866,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] @@ -1884,14 +1884,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1901,7 +1900,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.2", - "system-configuration", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", @@ -1910,9 +1909,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2166,7 +2165,7 @@ dependencies = [ "netwatch", "pin-project", "pkarr", - "pkcs8 0.11.0-rc.9", + "pkcs8 0.11.0-rc.10", "portmapper", "rand 0.9.2", "reqwest 0.12.28", @@ -2184,7 +2183,7 @@ dependencies = [ "tracing", "url", "wasm-bindgen-futures", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", "z32", ] @@ -2223,9 +2222,9 @@ dependencies = [ [[package]] name = "iroh-metrics-derive" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e12bd0763fd16062f5cc5e8db15dd52d26e75a8af4c7fb57ccee3589b344b8" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" dependencies = [ "heck", "proc-macro2", @@ -2330,7 +2329,7 @@ dependencies = [ "tokio-websockets", "tracing", "url", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", "ws_stream_wasm", "z32", ] @@ -2432,13 +2431,13 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "cbindgen", @@ -2589,9 +2588,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.12" +version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -2606,7 +2605,7 @@ dependencies = [ [[package]] name = "moq-clock" -version = "0.10.3" +version = "0.10.4" dependencies = [ "anyhow", "chrono", @@ -2620,7 +2619,7 @@ dependencies = [ [[package]] name = "moq-lite" -version = "0.12.0" +version = "0.13.0" dependencies = [ "async-channel", "bytes", @@ -2638,7 +2637,7 @@ dependencies = [ [[package]] name = "moq-native" -version = "0.12.0" +version = "0.12.1" dependencies = [ "anyhow", "bytes", @@ -2672,7 +2671,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.10.3" +version = "0.10.4" dependencies = [ "anyhow", "axum", @@ -2700,7 +2699,7 @@ dependencies = [ [[package]] name = "moq-token" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "aws-lc-rs", @@ -2718,7 +2717,7 @@ dependencies = [ [[package]] name = "moq-token-cli" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "clap", @@ -2807,7 +2806,7 @@ dependencies = [ "netlink-packet-route", "netlink-sys", "once_cell", - "system-configuration", + "system-configuration 0.6.1", "windows-sys 0.59.0", ] @@ -2978,9 +2977,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -3295,9 +3294,9 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.11.0-rc.9" +version = "0.11.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f8fa6196ede5a9f9ee95b44ca134bddc9b70e8913f9297bd58c909f5889a09" +checksum = "b226d2cc389763951db8869584fd800cbbe2962bf454e2edeb5172b31ee99774" dependencies = [ "der 0.8.0-rc.10", "spki 0.8.0-rc.4", @@ -3315,9 +3314,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portmapper" @@ -3634,9 +3633,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3646,9 +3645,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3657,9 +3656,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -3699,7 +3698,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] @@ -3918,7 +3917,7 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 1.0.5", + "webpki-root-certs 1.0.6", "windows-sys 0.61.2", ] @@ -3994,9 +3993,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -4220,7 +4219,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -4347,9 +4346,9 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.8" +version = "3.0.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c04b70a14ee5f15e2e0c785a5fdb2e9a51138dfe13ba3cf8eab037a9e60b1879" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" [[package]] name = "simd-adler32" @@ -4386,15 +4385,15 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -4549,6 +4548,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -4629,9 +4639,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", @@ -4645,15 +4655,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -4852,9 +4862,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", "axum", @@ -4881,9 +4891,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" dependencies = [ "bytes", "prost", @@ -5140,9 +5150,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -5395,14 +5405,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "webpki-root-certs 1.0.5", + "webpki-root-certs 1.0.6", ] [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -5413,14 +5423,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -6019,18 +6029,18 @@ checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" dependencies = [ "proc-macro2", "quote", @@ -6113,6 +6123,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml index 42ad62df05..dd738bea81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,8 @@ members = [ resolver = "2" [workspace.dependencies] -hang = { version = "0.11", path = "rs/hang" } -moq-lite = { version = "0.12", path = "rs/moq-lite" } +hang = { version = "0.12", path = "rs/hang" } +moq-lite = { version = "0.13", path = "rs/moq-lite" } moq-native = { version = "0.12", path = "rs/moq-native" } moq-token = { version = "0.5", path = "rs/moq-token" } diff --git a/rs/hang-cli/CHANGELOG.md b/rs/hang-cli/CHANGELOG.md index 85ca824a40..7cbaaf0e82 100644 --- a/rs/hang-cli/CHANGELOG.md +++ b/rs/hang-cli/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.5](https://github.com/moq-dev/moq/compare/hang-cli-v0.7.4...hang-cli-v0.7.5) - 2026-02-03 + +### Other + +- Remove Produce struct and simplify API ([#875](https://github.com/moq-dev/moq/pull/875)) +- CMAF passthrough attempt v3 ([#867](https://github.com/moq-dev/moq/pull/867)) + ## [0.7.4](https://github.com/moq-dev/moq/compare/hang-cli-v0.7.3...hang-cli-v0.7.4) - 2026-01-24 ### Other diff --git a/rs/hang-cli/Cargo.toml b/rs/hang-cli/Cargo.toml index d3fa7e8c24..25182eb674 100644 --- a/rs/hang-cli/Cargo.toml +++ b/rs/hang-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.7.4" +version = "0.7.5" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index f48fcf0559..39f4885b9d 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0](https://github.com/moq-dev/moq/compare/hang-v0.11.0...hang-v0.12.0) - 2026-02-03 + +### Other + +- Rename minBuffer to jitter ([#894](https://github.com/moq-dev/moq/pull/894)) +- Add support for multiple groups, and fetching them ([#877](https://github.com/moq-dev/moq/pull/877)) +- Tweak a few small things the AI merge missed. ([#876](https://github.com/moq-dev/moq/pull/876)) +- Remove Produce struct and simplify API ([#875](https://github.com/moq-dev/moq/pull/875)) +- Close audio groups immediately. ([#870](https://github.com/moq-dev/moq/pull/870)) +- CMAF passthrough attempt v3 ([#867](https://github.com/moq-dev/moq/pull/867)) + ## [0.11.0](https://github.com/moq-dev/moq/compare/hang-v0.10.0...hang-v0.11.0) - 2026-01-24 ### Added diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index a05f97debf..54e8c3b099 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.11.0" +version = "0.12.0" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index df9a56834e..fe05dc61a9 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.6](https://github.com/moq-dev/moq/compare/libmoq-v0.2.5...libmoq-v0.2.6) - 2026-02-03 + +### Other + +- updated the following local packages: moq-lite, hang + ## [0.2.5](https://github.com/moq-dev/moq/compare/libmoq-v0.2.4...libmoq-v0.2.5) - 2026-01-24 ### Other diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index d2d498f32a..6205fe8be0 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.5" +version = "0.2.6" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-clock/CHANGELOG.md b/rs/moq-clock/CHANGELOG.md index bb298b4d12..1cb79db3d6 100644 --- a/rs/moq-clock/CHANGELOG.md +++ b/rs/moq-clock/CHANGELOG.md @@ -45,6 +45,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.4](https://github.com/moq-dev/moq/compare/moq-clock-v0.10.3...moq-clock-v0.10.4) - 2026-02-03 + +### Other + +- Tweak a few small things the AI merge missed. ([#876](https://github.com/moq-dev/moq/pull/876)) +- Remove Produce struct and simplify API ([#875](https://github.com/moq-dev/moq/pull/875)) + ## [0.10.3](https://github.com/moq-dev/moq/compare/moq-clock-v0.10.2...moq-clock-v0.10.3) - 2026-01-24 ### Other diff --git a/rs/moq-clock/Cargo.toml b/rs/moq-clock/Cargo.toml index d1b78338fa..13fb2bc3d7 100644 --- a/rs/moq-clock/Cargo.toml +++ b/rs/moq-clock/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.3" +version = "0.10.4" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-lite/CHANGELOG.md b/rs/moq-lite/CHANGELOG.md index 53f33b32d3..0c4a3af903 100644 --- a/rs/moq-lite/CHANGELOG.md +++ b/rs/moq-lite/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0](https://github.com/moq-dev/moq/compare/moq-lite-v0.12.0...moq-lite-v0.13.0) - 2026-02-03 + +### Other + +- Add support for multiple groups, and fetching them ([#877](https://github.com/moq-dev/moq/pull/877)) +- Tweak a few small things the AI merge missed. ([#876](https://github.com/moq-dev/moq/pull/876)) +- Remove Produce struct and simplify API ([#875](https://github.com/moq-dev/moq/pull/875)) +- Close session on drop ([#872](https://github.com/moq-dev/moq/pull/872)) + ## [0.12.0](https://github.com/moq-dev/moq/compare/moq-lite-v0.11.0...moq-lite-v0.12.0) - 2026-01-24 ### Other diff --git a/rs/moq-lite/Cargo.toml b/rs/moq-lite/Cargo.toml index bd7539a3e3..685910724b 100644 --- a/rs/moq-lite/Cargo.toml +++ b/rs/moq-lite/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.12.0" +version = "0.13.0" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-native/CHANGELOG.md b/rs/moq-native/CHANGELOG.md index d42780c56d..fcf01c7ba1 100644 --- a/rs/moq-native/CHANGELOG.md +++ b/rs/moq-native/CHANGELOG.md @@ -44,6 +44,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.1](https://github.com/moq-dev/moq/compare/moq-native-v0.12.0...moq-native-v0.12.1) - 2026-02-03 + +### Other + +- Tweak a few small things the AI merge missed. ([#876](https://github.com/moq-dev/moq/pull/876)) +- Remove Produce struct and simplify API ([#875](https://github.com/moq-dev/moq/pull/875)) + ## [0.12.0](https://github.com/moq-dev/moq/compare/moq-native-v0.11.0...moq-native-v0.12.0) - 2026-01-24 ### Other diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 6d75d73ed8..e67dba7fe8 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.12.0" +version = "0.12.1" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 0735e8d4b1..66319c586b 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -64,6 +64,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.4](https://github.com/moq-dev/moq/compare/moq-relay-v0.10.3...moq-relay-v0.10.4) - 2026-02-03 + +### Other + +- Add support for multiple groups, and fetching them ([#877](https://github.com/moq-dev/moq/pull/877)) +- Tweak a few small things the AI merge missed. ([#876](https://github.com/moq-dev/moq/pull/876)) +- Remove Produce struct and simplify API ([#875](https://github.com/moq-dev/moq/pull/875)) +- Skip jwt query param when no token configured ([#873](https://github.com/moq-dev/moq/pull/873)) + ## [0.10.3](https://github.com/moq-dev/moq/compare/moq-relay-v0.10.2...moq-relay-v0.10.3) - 2026-01-24 ### Other diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 56403ef2d3..c609a0a6e0 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.3" +version = "0.10.4" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-token-cli/CHANGELOG.md b/rs/moq-token-cli/CHANGELOG.md index dc9a5c8342..10d87a441a 100644 --- a/rs/moq-token-cli/CHANGELOG.md +++ b/rs/moq-token-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.9](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.8...moq-token-cli-v0.5.9) - 2026-02-03 + +### Other + +- update Cargo.lock dependencies + ## [0.5.8](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.7...moq-token-cli-v0.5.8) - 2026-01-24 ### Other diff --git a/rs/moq-token-cli/Cargo.toml b/rs/moq-token-cli/Cargo.toml index 1026db8189..f823e535ec 100644 --- a/rs/moq-token-cli/Cargo.toml +++ b/rs/moq-token-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.8" +version = "0.5.9" edition = "2024" [[bin]] diff --git a/rs/moq-token/CHANGELOG.md b/rs/moq-token/CHANGELOG.md index 7d8e335f61..df316d457d 100644 --- a/rs/moq-token/CHANGELOG.md +++ b/rs/moq-token/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/moq-token-v0.5.7...moq-token-v0.5.8) - 2026-02-03 + +### Other + +- Add some more documentation ([#901](https://github.com/moq-dev/moq/pull/901)) + ## [0.5.7](https://github.com/moq-dev/moq/compare/moq-token-v0.5.6...moq-token-v0.5.7) - 2026-01-24 ### Other diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index 145bbec2e5..287be7de2c 100644 --- a/rs/moq-token/Cargo.toml +++ b/rs/moq-token/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.7" +version = "0.5.8" edition = "2024" [features] From a906c9597b43ed23259c8c11f135fa2770ef4f2f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Feb 2026 11:24:34 -0800 Subject: [PATCH 041/590] Add arithmetic helpers to Time types (#904) Co-authored-by: Claude Opus 4.5 --- js/hang/src/container/legacy.ts | 2 +- js/hang/src/publish/video/polyfill.ts | 6 +++--- js/hang/src/util/latency.ts | 3 ++- js/hang/src/watch/audio/decoder.ts | 10 +++++----- js/hang/src/watch/audio/mse.ts | 2 +- js/hang/src/watch/mse.ts | 2 +- js/hang/src/watch/sync.ts | 18 +++++++++--------- js/hang/src/watch/video/decoder.ts | 8 ++++---- js/hang/src/watch/video/mse.ts | 2 +- js/lite/src/time.ts | 24 ++++++++++++++++++++++++ 10 files changed, 51 insertions(+), 26 deletions(-) diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index 6111ade9e9..5860369490 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -296,7 +296,7 @@ export class Consumer { // Try to merge with the last range if contiguous const last = ranges.at(-1); if (last && last.end >= start) { - last.end = Math.max(last.end, end) as Time.Milli; + last.end = Moq.Time.Milli.max(last.end, end); } else { ranges.push({ start, end }); } diff --git a/js/hang/src/publish/video/polyfill.ts b/js/hang/src/publish/video/polyfill.ts index 79d6828f5c..aeba5767ed 100644 --- a/js/hang/src/publish/video/polyfill.ts +++ b/js/hang/src/publish/video/polyfill.ts @@ -48,12 +48,12 @@ export function TrackProcessor(track: StreamTrack): ReadableStream { }), ]); - last = performance.now() as Time.Milli; + last = Time.Milli.now(); }, async pull(controller) { while (true) { - const now = performance.now() as Time.Milli; - if (now - last < 1000 / frameRate) { + const now = Time.Milli.now(); + if (Time.Milli.sub(now, last) < ((1000 / frameRate) as Time.Milli)) { await new Promise((r) => requestAnimationFrame(r)); continue; } diff --git a/js/hang/src/util/latency.ts b/js/hang/src/util/latency.ts index 29d9122b8e..4a421a5ef6 100644 --- a/js/hang/src/util/latency.ts +++ b/js/hang/src/util/latency.ts @@ -1,4 +1,5 @@ import type * as Moq from "@moq/lite"; +import { Time } from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; type ConfigWithJitter = { jitter?: number; framerate?: number }; @@ -44,7 +45,7 @@ export class Latency { } jitter ??= 0; - const latency = (jitter + buffer) as Moq.Time.Milli; + const latency = Time.Milli.add(jitter as Moq.Time.Milli, buffer); this.#combined.set(latency); } diff --git a/js/hang/src/watch/audio/decoder.ts b/js/hang/src/watch/audio/decoder.ts index c06aa9f782..18c9aaab8f 100644 --- a/js/hang/src/watch/audio/decoder.ts +++ b/js/hang/src/watch/audio/decoder.ts @@ -313,7 +313,7 @@ export class Decoder { // Calculate end time from sample duration const durationMicro = ((sample.numberOfFrames / sample.sampleRate) * 1_000_000) as Time.Micro; const durationMilli = Time.Milli.fromMicro(durationMicro); - const end = (timestampMilli + durationMilli) as Time.Milli; + const end = Time.Milli.add(timestampMilli, durationMilli); // Add to decode buffer this.#addDecodeBuffered(timestampMilli, end); @@ -348,8 +348,8 @@ export class Decoder { for (const range of current) { // Extend range if new sample overlaps or is adjacent (1ms tolerance for float precision) if (start <= range.end + 1 && end >= range.start) { - range.start = Math.min(range.start, start) as Time.Milli; - range.end = Math.max(range.end, end) as Time.Milli; + range.start = Time.Milli.min(range.start, start); + range.end = Time.Milli.max(range.end, end); return; } } @@ -363,7 +363,7 @@ export class Decoder { this.#decodeBuffered.mutate((current) => { while (current.length > 0) { if (current[0].end >= timestamp) { - current[0].start = Math.max(current[0].start, timestamp) as Time.Milli; + current[0].start = Time.Milli.max(current[0].start, timestamp); break; } current.shift(); @@ -397,7 +397,7 @@ function mergeBufferedRanges(a: BufferedRanges, b: BufferedRanges): BufferedRang const last = result.at(-1); if (last && last.end >= range.start) { // Merge overlapping ranges - last.end = Math.max(last.end, range.end) as Time.Milli; + last.end = Time.Milli.max(last.end, range.end); } else { result.push({ ...range }); } diff --git a/js/hang/src/watch/audio/mse.ts b/js/hang/src/watch/audio/mse.ts index d6f3e2094d..96ecc304d5 100644 --- a/js/hang/src/watch/audio/mse.ts +++ b/js/hang/src/watch/audio/mse.ts @@ -175,7 +175,7 @@ export class Mse implements Backend { // Compute duration from next frame's timestamp, or use last known duration if stream ended if (frame) { - duration = (frame.timestamp - pending.timestamp) as Moq.Time.Micro; + duration = Moq.Time.Micro.sub(frame.timestamp, pending.timestamp); // Mark that we received this frame for latency calculation. this.source.sync.received(Moq.Time.Milli.fromMicro(frame.timestamp as Moq.Time.Micro)); diff --git a/js/hang/src/watch/mse.ts b/js/hang/src/watch/mse.ts index e28f34f479..8e71657609 100644 --- a/js/hang/src/watch/mse.ts +++ b/js/hang/src/watch/mse.ts @@ -140,7 +140,7 @@ export class Muxer { // Compute the target currentTime based on reference and latency. // reference = performance.now() - frameTimestamp (in ms) when we received the earliest frame // So the target media timestamp (in ms) at time `now` is: now - reference - latency - const target = (performance.now() - reference - latency) as Time.Milli; + const target = Time.Milli.sub(Time.Milli.sub(Time.Milli.now(), reference), latency); // Seek to the target position. element.currentTime = Time.Milli.toSecond(target); diff --git a/js/hang/src/watch/sync.ts b/js/hang/src/watch/sync.ts index 572ee0ce17..7552f40e62 100644 --- a/js/hang/src/watch/sync.ts +++ b/js/hang/src/watch/sync.ts @@ -1,4 +1,4 @@ -import type { Time } from "@moq/lite"; +import { Time } from "@moq/lite"; import { Effect, Signal } from "@moq/signals"; export interface SyncProps { @@ -22,7 +22,7 @@ export class Sync { video: Signal; // The buffer required, based on both audio and video. - #latency = new Signal(0 as Time.Milli); + #latency = new Signal(Time.Milli.zero); readonly latency: Signal = this.#latency; // A ghetto way to learn when the reference/latency changes. @@ -46,10 +46,10 @@ export class Sync { #runLatency(effect: Effect): void { const jitter = effect.get(this.jitter); - const video = effect.get(this.video) ?? 0; - const audio = effect.get(this.audio) ?? 0; + const video = effect.get(this.video) ?? Time.Milli.zero; + const audio = effect.get(this.audio) ?? Time.Milli.zero; - const latency = (Math.max(video, audio) + jitter) as Time.Milli; + const latency = Time.Milli.add(Time.Milli.max(video, audio), jitter); this.#latency.set(latency); this.#resolve(); @@ -61,7 +61,7 @@ export class Sync { // Update the reference if this is the earliest frame we've seen, relative to its timestamp. received(timestamp: Time.Milli): void { - const ref = (performance.now() - timestamp) as Time.Milli; + const ref = Time.Milli.sub(Time.Milli.now(), timestamp); const current = this.#reference.peek(); if (current !== undefined && ref >= current) { @@ -85,13 +85,13 @@ export class Sync { for (;;) { // Sleep until it's time to decode the next frame. // NOTE: This function runs in parallel for each frame. - const now = performance.now(); - const ref = (now - timestamp) as Time.Milli; + const now = Time.Milli.now(); + const ref = Time.Milli.sub(now, timestamp); const currentRef = this.#reference.peek(); if (currentRef === undefined) return; - const sleep = currentRef - ref + this.#latency.peek(); + const sleep = Time.Milli.add(Time.Milli.sub(currentRef, ref), this.#latency.peek()); if (sleep <= 0) return; const wait = new Promise((resolve) => setTimeout(resolve, sleep)).then(() => true); diff --git a/js/hang/src/watch/video/decoder.ts b/js/hang/src/watch/video/decoder.ts index 84f26405cc..e9cdc5400f 100644 --- a/js/hang/src/watch/video/decoder.ts +++ b/js/hang/src/watch/video/decoder.ts @@ -413,8 +413,8 @@ class DecoderTrack { for (const range of current) { // Check if there's any overlap, then merge if (range.start <= end && range.end >= start) { - range.start = Math.min(range.start, start) as Time.Milli; - range.end = Math.max(range.end, end) as Time.Milli; + range.start = Time.Milli.min(range.start, start); + range.end = Time.Milli.max(range.end, end); return; } } @@ -429,7 +429,7 @@ class DecoderTrack { this.#buffered.mutate((current) => { while (current.length > 0) { if (current[0].end >= timestamp) { - current[0].start = Math.max(current[0].start, timestamp) as Time.Milli; + current[0].start = Time.Milli.max(current[0].start, timestamp); break; } current.shift(); @@ -459,7 +459,7 @@ function mergeBufferedRanges(a: BufferedRanges, b: BufferedRanges): BufferedRang const last = result.at(-1); if (last && last.end >= range.start) { // Merge overlapping ranges - last.end = Math.max(last.end, range.end) as Time.Milli; + last.end = Time.Milli.max(last.end, range.end); } else { result.push({ ...range }); } diff --git a/js/hang/src/watch/video/mse.ts b/js/hang/src/watch/video/mse.ts index 6b1388b39f..1e8c8fd74d 100644 --- a/js/hang/src/watch/video/mse.ts +++ b/js/hang/src/watch/video/mse.ts @@ -182,7 +182,7 @@ export class Mse implements Backend { // Compute duration from next frame's timestamp, or use last known duration if stream ended if (frame) { - duration = (frame.timestamp - pending.timestamp) as Moq.Time.Micro; + duration = Moq.Time.Micro.sub(frame.timestamp, pending.timestamp); // Mark that we received this frame right now for latency calculation. this.source.sync.received(Moq.Time.Milli.fromMicro(frame.timestamp as Moq.Time.Micro)); diff --git a/js/lite/src/time.ts b/js/lite/src/time.ts index dfad2d0434..78d755cf58 100644 --- a/js/lite/src/time.ts +++ b/js/lite/src/time.ts @@ -9,6 +9,12 @@ export const Nano = { toMilli: (ns: Nano): Milli => (ns / 1_000_000) as Milli, toSecond: (ns: Nano): Second => (ns / 1_000_000_000) as Second, now: (): Nano => (performance.now() * 1_000_000) as Nano, + add: (a: Nano, b: Nano): Nano => (a + b) as Nano, + sub: (a: Nano, b: Nano): Nano => (a - b) as Nano, + mul: (a: Nano, b: number): Nano => (a * b) as Nano, + div: (a: Nano, b: number): Nano => (a / b) as Nano, + max: (a: Nano, b: Nano): Nano => Math.max(a, b) as Nano, + min: (a: Nano, b: Nano): Nano => Math.min(a, b) as Nano, } as const; export type Micro = number & { readonly _brand: "micro" }; @@ -22,6 +28,12 @@ export const Micro = { toMilli: (us: Micro): Milli => (us / 1_000) as Milli, toSecond: (us: Micro): Second => (us / 1_000_000) as Second, now: (): Micro => (performance.now() * 1_000) as Micro, + add: (a: Micro, b: Micro): Micro => (a + b) as Micro, + sub: (a: Micro, b: Micro): Micro => (a - b) as Micro, + mul: (a: Micro, b: number): Micro => (a * b) as Micro, + div: (a: Micro, b: number): Micro => (a / b) as Micro, + max: (a: Micro, b: Micro): Micro => Math.max(a, b) as Micro, + min: (a: Micro, b: Micro): Micro => Math.min(a, b) as Micro, } as const; export type Milli = number & { readonly _brand: "milli" }; @@ -35,6 +47,12 @@ export const Milli = { toMicro: (ms: Milli): Micro => (ms * 1_000) as Micro, toSecond: (ms: Milli): Second => (ms / 1_000) as Second, now: (): Milli => performance.now() as Milli, + add: (a: Milli, b: Milli): Milli => (a + b) as Milli, + sub: (a: Milli, b: Milli): Milli => (a - b) as Milli, + mul: (a: Milli, b: number): Milli => (a * b) as Milli, + div: (a: Milli, b: number): Milli => (a / b) as Milli, + max: (a: Milli, b: Milli): Milli => Math.max(a, b) as Milli, + min: (a: Milli, b: Milli): Milli => Math.min(a, b) as Milli, } as const; export type Second = number & { readonly _brand: "second" }; @@ -48,4 +66,10 @@ export const Second = { toMicro: (s: Second): Micro => (s * 1_000_000) as Micro, toMilli: (s: Second): Milli => (s * 1_000) as Milli, now: (): Second => (performance.now() / 1_000) as Second, + add: (a: Second, b: Second): Second => (a + b) as Second, + sub: (a: Second, b: Second): Second => (a - b) as Second, + mul: (a: Second, b: number): Second => (a * b) as Second, + div: (a: Second, b: number): Second => (a / b) as Second, + max: (a: Second, b: Second): Second => Math.max(a, b) as Second, + min: (a: Second, b: Second): Second => Math.min(a, b) as Second, } as const; From eb3fbf1fd1b12e8304306470752a98c0a11767fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Feb 2026 13:36:04 -0800 Subject: [PATCH 042/590] Remove priority from the catalog (#905) Co-authored-by: Claude --- doc/concept/layer/hang.md | 6 ++---- js/hang/src/catalog/audio.ts | 5 ----- js/hang/src/catalog/track.ts | 1 - js/hang/src/catalog/video.ts | 5 ----- js/hang/src/publish/audio/encoder.ts | 1 - js/hang/src/publish/chat/message.ts | 2 +- js/hang/src/publish/chat/typing.ts | 2 +- js/hang/src/publish/location/peers.ts | 2 +- js/hang/src/publish/location/window.ts | 2 +- js/hang/src/publish/preview.ts | 2 +- js/hang/src/publish/video/index.ts | 1 - js/hang/src/watch/chat/message.ts | 4 ++-- js/hang/src/watch/chat/typing.ts | 4 ++-- js/hang/src/watch/location/peers.ts | 2 +- js/hang/src/watch/location/window.ts | 2 +- js/hang/src/watch/preview.ts | 2 +- rs/hang/examples/video.rs | 1 - rs/hang/src/catalog/audio/mod.rs | 3 --- rs/hang/src/catalog/root.rs | 2 -- rs/hang/src/catalog/video/mod.rs | 3 --- rs/hang/src/import/aac.rs | 3 +-- rs/hang/src/import/avc3.rs | 3 +-- rs/hang/src/import/fmp4.rs | 6 ++---- rs/hang/src/import/hev1.rs | 3 +-- rs/hang/src/import/opus.rs | 3 +-- rs/libmoq/src/consume.rs | 4 ++-- 26 files changed, 22 insertions(+), 52 deletions(-) diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index 2f97edfa54..79cd907ee3 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -32,8 +32,7 @@ Here is Big Buck Bunny's `catalog.json` as of 2026-02-02: "codedHeight": 720, "container": "legacy" } - }, - "priority": 1 + } }, "audio": { "renditions": { @@ -44,8 +43,7 @@ Here is Big Buck Bunny's `catalog.json` as of 2026-02-02: "bitrate": 283637, "container": "legacy" } - }, - "priority": 2 + } } } ``` diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index 58ab02c857..91ed391675 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -5,7 +5,6 @@ import { u53Schema } from "./integers"; // Backwards compatibility: old track schema const TrackSchema = z.object({ name: z.string(), - priority: z.number().int().min(0).max(255), }); // Mirrors AudioDecoderConfig @@ -46,9 +45,6 @@ export const AudioSchema = z // A map of track name to rendition configuration. // This is not an array so it will work with JSON Merge Patch. renditions: z.record(z.string(), AudioConfigSchema), - - // The priority of the audio track, relative to other tracks in the broadcast. - priority: z.number().int().min(0).max(255), }) .or( // Backwards compatibility: transform old {track, config} format to new object format @@ -59,7 +55,6 @@ export const AudioSchema = z }) .transform((old) => ({ renditions: { [old.track.name]: old.config }, - priority: old.track.priority, })), ); diff --git a/js/hang/src/catalog/track.ts b/js/hang/src/catalog/track.ts index 7eb18e5ac3..2411a646b7 100644 --- a/js/hang/src/catalog/track.ts +++ b/js/hang/src/catalog/track.ts @@ -2,6 +2,5 @@ import { z } from "zod"; export const TrackSchema = z.object({ name: z.string(), - priority: z.number().int().min(0).max(255), }); export type Track = z.infer; diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index fdd5b62d2e..f2db22edfa 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -5,7 +5,6 @@ import { u53Schema } from "./integers"; // Backwards compatibility: old track schema const TrackSchema = z.object({ name: z.string(), - priority: z.number().int().min(0).max(255), }); // Based on VideoDecoderConfig @@ -62,9 +61,6 @@ export const VideoSchema = z // This is not an array in order for it to work with JSON Merge Patch. renditions: z.record(z.string(), VideoConfigSchema), - // The priority of the video track, relative to other tracks in the broadcast. - priority: z.number().int().min(0).max(255), - // Render the video at this size in pixels. // This is separate from the display aspect ratio because it does not require reinitialization. display: z @@ -95,7 +91,6 @@ export const VideoSchema = z const config = arr[0]?.config; return { renditions: Object.fromEntries(arr.map((item) => [item.track.name, item.config])), - priority: arr[0]?.track.priority ?? 128, display: config?.displayAspectWidth && config?.displayAspectHeight ? { width: config.displayAspectWidth, height: config.displayAspectHeight } diff --git a/js/hang/src/publish/audio/encoder.ts b/js/hang/src/publish/audio/encoder.ts index 97514a06f1..706f3a4256 100644 --- a/js/hang/src/publish/audio/encoder.ts +++ b/js/hang/src/publish/audio/encoder.ts @@ -220,7 +220,6 @@ export class Encoder { const catalog: Catalog.Audio = { renditions: { [Encoder.TRACK]: config }, - priority: Encoder.PRIORITY, }; effect.set(this.#catalog, catalog); diff --git a/js/hang/src/publish/chat/message.ts b/js/hang/src/publish/chat/message.ts index b8ef2f8108..91010e63a6 100644 --- a/js/hang/src/publish/chat/message.ts +++ b/js/hang/src/publish/chat/message.ts @@ -27,7 +27,7 @@ export class Message { const enabled = effect.get(this.enabled); if (!enabled) return; - effect.set(this.catalog, { name: Message.TRACK, priority: Message.PRIORITY }); + effect.set(this.catalog, { name: Message.TRACK }); }); } diff --git a/js/hang/src/publish/chat/typing.ts b/js/hang/src/publish/chat/typing.ts index 0064f30eb4..d6de88ef99 100644 --- a/js/hang/src/publish/chat/typing.ts +++ b/js/hang/src/publish/chat/typing.ts @@ -27,7 +27,7 @@ export class Typing { const enabled = effect.get(this.enabled); if (!enabled) return; - effect.set(this.catalog, { name: Typing.TRACK, priority: Typing.PRIORITY }); + effect.set(this.catalog, { name: Typing.TRACK }); }); } diff --git a/js/hang/src/publish/location/peers.ts b/js/hang/src/publish/location/peers.ts index 9fdc7b3167..2a0222fe0f 100644 --- a/js/hang/src/publish/location/peers.ts +++ b/js/hang/src/publish/location/peers.ts @@ -26,7 +26,7 @@ export class Peers { const enabled = effect.get(this.enabled); if (!enabled) return; - effect.set(this.catalog, { name: Peers.TRACK, priority: Peers.PRIORITY }); + effect.set(this.catalog, { name: Peers.TRACK }); }); } diff --git a/js/hang/src/publish/location/window.ts b/js/hang/src/publish/location/window.ts index 3ee5789541..e7a2d3529c 100644 --- a/js/hang/src/publish/location/window.ts +++ b/js/hang/src/publish/location/window.ts @@ -37,7 +37,7 @@ export class Window { effect.set(this.catalog, { initial: this.position.peek(), - track: { name: Window.TRACK, priority: Window.PRIORITY }, + track: { name: Window.TRACK }, handle: effect.get(this.handle), }); }); diff --git a/js/hang/src/publish/preview.ts b/js/hang/src/publish/preview.ts index 5d5e5487dd..375e60994e 100644 --- a/js/hang/src/publish/preview.ts +++ b/js/hang/src/publish/preview.ts @@ -24,7 +24,7 @@ export class Preview { this.signals.effect((effect) => { if (!effect.get(this.enabled)) return; - effect.set(this.catalog, { name: Preview.TRACK, priority: Preview.PRIORITY }); + effect.set(this.catalog, { name: Preview.TRACK }); }); } diff --git a/js/hang/src/publish/video/index.ts b/js/hang/src/publish/video/index.ts index 97bfaf22c8..2f254a7869 100644 --- a/js/hang/src/publish/video/index.ts +++ b/js/hang/src/publish/video/index.ts @@ -91,7 +91,6 @@ export class Root { const catalog: Catalog.Video = { renditions, - priority: Root.PRIORITY, display: { width: Catalog.u53(display.width), height: Catalog.u53(display.height), diff --git a/js/hang/src/watch/chat/message.ts b/js/hang/src/watch/chat/message.ts index 686d4356d6..41959c1c30 100644 --- a/js/hang/src/watch/chat/message.ts +++ b/js/hang/src/watch/chat/message.ts @@ -1,6 +1,6 @@ import type * as Moq from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; -import type * as Catalog from "../../catalog"; +import * as Catalog from "../../catalog"; export interface MessageProps { // Whether to start downloading the chat. @@ -43,7 +43,7 @@ export class Message { if (!values) return; const [_, catalog, broadcast] = values; - const track = broadcast.subscribe(catalog.name, catalog.priority); + const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.chat); effect.cleanup(() => track.close()); // Undefined is only when we're not subscribed to the track. diff --git a/js/hang/src/watch/chat/typing.ts b/js/hang/src/watch/chat/typing.ts index a5ce2a0f49..f4da07394b 100644 --- a/js/hang/src/watch/chat/typing.ts +++ b/js/hang/src/watch/chat/typing.ts @@ -1,6 +1,6 @@ import type * as Moq from "@moq/lite"; import { Effect, type Getter, Signal } from "@moq/signals"; -import type * as Catalog from "../../catalog"; +import * as Catalog from "../../catalog"; export interface TypingProps { // Whether to start downloading the chat. @@ -41,7 +41,7 @@ export class Typing { if (!values) return; const [_, catalog, broadcast] = values; - const track = broadcast.subscribe(catalog.name, catalog.priority); + const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.typing); effect.cleanup(() => track.close()); effect.spawn(async () => { diff --git a/js/hang/src/watch/location/peers.ts b/js/hang/src/watch/location/peers.ts index 2836481105..f83a442536 100644 --- a/js/hang/src/watch/location/peers.ts +++ b/js/hang/src/watch/location/peers.ts @@ -36,7 +36,7 @@ export class Peers { if (!values) return; const [_, catalog, broadcast] = values; - const track = broadcast.subscribe(catalog.name, catalog.priority); + const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.location); effect.cleanup(() => track.close()); effect.spawn(this.#runTrack.bind(this, track)); diff --git a/js/hang/src/watch/location/window.ts b/js/hang/src/watch/location/window.ts index 79b7c53d2b..4b743be646 100644 --- a/js/hang/src/watch/location/window.ts +++ b/js/hang/src/watch/location/window.ts @@ -50,7 +50,7 @@ export class Window { const updates = effect.get(this.#catalog)?.track; if (!updates) return; - const track = broadcast.subscribe(updates.name, updates.priority); + const track = broadcast.subscribe(updates.name, Catalog.PRIORITY.location); effect.cleanup(() => track.close()); effect.spawn(this.#runTrack.bind(this, track)); diff --git a/js/hang/src/watch/preview.ts b/js/hang/src/watch/preview.ts index 653cdf440d..8b451112fa 100644 --- a/js/hang/src/watch/preview.ts +++ b/js/hang/src/watch/preview.ts @@ -33,7 +33,7 @@ export class Preview { const [_, broadcast, catalog] = values; // Subscribe to the preview.json track directly - const track = broadcast.subscribe(catalog.name, catalog.priority); + const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.preview); effect.cleanup(() => track.close()); effect.spawn(async () => { diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index 04dc0721e4..436cadae58 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -78,7 +78,6 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> hang::TrackProdu // Create the video catalog entry with the renditions let video = hang::catalog::Video { renditions, - priority: 1, display: None, rotation: None, flip: None, diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 89b7202f51..ff1c8ab954 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -25,9 +25,6 @@ pub struct Audio { /// This is not an array so it will work with JSON Merge Patch. /// We use a BTreeMap so keys are sorted alphabetically for *some* deterministic behavior. pub renditions: BTreeMap, - - /// The priority of the audio track, relative to other tracks in the broadcast. - pub priority: u8, } /// Audio decoder configuration based on WebCodecs AudioDecoderConfig. diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index dafe2744db..8b6f15ed9d 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -353,14 +353,12 @@ mod test { let decoded = Catalog { video: Some(Video { renditions: video_renditions, - priority: 1, display: None, rotation: None, flip: None, }), audio: Some(Audio { renditions: audio_renditions, - priority: 2, }), ..Default::default() }; diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 08999a5bb0..2e0c1036ab 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -32,9 +32,6 @@ pub struct Video { /// We use a BTreeMap so keys are sorted alphabetically for *some* deterministic behavior. pub renditions: BTreeMap, - /// The priority of the video track, relative to other tracks in the broadcast. - pub priority: u8, - /// Render the video at this size in pixels. /// This is separate from the display aspect ratio because it does not require reinitialization. #[serde(default)] diff --git a/rs/hang/src/import/aac.rs b/rs/hang/src/import/aac.rs index a74773167d..e3c441147c 100644 --- a/rs/hang/src/import/aac.rs +++ b/rs/hang/src/import/aac.rs @@ -116,8 +116,7 @@ impl Aac { let track = self.broadcast.create_track(track); let mut catalog = self.broadcast.catalog.lock(); - let audio = catalog.insert_audio(track.info.name.clone(), config); - audio.priority = 2; + catalog.insert_audio(track.info.name.clone(), config); self.track = Some(track); diff --git a/rs/hang/src/import/avc3.rs b/rs/hang/src/import/avc3.rs index 4620efc795..4ae14f877d 100644 --- a/rs/hang/src/import/avc3.rs +++ b/rs/hang/src/import/avc3.rs @@ -87,8 +87,7 @@ impl Avc3 { { let mut catalog = self.broadcast.catalog.lock(); - let video = catalog.insert_video(track.name.clone(), config.clone()); - video.priority = 2; + catalog.insert_video(track.name.clone(), config.clone()); } let track = self.broadcast.create_track(track); diff --git a/rs/hang/src/import/fmp4.rs b/rs/hang/src/import/fmp4.rs index 2de366ba2e..ea57d51820 100644 --- a/rs/hang/src/import/fmp4.rs +++ b/rs/hang/src/import/fmp4.rs @@ -188,8 +188,7 @@ impl Fmp4 { priority: 1, }; - let video = catalog.insert_video(track.name.clone(), config.clone()); - video.priority = 1; + catalog.insert_video(track.name.clone(), config.clone()); // Record this track name created_video_tracks.push(track.name.clone()); @@ -204,8 +203,7 @@ impl Fmp4 { priority: 2, }; - let audio = catalog.insert_audio(track.name.clone(), config.clone()); - audio.priority = 2; + catalog.insert_audio(track.name.clone(), config.clone()); // Record this track name created_audio_tracks.push(track.name.clone()); diff --git a/rs/hang/src/import/hev1.rs b/rs/hang/src/import/hev1.rs index 5a4d8dfe4c..83854f793e 100644 --- a/rs/hang/src/import/hev1.rs +++ b/rs/hang/src/import/hev1.rs @@ -86,8 +86,7 @@ impl Hev1 { { let mut catalog = self.broadcast.catalog.lock(); - let video = catalog.insert_video(track.name.clone(), config.clone()); - video.priority = 2; + catalog.insert_video(track.name.clone(), config.clone()); } let track = self.broadcast.create_track(track); diff --git a/rs/hang/src/import/opus.rs b/rs/hang/src/import/opus.rs index 7003b202e2..578d960f68 100644 --- a/rs/hang/src/import/opus.rs +++ b/rs/hang/src/import/opus.rs @@ -62,8 +62,7 @@ impl Opus { let track = self.broadcast.create_track(track); let mut catalog = self.broadcast.catalog.lock(); - let audio = catalog.insert_audio(track.info.name.clone(), config); - audio.priority = 2; + catalog.insert_audio(track.info.name.clone(), config); self.track = Some(track); diff --git a/rs/libmoq/src/consume.rs b/rs/libmoq/src/consume.rs index 93241e1e0a..10f2e0b9d2 100644 --- a/rs/libmoq/src/consume.rs +++ b/rs/libmoq/src/consume.rs @@ -181,7 +181,7 @@ impl Consume { let track = consume.broadcast.subscribe_track(&moq_lite::Track { name: rendition.clone(), - priority: video.priority, + priority: 1, }); let track = TrackConsumer::new(track, latency); @@ -215,7 +215,7 @@ impl Consume { let track = consume.broadcast.subscribe_track(&moq_lite::Track { name: rendition.clone(), - priority: audio.priority, + priority: 2, }); let track = TrackConsumer::new(track, latency); From 0ddb009a8bbf68e2b8f16a4b59dc5a7d564c9b3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Feb 2026 14:28:31 -0800 Subject: [PATCH 043/590] Add some icons instead of emojis. (#907) --- doc/index.md | 57 +++++++++++++++++++++--------------- doc/public/emoji/back.svg | 6 ++++ doc/public/emoji/battery.svg | 6 ++++ doc/public/emoji/box.svg | 6 ++++ doc/public/emoji/globe.svg | 4 +++ doc/public/emoji/link.svg | 4 +++ doc/public/emoji/lock.svg | 5 ++++ doc/public/emoji/puzzle.svg | 4 +++ doc/public/emoji/rocket.svg | 1 + doc/public/emoji/stonk.svg | 1 + 10 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 doc/public/emoji/back.svg create mode 100644 doc/public/emoji/battery.svg create mode 100644 doc/public/emoji/box.svg create mode 100644 doc/public/emoji/globe.svg create mode 100644 doc/public/emoji/link.svg create mode 100644 doc/public/emoji/lock.svg create mode 100644 doc/public/emoji/puzzle.svg create mode 100644 doc/public/emoji/rocket.svg create mode 100644 doc/public/emoji/stonk.svg diff --git a/doc/index.md b/doc/index.md index 4111a1cb94..e7f8e2d98c 100644 --- a/doc/index.md +++ b/doc/index.md @@ -23,41 +23,50 @@ hero: link: https://moq.dev/ features: - - icon: 🚀 - title: Latency - details: MoQ supports the entire latency spectrum on a per-viewer basis. Support real-time, interactive, or sit-back user experiences with a unified stack. - - - icon: 📈 - title: Massive Scale - details: All content can be cached and fanned-out via a generic CDN. Serve millions of concurrent viewers across the globe, including via Cloudflare (and more soon). - - - icon: 🔧 - title: Flexible - details: Supports contribution, distribution, and even conferencing. Extend the protocol with custom tracks for any sort of live content. - - - icon: 🌐 + - icon: + src: /emoji/rocket.svg + title: Adaptive + details: MoQ supports the entire latency spectrum. Simultaneously support real-time, interactive, or lean-back experiences with a unified stack. + + - icon: + src: /emoji/stonk.svg + title: Scalable + details: All content can be cached and fanned-out via a CDN. Serve millions of concurrent viewers across the globe, including via Cloudflare. + + - icon: + src: /emoji/puzzle.svg + title: Extensible + details: Supports contribution, distribution, conferencing, and whatever you can dream up. Extend the protocol with custom tracks for any live content. + + - icon: + src: /emoji/globe.svg title: Modern Web - details: Utilizes WebTransport, WebCodecs, and WebAudio APIs for native browser compatibility without hacks. + details: Utilizes WebTransport, WebCodecs, and WebAudio APIs for modern browser support without hacks. - - icon: 🎯 - title: Multi-platform - details: Libraries in Rust (native) and TypeScript (web). Integrations for ffmpeg, OBS, Gstreamer, and more to come. + - icon: + src: /emoji/box.svg + title: Cross-Platform + details: Libraries for Rust (native) and TypeScript (web). Integrations with ffmpeg, OBS, GStreamer, and more to come. And C bindings! - - icon: 💪 + - icon: + src: /emoji/battery.svg title: Efficient details: Save resources by only encoding or transmitting data when needed. Built on top of production-ready QUIC libraries. - - icon: 🔒 + - icon: + src: /emoji/lock.svg title: Secure details: Encrypted via TLS and authenticated via JWT. You can optionally self-host a private CDN or end-to-end encrypt your content. - - icon: ⏪ + - icon: + src: /emoji/back.svg title: Backwards Compatible - details: Supports CMAF and HLS for legacy device support. Gradually migrate your users without breaking a leg. + details: Supports CMAF and HLS for legacy device support. Migrate legacy devices at your own pace. - - icon: 👯 - title: Peer-to-Peer - details: Utilize a CDN and/or connect directly to peers via Iroh (native only). Automatically discovers broadcasts available on a connection. + - icon: + src: /emoji/link.svg + title: Decentralized + details: Host your own CDN, use a 3rd party service, and/or connect P2P via Iroh (native only). Broadcasts are automatically discovered and gossiped. --- ## What is MoQ? diff --git a/doc/public/emoji/back.svg b/doc/public/emoji/back.svg new file mode 100644 index 0000000000..f67368f301 --- /dev/null +++ b/doc/public/emoji/back.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/doc/public/emoji/battery.svg b/doc/public/emoji/battery.svg new file mode 100644 index 0000000000..c44532ebb3 --- /dev/null +++ b/doc/public/emoji/battery.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/doc/public/emoji/box.svg b/doc/public/emoji/box.svg new file mode 100644 index 0000000000..6f63014a46 --- /dev/null +++ b/doc/public/emoji/box.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/doc/public/emoji/globe.svg b/doc/public/emoji/globe.svg new file mode 100644 index 0000000000..58ab38ee54 --- /dev/null +++ b/doc/public/emoji/globe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/public/emoji/link.svg b/doc/public/emoji/link.svg new file mode 100644 index 0000000000..2a3673d9f6 --- /dev/null +++ b/doc/public/emoji/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/public/emoji/lock.svg b/doc/public/emoji/lock.svg new file mode 100644 index 0000000000..14930ee8d5 --- /dev/null +++ b/doc/public/emoji/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/doc/public/emoji/puzzle.svg b/doc/public/emoji/puzzle.svg new file mode 100644 index 0000000000..9f5c965b66 --- /dev/null +++ b/doc/public/emoji/puzzle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/public/emoji/rocket.svg b/doc/public/emoji/rocket.svg new file mode 100644 index 0000000000..fd12f47b6e --- /dev/null +++ b/doc/public/emoji/rocket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/public/emoji/stonk.svg b/doc/public/emoji/stonk.svg new file mode 100644 index 0000000000..2c8ba8192d --- /dev/null +++ b/doc/public/emoji/stonk.svg @@ -0,0 +1 @@ + \ No newline at end of file From 2c85558b4bd566de4949000242fe92a7da7f6d2a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Feb 2026 14:47:49 -0800 Subject: [PATCH 044/590] Use `moq` instead of `hang` for some crates (#906) Co-authored-by: Claude --- .github/workflows/cachix.yml | 2 +- .github/workflows/docker.yml | 2 +- CLAUDE.md | 5 +- Cargo.lock | 67 ++++-- Cargo.toml | 6 +- README.md | 6 +- cdn/README.md | 2 +- doc/app/cli.md | 38 +-- doc/app/index.md | 4 +- doc/index.md | 5 +- doc/rs/crate/hang.md | 31 ++- doc/rs/crate/moq-mux.md | 169 ++++++++++++++ doc/rs/index.md | 32 ++- doc/setup/index.md | 2 +- flake.nix | 4 +- justfile | 24 +- nix/overlay.nix | 6 +- rs/hang/Cargo.toml | 19 +- rs/hang/README.md | 3 +- rs/hang/examples/video.rs | 53 +++-- rs/hang/src/catalog/audio/mod.rs | 27 ++- rs/hang/src/catalog/consumer.rs | 56 +++++ rs/hang/src/catalog/mod.rs | 4 + rs/hang/src/catalog/producer.rs | 96 ++++++++ rs/hang/src/catalog/root.rs | 199 +--------------- rs/hang/src/catalog/video/mod.rs | 23 +- .../{model/track.rs => container/consumer.rs} | 216 +++++++++--------- rs/hang/src/{model => container}/frame.rs | 5 +- rs/hang/src/container/mod.rs | 7 + rs/hang/src/container/producer.rs | 103 +++++++++ rs/hang/src/error.rs | 12 - rs/hang/src/import/mod.rs | 27 --- rs/hang/src/lib.rs | 32 ++- rs/hang/src/model/broadcast.rs | 99 -------- rs/hang/src/model/group.rs | 116 ---------- rs/hang/src/model/mod.rs | 11 - rs/libmoq/Cargo.toml | 1 + rs/libmoq/src/api.rs | 4 +- rs/libmoq/src/consume.rs | 98 ++++---- rs/libmoq/src/publish.rs | 33 ++- rs/{hang-cli => moq-cli}/CHANGELOG.md | 0 rs/{hang-cli => moq-cli}/Cargo.toml | 5 +- rs/{hang-cli => moq-cli}/src/client.rs | 0 rs/{hang-cli => moq-cli}/src/main.rs | 0 rs/{hang-cli => moq-cli}/src/publish.rs | 30 ++- rs/{hang-cli => moq-cli}/src/server.rs | 0 rs/{hang-cli => moq-cli}/src/web.rs | 0 rs/moq-mux/Cargo.toml | 50 ++++ rs/{hang => moq-mux}/src/import/aac.rs | 43 ++-- rs/{hang => moq-mux}/src/import/annexb.rs | 0 rs/{hang => moq-mux}/src/import/avc3.rs | 50 ++-- rs/{hang => moq-mux}/src/import/decoder.rs | 48 ++-- rs/{hang => moq-mux}/src/import/fmp4.rs | 72 ++---- rs/{hang => moq-mux}/src/import/hev1.rs | 48 ++-- rs/{hang => moq-mux}/src/import/hls.rs | 23 +- rs/moq-mux/src/import/mod.rs | 45 ++++ rs/{hang => moq-mux}/src/import/opus.rs | 41 ++-- rs/moq-mux/src/lib.rs | 3 + 58 files changed, 1131 insertions(+), 976 deletions(-) create mode 100644 doc/rs/crate/moq-mux.md create mode 100644 rs/hang/src/catalog/consumer.rs create mode 100644 rs/hang/src/catalog/producer.rs rename rs/hang/src/{model/track.rs => container/consumer.rs} (53%) rename rs/hang/src/{model => container}/frame.rs (89%) create mode 100644 rs/hang/src/container/mod.rs create mode 100644 rs/hang/src/container/producer.rs delete mode 100644 rs/hang/src/import/mod.rs delete mode 100644 rs/hang/src/model/broadcast.rs delete mode 100644 rs/hang/src/model/group.rs delete mode 100644 rs/hang/src/model/mod.rs rename rs/{hang-cli => moq-cli}/CHANGELOG.md (100%) rename rs/{hang-cli => moq-cli}/Cargo.toml (94%) rename rs/{hang-cli => moq-cli}/src/client.rs (100%) rename rs/{hang-cli => moq-cli}/src/main.rs (100%) rename rs/{hang-cli => moq-cli}/src/publish.rs (71%) rename rs/{hang-cli => moq-cli}/src/server.rs (100%) rename rs/{hang-cli => moq-cli}/src/web.rs (100%) create mode 100644 rs/moq-mux/Cargo.toml rename rs/{hang => moq-mux}/src/import/aac.rs (84%) rename rs/{hang => moq-mux}/src/import/annexb.rs (100%) rename rs/{hang => moq-mux}/src/import/avc3.rs (86%) rename rs/{hang => moq-mux}/src/import/decoder.rs (83%) rename rs/{hang => moq-mux}/src/import/fmp4.rs (91%) rename rs/{hang => moq-mux}/src/import/hev1.rs (90%) rename rs/{hang => moq-mux}/src/import/hls.rs (96%) create mode 100644 rs/moq-mux/src/import/mod.rs rename rs/{hang => moq-mux}/src/import/opus.rs (71%) create mode 100644 rs/moq-mux/src/lib.rs diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index d2c317c54b..382f20b3ad 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -5,7 +5,7 @@ on: tags: - 'moq-relay-v*' - 'moq-token-cli-v*' - - 'hang-cli-v*' + - 'moq-cli-v*' jobs: cache: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 01b3e80172..c1d81e7f0f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,7 +6,7 @@ on: - 'moq-relay-v*' - 'moq-clock-v*' # NOTE: The -cli is automatically removed - - 'hang-cli-v*' + - 'moq-cli-v*' - 'moq-token-cli-v*' env: diff --git a/CLAUDE.md b/CLAUDE.md index b3c3358d84..d21dd42cec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,9 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi moq/ # Core protocol (published as moq-lite) moq-relay/ # Clusterable relay server moq-token/ # JWT authentication - hang/ # Media encoding/streaming - hang-cli/ # CLI tool for media operations (binary is named `hang`) + hang/ # Media encoding/streaming (catalog/container format) + moq-mux/ # Media muxers/demuxers (fMP4, CMAF, HLS) + moq-cli/ # CLI tool for media operations (binary is named `moq`) /js/ # TypeScript/JavaScript packages moq/ # Core protocol for browsers (published as @moq/lite) diff --git a/Cargo.lock b/Cargo.lock index 32cccdfba2..d8b09acfc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1571,24 +1571,18 @@ checksum = "253b313319f7109de64e480ffb606f89475cd758bae82e096e00c5d95341d30e" [[package]] name = "hang" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "buf-list", "bytes", "derive_more", "futures", - "h264-parser", "hex", "lazy_static", - "m3u8-rs", "moq-lite", "moq-native", - "mp4-atom", - "num_enum", "regex", - "reqwest 0.12.28", - "scuffle-h265", "serde", "serde_json", "serde_with", @@ -1598,24 +1592,6 @@ dependencies = [ "url", ] -[[package]] -name = "hang-cli" -version = "0.7.5" -dependencies = [ - "anyhow", - "axum", - "axum-server", - "clap", - "hang", - "moq-native", - "rustls", - "sd-notify", - "tokio", - "tower-http", - "tracing", - "url", -] - [[package]] name = "hash32" version = "0.2.1" @@ -2443,6 +2419,7 @@ dependencies = [ "cbindgen", "hang", "moq-lite", + "moq-mux", "moq-native", "slab", "thiserror 2.0.18", @@ -2603,6 +2580,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "moq-cli" +version = "0.7.5" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "clap", + "hang", + "moq-mux", + "moq-native", + "rustls", + "sd-notify", + "tokio", + "tower-http", + "tracing", + "url", +] + [[package]] name = "moq-clock" version = "0.10.4" @@ -2635,6 +2631,27 @@ dependencies = [ "web-transport-trait", ] +[[package]] +name = "moq-mux" +version = "0.1.0" +dependencies = [ + "anyhow", + "buf-list", + "bytes", + "derive_more", + "h264-parser", + "hang", + "m3u8-rs", + "moq-lite", + "mp4-atom", + "num_enum", + "reqwest 0.12.28", + "scuffle-h265", + "tokio", + "tracing", + "url", +] + [[package]] name = "moq-native" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index dd738bea81..a615d694c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [workspace] members = [ "rs/hang", - "rs/hang-cli", "rs/libmoq", + "rs/moq-cli", "rs/moq-clock", "rs/moq-lite", + "rs/moq-mux", "rs/moq-native", "rs/moq-relay", "rs/moq-token", @@ -13,8 +14,9 @@ members = [ resolver = "2" [workspace.dependencies] -hang = { version = "0.12", path = "rs/hang" } +hang = { version = "0.13", path = "rs/hang" } moq-lite = { version = "0.13", path = "rs/moq-lite" } +moq-mux = { version = "0.1", path = "rs/moq-mux" } moq-native = { version = "0.12", path = "rs/moq-native" } moq-token = { version = "0.5", path = "rs/moq-token" } diff --git a/README.md b/README.md index f8399261b4..30b2b191a8 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,9 @@ This repository provides both [Rust](/rs) and [TypeScript](/js) libraries with s | [moq-token](rs/moq-token) | An authentication scheme supported by `moq-relay`. Can be used as a library or as [a CLI](rs/moq-token-cli) to authenticate sessions. | | | [moq-native](rs/moq-native) | Opinionated helpers to configure a Quinn QUIC endpoint. It's harder than it should be. | [![docs.rs](https://docs.rs/moq-native/badge.svg)](https://docs.rs/moq-native) | | [libmoq](rs/libmoq) | C bindings for `moq-lite`. | [![docs.rs](https://docs.rs/libmoq/badge.svg)](https://docs.rs/libmoq) | -| [hang](rs/hang) | Media-specific encoding/streaming layered on top of `moq-lite`. Can be used as a library or [a CLI](rs/hang-cli). | [![docs.rs](https://docs.rs/hang/badge.svg)](https://docs.rs/hang) | +| [hang](rs/hang) | Media-specific encoding/streaming layered on top of `moq-lite`. Can be used as a library. | [![docs.rs](https://docs.rs/hang/badge.svg)](https://docs.rs/hang) | +| [moq-cli](rs/moq-cli) | A CLI for publishing media to MoQ relays. | | +| [moq-mux](rs/moq-mux) | Media muxers and demuxers (fMP4/CMAF, HLS) for importing content into MoQ broadcasts. | [![docs.rs](https://docs.rs/moq-mux/badge.svg)](https://docs.rs/moq-mux) | | [hang-gst](https://github.com/moq-dev/gstreamer) | A GStreamer plugin for publishing or consuming hang broadcasts. A separate repo to avoid requiring gstreamer as a build dependency. | | @@ -202,7 +204,7 @@ just web Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 1 via iroh to the relay running in terminal 2, from where the browser fetches it over regular WebTransport. -`just serve` serves a video via iroh alongside regular QUIC (it enables the `iroh` feature). This repo currently does not provide a native viewer, so you can't subscribe to it directly. However, you can use the [watch example from iroh-live](https://github.com/n0-computer/iroh-live/blob/main/iroh-live/examples/watch.rs) to view a video published via `hang-native`. +`just serve` serves a video via iroh alongside regular QUIC (it enables the `iroh` feature). This repo currently does not provide a native viewer, so you can't subscribe to it directly. However, you can use the [watch example from iroh-live](https://github.com/n0-computer/iroh-live/blob/main/iroh-live/examples/watch.rs) to view a video published via `moq-native`. ## License diff --git a/cdn/README.md b/cdn/README.md index 8c39c67861..d9e7ec244b 100644 --- a/cdn/README.md +++ b/cdn/README.md @@ -26,7 +26,7 @@ However, we do use GCP for GeoDNS because most providers don't support it or too ## Deploy -1. `nix flake update` to update the `moq-relay` and `hang-cli` binaries. +1. `nix flake update` to update the `moq-relay` and `moq-cli` binaries. - **NOTE**: This pulls from `main` on github, not a local path or the latest release. 2. `just deploy-all` to deploy to all nodes in parallel. - This will take a while as the builds *currently* occur on the remote nodes. diff --git a/doc/app/cli.md b/doc/app/cli.md index ac1e1271a0..b0d9a6f816 100644 --- a/doc/app/cli.md +++ b/doc/app/cli.md @@ -1,24 +1,24 @@ --- -title: FFmpeg / hang-cli +title: FFmpeg / moq-cli description: Command-line tools for MoQ media --- -# FFmpeg / hang-cli +# FFmpeg / moq-cli -`hang-cli` is a command-line tool for publishing media to MoQ relays. It works with FFmpeg for encoding. +`moq-cli` is a command-line tool for publishing media to MoQ relays. It works with FFmpeg for encoding. ## Installation ### Using Cargo ```bash -cargo install hang-cli +cargo install moq-cli ``` ### Using Nix ```bash -nix build github:moq-dev/moq#hang-cli +nix build github:moq-dev/moq#moq ``` ### From Source @@ -26,45 +26,45 @@ nix build github:moq-dev/moq#hang-cli ```bash git clone https://github.com/moq-dev/moq cd moq -cargo build --release --bin hang-cli +cargo build --release --bin moq ``` -The binary will be in `target/release/hang-cli` (or just `hang` when installed). +The binary will be in `target/release/moq`. ## Basic Usage ### Publish a Video File ```bash -hang publish video.mp4 https://relay.example.com/anon/my-stream +moq publish video.mp4 https://relay.example.com/anon/my-stream ``` ### Publish from FFmpeg -Pipe FFmpeg output directly to hang: +Pipe FFmpeg output directly to moq: ```bash -ffmpeg -i input.mp4 -f mpegts - | hang publish - https://relay.example.com/anon/my-stream +ffmpeg -i input.mp4 -f mpegts - | moq publish - https://relay.example.com/anon/my-stream ``` ### Publish a Webcam ```bash # macOS -ffmpeg -f avfoundation -i "0:0" -f mpegts - | hang publish - https://relay.example.com/anon/webcam +ffmpeg -f avfoundation -i "0:0" -f mpegts - | moq publish - https://relay.example.com/anon/webcam # Linux -ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | hang publish - https://relay.example.com/anon/webcam +ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | moq publish - https://relay.example.com/anon/webcam ``` ### Publish Screen ```bash # macOS -ffmpeg -f avfoundation -i "1:" -f mpegts - | hang publish - https://relay.example.com/anon/screen +ffmpeg -f avfoundation -i "1:" -f mpegts - | moq publish - https://relay.example.com/anon/screen # Linux (X11) -ffmpeg -f x11grab -i :0.0 -f mpegts - | hang publish - https://relay.example.com/anon/screen +ffmpeg -f x11grab -i :0.0 -f mpegts - | moq publish - https://relay.example.com/anon/screen ``` ## Encoding Options @@ -76,7 +76,7 @@ ffmpeg -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -b:v 2500k -maxrate 2500k -bufsize 5000k \ -c:a aac -b:a 128k \ - -f mpegts - | hang publish - https://relay.example.com/anon/stream + -f mpegts - | moq publish - https://relay.example.com/anon/stream ``` ### Low Latency Settings @@ -86,7 +86,7 @@ ffmpeg -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -g 30 -keyint_min 30 \ -c:a aac \ - -f mpegts - | hang publish - https://relay.example.com/anon/stream + -f mpegts - | moq publish - https://relay.example.com/anon/stream ``` ### H.265/HEVC @@ -95,7 +95,7 @@ ffmpeg -i input.mp4 \ ffmpeg -i input.mp4 \ -c:v libx265 -preset ultrafast \ -c:a aac \ - -f mpegts - | hang publish - https://relay.example.com/anon/stream + -f mpegts - | moq publish - https://relay.example.com/anon/stream ``` ## Authentication @@ -103,7 +103,7 @@ ffmpeg -i input.mp4 \ Pass a JWT token via the URL: ```bash -hang publish video.mp4 "https://relay.example.com/room/123?jwt=" +moq publish video.mp4 "https://relay.example.com/room/123?jwt=" ``` See [Authentication](/app/relay/auth) for token generation. @@ -137,7 +137,7 @@ just clock subscribe https://relay.example.com/anon ### Verbose Output ```bash -RUST_LOG=debug hang publish video.mp4 https://relay.example.com/anon/stream +RUST_LOG=debug moq publish video.mp4 https://relay.example.com/anon/stream ``` ### Check Connection diff --git a/doc/app/index.md b/doc/app/index.md index 8c0d7531e0..5596652c0e 100644 --- a/doc/app/index.md +++ b/doc/app/index.md @@ -20,13 +20,13 @@ Run it yourself, or pay for an external service (ex. Cloudflare). - [Clustering](/app/relay/cluster) - Multi-region deployment -## [hang-cli](/app/cli) +## [moq-cli](/app/cli) A CLI for publishing to media streams. Another tool does the encoding (ex. ffmpeg), making it easy to pipe any media into MoQ. ```bash # Publish your webcam -ffmpeg -f avfoundation -i "0" -f mp4 - | hang publish https://relay.example.com my-stream +ffmpeg -f avfoundation -i "0" -f mp4 - | moq publish https://relay.example.com my-stream ``` ## [OBS Plugin](/app/obs) diff --git a/doc/index.md b/doc/index.md index e7f8e2d98c..71803b57a3 100644 --- a/doc/index.md +++ b/doc/index.md @@ -98,7 +98,7 @@ If everything works, a browser window will pop up demoing how to both publish an There are a bunch of MoQ binaries and plugins. Some highlights: - [moq-relay](/app/relay/) - A server connecting publishers to subscribers, able to form a [self-hosted CDN cluster](/app/relay/cluster). -- [hang-cli](/app/cli) - A CLI that can import and publish MoQ broadcasts from a variety of formats (fMP4, HLS, etc), including via ffmpeg. +- [moq-cli](/app/cli) - A CLI that can import and publish MoQ broadcasts from a variety of formats (fMP4, HLS, etc), including via ffmpeg. - [obs](/app/obs) - An OBS plugin, able to publish a MoQ broadcast and/or use MoQ broadcasts as sources. - [gstreamer](/app/gstreamer) - A gstreamer plugin, split into a source and a sink. - [web](/app/web) - A web component you can throw on your website, wrapping a `