From 27e6ed4c4556cc9d391cf33d2c253204af6187fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 10:45:43 -0700 Subject: [PATCH 01/85] fix(moq-video): compile the MediaFoundation camera texture test (#4036) Co-authored-by: Claude Opus 5.5 --- rs/moq-video/src/encode/encoder.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/rs/moq-video/src/encode/encoder.rs b/rs/moq-video/src/encode/encoder.rs index df68a3a7d8..fa491eae44 100644 --- a/rs/moq-video/src/encode/encoder.rs +++ b/rs/moq-video/src/encode/encoder.rs @@ -849,22 +849,25 @@ mod tests { let config = Config { kind: Kind::Named("mediafoundation".into()), - ..Config::new(w, h, camera.framerate().unwrap_or(30)) + ..Config::new(w, h, camera.framerate().unwrap_or(crate::Rate::integer(30))) }; let mut encoder = Encoder::new(&config).expect("hardware H.264 encoder available"); let mut frames = Vec::new(); let mut textures = 0; for i in 0..30 { - let surface = camera.read().await.expect("read camera frame"); - if matches!(surface, Some(Surface::Texture(_))) { + let frame = camera + .read() + .await + .expect("read camera frame") + .expect("frame, not end of stream"); + if matches!(frame.surface, Surface::Texture(_)) { textures += 1; } if i == 0 { encoder.cut().unwrap(); } - let surface = surface.expect("frame, not end of stream"); - frames.extend(encoder.encode(&Frame::new(surface, at(i))).unwrap()); + frames.extend(encoder.encode(&frame).unwrap()); } frames.extend(encoder.finish().unwrap()); From 0bb7b2dd0de22e30a34f5b3a49efa3883c4499f0 Mon Sep 17 00:00:00 2001 From: Franz Heinzmann Date: Thu, 24 Sep 2026 19:57:37 +0200 Subject: [PATCH 02/85] feat(moq-video): encode and scale DMA-BUFs on VAAPI without a download (#4012) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Luke Curley --- Cargo.lock | 4 +- Cargo.toml | 2 +- quest/m4/video-vaapi.md | 20 +- rs/moq-video/Cargo.toml | 4 +- rs/moq-video/DESIGN-native-codecs.md | 43 ++- rs/moq-video/src/decode/backend/vaapi.rs | 120 +----- rs/moq-video/src/encode/backend/mod.rs | 2 +- rs/moq-video/src/encode/backend/vaapi.rs | 462 +++++++++++++++++++++-- rs/moq-video/src/frame.rs | 17 + rs/moq-video/src/frame/vaapi.rs | 388 +++++++++++++++++++ 10 files changed, 878 insertions(+), 184 deletions(-) create mode 100644 rs/moq-video/src/frame/vaapi.rs diff --git a/Cargo.lock b/Cargo.lock index ab053a33f4..259d293475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4859,9 +4859,9 @@ dependencies = [ [[package]] name = "moq-vaapi" -version = "0.0.4" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091df44de531fc6e5346da91f2dd7d1ebbb4a2e741ddadaf49aef017e659cb57" +checksum = "a697143830ed57c5ce0e96016e845e0b097f607c536847c12890e3bd23ff4db9" dependencies = [ "anyhow", "bindgen 0.70.1", diff --git a/Cargo.toml b/Cargo.toml index 5a529c9cd6..9a4d0496c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,7 +179,7 @@ moq-uring = { version = "0.0.2", path = "rs/moq-uring", default-features = false moq-v4l = { version = "0.0.1", path = "rs/moq-v4l" } # Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs. # dlopen's libva at runtime (no libva-dev at build, no NEEDED libva in the binary). -moq-vaapi = "0.0.4" +moq-vaapi = "0.1.0" # `default-features = false` is here for `capture` (V4L2, libclang), which the # cross-compiled Python, Swift, Kotlin, and Go builds have no use for: adding # `features = ["capture"]` to a consumer that ships in those bindings pulls the diff --git a/quest/m4/video-vaapi.md b/quest/m4/video-vaapi.md index 350f5c73d6..e2bc947fce 100644 --- a/quest/m4/video-vaapi.md +++ b/quest/m4/video-vaapi.md @@ -9,21 +9,23 @@ release first. ## Plan -Four gaps, one external dependency. +Three gaps, one external dependency. **Decode.** The H.264 decoder landed (moq-vaapi 0.0.4, `decode/backend/vaapi.rs`), with the default `decode::Config::output` of `Output::Native` handing out DMA-BUF surfaces the renderer imports without a download. H.265 decode is still missing, so a Linux box without NVDEC has no hardware path for it. -**The encoder.** Ours is a 111-line CPU-only adapter whose own header says it -is unvalidated on hardware. iroh-live's imports a DMA-BUF directly and does -scale and convert through VPP, validated on Intel Meteor Lake. Take theirs and -reshape it to our surface rather than growing ours toward it. +**The encoder.** H.264 is done. `moq-vaapi` imports DMA-BUFs and scales and +converts them through VPP (`dmabuf`, `vpp`, `Encoder::encode_dmabuf`, +`Encoder::set_bitrate`); `encode/backend/vaapi.rs` encodes a `Surface::DmaBuf` +without a download, and `Surface::resize` scales one through VPP. Validated on +Intel Meteor Lake. What is left is H.265, below. **H.265.** The VAAPI backend advertises H.264 only. `moq-vaapi` 0.0.2 vendors the HEVC buffer types (`src/buffer/hevc.rs`) but its `Encoder` is hardcoded to -`VAProfileH264Main` / `VAEntrypointEncSlice`, so exposing an HEVC encoder is a +`VAProfileH264Main` (with `VAEntrypointEncSlice`, or the low-power entrypoint +where that is all a device has), so exposing an HEVC encoder is a change to that crate, not a flag here. **Build cost.** `moq-vaapi` 0.0.3 dlopens libva (no `DT_NEEDED`), so a @@ -43,6 +45,6 @@ already falls back cleanly, since `Encoder::new` returns `Err` and ## Required -- A `moq-dev/vaapi` release exposing an HEVC encoder, the decode half and a - VPP wrapper, and shipping pre-generated bindings instead of a bindgen build - script +- A `moq-dev/vaapi` release exposing an HEVC encoder (H.264 decode is in + 0.0.4; DMA-BUF encode and VPP shipped in 0.1.0) and pre-generated bindings + instead of a bindgen build script diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 3e5e6508d8..c7fe32b53e 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -51,8 +51,8 @@ openh264 = ["dep:openh264", "dep:openh264-sys2"] # default because moq-vaapi's build script runs bindgen over its vendored libva # headers, so the build host needs libclang. At runtime libva is dlopen'd: the # binary carries no NEEDED libva and still starts on a libva-less host, where -# automatic backend selection falls back to the next candidate. The decoder is -# hardware-validated, while the encoder is not yet. +# automatic backend selection falls back to the next candidate. Both are +# validated on Intel Meteor Lake. vaapi = ["dmabuf", "dep:moq-vaapi"] # The V4L2 stateful memory-to-memory hardware codecs on Linux: the H.264 encoder # and decoder most ARM SoCs expose (a Raspberry Pi's VideoCore, and the diff --git a/rs/moq-video/DESIGN-native-codecs.md b/rs/moq-video/DESIGN-native-codecs.md index 3ee8a755c7..33217db86f 100644 --- a/rs/moq-video/DESIGN-native-codecs.md +++ b/rs/moq-video/DESIGN-native-codecs.md @@ -3,9 +3,9 @@ Status: **phases 2-6 implemented** (openh264, VideoToolbox, NVENC, VAAPI, capture swap + ffmpeg removal). Capture is now native on all three platforms (AVFoundation / ScreenCaptureKit on macOS, V4L2 on Linux, Media Foundation on Windows), so -**nokhwa is fully removed**. VAAPI is back via discord/cros-codecs with an NV12 -surface-upload input path; the zero-copy dmabuf capture is a follow-up. See "As -built" at the bottom for where the implementation diverged from this plan. +**nokhwa is fully removed**. VAAPI is back via moq-vaapi, which encodes DMA-BUFs +on the GPU and uploads CPU frames as NV12. See "As built" at the bottom for where +the implementation diverged from this plan. ## Goal @@ -412,25 +412,24 @@ software (see `backend::open`). > libva and vendors the libva headers its bindgen reads, so VAAPI costs the build > nothing: no libva-dev, no `NEEDED libva.so.2`, no entry in the nix devShell. -**Input is an NV12 surface upload, not zero-copy dmabuf.** The encoder wants an -NV12 VA surface, but UVC webcams deliver YUYV/MJPEG (decoded to CPU I420); they -rarely expose NV12 to import zero-copy. So `backend/vaapi.rs` drives -`new_native_vaapi` with a `VaSurfacePool`: each frame uploads I420 into a pooled -surface as NV12 (`libva::Image`, honoring plane pitches) and encodes the surface. -This works with the existing CPU V4L2 capture, no new capture code. - -Follow-up (not in this PR): the **zero-copy dmabuf path** for the rare NV12-capable -V4L2 source. Re-add `Frame::DmaBuf`, a V4L2 `VIDIOC_EXPBUF` capture (the `v4l` -crate exposes the raw ioctl but no dmabuf stream), and a `requires_dmabuf` capture -coupling, then import the dmabuf into a VA surface (`MemoryType::DrmPrime2`). - -**NOT YET VALIDATED ON HARDWARE.** Compiles on Linux with libva headers; written -against discord/cros-codecs `discord-0.0.5` with type/field names checked against -source. Needs a Linux + Intel/AMD GPU to confirm: (1) the `low_power` entrypoint -(recent Intel iHD often requires the low-power encode entrypoint, AMD the full -one; we request full and let `Kind::Auto` fall back); (2) the NV12 upload -pitch/offset handling round-trips; (3) `cargo deny` accepts the new transitive -licenses (drm, drm-fourcc, etc.) once the vaapi graph resolves. +**Input: DMA-BUFs stay on the GPU, CPU frames are uploaded.** A +`Surface::DmaBuf` is encoded without a download. An NV12 buffer at the encoder's +size (a VA-API decode, or one `Surface::resize` already scaled through VPP) is +imported and encoded in place when that size is a whole number of macroblocks; +anything else the driver imports, packed RGB from a PipeWire screen capture in +particular, goes through VPP into the encoder's own surface. A layout the driver +refuses falls back to the CPU path and is not tried again. Every other surface +is converted to I420, interleaved to NV12, and uploaded into the encoder's input +surface, which is the path a UVC webcam's YUYV or MJPEG frames take. A V4L2 capture that exports DMA-BUFs (`VIDIOC_EXPBUF`) would let an +NV12-capable camera skip the upload too; it is not built. + +**Validated on Intel Meteor Lake** (iHD 26.1.5) by the tests in +`encode/backend/vaapi.rs`: they encode CPU frames, VA-API decodes handed over as +DMA-BUFs, and packed RGB buffers at and above the encoder's size, decode each +stream with openh264, and compare pixels. They skip without a VA-API device. +moq-vaapi opens the full encode entrypoint and falls back to the low-power one +(`VAEntrypointEncSliceLP`) that some Intel parts expose alone; no part that +needs the fallback has run it yet. ### NVENC ships via dlopen (no driver dependency at build or load) diff --git a/rs/moq-video/src/decode/backend/vaapi.rs b/rs/moq-video/src/decode/backend/vaapi.rs index 632cd616fe..7d92c6bca1 100644 --- a/rs/moq-video/src/decode/backend/vaapi.rs +++ b/rs/moq-video/src/decode/backend/vaapi.rs @@ -47,15 +47,12 @@ //! back through `vaDeriveImage` rather than trying to read a tiled buffer as //! rows. -use std::os::fd::{AsFd, OwnedFd}; -use std::sync::{Arc, Mutex}; - use bytes::Bytes; use moq_net::Timestamp; use moq_vaapi::decode::{Config as VaapiConfig, Decoder, ExportedFrame}; use super::{Backend, Codec, Config}; -use crate::frame::{DmaBuf, DmaBufFrame, DmaBufPlane, DrmFormat, I420, Surface}; +use crate::frame::{I420, Surface, vaapi}; use crate::{Error, Frame, Output}; pub(crate) const NAME: &str = "vaapi"; @@ -204,122 +201,13 @@ fn share(exported: Vec) -> anyhow::Result> { .into_iter() .map(|frame| { let timestamp = Timestamp::from_micros(frame.timestamp).unwrap_or(Timestamp::ZERO); - Ok(Frame::new(Surface::DmaBuf(adopt(frame)?), timestamp)) + // A decode target names no color space, so the renderer infers one + // from the frame size exactly as it does for a downloaded picture. + Ok(Frame::new(Surface::DmaBuf(vaapi::adopt(frame, None)?), timestamp)) }) .collect() } -/// Describe an exported picture as a [`DmaBuf`]: the driver's format modifier, -/// and the offset and pitch of each of its memory planes. -/// -/// The width and height are the visible frame rather than the exported extent, -/// which is the driver's padded allocation. Neither the pitches nor the offsets -/// follow from the visible size, which is exactly why they are read off the -/// export rather than computed from it. -/// -/// # Errors -/// -/// When the export is not the one shape a [`DmaBuf`] can describe: a single NV12 -/// layer whose planes all live in a single object. The Intel and AMD drivers -/// export exactly that, and the alternatives are refused rather than guessed at, -/// because every one of them draws as a plausible-looking picture made of the -/// wrong bytes. -fn adopt(frame: ExportedFrame) -> anyhow::Result { - let (width, height) = (frame.width, frame.height); - // One object, because a consumer imports every plane from the one descriptor - // `Exported::export` hands out, and one layer, because the planes are read - // off it as a group. Both are what `VA_EXPORT_SURFACE_COMPOSED_LAYERS` asks - // for; neither is what it guarantees. - let [object] = frame.descriptor.objects.as_slice() else { - anyhow::bail!( - "VA-API exported {} objects, expected one holding every plane", - frame.descriptor.objects.len() - ); - }; - let [layer] = frame.descriptor.layers.as_slice() else { - anyhow::bail!( - "VA-API exported {} layers, expected one composed layer", - frame.descriptor.layers.len() - ); - }; - if layer.drm_format != DrmFormat::NV12.as_raw() { - anyhow::bail!("VA-API exported DRM format {:#x}, expected NV12", layer.drm_format); - } - - // `num_planes` and the arrays it indexes both come from the driver, and only - // the arrays are bounded, so indexing on the count would panic rather than - // fail. - let count = layer.num_planes as usize; - anyhow::ensure!( - count <= layer.offset.len(), - "VA-API exported {count} planes, more than a PRIME descriptor holds" - ); - let planes = (0..count) - .map(|plane| DmaBufPlane::new(layer.offset[plane], layer.pitch[plane])) - .collect(); - let modifier = object.drm_format_modifier; - - // A decode target names no color space, so the renderer infers one from the - // frame size exactly as it does for a downloaded picture. - DmaBuf::new( - DrmFormat::NV12, - modifier, - width, - height, - planes, - None, - Arc::new(Exported::new(frame)), - ) - .map_err(|e| anyhow::anyhow!("{e}")) -} - -/// A decoded picture the consumer holds as a DMA-BUF. -/// -/// Both of the things a consumer can do with one: hand a descriptor to a -/// graphics API, or give up on drawing it and read the pixels back. Dropping the -/// last clone destroys the surface, which is what returns its allocation to the -/// driver. -struct Exported { - /// Locked because [`DmaBufFrame`] hands out `&self` while what is behind it - /// is a single libva surface: `download_i420` maps that surface, and two - /// threads doing so at once is more than libva promises to serialize. The - /// frame is [`Send`] on its own, so a lock is enough and no `unsafe impl` is - /// involved. - frame: Mutex, -} - -impl Exported { - fn new(frame: ExportedFrame) -> Self { - Self { - frame: Mutex::new(frame), - } - } -} - -impl DmaBufFrame for Exported { - /// Vulkan takes ownership of an imported descriptor on success and closes it - /// on failure, so every import needs one of its own and the original stays - /// with the picture. - fn export(&self) -> std::io::Result { - let frame = self.frame.lock().expect("poisoned"); - let object = frame.descriptor.objects.first().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidData, "the VA-API export carries no object") - })?; - object.fd.as_fd().try_clone_to_owned() - } - - /// Read the picture back through the retained surface rather than the - /// descriptor: a decode target is tiled, so mapping the file descriptor as - /// rows would be wrong. - fn download_i420(&self) -> Result { - let frame = self.frame.lock().expect("poisoned"); - let nv12 = frame - .download() - .map_err(|e| Error::Codec(anyhow::anyhow!("read a VA-API decode surface back: {e:?}")))?; - I420::from_nv12(&nv12.data, crate::Size::new(nv12.width, nv12.height)) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/rs/moq-video/src/encode/backend/mod.rs b/rs/moq-video/src/encode/backend/mod.rs index fdb637e8c6..96530f8cf8 100644 --- a/rs/moq-video/src/encode/backend/mod.rs +++ b/rs/moq-video/src/encode/backend/mod.rs @@ -357,7 +357,7 @@ pub(crate) mod test_util { /// BT.601 goes out as SMPTE 170M primaries and matrix (code point 6) with the /// BT.709 transfer curve (1). The two curves are defined identically, and /// CoreVideo's SMPTE 170M transfer constant is deprecated while Media - /// Foundation has none at all, so 1 is the only value all four backends can + /// Foundation has none at all, so 1 is the only value all five backends can /// actually emit. pub(crate) const BT601_DESCRIBED: Described = Described { primaries: 6, diff --git a/rs/moq-video/src/encode/backend/vaapi.rs b/rs/moq-video/src/encode/backend/vaapi.rs index ce4f3b8c6e..be0fc8e6a5 100644 --- a/rs/moq-video/src/encode/backend/vaapi.rs +++ b/rs/moq-video/src/encode/backend/vaapi.rs @@ -1,8 +1,8 @@ //! Intel/AMD VAAPI hardware backend via the `moq-vaapi` crate on Linux. //! //! `moq-vaapi` is a focused VA-API H.264 encoder vendored and trimmed from -//! cros-libva + discord/cros-codecs. It takes tightly-packed NV12 and emits an -//! Annex-B elementary stream with in-band SPS/PPS, matching avc3 mode. +//! cros-libva + discord/cros-codecs. It emits an Annex-B elementary stream with +//! in-band SPS/PPS, matching avc3 mode, and labels the color space in the VUI. //! //! As of moq-vaapi 0.0.3 libva is `dlopen`'d at runtime, so a VAAPI-enabled build //! needs no libva at build time and the binary carries no `NEEDED libva`. A @@ -10,38 +10,68 @@ //! driver), makes `Encoder::new` return an error; under automatic selection //! [`backend::open`](super::open) then moves on to openh264, like the NVENC backend. //! -//! Our captures hand us CPU I420 (webcams deliver YUYV/MJPEG, decoded to I420), -//! so each frame is interleaved to NV12 before encoding. +//! A [`Surface::DmaBuf`] is encoded without touching the CPU. An NV12 buffer at +//! the encoder's size (a VA-API decode, or one [`Surface::resize`] already +//! scaled on the GPU) is imported and encoded in place when its size is a +//! whole number of macroblocks. Anything else the driver imports, packed RGB +//! from a PipeWire screen capture above all, goes through the video processor +//! into the encoder's own surface first. RGB and YUV labelled with another +//! space are converted into the stream's; unlabelled YUV is taken to be in it. //! -//! NOT YET VALIDATED ON HARDWARE: `moq-vaapi`'s encode path is compile-verified -//! only, so the emitted bitstream needs a Linux + Intel/AMD GPU to confirm at -//! playback. +//! When the driver refuses to encode a buffer, the backend says so once and +//! sends every later buffer of the same format and modifier through the CPU +//! path. A failure before the driver sees the buffer, such as a producer fence +//! that times out, affects that frame only. +//! +//! Every other surface takes the CPU path: [`Surface::to_i420`], interleaved to +//! NV12, and uploaded. +//! +//! Validated on Intel Meteor Lake with iHD 26.1.5: the tests below encode CPU +//! frames, VA-API decodes handed over as DMA-BUFs, and packed RGB buffers at +//! and above the encoder's size, decode each stream with openh264, and compare +//! pixels. They skip on a machine without a VA-API device. + +use std::collections::HashSet; use bytes::Bytes; use moq_vaapi::encode::{Config as VaapiConfig, Encoder}; use super::super::encoder::{Config, Gop}; use super::{Backend, Encoded}; -use crate::frame::I420; -use crate::{Error, Frame}; +use crate::frame::{DmaBuf, DrmFormat, I420, vaapi}; +use crate::{Error, Frame, Surface}; pub(crate) const NAME: &str = "vaapi"; pub(crate) struct Vaapi { encoder: Encoder, + /// Buffer layouts the driver refused to encode, by format and modifier, so + /// each costs one failed attempt rather than one per frame. + refused: HashSet<(DrmFormat, u64)>, + /// Frames encoded from a DMA-BUF on the GPU, for the tests to tell the two + /// paths apart. + #[cfg(test)] + gpu_frames: u64, } impl Vaapi { pub(crate) fn open(config: &Config) -> Result, Error> { + Ok(Box::new(Self::new(config)?)) + } + + fn new(config: &Config) -> Result { let bitrate = config.resolved_bitrate().as_bps().min(u32::MAX as u64) as u32; let Gop::Keyframe { interval } = config.gop; - let vaapi = VaapiConfig::new( - config.width, - config.height, - config.framerate.rounded(), - bitrate, - interval, - ); + let vaapi = VaapiConfig { + color: vaapi::color(config.resolved_color()), + ..VaapiConfig::new( + config.width, + config.height, + config.framerate.rounded(), + bitrate, + interval, + ) + }; let encoder = Encoder::new(vaapi).map_err(|e| Error::Codec(anyhow::anyhow!("VAAPI encoder init: {e:?}")))?; tracing::info!( @@ -50,18 +80,72 @@ impl Vaapi { height = config.height, "opened H.264 encoder" ); - Ok(Box::new(Self { encoder })) + Ok(Self { + encoder, + refused: HashSet::new(), + #[cfg(test)] + gpu_frames: 0, + }) } -} -impl Backend for Vaapi { - fn encode(&mut self, frame: &Frame, cut: bool) -> Result, Error> { + /// Encodes `buffer` on the GPU, or returns `None` when it has to go through the CPU instead. + /// + /// Holds the producer's lease until the encode has read the buffer. + fn encode_dmabuf(&mut self, buffer: &DmaBuf, cut: bool) -> Option> { + let key = (buffer.format(), buffer.modifier()); + if self.refused.contains(&key) { + return None; + } + let (descriptor, lease) = match vaapi::import(buffer) { + Ok(imported) => imported, + Err(err) => { + tracing::debug!(encoder = NAME, %err, "DMA-BUF export failed; encoding this frame through the CPU"); + return None; + } + }; + let annexb = self.encoder.encode_dmabuf(descriptor, cut); + drop(lease); + match annexb { + Ok(annexb) => { + #[cfg(test)] + { + self.gpu_frames += 1; + } + Some(annexb) + } + Err(err) => { + tracing::warn!( + encoder = NAME, + format = ?key.0, + modifier = format_args!("{:#x}", key.1), + err = format!("{err:#}"), + "VAAPI cannot encode this DMA-BUF layout on the GPU; encoding it through the CPU from now on" + ); + self.refused.insert(key); + None + } + } + } + + fn encode_cpu(&mut self, frame: &Frame, cut: bool) -> Result, Error> { let i420 = frame.surface.to_i420()?; let nv12 = i420_to_nv12(&i420); - let annexb = self - .encoder + self.encoder .encode_nv12(&nv12, cut) - .map_err(|e| Error::Codec(anyhow::anyhow!("VAAPI encode: {e:?}")))?; + .map_err(|e| Error::Codec(e.context("VAAPI encode"))) + } +} + +impl Backend for Vaapi { + fn encode(&mut self, frame: &Frame, cut: bool) -> Result, Error> { + let gpu = match &frame.surface { + Surface::DmaBuf(buffer) => self.encode_dmabuf(buffer, cut), + _ => None, + }; + let annexb = match gpu { + Some(annexb) => annexb, + None => self.encode_cpu(frame, cut)?, + }; // Submitted and read back within the call, so this is that frame's output. Ok(if annexb.is_empty() { @@ -83,14 +167,12 @@ impl Backend for Vaapi { Ok(Vec::new()) } - fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> { - // moq-vaapi rebuilds its rate control parameter from `Config::bitrate` on - // every frame, so this needs nothing more than a setter on that config to - // work. It doesn't have one as of 0.0.3 and the field is private, so the - // rate is fixed at open until moq-vaapi exposes one. Rebuilding the - // session per estimate isn't worth it: that forces an IDR on a link that - // just told us it's congested. - Err(Error::BitrateUnsupported(NAME)) + fn set_bitrate(&mut self, bitrate: u64) -> Result<(), Error> { + // The rate control parameters go out with every frame, so the new rate + // applies from the next one without an IDR. + self.encoder + .set_bitrate(bitrate.min(u32::MAX as u64) as u32) + .map_err(|e| Error::Codec(e.context("VAAPI bitrate change"))) } fn can_cut(&self) -> bool { @@ -119,3 +201,321 @@ fn i420_to_nv12(i420: &I420) -> Vec { } out } + +// The round trips decode with openh264, an independent decoder. +#[cfg(all(test, feature = "openh264"))] +mod tests { + use moq_net::Timestamp; + + use super::*; + use crate::decode::backend as decode_backend; + use crate::decode::{Codec as DecodeCodec, Config as DecodeConfig, Kind as DecodeKind}; + use crate::encode::{Encoder as CrateEncoder, Kind as EncodeKind}; + use crate::{Output, Rate, Size}; + + const WIDTH: u32 = 320; + const HEIGHT: u32 = 240; + + fn config() -> Config { + Config { + kind: EncodeKind::Named(NAME.into()), + ..Config::new(WIDTH, HEIGHT, Rate::new(30, 1).unwrap()) + } + } + + /// Real hardware only: the backend itself, so a test can see which path + /// it took, or `None` to skip on a box with no VA-API H.264 encoder. + fn backend() -> Option { + match Vaapi::new(&config()) { + Ok(backend) => Some(backend), + Err(err) => { + eprintln!("skipping: no VA-API H.264 encoder: {err}"); + None + } + } + } + + fn decoder(name: &str, output: Output) -> Box { + let config = DecodeConfig { + kind: DecodeKind::Named(name.into()), + output, + ..DecodeConfig::new() + }; + decode_backend::open(DecodeCodec::H264, &config).expect("open the decoder") + } + + /// A static RGBA gradient that varies in both axes, so the chroma planes have + /// spatial structure and a pitch or plane-split bug corrupts the picture. + fn gradient_rgba(size: Size) -> Vec { + let (w, h) = (size.width as usize, size.height as usize); + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 4; + rgba[i] = (x * 255 / w) as u8; + rgba[i + 1] = (y * 255 / h) as u8; + rgba[i + 2] = ((x + y) * 255 / (w + h)) as u8; + rgba[i + 3] = 255; + } + } + rgba + } + + /// Mean absolute difference between two planes. + fn mae(a: &[u8], b: &[u8]) -> u64 { + assert_eq!(a.len(), b.len()); + a.iter().zip(b).map(|(&x, &y)| x.abs_diff(y) as u64).sum::() / a.len() as u64 + } + + fn at(index: u64) -> Timestamp { + Timestamp::from_micros(index * 33_333).unwrap() + } + + /// Encodes each frame with `backend` and decodes the stream with openh264, + /// an independent decoder. + fn round_trip(backend: &mut Vaapi, frames: &[Frame]) -> Vec { + let mut software = decoder("openh264", Output::Cpu); + let mut decoded = Vec::new(); + for (index, frame) in frames.iter().enumerate() { + for encoded in backend.encode(frame, index == 0).expect("encode") { + decoded.extend( + software + .decode(encoded.payload, encoded.timestamp, index == 0) + .expect("decode"), + ); + } + } + decoded.extend(software.flush().expect("flush")); + assert_eq!(decoded.len(), frames.len(), "pictures went missing"); + decoded + } + + /// Asserts every plane of `decoded` is within `tolerance` of `expected` on average. + /// + /// A lossy round trip of this smooth gradient lands within 2 to 4 code + /// values; 8 (10 across two generations of coding) leaves room for encoder + /// variation while a swapped chroma plane or a stride bug misses by 30 or + /// more. + fn assert_close(decoded: &Frame, expected: &I420, tolerance: u64) { + let i420 = decoded.surface.to_i420().unwrap(); + assert_eq!((i420.width(), i420.height()), (expected.width(), expected.height())); + for (plane, (a, b)) in [ + (i420.y(), expected.y()), + (i420.u(), expected.u()), + (i420.v(), expected.v()), + ] + .into_iter() + .enumerate() + { + let error = mae(a, b); + assert!(error < tolerance, "plane {plane} is off by {error} on average"); + } + } + + /// CPU frames come back from an independent decoder as the picture that + /// went in, which a stride or chroma-order bug in the upload would break. + #[test] + fn cpu_frames_round_trip_through_a_software_decoder() { + let Some(mut backend) = backend() else { return }; + let size = Size::new(WIDTH, HEIGHT); + let rgba = gradient_rgba(size); + let expected = I420::from_rgba(&rgba, WIDTH * 4, size).unwrap(); + let frames: Vec = (0..5) + .map(|i| Frame::new(Surface::rgba(&rgba, size).unwrap(), at(i))) + .collect(); + + for frame in round_trip(&mut backend, &frames) { + assert_close(&frame, &expected, 8); + } + } + + /// A VA-API decode handed out as DMA-BUFs is re-encoded without leaving the + /// GPU, the transcode path, and still carries the picture. + #[test] + fn decoded_dmabufs_are_encoded_on_the_gpu() { + let Some(mut backend) = backend() else { return }; + let size = Size::new(WIDTH, HEIGHT); + let rgba = gradient_rgba(size); + let expected = I420::from_rgba(&rgba, WIDTH * 4, size).unwrap(); + + // A software-encoded source stream, decoded by VA-API into DMA-BUFs. + let mut source = CrateEncoder::new(&Config { + kind: EncodeKind::Software, + ..Config::new(WIDTH, HEIGHT, Rate::new(30, 1).unwrap()) + }) + .unwrap(); + let mut hardware = decoder(NAME, Output::Native); + let mut frames = Vec::new(); + for i in 0..5 { + if i == 0 { + source.cut().unwrap(); + } + let frame = Frame::new(Surface::rgba(&rgba, size).unwrap(), at(i)); + for encoded in source.encode(&frame).unwrap() { + frames.extend(hardware.decode(encoded.payload, encoded.timestamp, i == 0).unwrap()); + } + } + frames.extend(hardware.flush().unwrap()); + assert!( + frames.iter().all(|frame| matches!(frame.surface, Surface::DmaBuf(_))), + "the decoder handed out CPU frames, so nothing here tests the GPU path" + ); + + let decoded = round_trip(&mut backend, &frames); + assert!( + backend.gpu_frames > 0 && backend.refused.is_empty(), + "the encoder fell back to the CPU" + ); + for frame in decoded { + // Two generations of lossy coding. + assert_close(&frame, &expected, 10); + } + } + + /// A packed RGB DMA-BUF, what a PipeWire screen capture delivers, is scaled + /// and converted on the GPU and encoded from there. + #[test] + fn an_rgb_dmabuf_is_scaled_and_encoded_on_the_gpu() { + let Some(mut backend) = backend() else { return }; + let source_size = Size::new(WIDTH * 2, HEIGHT * 2); + let rgba = gradient_rgba(source_size); + let Some(buffer) = vaapi::testing::bgrx_dmabuf(&rgba, source_size) else { + eprintln!("skipping: no VA-API device to allocate a BGRX surface on"); + return; + }; + let frame = Frame::new(Surface::DmaBuf(buffer), at(0)); + + let scaled = frame + .resize(Size::new(WIDTH, HEIGHT), &crate::resize::Config::default()) + .expect("resize"); + let Surface::DmaBuf(ref buffer) = scaled.surface else { + panic!("the resize left the GPU"); + }; + assert_eq!(buffer.format(), crate::DrmFormat::NV12); + assert_eq!((buffer.width(), buffer.height()), (WIDTH, HEIGHT)); + + let expected = I420::from_rgba(&rgba, source_size.width * 4, source_size) + .unwrap() + .resize(Size::new(WIDTH, HEIGHT)) + .unwrap(); + // The GPU read-back of the scaled buffer, before any coding. + let read_back = scaled.surface.to_i420().expect("read the scaled buffer back"); + assert_eq!(read_back.color(), Some(crate::Color::infer(source_size))); + + let decoded = round_trip(&mut backend, std::slice::from_ref(&scaled)); + assert!( + backend.gpu_frames > 0 && backend.refused.is_empty(), + "the encoder fell back to the CPU" + ); + assert_close(&decoded[0], &expected, 10); + } + + /// The encoder takes a packed RGB DMA-BUF at its own size directly. + #[test] + fn an_rgb_dmabuf_at_the_encoder_size_is_converted_on_the_gpu() { + let Some(mut backend) = backend() else { return }; + let size = Size::new(WIDTH, HEIGHT); + let rgba = gradient_rgba(size); + let Some(buffer) = vaapi::testing::bgrx_dmabuf(&rgba, size) else { + eprintln!("skipping: no VA-API device to allocate a BGRX surface on"); + return; + }; + let expected = I420::from_rgba(&rgba, WIDTH * 4, size).unwrap(); + + let decoded = round_trip(&mut backend, &[Frame::new(Surface::DmaBuf(buffer), at(0))]); + assert!( + backend.gpu_frames > 0 && backend.refused.is_empty(), + "the encoder fell back to the CPU" + ); + assert_close(&decoded[0], &expected, 10); + } + + /// A bitrate change reaches the encoder instead of being refused. + #[test] + fn the_bitrate_can_change_mid_stream() { + let Some(mut backend) = backend() else { return }; + let size = Size::new(WIDTH, HEIGHT); + let rgba = gradient_rgba(size); + let frames: Vec = (0..6) + .map(|i| Frame::new(Surface::rgba(&rgba, size).unwrap(), at(i))) + .collect(); + for (index, frame) in frames.iter().enumerate() { + if index == 3 { + backend.set_bitrate(250_000).expect("set the bitrate"); + } + assert!(!backend.encode(frame, index == 0).expect("encode").is_empty()); + } + assert_eq!(backend.encoder.config().bitrate, 250_000); + } + + /// A buffer the driver cannot import is encoded through the CPU, and its + /// layout is not tried on the GPU again. + #[test] + fn a_refused_dmabuf_is_encoded_through_the_cpu() { + let Some(mut backend) = backend() else { return }; + let size = Size::new(WIDTH, HEIGHT); + let rgba = gradient_rgba(size); + let expected = I420::from_rgba(&rgba, WIDTH * 4, size).unwrap(); + let frames: Vec = (0..3) + .map(|i| { + let buffer = vaapi::testing::unimportable_dmabuf(expected.clone()); + Frame::new(Surface::DmaBuf(buffer), at(i)) + }) + .collect(); + + let decoded = round_trip(&mut backend, &frames); + assert_eq!(backend.gpu_frames, 0); + assert_eq!(backend.refused.len(), 1, "the refused layout is remembered once"); + for frame in decoded { + assert_close(&frame, &expected, 8); + } + } + + /// A buffer the video processor cannot import is resized on the CPU, the + /// same as without VA-API. + #[test] + fn a_refused_dmabuf_is_resized_on_the_cpu() { + let size = Size::new(WIDTH, HEIGHT); + let expected = I420::from_rgba(&gradient_rgba(size), WIDTH * 4, size).unwrap(); + let buffer = vaapi::testing::unimportable_dmabuf(expected.clone()); + let half = Size::new(WIDTH / 2, HEIGHT / 2); + let scaled = Frame::new(Surface::DmaBuf(buffer), at(0)) + .resize(half, &crate::resize::Config::default()) + .expect("resize"); + let Surface::I420(ref pixels) = scaled.surface else { + panic!("a refused buffer came back on the GPU"); + }; + let reference = expected.resize(half).unwrap(); + assert_eq!(mae(pixels.y(), reference.y()), 0, "the CPU path is the CPU resize"); + } + + /// The SPS names the color space the stream was configured with, read back + /// out of the bitstream. + #[test] + fn the_sps_declares_the_color_space() { + use super::super::test_util::{BT601_DESCRIBED, BT709_DESCRIBED, declared_color}; + use crate::Color; + + // Opposite of the space `resolved_color` would infer from the size, so a + // backend that ignores `Config::color` cannot pass. + for (size, color, described) in [ + (Size::new(640, 480), Color::Bt709Limited, BT709_DESCRIBED), + (Size::new(1920, 1080), Color::Bt601Limited, BT601_DESCRIBED), + ] { + let config = Config { + kind: EncodeKind::Named(NAME.into()), + color: Some(color), + ..Config::new(size.width, size.height, Rate::new(30, 1).unwrap()) + }; + let Ok(mut backend) = Vaapi::new(&config) else { + eprintln!("skipping: no VA-API H.264 encoder"); + return; + }; + let rgba = [255u8, 0, 0, 255].repeat(size.pixels() as usize); + let frame = Frame::new(Surface::rgba(&rgba, size).unwrap(), at(0)); + let encoded = backend.encode(&frame, true).unwrap(); + let annexb = &encoded.first().expect("a keyframe").payload; + assert_eq!(declared_color(annexb), Some(described), "{size} SPS color description"); + } + } +} diff --git a/rs/moq-video/src/frame.rs b/rs/moq-video/src/frame.rs index 6c166c96e1..4a403ccc46 100644 --- a/rs/moq-video/src/frame.rs +++ b/rs/moq-video/src/frame.rs @@ -543,6 +543,19 @@ impl Surface { Surface::I420(texture.download_i420()?.resize(size)?) } }, + // Scaled by the VA-API video processor, which also converts packed + // RGB to NV12 on the way, so the result is ready for the VAAPI + // encoder to import. If VA-API is missing or refuses the buffer, + // download and resize on the CPU. + #[cfg(all(target_os = "linux", feature = "vaapi"))] + Surface::DmaBuf(buffer) if config.output == crate::Output::Native => match vaapi::resize(buffer, size) { + Ok(scaled) => Surface::DmaBuf(scaled), + Err(err) => { + static WARN_ONCE: std::sync::Once = std::sync::Once::new(); + WARN_ONCE.call_once(|| tracing::warn!(%err, "GPU resize failed; falling back to the CPU")); + Surface::I420(buffer.inner.download_i420()?.resize(size)?) + } + }, #[allow(unreachable_patterns)] other => Surface::I420(other.to_i420()?.into_owned().resize(size)?), }) @@ -1916,6 +1929,10 @@ pub mod vulkan; #[path = "frame/cuda.rs"] pub mod cuda; +#[cfg(all(target_os = "linux", feature = "vaapi"))] +#[path = "frame/vaapi.rs"] +pub(crate) mod vaapi; + // Compiled for every test build so its policy tests run without a GPU. #[cfg(any(test, all(target_os = "linux", feature = "nvidia")))] #[path = "frame/pool.rs"] diff --git a/rs/moq-video/src/frame/vaapi.rs b/rs/moq-video/src/frame/vaapi.rs new file mode 100644 index 0000000000..84f30df9aa --- /dev/null +++ b/rs/moq-video/src/frame/vaapi.rs @@ -0,0 +1,388 @@ +//! VA-API's side of [`DmaBuf`](super::DmaBuf): handing one to `moq-vaapi`, +//! wrapping what `moq-vaapi` exports, and scaling one on the GPU. +//! +//! The decoder and the encoder backends and +//! [`Surface::resize`](super::Surface::resize) all meet +//! `moq-vaapi` here, so there is one place that knows how a crate `DmaBuf` maps +//! to a VA-API import descriptor and back. +//! +//! Scaling goes through the VA-API video processor on whichever device answers +//! first. It stays on the GPU: the input is imported, blitted into an NV12 +//! surface of the target size (converting packed RGB on the way), and that +//! surface is exported as a new DMA-BUF, which the VAAPI encoder then imports +//! in turn. A simulcast publisher resizing one captured frame into several +//! renditions pays one blit per rendition instead of a download and a CPU +//! scale each. + +use std::cell::RefCell; +use std::collections::HashSet; +use std::os::fd::{AsFd, OwnedFd}; +use std::sync::{Arc, Mutex}; + +use moq_vaapi::decode::ExportedFrame; +use moq_vaapi::dmabuf::{DmaBuf as VaapiDmaBuf, Plane}; +use moq_vaapi::vpp::Processor; +use moq_vaapi::{Matrix, VA_FOURCC_NV12}; + +use super::{DmaBuf, DmaBufExport, DmaBufFrame, DmaBufPlane, DrmFormat, I420}; +use crate::{Color, Error, Size}; + +/// Describes `buffer` for a VA-API import, returning the producer lease to hold until the import is done with. +/// +/// Waits on the producer's write fence first, since a VA-API import does not. +/// The descriptor handed to `moq-vaapi` is a duplicate, owned by the imported +/// surface; the returned lease keeps the producer from recycling the buffer +/// underneath it. +pub(crate) fn import(buffer: &DmaBuf) -> Result<(VaapiDmaBuf, DmaBufExport), Error> { + let export = buffer + .export() + .map_err(|e| Error::Codec(anyhow::anyhow!("export a DMA-BUF for VA-API: {e}")))?; + let fd: OwnedFd = export + .as_fd() + .try_clone_to_owned() + .map_err(|e| Error::Codec(anyhow::anyhow!("duplicate a DMA-BUF descriptor: {e}")))?; + let descriptor = VaapiDmaBuf { + drm_format: buffer.format().as_raw(), + modifier: buffer.modifier(), + width: buffer.width(), + height: buffer.height(), + planes: buffer + .planes() + .iter() + .map(|plane| Plane { + offset: plane.offset(), + pitch: plane.stride(), + }) + .collect(), + fd, + color: buffer.color().map(color), + }; + Ok((descriptor, export)) +} + +/// Returns the `moq-vaapi` color space matching `color`. +pub(crate) fn color(color: Color) -> moq_vaapi::Color { + match color { + Color::Bt601Limited => moq_vaapi::Color { + matrix: Matrix::Bt601, + full_range: false, + }, + Color::Bt601Full => moq_vaapi::Color { + matrix: Matrix::Bt601, + full_range: true, + }, + Color::Bt709Limited => moq_vaapi::Color { + matrix: Matrix::Bt709, + full_range: false, + }, + Color::Bt709Full => moq_vaapi::Color { + matrix: Matrix::Bt709, + full_range: true, + }, + } +} + +/// Returns whether `format` is packed RGB, which carries no YUV matrix of its own. +pub(crate) fn is_rgb(format: DrmFormat) -> bool { + matches!( + format, + DrmFormat::XRGB8888 | DrmFormat::ARGB8888 | DrmFormat::XBGR8888 | DrmFormat::ABGR8888 + ) +} + +thread_local! { + /// The video processor this thread scales with, opened on the first resize. + /// + /// Per thread because a processor keeps its contexts behind an `Rc`. Each + /// thread that resizes pays for one display and one context per size. A + /// failed open is kept too, so a machine without VA-API finds that out once + /// per thread rather than once per frame. + static PROCESSOR: RefCell>> = const { RefCell::new(None) }; + + /// Buffer layouts the video processor refused on this thread, by format and + /// modifier, so a buffer it cannot read goes straight to the CPU path. + static REFUSED: RefCell> = RefCell::new(HashSet::new()); +} + +/// Scales `buffer` to `size` on the GPU, returning an NV12 DMA-BUF. +/// +/// The color handling matches the CPU resize. A YUV input keeps whatever space +/// it was labelled with, including none. A packed RGB input is converted into +/// [`Color::infer`] for its own size, the space the CPU download would have +/// picked, and the result is labelled with it. +/// +/// # Errors +/// +/// Fails when this thread has no video processor, when the processor refused +/// this buffer's format and modifier before, and when the export, the import, +/// or the blit fails. The caller falls back to the CPU. +pub(crate) fn resize(buffer: &DmaBuf, size: Size) -> Result { + let key = (buffer.format(), buffer.modifier()); + if REFUSED.with(|refused| refused.borrow().contains(&key)) { + return Err(Error::Unsupported(format!( + "the VA-API video processor refused {:?} with modifier {:#x}", + key.0, key.1 + ))); + } + let (input_space, output_space, label) = match is_rgb(buffer.format()) { + true => { + let inferred = Color::infer(Size::new(buffer.width(), buffer.height())); + (None, Some(color(inferred)), Some(inferred)) + } + false => (buffer.color().map(color), buffer.color().map(color), buffer.color()), + }; + + let exported = PROCESSOR.with(|slot| -> Result { + let mut slot = slot.borrow_mut(); + // Checked before exporting, so a machine without VA-API does not pay a + // fence wait and a descriptor duplicate on every frame to learn it again. + let processor = slot + .get_or_insert_with(|| Processor::open().map_err(|e| format!("{e:#}"))) + .as_ref() + .map_err(|e| Error::Unsupported(format!("no VA-API video processor: {e}")))?; + let (descriptor, lease) = import(buffer)?; + let processed = processor.import(descriptor).and_then(|input| { + processor.process( + &input, + VA_FOURCC_NV12, + (size.width, size.height), + input_space, + output_space, + ) + }); + // The blit has synced, so the producer's buffer is free to go back. + drop(lease); + let output = processed.map_err(|e| { + REFUSED.with(|refused| refused.borrow_mut().insert(key)); + Error::Codec(e.context("scale a DMA-BUF with the VA-API video processor")) + })?; + ExportedFrame::from_surface(output, 0).map_err(Error::Codec) + })?; + adopt(exported, label).map_err(Error::Codec) +} + +/// Describes an exported picture as a [`DmaBuf`]: the driver's format modifier, +/// and the offset and pitch of each of its memory planes. +/// +/// The width and height are the visible frame rather than the exported extent, +/// which is the driver's padded allocation. Neither the pitches nor the offsets +/// follow from the visible size, which is exactly why they are read off the +/// export rather than computed from it. `color` is the space the pixels are in, +/// where the producer knows it. +/// +/// # Errors +/// +/// When the export is not the one shape a [`DmaBuf`] can describe: a single NV12 +/// layer whose planes all live in a single object. The Intel and AMD drivers +/// export exactly that, and the alternatives are refused rather than guessed at, +/// because every one of them draws as a plausible-looking picture made of the +/// wrong bytes. +pub(crate) fn adopt(frame: ExportedFrame, color: Option) -> anyhow::Result { + let (width, height) = (frame.width, frame.height); + // One object, because a consumer imports every plane from the one descriptor + // `Exported::export` hands out, and one layer, because the planes are read + // off it as a group. Both are what `VA_EXPORT_SURFACE_COMPOSED_LAYERS` asks + // for; neither is what it guarantees. + let [object] = frame.descriptor.objects.as_slice() else { + anyhow::bail!( + "VA-API exported {} objects, expected one holding every plane", + frame.descriptor.objects.len() + ); + }; + let [layer] = frame.descriptor.layers.as_slice() else { + anyhow::bail!( + "VA-API exported {} layers, expected one composed layer", + frame.descriptor.layers.len() + ); + }; + if layer.drm_format != DrmFormat::NV12.as_raw() { + anyhow::bail!("VA-API exported DRM format {:#x}, expected NV12", layer.drm_format); + } + + // `num_planes` and the arrays it indexes both come from the driver, and only + // the arrays are bounded, so indexing on the count would panic rather than + // fail. + let count = layer.num_planes as usize; + anyhow::ensure!( + count <= layer.offset.len(), + "VA-API exported {count} planes, more than a PRIME descriptor holds" + ); + let planes = (0..count) + .map(|plane| DmaBufPlane::new(layer.offset[plane], layer.pitch[plane])) + .collect(); + let modifier = object.drm_format_modifier; + + DmaBuf::new( + DrmFormat::NV12, + modifier, + width, + height, + planes, + color, + Arc::new(Exported { + frame: Mutex::new(frame), + color, + }), + ) + .map_err(|e| anyhow::anyhow!("{e}")) +} + +/// A picture on a VA-API surface the consumer holds as a DMA-BUF. +/// +/// Both of the things a consumer can do with one: hand a descriptor to a +/// graphics API, or give up on drawing it and read the pixels back. Dropping the +/// last clone destroys the surface, which is what returns its allocation to the +/// driver. +struct Exported { + /// Locked because [`DmaBufFrame`] hands out `&self` while what is behind it + /// is a single libva surface: `download_i420` maps that surface, and two + /// threads doing so at once is more than libva promises to serialize. The + /// frame is [`Send`] on its own, so a lock is enough and no `unsafe impl` is + /// involved. + frame: Mutex, + /// The space the pixels are in, for the read-back to carry. + color: Option, +} + +impl DmaBufFrame for Exported { + /// Vulkan takes ownership of an imported descriptor on success and closes it + /// on failure, so every import needs one of its own and the original stays + /// with the picture. + fn export(&self) -> std::io::Result { + let frame = self.frame.lock().expect("poisoned"); + let object = frame.descriptor.objects.first().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "the VA-API export carries no object") + })?; + object.fd.as_fd().try_clone_to_owned() + } + + /// Read the picture back through the retained surface rather than the + /// descriptor: a VA-API surface is tiled, so mapping the file descriptor as + /// rows would be wrong. + fn download_i420(&self) -> Result { + let frame = self.frame.lock().expect("poisoned"); + let nv12 = frame + .download() + .map_err(|e| Error::Codec(anyhow::anyhow!("read a VA-API surface back: {e:?}")))?; + let i420 = I420::from_nv12(&nv12.data, crate::Size::new(nv12.width, nv12.height))?; + Ok(match self.color { + Some(color) => i420.with_color(color), + None => i420, + }) + } +} + +// For the encoder backend's tests, which need openh264 to decode. +#[cfg(all(test, feature = "openh264"))] +pub(crate) mod testing { + use std::os::fd::OwnedFd; + use std::sync::Arc; + + use moq_vaapi::{Display, Image, Surface as VaSurface, UsageHint, VA_FOURCC_BGRX, VA_RT_FORMAT_RGB32}; + + use super::super::{DmaBuf, DmaBufFrame, DmaBufPlane, DrmFormat, I420}; + use crate::{Error, Size}; + + /// A driver surface exported as a DMA-BUF, standing in for a PipeWire buffer. + struct Allocated { + _surface: VaSurface<()>, + fd: OwnedFd, + } + + impl DmaBufFrame for Allocated { + fn export(&self) -> std::io::Result { + self.fd.try_clone() + } + + fn download_i420(&self) -> Result { + Err(Error::Unsupported("a test buffer has no CPU path".into())) + } + } + + /// A buffer no driver imports, whose CPU read-back still works. + struct Unimportable { + fd: OwnedFd, + pixels: I420, + } + + impl DmaBufFrame for Unimportable { + fn export(&self) -> std::io::Result { + self.fd.try_clone() + } + + fn download_i420(&self) -> Result { + Ok(self.pixels.clone()) + } + } + + /// Returns an NV12 DMA-BUF the driver refuses to import (its descriptor is + /// `/dev/null` and its modifier made up) whose CPU read-back yields `pixels`. + pub(crate) fn unimportable_dmabuf(pixels: I420) -> DmaBuf { + let fd = OwnedFd::from(std::fs::File::open("/dev/null").expect("open /dev/null")); + let (width, height) = (pixels.width(), pixels.height()); + DmaBuf::new( + DrmFormat::NV12, + 0x00ff_ffff_ffff_fffe, + width, + height, + vec![DmaBufPlane::new(0, width), DmaBufPlane::new(width * height, width)], + None, + Arc::new(Unimportable { fd, pixels }), + ) + .expect("a valid description") + } + + /// Returns a BGRX DMA-BUF holding `rgba`, allocated by VA-API, or `None` without a device. + pub(crate) fn bgrx_dmabuf(rgba: &[u8], size: Size) -> Option { + let display = Display::open()?; + let (width, height) = (size.width, size.height); + let surface = display + .create_surfaces( + VA_RT_FORMAT_RGB32, + Some(VA_FOURCC_BGRX), + width, + height, + Some(UsageHint::USAGE_HINT_EXPORT), + vec![()], + ) + .ok()? + .pop()?; + let format = display + .query_image_formats() + .ok()? + .into_iter() + .find(|format| format.fourcc == VA_FOURCC_BGRX)?; + { + let mut image = Image::create_from(&surface, format, (width, height), (width, height)).ok()?; + let va_image = *image.image(); + let data = image.as_mut(); + for y in 0..height as usize { + for x in 0..width as usize { + let from = (y * width as usize + x) * 4; + let to = va_image.offsets[0] as usize + y * va_image.pitches[0] as usize + x * 4; + let [r, g, b, _] = [rgba[from], rgba[from + 1], rgba[from + 2], rgba[from + 3]]; + data[to..to + 4].copy_from_slice(&[b, g, r, 255]); + } + } + } + surface.sync().ok()?; + let mut exported = surface.export_prime().ok()?; + let layer = &exported.layers[0]; + assert_eq!(layer.drm_format, DrmFormat::XRGB8888.as_raw()); + let plane = DmaBufPlane::new(layer.offset[0], layer.pitch[0]); + let object = exported.objects.remove(0); + DmaBuf::new( + DrmFormat::XRGB8888, + object.drm_format_modifier, + width, + height, + vec![plane], + None, + Arc::new(Allocated { + _surface: surface, + fd: object.fd, + }), + ) + .ok() + } +} From 5246ab51e79557a61cbd844876789103c2bd0b7e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 11:01:38 -0700 Subject: [PATCH 03/85] docs: fill release doc gaps (#4027) Co-authored-by: Claude Opus 5.5 --- doc/.vitepress/config.ts | 1 + doc/lib/js/net.md | 4 +- doc/lib/rs/index.md | 4 + doc/lib/rs/moq-net.md | 2 +- doc/setup/upgrade.md | 192 +++++++++++++++++++++++++++++++++++++++ rs/moq-archive/README.md | 28 ++++++ rs/moq-binary/README.md | 24 +++++ 7 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 doc/setup/upgrade.md create mode 100644 rs/moq-archive/README.md create mode 100644 rs/moq-binary/README.md diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 53fdc40395..2174f1dcdc 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: "Install", link: "/setup/install" }, { text: "Development", link: "/setup/dev" }, { text: "Production", link: "/setup/prod" }, + { text: "Upgrade", link: "/setup/upgrade" }, { text: "Coding agents", link: "/setup/agent" }, ], }, diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 0802896244..988c05dbc8 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -50,7 +50,7 @@ for (;;) { - **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` prefers a local broadcast, so a page that watches what it publishes reads its own copy with no round trip. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and announcing advertises the path to peers. - **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. Supplying WebTransport/WebSocket options, discovery, delay, or a caller-owned origin selects a private loop; explicit `share: true` refuses those options. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. -- **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `path` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. +- **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. @@ -98,7 +98,7 @@ Three operations, on an origin: route is always a prefix on every wire. A route is a capability, not an inventory. `origin.announced(scope)` yields -`Announce.Update` values: `path` is the covered prefix relative to the origin, +`Announce.Update` values: `prefix` is the covered prefix relative to the origin, `captures` is one pattern per scope wildcard when the prefix pins a complete match (otherwise `undefined`), `kind` is `"announced"`, `"updated"` (a reprice in place), or `"retracted"`, and `route` carries hops and cost (on a diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 17cd466441..2a02438809 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -15,9 +15,13 @@ The reference implementation. Every crate is on | [moq-net](/lib/rs/moq-net) | The pub/sub layer: sessions, origins, broadcasts, tracks, groups, frames. Transport-agnostic. | | [moq-pattern](https://docs.rs/moq-pattern) | Exact path patterns: grammar, matching, and set algebra. Re-exported by moq-net and moq-auth. | | [moq-tokio](https://docs.rs/moq-tokio) | Stands up QUIC with noq, TLS, WebSocket fallback, and iroh, from config or CLI flags. | +| [moq-sock](https://docs.rs/moq-sock) | Dual-stack socket binding, `SO_REUSEPORT` groups steered by QUIC connection ID, and CPU pinning for thread-per-core listeners. | +| [moq-uring](https://docs.rs/moq-uring) | Experimental Linux io\_uring worker: one pinned thread per ring serving moq-lite over its own QUIC stack. | | [hang](/lib/rs/hang) | The media layer: catalog, containers, ordered frame delivery. | | [moq-mux](/lib/rs/moq-mux) | Import and export fMP4/CMAF, MPEG-TS, Matroska, FLV, and Annex-B. | +| [moq-archive](https://docs.rs/moq-archive) | Versioned hang recordings on any `object_store` backend: track layout, `.info` JSON, and segment objects. | | [moq-video](/lib/rs/moq-video) | Native capture, hardware encode/decode (Apple, Windows, NVIDIA, VAAPI, V4L2, Android), and GPU rendering. | +| [moq-v4l](https://docs.rs/moq-v4l) | Safe Video4Linux 2 bindings with the kernel headers checked in, so a build needs no libclang. | | [moq-audio](/lib/rs/moq-audio) | Microphone and speaker, Opus/PCM/AAC codecs, echo cancellation. | | [moq-transcode](https://docs.rs/moq-transcode) | Just-in-time rendition ladders, GPU-resident on NVIDIA. | | [moq-auth](/lib/rs/moq-auth) | The authorization contract: requests, grants, leases, the HTTP client, the reference server, JWT keys, signing, and verification, plus listing live sessions and pushing a re-check. | diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 33f67336aa..b465d86ae1 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -126,7 +126,7 @@ overlaps that scope; exact creates and requests must match it, so a broad route can advertise the wire-compatible prefix while excluded requests are refused locally. A disjoint route is `Unauthorized`. -`origin.consume().announced()` yields `announce::Update` values: `path` is the +`origin.consume().announced()` yields `announce::Update` values: `prefix` is the covered prefix relative to the consumer's root, `kind` is `Announced`, `Updated` (a reprice in place), or `Retracted`, `captures` reports what the most specific matching scope member's wildcards stood for when the prefix diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md new file mode 100644 index 0000000000..a12225da3e --- /dev/null +++ b/doc/setup/upgrade.md @@ -0,0 +1,192 @@ +--- +title: Upgrade +description: Breaking changes between the 2026-09-17 releases and the 2026-09-23 release train, with the replacement for each +--- + +# Upgrade + +This page walks from the 2026-09-17 releases (moq-relay 0.14.18, moq-cli +0.11.2, moq-net 0.2.22, @moq/net 0.3.5, moq-ffi 0.3.19) to the 2026-09-23 +release train (moq-relay 0.15.1, moq-cli 0.12.1, moq-net 0.3.0, @moq/net 0.4.0, +moq-ffi 0.4.1). Each crate's `CHANGELOG.md` has the full list; this page is the +subset that breaks a working setup. + +A released flag, environment variable, or config key that was renamed is +refused at startup with its replacement named, rather than ignored. Fix what the +error lists and rerun. + +## Wire + +Older protocol versions still negotiate, so relays and clients can be upgraded +in any order, apart from [re-minting tokens](#relay-and-cli) and two wire +changes: + +- The lite 06 ALPN is `moq-lite-06`, not `moq-lite-06-wip`. An explicit + `moq-lite-06-wip` in a version list is refused (#3941). +- The hang catalog's root `timeline` entry is `archive`, and wall time moved to + a root `clock: { wall, timescale }` (#3612, #3675). A new `moq export hls` + finds no timeline in an old publisher's catalog, so upgrade publishers + before the HLS gateway. + +## Relay and CLI + +The `moq-relay` and `moq` flags split into `--listen-*` (accepting), +`--connect-*` (dialing), and a shared `--quic-*` section. Environment +variables follow the flag (`MOQ_SERVER_BIND` is `MOQ_LISTEN`). + +| Before | After | +| --- | --- | +| `--server-bind` | `--listen` | +| `--server-*`, `--tls-cert`, `--tls-key`, `--tls-generate` | `--listen-*`, `--listen-tls-cert`, `--listen-tls-key`, `--listen-tls-generate` | +| `--client-connect` | `--connect` | +| `--client-*` | `--connect-*` | +| `--client-failover-delay` | `--connect-race` | +| `--client-reconnect=false` | `--connect-once` (inverted) | +| `--tls-disable-verify`, `--client-tls-disable-verify` | `--connect-tls-insecure` | +| `--server-quic-*`, `--client-quic-*` | `--quic-*`, applied to both directions | +| TOML `[server]`, `[client]` | `[listen]`, `[connect]` | +| TOML `[server.quic]`, `[client.quic]` | `[quic]` | +| TOML `listen`, `connect`, `failover_delay`, `reconnect`, `disable_verify` | `bind`, `url`, `race`, `once` (inverted), `insecure` | +| `--cluster-linger` | removed; a broadcast closes when its last publisher is lost | +| `--cluster-connect host:port` | a full URL, `https://host/?jwt=TOKEN` | +| `moq --origin`, `--name`, `--latency-max` | `--hop`, `--broadcast`, `--max-age` | +| `moq publish`, `moq subscribe` | `moq import`, `moq export` | +| `moq token`, the `moq-token` binary | `moq auth` | + +Other changes to a deployment: + +- **Auth is one contract** (#3688). The relay asks an auth server per session + (`--auth-url`) or applies a static anonymous grant (`--auth-public`); exactly + one is required. `--auth-key`, `--auth-key-dir`, `--auth-api`, + `--auth-api-mode`, `--auth-public-api`, `--auth-domain`, `--auth-mtls-tier`, and `--auth-tls-*` + are gone: run `moq auth serve --key-dir ...` next to the relay and point + `--auth-url` at it. The flag-by-flag mapping is in + [Migrating from the relay flags](/bin/relay/auth#migrating-from-the-relay-flags). +- **Grants are patterns, not prefixes.** `anon` is now exactly the broadcast + `anon`; write `anon/**` for the subtree. This applies to `--auth-public`, + TOML `public`, and the `[auth.public]` table, which is now + `public_subscribe` / `public_publish`. +- **Re-mint tokens.** JWT `publish` and `subscribe` claims are patterns, so a + token granting `alice` covers only `alice`; sign `alice/**` instead. Tokens + carrying the retired `put` or `get` claims fail verification, so re-mint + them when the relay and auth server upgrade. +- **mTLS admits nothing on its own.** A verified client certificate is reported + to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` restores the old full access for every certificate + the relay's client CA verifies, so keep that CA to cluster peers. +- **`moq --listen` needs auth.** A CLI listener refuses to start without + `--auth-url` or `--auth-public` instead of accepting everyone. +- **noq is the only QUIC stack** (#3811). The `quinn` and `quiche` cargo + features and the backend setting are gone. +- **Stats counters** are `*_started` / `*_ended` (`sessions_started`, + `announces_ended`, ...). This release still writes the old `announced` / + `*_closed` names beside them, so move consumers now. + +## GStreamer + +- `moqsink` properties `estimated-send-bitrate` / `estimated-recv-bitrate` are + `estimated-send-rate` / `estimated-recv-rate`, with no alias; a `gst-launch` + line naming the old ones fails at runtime. + +## Rust + +- **moq-native is moq-tokio** (#2896). moq-native 0.20.0 is a stub that fails + to compile with the rename. `ClientConfig::default().init()?` is + `moq_tokio::connect::Config::default().init(quic)?`, and + `with_publisher(&origin).with_subscriber(origin)` is `with_origin(origin)`. + Names sit under their modules (`connection::Goaway`, `connection::Monitor`, + `transport::Session`; #3745). +- **moq-token is moq-auth** (#3684). `Claims` holds `Patterns`, and + `Claims::authorize` returns pattern residuals. +- **Origins** (#3400, #3804). `Origin::random().produce()` is + `moq_tokio::origin::spawn()`. `create_broadcast(path, route)` is + `origin.publish(path, route)`, or `create_broadcast(path)` then + `broadcast.announce(route)`. `with_root` plus `scope(prefixes)` is one + `scope(root, &patterns)` returning `Result`. `origin::Info` is + `origin::Config`. +- **Announcements are prefix routes** (#3225, #3770). `announce::Update` is + `{ prefix, route, kind, captures }`: skip `!update.kind.is_active()` and + resolve the broadcast with `consumer.request_broadcast(&update.prefix)`. + Serve a subtree on demand with `origin.dynamic(prefix, route)`. +- **Tracks.** `with_latency_max` / `latency_max` is `with_max_age` / `max_age`. + `write_datagram(Datagram)` is `insert_datagram(sequence, timestamp, payload)` + (#3666); `append_datagram` is unchanged. `track::SubscriberControl` is + `track::Control`, `track::GroupRequest` is `group::Request`, and + `ConnectionStats` is `session::Stats` with `estimated_send_rate` / + `estimated_recv_rate`. +- **Oversized groups abort** with `GroupTooLarge` instead of shedding their head + (#3585). +- **Catalog edits are fallible** (#3644, #3813). moq-mux and moq-json `lock()` + is `modify()?`, and a guard that fails to publish on drop aborts the + track. `timeline::Config::wall` is the broadcast `Clock`. +- **moq-json config** (#3718). `compression: bool` is a `Compression` enum; + track-owning options are `producer::Config` / `consumer::Config`. +- **moq-mux imports are typed.** `import::Init` splits into `AudioInit`, + `VideoInit`, and `ContainerInit`, with typed formats instead of strings, and + `Track::new` is `Track::audio` / `Track::video`. +- **moq-relay embedding** (#3638). `Relay` fields are private: clone the + handles you need, mount routes, then call `Relay::run`. + `Cluster::with_cache` moved to `cluster::Options`. + +## JavaScript + +The JavaScript packages have no changelog; this list follows the breaking +PRs, so a minor rename may be missing. + +- **@moq/token is @moq/auth.** `sign` / `verify` are `Key.sign` / `Key.verify`, + and claims are pattern unions (see [Re-mint tokens](#relay-and-cli)). +- **One `Connection`** (#3614, #3636). `Connection.Reload` is + `new Moq.Connection({ url })`, which pools one connection per relay. `closed` + settles only on `close()`; the error that stopped retrying is `error`. +- **Origins hold broadcasts** (#2705, #3225). `connection.publish(path, + broadcast)` is `origin.createBroadcast(path)` then `broadcast.announce()`, and + `connection.announced(prefix)` is `origin.announced(scope)` yielding + `{ prefix, kind, route }`. `ignoreSelf` is gone: reflected announces + are always dropped. +- **Names mirror Rust** (#3710). `latencyMax` is `maxAge` (a `Time.Milli`), + `writeDatagram` is `insertDatagram`, and `RemoteError` is `Error.Stream` / + `Error.Session`. +- **@moq/watch** (#3396, #3817). `latency`, `latency-min`, `latency-max`, and + `jitter` are `delay` and `buffer`, which need a unit (`delay="100ms"`, + `buffer="30s"`). `reload` is `announced`. `Watch.Broadcast({ connection })` + is `Watch.Player({ origin: connection.origin, ... })`. +- **@moq/publish** takes `origin: connection.origin` instead of a + `connection` signal. +- **@moq/json and @moq/binary** take one options object (#3640): + `new Json.Snapshot.Consumer({ track })` instead of `(track, config)`. +- **@moq/hang** reads the catalog `archive` entry instead of `timeline`. + +## Bindings + +Python (`moq-rs` 0.5.0), Swift (0.5.0), Kotlin (`dev.moq:moq` 0.5.0), and Go +wrap moq-ffi 0.4. These are the moq-ffi names; each wrapper follows them in its +own casing: + +- **Go module path** is `moq.dev/moq` (was `github.com/moq-dev/moq-go/moq`). +- **Publish split by kind.** `publish_media*` is `publish_audio`, + `publish_video`, or `publish_container`, each taking its own init. The raw + encoder paths that used to be `publish_audio` / `publish_video` are + `encode_audio` / `encode_video`. +- **Durations are microseconds** (`max_age_us`, `MoqBackoff.initial_us`), and + rate estimates are `estimated_send_rate` / `estimated_recv_rate`. +- **Setters are fallible** (#3642). Client and server configuration setters + return an error (`Busy` during connect or listen) instead of dropping the + value. `set_tls_disable_verify(bool)` is `set_tls_verify(bool)`. +- **Announcements.** `MoqAnnounced` is `MoqAnnounceConsumer` and + `MoqAnnouncement` is `MoqAnnounceUpdate`; `MoqBroadcastRequest::abort` is + `reject`, and `MoqOriginOptions` is `MoqOriginConfig`. +- **`MoqAudioCodec`** is an `opus()` object (#3671). +- **Errors.** `MoqError::Protocol` carries a `MoqProtocolError` (scope, wire + code, kind) instead of a flattened message. +- **Track and group `finish()`** keeps the handle open so a later `abort()` can + still run. + +C (libmoq 0.6): + +- The 41 `moq_client_*` setters are one zero-initializable `moq_client_config`, + whose durations are `_us`. +- `moq_publish_media` is `moq_publish_audio`, `moq_publish_video`, or + `moq_publish_container`; the raw encoders are `moq_encode_audio*` / + `moq_encode_video*`. Formats are enums instead of strings. +- `moq_announced` is `moq_announce_update`, `moq_origin_consume_announced` is + `moq_origin_announced_broadcast`, and `moq_broadcast_request_abort` is + `moq_broadcast_request_reject`. diff --git a/rs/moq-archive/README.md b/rs/moq-archive/README.md new file mode 100644 index 0000000000..a357d1f807 --- /dev/null +++ b/rs/moq-archive/README.md @@ -0,0 +1,28 @@ +[![Documentation](https://docs.rs/moq-archive/badge.svg)](https://docs.rs/moq-archive/) +[![Crates.io](https://img.shields.io/crates/v/moq-archive.svg)](https://crates.io/crates/moq-archive) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT) + +# moq-archive + +Versioned [hang](https://docs.rs/hang) recordings on any +[`object_store::ObjectStore`](https://docs.rs/object_store). + +The crate owns the portable layout and codecs: percent-encoded track names, +per-track `.info` JSON, the binary segment envelope, and put/get/list/delete. +`Store` wraps a store under a recording prefix and stays generic, so a caller +that needs runtime dispatch passes `Arc`. A broadcast +advertises its recording through the catalog's +[`archive`](https://doc.moq.dev/concept/hang) entry. + +```bash +cargo add moq-archive +``` + +Group bounds are finite inclusive ranges in first-to-last order: + +```rust +let key = moq_archive::Key::groups("video", 5..=7)?; +assert_eq!(key.track(), "video"); +``` + +See [docs.rs/moq-archive](https://docs.rs/moq-archive) for the object layout. diff --git a/rs/moq-binary/README.md b/rs/moq-binary/README.md new file mode 100644 index 0000000000..bcd4c2519f --- /dev/null +++ b/rs/moq-binary/README.md @@ -0,0 +1,24 @@ +[![Documentation](https://docs.rs/moq-binary/badge.svg)](https://docs.rs/moq-binary/) +[![Crates.io](https://img.shields.io/crates/v/moq-binary.svg)](https://crates.io/crates/moq-binary) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT) + +# moq-binary + +Opaque binary payloads over [`moq-net`](https://docs.rs/moq-net) tracks, in two +modes: + +- **snapshot**: lossy latest value. A consumer gets only the most recent payload. +- **stream**: lossless append-log. Every payload is delivered in order. + +The bytes are never inspected. Compression is +[`moq-flate`](https://docs.rs/moq-flate), the same group-scoped DEFLATE +[`moq-json`](https://docs.rs/moq-json) uses, which adds merge-patch deltas for +JSON documents. The TypeScript twin is +[`@moq/binary`](https://www.npmjs.com/package/@moq/binary). + +```bash +cargo add moq-binary +``` + +See [doc.moq.dev](https://doc.moq.dev/lib/rs/moq-binary) and +[docs.rs/moq-binary](https://docs.rs/moq-binary). From 8cda5d0784844ce173e06377f6dd2430ef95ed16 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 11:17:03 -0700 Subject: [PATCH 04/85] chore(rs): bump depended-on 0.0.x crates to 0.1.0 (#4033) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 12 ++++++------ Cargo.toml | 12 ++++++------ quest/m0/README.md | 18 +++++++++--------- ...rs-dropping-one-split-server-resizes-the.md | 2 +- quest/m1/gpu-pool-reservation.md | 4 ++-- quest/m1/ladder/README.md | 2 +- rs/moq-audio/Cargo.toml | 2 +- rs/moq-nvenc/Cargo.toml | 2 +- rs/moq-sock/Cargo.toml | 2 +- rs/moq-transcode/Cargo.toml | 2 +- rs/moq-v4l/Cargo.toml | 2 +- rs/moq-video/Cargo.toml | 2 +- 12 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 259d293475..12d5ba6958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4171,7 +4171,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.0.27" +version = "0.1.0" dependencies = [ "block2 0.6.2", "bytes", @@ -4584,7 +4584,7 @@ dependencies = [ [[package]] name = "moq-nvenc" -version = "0.0.6" +version = "0.1.0" dependencies = [ "cudarc", "libloading 0.9.0", @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "moq-sock" -version = "0.0.1" +version = "0.1.0" dependencies = [ "core_affinity", "libc", @@ -4798,7 +4798,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.0.21" +version = "0.1.0" dependencies = [ "anyhow", "bytes", @@ -4851,7 +4851,7 @@ dependencies = [ [[package]] name = "moq-v4l" -version = "0.0.1" +version = "0.1.0" dependencies = [ "bitflags 2.13.2", "libc", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.0.27" +version = "0.1.0" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 9a4d0496c3..4bc55e9ece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,7 +137,7 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.0.27", path = "rs/moq-audio", default-features = false } +moq-audio = { version = "0.1.0", path = "rs/moq-audio", default-features = false } moq-auth = { version = "0.1.0", path = "rs/moq-auth" } moq-binary = { version = "0.1.0", path = "rs/moq-binary" } moq-flate = { version = "0.1.3", path = "rs/moq-flate" } @@ -154,12 +154,12 @@ moq-noq-udp = "1.3" # NVENC bindings, forked from ViliamVadocz/nvidia-video-codec-sdk to dlopen the # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. -moq-nvenc = { version = "0.0.6", path = "rs/moq-nvenc" } +moq-nvenc = { version = "0.1.0", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } moq-relay = { version = "0.15.1", path = "rs/moq-relay", default-features = false } moq-rtc = { version = "0.3.1", path = "rs/moq-rtc" } moq-rtmp = { version = "0.3.1", path = "rs/moq-rtmp" } -moq-sock = { version = "0.0.1", path = "rs/moq-sock" } +moq-sock = { version = "0.1.0", path = "rs/moq-sock" } moq-srt = { version = "0.3.1", path = "rs/moq-srt" } moq-stats = { version = "0.2.1", path = "rs/moq-stats" } moq-tokio = { version = "0.19.12", path = "rs/moq-tokio", default-features = false } @@ -168,7 +168,7 @@ moq-tokio = { version = "0.19.12", path = "rs/moq-tokio", default-features = fal # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.0.21", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.0", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. @@ -176,7 +176,7 @@ moq-uring = { version = "0.0.2", path = "rs/moq-uring", default-features = false # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. -moq-v4l = { version = "0.0.1", path = "rs/moq-v4l" } +moq-v4l = { version = "0.1.0", path = "rs/moq-v4l" } # Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs. # dlopen's libva at runtime (no libva-dev at build, no NEEDED libva in the binary). moq-vaapi = "0.1.0" @@ -185,7 +185,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.0.27", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.0", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/quest/m0/README.md b/quest/m0/README.md index af59ce9033..eb67865d6d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -15,11 +15,12 @@ dev landed on main as #3793 on 2026-09-20; [Release](/quest/m0/release.md) names what gates the release that follows. Published API or wire breaks still land on dev; the quest's Plan says so. -The archive, E2EE, socket, and uring crates are 0.0.1 on main after the dev -merge. Their six API quests gate the release and target main under the 0.0.x -exception. Inspect transitive public exposure before changing a shared symbol: -`moq-tokio` publicly re-exports `moq-sock`'s bind module. Keep that re-export -and its current names. +The archive, E2EE, and uring crates are 0.0.x; socket, audio, video, +transcode, and nvenc are 0.1.x so dependents can take compatible patches. Their +API quests gate the release; a published break to a 0.1.x crate targets dev. +Inspect transitive public exposure before changing a shared symbol: `moq-tokio` +publicly re-exports `moq-sock`'s bind module. Keep that re-export and its +current names. Keep the useful boundaries: archive owns storage and codecs, E2EE owns protection rather than catalogs, sock owns runtime-neutral sockets, and uring @@ -44,10 +45,9 @@ Their package boundaries are explicit: - Published `moq-tokio` keeps its worker signatures and `bind` re-export while adapting internal plumbing. Its root names do not move. -The media crates are also 0.0.x, so their changes target main. Adapt callers in -other packages without breaking their published APIs, C layouts, or wire -formats. Do not bump versions as part of these quests. The media review -found the four crates ready for a separately requested 0.1 release. +The media crates are 0.1.x too, so a published break to them targets dev. +Adapt callers in other packages without breaking their published APIs, C +layouts, or wire formats. Do not bump versions as part of these quests. Their package boundaries are explicit: diff --git a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md index c70982699d..43f4a0afed 100644 --- a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md +++ b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md @@ -26,7 +26,7 @@ session while unused handles are dropped, and prove all serving stops when the group terminates. Wire the tests into normal or nightly CI. Public API: no further `moq-tokio` ownership change. The prerequisite may -change `moq-sock`'s 0.0.x API. Wire: no format change. Close #2964 only when +change `moq-sock`'s 0.1.x API. Wire: no format change. Close #2964 only when both the dev ownership proof and this integration are complete. ## Closes diff --git a/quest/m1/gpu-pool-reservation.md b/quest/m1/gpu-pool-reservation.md index 65d7091c27..d0fad928ec 100644 --- a/quest/m1/gpu-pool-reservation.md +++ b/quest/m1/gpu-pool-reservation.md @@ -25,8 +25,8 @@ then `None`, dropping an unconverted slot frees it, and a failed conversion does not leak the buffer. Update the `just rs vulkan-cuda` hardware test and `doc/lib/rs/moq-video.md` inline. -Public API: `moq-video` 0.0.x, breaking for `Converter::convert` callers. Wire: -none. +Public API: `moq-video` 0.1.x, breaking for `Converter::convert` callers, so +it targets dev. Wire: none. ## Related diff --git a/quest/m1/ladder/README.md b/quest/m1/ladder/README.md index 6d8986db12..20dae78a47 100644 --- a/quest/m1/ladder/README.md +++ b/quest/m1/ladder/README.md @@ -34,7 +34,7 @@ first, so the same number decides what to produce and what to send first; ranking means on the first mile versus a cluster session before the controller depends on it. -`moq-transcode` is 0.0.x, so this line lands on main. +`moq-transcode` is 0.1.x, so a published break in this line targets dev. ### Adaptive bands diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index bf287757ad..bcd477c666 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.27" +version = "0.1.0" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-nvenc/Cargo.toml b/rs/moq-nvenc/Cargo.toml index f2b7511a45..33ad146d72 100644 --- a/rs/moq-nvenc/Cargo.toml +++ b/rs/moq-nvenc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moq-nvenc" -version = "0.0.6" +version = "0.1.0" edition = "2021" license = "MIT" rust-version.workspace = true diff --git a/rs/moq-sock/Cargo.toml b/rs/moq-sock/Cargo.toml index 1c994560cb..c86336f496 100644 --- a/rs/moq-sock/Cargo.toml +++ b/rs/moq-sock/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.1" +version = "0.1.0" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 38106e1d44..eafbea39d4 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.21" +version = "0.1.0" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-v4l/Cargo.toml b/rs/moq-v4l/Cargo.toml index 3ac5ae7273..e6229eaa55 100644 --- a/rs/moq-v4l/Cargo.toml +++ b/rs/moq-v4l/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moq-v4l" -version = "0.0.1" +version = "0.1.0" edition = "2021" license = "MIT" rust-version.workspace = true diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index c7fe32b53e..56a6d5c19e 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.27" +version = "0.1.0" edition = "2024" rust-version.workspace = true From b32b7a742187265104c57932f26f959a71b34acb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 11:19:30 -0700 Subject: [PATCH 05/85] fix(publish): keep audio held across a demand gap from rewinding the live edge (#4041) Co-authored-by: Claude Opus 5.5 --- js/publish/src/audio/encoder.test.ts | 201 ++++++++++++++++++++++++++- js/publish/src/audio/encoder.ts | 26 ++-- js/publish/src/audio/framer.ts | 5 + 3 files changed, 222 insertions(+), 10 deletions(-) diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 076d29d4fd..76be253266 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -1,8 +1,10 @@ import { describe, expect, mock, test } from "bun:test"; import * as Catalog from "@moq/hang/catalog"; +import * as Moq from "@moq/net"; import { Time } from "@moq/net"; -import type { Format } from "./capture"; -import { resolve } from "./encoder"; +import { Signal } from "@moq/signals"; +import type { AudioFrame, Format } from "./capture"; +import { Encoder, resolve } from "./encoder"; // Bun does not load Vite's worklet URL imports from the public audio entrypoint. mock.module("./capture-worklet.ts?worklet", () => ({ default: "blob:fake-capture" })); @@ -52,3 +54,198 @@ describe("resolve", () => { expect(resolved.catalog.jitter).toBe(Catalog.u53(Math.ceil((1024 / 48_000) * 1000))); }); }); + +// Like Chrome's Opus encoder, it holds the newest chunks until later input pushes them out. +class LaggingAudioEncoder { + static readonly LAG = 2; + + // Called on configure; the encoder publishes its pipeline synchronously right after. + static onConfigure: (() => void) | undefined; + + state: CodecState = "unconfigured"; + #output: EncodedAudioChunkOutputCallback; + #held: { timestamp: number; duration: number }[] = []; + + constructor(init: AudioEncoderInit) { + this.#output = init.output; + } + + configure(): void { + this.state = "configured"; + LaggingAudioEncoder.onConfigure?.(); + } + + encode(data: AudioData): void { + const duration = Math.round((data.numberOfFrames / data.sampleRate) * 1_000_000); + this.#held.push({ timestamp: data.timestamp, duration }); + while (this.#held.length > LaggingAudioEncoder.LAG) { + const { timestamp, duration } = this.#held.shift() as { timestamp: number; duration: number }; + const chunk = { + type: "key", + timestamp, + duration, + byteLength: 1, + copyTo: (dest: Uint8Array) => dest.set([1]), + }; + this.#output(chunk as unknown as EncodedAudioChunk); + } + } + + close(): void { + this.state = "closed"; + } +} + +class FakeAudioData { + readonly timestamp: number; + readonly numberOfFrames: number; + readonly sampleRate: number; + + constructor(init: AudioDataInit) { + // WebIDL's `long long` conversion truncates a fractional timestamp. + this.timestamp = Math.trunc(init.timestamp); + this.numberOfFrames = init.numberOfFrames; + this.sampleRate = init.sampleRate; + } + + close(): void {} +} + +function installFakeWebCodecs() { + const names = ["AudioEncoder", "AudioDecoder", "AudioData"] as const; + const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); + const fakes = [LaggingAudioEncoder, class {}, FakeAudioData]; + names.forEach((name, i) => { + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: fakes[i] }); + }); + + return { + [Symbol.dispose]() { + names.forEach((name, i) => { + const original = originals[i]; + if (original) Object.defineProperty(globalThis, name, original); + else Reflect.deleteProperty(globalThis, name); + }); + }, + }; +} + +// A capture stream that hands over one frame per read. The reader pushes each frame through the +// pipeline before reading again, so a pending read proves the previous frame was fully processed. +class Feed { + readonly stream: ReadableStream; + #deliver: ((frame: AudioFrame) => void) | undefined; + #requested!: () => void; + #request = this.#next(); + + constructor() { + this.stream = new ReadableStream( + { + pull: (controller) => + new Promise((resolve) => { + this.#deliver = (frame) => { + controller.enqueue(frame); + resolve(); + }; + this.#requested(); + }), + }, + { highWaterMark: 0 }, + ); + } + + #next(): Promise { + return new Promise((resolve) => { + this.#requested = resolve; + }); + } + + // Resolves once every frame pushed so far has been processed. + async drain(): Promise { + await this.#request; + } + + async push(frame: AudioFrame): Promise { + await this.drain(); + this.#request = this.#next(); + this.#deliver?.(frame); + } +} + +// The encoder outlives a demand gap, so chunks it held when demand disappeared surface after the +// resume. Written after the marker, they would put pre-gap media on the live edge, and a rounding +// step below the marker aborts every subscriber. +test("a demand gap marks where submitted audio ends and drops the chunks held across it", async () => { + using _webcodecs = installFakeWebCodecs(); + const configured = new Promise((resolve) => { + LaggingAudioEncoder.onConfigure = resolve; + }); + + const track = new Moq.Track.Producer("audio").accept(); + const written: [number, number][] = []; + let onWrite: (() => void) | undefined; + const writeFrame = track.writeFrame.bind(track); + track.writeFrame = (frame) => { + const [timestamp, payload] = Moq.Varint.decode(frame.payload); + written.push([timestamp, payload.byteLength]); + writeFrame(frame); + onWrite?.(); + }; + + const rendition = { + config: new Signal(undefined), + track: new Signal(track), + close: () => track.close(), + }; + + const feed = new Feed(); + const capture = { + in: { source: new Signal(undefined) }, + out: { + root: new Signal(undefined), + format: new Signal({ sampleRate: 48_000, channelCount: 1 }), + frames: new Signal({ subscribe: () => feed.stream }), + }, + }; + + const encoder = new Encoder("audio", { + broadcast: { audio: () => rendition } as never, + capture: capture as never, + }); + + // One 20ms Opus frame per push, on a clock with a fractional microsecond origin. + let index = 0; + const push = async (count: number) => { + for (let i = 0; i < count; i++, index++) { + await feed.push({ timestamp: Time.Micro(18_699.6 + index * 20_000), channels: [new Float32Array(960)] }); + } + await feed.drain(); + }; + + try { + await configured; + await push(4); // two written, two held + + const marked = new Promise((resolve) => { + onWrite = resolve; + }); + rendition.track.set(undefined); + await marked; + onWrite = undefined; + + await push(2); // gated + rendition.track.set(track); + await push(4); // releases the two held pre-gap chunks, then two resumed ones + + expect(written).toEqual([ + [18_700, 1], + [38_700, 1], + [98_700, 0], + [138_700, 1], + [158_700, 1], + ]); + } finally { + LaggingAudioEncoder.onConfigure = undefined; + encoder.close(); + } +}); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 9bfabb30f1..d86a973bee 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -155,9 +155,13 @@ export class Encoder { // discontinuity and re-anchors on. #pipeline: Pipeline | undefined; - // The exclusive end of the newest frame written to the live track, where a demand gap's - // discontinuity marker goes. Cleared once the marker is written. - #end: Time.Micro | undefined; + // Where the next frame submitted to the AudioEncoder starts, i.e. the exclusive end of the + // newest one, where a demand gap's discontinuity marker goes. Cleared once the marker is written. + #next: Time.Micro | undefined; + + // The newest demand gap's marker. The AudioEncoder outlives the gap, so chunks it still held + // when demand disappeared surface after the resume; they sit below the marker and are dropped. + #floor: Time.Micro | undefined; // The fatal error an AudioEncoder reported, if any. That instance can never encode again and // reconfiguring it would be a retry, so the rendition stays down for the life of this encoder. @@ -264,14 +268,15 @@ export class Encoder { // When demand disappears, end the epoch with a discontinuity marker (see // Container.Legacy.Producer.cut) so a later subscriber resumes on the same track without the - // pre-gap frames reading as live. Its empty payload marks where the source media ends. + // pre-gap frames reading as live. Its empty payload marks where the submitted media ends. effect.run((effect) => { const track = effect.get(rendition.track); if (!track) return; effect.cleanup(() => { - const end = this.#end; - this.#end = undefined; + const end = this.#next; + this.#next = undefined; if (end === undefined || track.closed.peek() !== undefined) return; + this.#floor = end; track.writeFrame({ payload: Container.Legacy.encodeFrame(new Uint8Array(), end), timestamp: Time.Timestamp.fromMicros(end), @@ -407,11 +412,11 @@ export class Encoder { // waiting for a group boundary. Loss is handled by the codec's PLC. const live = track.peek(); if (!live) return; + if (this.#floor !== undefined && frame.timestamp < this.#floor) return; live.writeFrame({ payload: Container.Legacy.encodeFrame(frame, frame.timestamp as Time.Micro), timestamp: Time.Timestamp.fromMicros(frame.timestamp as Time.Micro), }); - this.#end = (frame.timestamp + (frame.duration ?? 0)) as Time.Micro; }, error: (err) => { console.error("encoder error", err); @@ -439,6 +444,10 @@ export class Encoder { // on the capture clock, but there is nowhere to send a chunk with no subscriber. if (!track.peek()) continue; + // Round to whole microseconds once, here, so a chunk's timestamp and the marker + // placed at the next frame's start agree exactly. + const timestamp = Math.round(data.timestamp) as Time.Micro; + const joinedLength = data.channels.reduce((total, channel) => total + channel.length, 0); const joined = new Float32Array(joinedLength); @@ -452,13 +461,14 @@ export class Encoder { sampleRate: config.sampleRate, numberOfFrames: data.channels[0].length, numberOfChannels: data.channels.length, - timestamp: data.timestamp, + timestamp, data: joined, transfer: [joined.buffer], }); encoder.encode(frame); frame.close(); + this.#next = Math.round(framer.next) as Time.Micro; } }, }; diff --git a/js/publish/src/audio/framer.ts b/js/publish/src/audio/framer.ts index b1ca2ef6c3..ad2352361b 100644 --- a/js/publish/src/audio/framer.ts +++ b/js/publish/src/audio/framer.ts @@ -80,6 +80,11 @@ export class Framer { return output; } + /** Where the next frame will start. Throws until the first input sets the origin. */ + get next(): Time.Micro { + return this.#timestamp(); + } + // Whether this chunk starts somewhere other than where the previous one left off. #discontinuous(timestamp: Time.Micro): boolean { if (this.#origin === undefined) return false; From 2e27b4b3a986eb4c1dd56b2c0361a50bc0f311b8 Mon Sep 17 00:00:00 2001 From: Franz Heinzmann Date: Thu, 24 Sep 2026 20:23:49 +0200 Subject: [PATCH 06/85] feat(moq-video): one VAAPI render node for encode, decode and resize (#4023) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Luke Curley Co-authored-by: Grok --- doc/lib/rs/moq-video.md | 2 +- rs/moq-video/src/decode/backend/vaapi.rs | 27 +++-- rs/moq-video/src/encode/backend/vaapi.rs | 57 ++++++++--- rs/moq-video/src/frame/vaapi.rs | 89 ++++++++++++++-- rs/moq-video/src/render/renderer.rs | 124 +++++++++++++++++++++++ 5 files changed, 266 insertions(+), 33 deletions(-) diff --git a/doc/lib/rs/moq-video.md b/doc/lib/rs/moq-video.md index 1c5ab81336..11249d2e36 100644 --- a/doc/lib/rs/moq-video.md +++ b/doc/lib/rs/moq-video.md @@ -20,7 +20,7 @@ ffmpeg, no GStreamer, no system codec to install. Highlights: -- **Automatic backend selection**, hardware first. Linux GPU libraries are `dlopen`ed at runtime, so one binary starts anywhere and warns when it falls back to software. openh264 (the default-on `openh264` feature) is statically linked as the H.264 fallback; H.265 is hardware-only; AV1 decodes via NVDEC. The VAAPI encoder is compile-verified but not yet validated on hardware. +- **Automatic backend selection**, hardware first. Linux GPU libraries are `dlopen`ed at runtime, so one binary starts anywhere and warns when it falls back to software. openh264 (the default-on `openh264` feature) is statically linked as the H.264 fallback; H.265 is hardware-only; AV1 decodes via NVDEC. The VAAPI encoder, decoder, and GPU resize share one render node: the first whose driver does all three, or the one the `MOQ_VAAPI_DEVICE` environment variable names (for example `/dev/dri/renderD129`). - **Publish on demand.** `encode::publish_capture` advertises the track up front and opens the camera only while someone subscribes. - **GPU ownership where the platform allows.** Matching codec backends consume their native GPU surfaces directly. The renderer imports `CVPixelBuffer` and supported DMA-BUF formats. Linux/NVIDIA producers can import dedicated Vulkan RGBA8 slots into CUDA with timeline-semaphore ordering and completion-driven slot return. Vulkan/CUDA surfaces deliberately have no CPU pixel fallback; other surfaces use the typed `Surface::into_i420()` and configured `Surface::to_rgba(config)` when needed. - **Live bitrate control** where the selected backend supports it, without forcing a keyframe. An unsupported backend keeps its opening rate. diff --git a/rs/moq-video/src/decode/backend/vaapi.rs b/rs/moq-video/src/decode/backend/vaapi.rs index 7d92c6bca1..25b63e81a5 100644 --- a/rs/moq-video/src/decode/backend/vaapi.rs +++ b/rs/moq-video/src/decode/backend/vaapi.rs @@ -11,7 +11,8 @@ //! missing render node, or a driver with no H.264 decode entrypoint makes //! `Decoder::new` return an error; under automatic selection //! [`backend::open`](super::open) then moves on to the next candidate, like the -//! NVDEC backend. +//! NVDEC backend. The render node is the one the encoder and the GPU resize +//! share, which `MOQ_VAAPI_DEVICE` can name; see `frame::vaapi::device`. //! //! Progressive 8-bit 4:2:0 only, which is everything a browser's `VideoEncoder`, //! WebRTC, or this crate's own encoders emit. The decoder rejects an interlaced or @@ -37,15 +38,17 @@ //! downloading, which native output permits. //! //! [`Output::Cpu`](crate::Output::Cpu) downloads inside the backend rather than -//! leaving it to the generic conversion, because exporting is not free: -//! handing a surface out retires it from the decoder's recycling pool, since a -//! later picture decoded over it would corrupt one the consumer still holds, so -//! it costs an allocation per picture on top of the download the CPU consumer -//! pays anyway. A native consumer that still wants pixels is not stranded: +//! leaving it to the generic conversion, because exporting is not free: each +//! picture costs a PRIME export, and its surface stays out of the decoder's +//! recycling pool until the frame drops, since a later picture decoded over it +//! would corrupt one the consumer still holds. A native consumer that draws a +//! picture and lets it go gets the surface decoded into again rather than a new +//! allocation per picture. One that still wants pixels is not stranded: //! [`Surface::into_i420`](crate::Surface::into_i420) answers, because -//! `moq-vaapi` keeps the retired surface alongside the descriptor and reads it -//! back through `vaDeriveImage` rather than trying to read a tiled buffer as -//! rows. +//! `moq-vaapi` keeps the surface alongside the descriptor and reads it back +//! through `vaDeriveImage` rather than trying to read a tiled buffer as rows. + +use std::path::Path; use bytes::Bytes; use moq_net::Timestamp; @@ -79,8 +82,10 @@ impl Vaapi { return Err(Error::Codec(anyhow::anyhow!("VAAPI cannot decode {}", codec.label()))); } - let decoder = - Decoder::new(VaapiConfig::new()).map_err(|e| Error::Codec(anyhow::anyhow!("VAAPI decoder init: {e:?}")))?; + let vaapi = VaapiConfig { + device: vaapi::device().map(Path::to_path_buf), + }; + let decoder = Decoder::new(vaapi).map_err(|e| Error::Codec(anyhow::anyhow!("VAAPI decoder init: {e:?}")))?; let exporting = config.output == Output::Native; tracing::info!(decoder = NAME, exporting, "opened H.264 decoder"); diff --git a/rs/moq-video/src/encode/backend/vaapi.rs b/rs/moq-video/src/encode/backend/vaapi.rs index be0fc8e6a5..ec9061ac22 100644 --- a/rs/moq-video/src/encode/backend/vaapi.rs +++ b/rs/moq-video/src/encode/backend/vaapi.rs @@ -9,6 +9,8 @@ //! libva-less host, or a present-but-unusable VA stack (no render node, no usable //! driver), makes `Encoder::new` return an error; under automatic selection //! [`backend::open`](super::open) then moves on to openh264, like the NVENC backend. +//! The render node is the one the decoder and the GPU resize share, which +//! `MOQ_VAAPI_DEVICE` can name; see `frame::vaapi::device`. //! //! A [`Surface::DmaBuf`] is encoded without touching the CPU. An NV12 buffer at //! the encoder's size (a VA-API decode, or one [`Surface::resize`] already @@ -32,6 +34,7 @@ //! pixels. They skip on a machine without a VA-API device. use std::collections::HashSet; +use std::path::Path; use bytes::Bytes; use moq_vaapi::encode::{Config as VaapiConfig, Encoder}; @@ -48,6 +51,9 @@ pub(crate) struct Vaapi { /// Buffer layouts the driver refused to encode, by format and modifier, so /// each costs one failed attempt rather than one per frame. refused: HashSet<(DrmFormat, u64)>, + /// The CPU path's NV12 staging buffer, kept so a frame reuses the last one's + /// allocation. + nv12: Vec, /// Frames encoded from a DMA-BUF on the GPU, for the tests to tell the two /// paths apart. #[cfg(test)] @@ -63,6 +69,7 @@ impl Vaapi { let bitrate = config.resolved_bitrate().as_bps().min(u32::MAX as u64) as u32; let Gop::Keyframe { interval } = config.gop; let vaapi = VaapiConfig { + device: vaapi::device().map(Path::to_path_buf), color: vaapi::color(config.resolved_color()), ..VaapiConfig::new( config.width, @@ -76,6 +83,7 @@ impl Vaapi { tracing::info!( encoder = NAME, + device = ?encoder.config().device, width = config.width, height = config.height, "opened H.264 encoder" @@ -83,6 +91,7 @@ impl Vaapi { Ok(Self { encoder, refused: HashSet::new(), + nv12: Vec::new(), #[cfg(test)] gpu_frames: 0, }) @@ -129,9 +138,9 @@ impl Vaapi { fn encode_cpu(&mut self, frame: &Frame, cut: bool) -> Result, Error> { let i420 = frame.surface.to_i420()?; - let nv12 = i420_to_nv12(&i420); + i420_to_nv12(&i420, &mut self.nv12); self.encoder - .encode_nv12(&nv12, cut) + .encode_nv12(&self.nv12, cut) .map_err(|e| Error::Codec(e.context("VAAPI encode"))) } } @@ -184,22 +193,21 @@ impl Backend for Vaapi { } } -/// Interleave tightly-packed I420 into tightly-packed NV12: copy Y as-is, then -/// interleave U and V into the chroma plane. -fn i420_to_nv12(i420: &I420) -> Vec { +/// Interleave tightly-packed I420 into tightly-packed NV12 in `out`: copy Y +/// as-is, then interleave U and V into the chroma plane. +/// +/// `out` is resized to the frame and every byte of it is written, so it can be +/// reused from frame to frame without clearing. +fn i420_to_nv12(i420: &I420, out: &mut Vec) { let (w, h) = (i420.width as usize, i420.height as usize); let (cw, ch) = (w / 2, h / 2); - let mut out = vec![0u8; w * h + 2 * cw * ch]; - out[..w * h].copy_from_slice(i420.y()); - - let (u, v) = (i420.u(), i420.v()); - let uv = &mut out[w * h..]; - for i in 0..cw * ch { - uv[i * 2] = u[i]; - uv[i * 2 + 1] = v[i]; + out.resize(w * h + 2 * cw * ch, 0); + let (y, uv) = out.split_at_mut(w * h); + y.copy_from_slice(i420.y()); + for ((pair, &u), &v) in uv.as_chunks_mut::<2>().0.iter_mut().zip(i420.u()).zip(i420.v()) { + *pair = [u, v]; } - out } // The round trips decode with openh264, an independent decoder. @@ -312,6 +320,27 @@ mod tests { } } + /// The reused staging buffer takes each frame's size and carries nothing + /// over from a larger frame before it. Needs no hardware. + #[test] + fn the_nv12_staging_buffer_is_rewritten_per_frame() { + let mut nv12 = Vec::new(); + let large = Size::new(64, 64); + let large = I420::from_rgba(&gradient_rgba(large), large.width * 4, large).unwrap(); + i420_to_nv12(&large, &mut nv12); + + let small = Size::new(32, 16); + let small = I420::from_rgba(&gradient_rgba(small), small.width * 4, small).unwrap(); + i420_to_nv12(&small, &mut nv12); + + let luma = small.y().len(); + assert_eq!(nv12.len(), luma * 3 / 2); + assert_eq!(&nv12[..luma], small.y()); + for (index, pair) in nv12[luma..].as_chunks::<2>().0.iter().enumerate() { + assert_eq!(*pair, [small.u()[index], small.v()[index]], "chroma pair {index}"); + } + } + /// CPU frames come back from an independent decoder as the picture that /// went in, which a stride or chroma-order bug in the upload would break. #[test] diff --git a/rs/moq-video/src/frame/vaapi.rs b/rs/moq-video/src/frame/vaapi.rs index 84f30df9aa..569421bcbe 100644 --- a/rs/moq-video/src/frame/vaapi.rs +++ b/rs/moq-video/src/frame/vaapi.rs @@ -6,8 +6,9 @@ //! `moq-vaapi` here, so there is one place that knows how a crate `DmaBuf` maps //! to a VA-API import descriptor and back. //! -//! Scaling goes through the VA-API video processor on whichever device answers -//! first. It stays on the GPU: the input is imported, blitted into an NV12 +//! All three open the render node [`device`] names, so a picture moving between +//! them stays on one GPU. Scaling goes through the VA-API video processor on +//! that node. It stays on the GPU: the input is imported, blitted into an NV12 //! surface of the target size (converting packed RGB on the way), and that //! surface is exported as a new DMA-BUF, which the VAAPI encoder then imports //! in turn. A simulcast publisher resizing one captured frame into several @@ -17,7 +18,8 @@ use std::cell::RefCell; use std::collections::HashSet; use std::os::fd::{AsFd, OwnedFd}; -use std::sync::{Arc, Mutex}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; use moq_vaapi::decode::ExportedFrame; use moq_vaapi::dmabuf::{DmaBuf as VaapiDmaBuf, Plane}; @@ -27,6 +29,45 @@ use moq_vaapi::{Matrix, VA_FOURCC_NV12}; use super::{DmaBuf, DmaBufExport, DmaBufFrame, DmaBufPlane, DrmFormat, I420}; use crate::{Color, Error, Size}; +/// Environment variable naming the render node, such as `/dev/dri/renderD129`, that [`device`] returns. +const DEVICE_ENV: &str = "MOQ_VAAPI_DEVICE"; + +/// Returns the render node the VA-API encoder, decoder, and resize open, or `None` for each to find its own. +/// +/// [`DEVICE_ENV`] names one outright, and a named node is used even when it +/// fails to open, so a typo is an error rather than a silent fallback. +/// Otherwise this is the first node whose driver encodes and decodes H.264 and +/// post-processes video. Sharing one node is what lets a DMA-BUF move from the +/// decoder through a resize into the encoder without a copy; on a machine with +/// two GPUs, the first node that decodes need not be one that encodes. Where no +/// node offers all three, each opens the first node that offers what it needs. +/// +/// Resolved once per process, since finding it opens every render node. +pub(crate) fn device() -> Option<&'static Path> { + static DEVICE: OnceLock> = OnceLock::new(); + DEVICE + .get_or_init(|| { + if let Some(node) = std::env::var_os(DEVICE_ENV) { + let node = PathBuf::from(node); + tracing::info!(device = %node.display(), "using the VA-API render node {DEVICE_ENV} names"); + return Some(node); + } + let node = moq_vaapi::DrmDeviceIterator::default().find(|node| { + moq_vaapi::Display::open_drm_display(node).is_ok_and(|display| { + moq_vaapi::encode::probe(&display).is_ok() + && moq_vaapi::decode::probe(&display).is_ok() + && moq_vaapi::vpp::probe(&display).is_ok() + }) + }); + match &node { + Some(node) => tracing::debug!(device = %node.display(), "VA-API render node"), + None => tracing::debug!("no VA-API render node encodes, decodes, and scales; each opens its own"), + } + node + }) + .as_deref() +} + /// Describes `buffer` for a VA-API import, returning the producer lease to hold until the import is done with. /// /// Waits on the producer's write fence first, since a VA-API import does not. @@ -137,7 +178,13 @@ pub(crate) fn resize(buffer: &DmaBuf, size: Size) -> Result { // Checked before exporting, so a machine without VA-API does not pay a // fence wait and a descriptor duplicate on every frame to learn it again. let processor = slot - .get_or_insert_with(|| Processor::open().map_err(|e| format!("{e:#}"))) + .get_or_insert_with(|| { + match device() { + Some(node) => Processor::new(node), + None => Processor::open(), + } + .map_err(|e| format!("{e:#}")) + }) .as_ref() .map_err(|e| Error::Unsupported(format!("no VA-API video processor: {e}")))?; let (descriptor, lease) = import(buffer)?; @@ -231,8 +278,8 @@ pub(crate) fn adopt(frame: ExportedFrame, color: Option) -> anyhow::Resul /// /// Both of the things a consumer can do with one: hand a descriptor to a /// graphics API, or give up on drawing it and read the pixels back. Dropping the -/// last clone destroys the surface, which is what returns its allocation to the -/// driver. +/// last clone hands a decoded picture's surface back to the decoder, which +/// decodes into it again, and destroys any other surface. struct Exported { /// Locked because [`DmaBufFrame`] hands out `&self` while what is behind it /// is a single libva surface: `download_i420` maps that surface, and two @@ -272,6 +319,29 @@ impl DmaBufFrame for Exported { } } +#[cfg(test)] +mod tests { + use super::*; + + /// Without an override, the node the codecs share is one that does + /// everything it is shared for. + #[test] + fn the_shared_node_encodes_decodes_and_scales() { + if std::env::var_os(DEVICE_ENV).is_some() { + eprintln!("skipping: {DEVICE_ENV} names the node"); + return; + } + let Some(node) = device() else { + eprintln!("skipping: no render node encodes, decodes, and scales"); + return; + }; + let display = moq_vaapi::Display::open_drm_display(node).expect("reopen the shared node"); + moq_vaapi::encode::probe(&display).expect("the shared node encodes H.264"); + moq_vaapi::decode::probe(&display).expect("the shared node decodes H.264"); + moq_vaapi::vpp::probe(&display).expect("the shared node scales"); + } +} + // For the encoder backend's tests, which need openh264 to decode. #[cfg(all(test, feature = "openh264"))] pub(crate) mod testing { @@ -334,7 +404,12 @@ pub(crate) mod testing { /// Returns a BGRX DMA-BUF holding `rgba`, allocated by VA-API, or `None` without a device. pub(crate) fn bgrx_dmabuf(rgba: &[u8], size: Size) -> Option { - let display = Display::open()?; + // The same node resize and the encoder open. On two GPUs `Display::open` + // can be a different device, and the processor then refuses the import. + let display = match super::device() { + Some(node) => Display::open_drm_display(node).ok()?, + None => Display::open()?, + }; let (width, height) = (size.width, size.height); let surface = display .create_surfaces( diff --git a/rs/moq-video/src/render/renderer.rs b/rs/moq-video/src/render/renderer.rs index fa2049201a..1eb0575ef3 100644 --- a/rs/moq-video/src/render/renderer.rs +++ b/rs/moq-video/src/render/renderer.rs @@ -1287,6 +1287,130 @@ mod tests { } } + /// A decoder that decodes into its surfaces again still has every picture + /// drawn as the one just decoded. + /// + /// Each picture is drawn and dropped before the next access unit goes in, + /// the way a player draws a picture and moves on, so the decoder gets its + /// surfaces back once the renderer's GPU work is done with them. A surface + /// handed back too early shows up as a picture with the block where a later + /// one has it. The same stream decoded to the CPU is the reference. + /// + /// Ignored: needs a Vulkan GPU and a VA-API device. Run with + /// `cargo test -p moq-video --features render,vaapi recycled -- --ignored --nocapture`. + #[cfg(all(target_os = "linux", feature = "vaapi"))] + #[tokio::test] + #[ignore = "needs a Vulkan GPU and a VA-API device"] + async fn recycled_decoder_surfaces_draw_the_latest_picture() { + use std::os::unix::fs::MetadataExt as _; + + use crate::decode::backend::{self, Codec, vaapi}; + + const PICTURES: u64 = 30; + let Some((device, queue)) = dmabuf_gpu().await else { + eprintln!("skipping: no Vulkan adapter with DMA-BUF external memory"); + return; + }; + let decode = |output| { + backend::open( + Codec::H264, + &crate::decode::Config { + kind: crate::decode::Kind::Named(vaapi::NAME.into()), + output, + ..crate::decode::Config::new() + }, + ) + }; + let Ok(mut exporting) = decode(crate::Output::Native) else { + eprintln!("skipping: no VA-API H.264 decoder"); + return; + }; + let mut downloading = decode(crate::Output::Cpu).expect("a second decoder"); + + // A block moving across a gradient, so a picture drawn from a stale + // import shows the block where an earlier picture had it. + let size = Size::new(320, 240); + let (width, height) = (size.width, size.height); + let picture = |step: u64| { + let mut rgba = vec![0u8; (width * height * 4) as usize]; + let x0 = (step as u32 * 8) % (width / 2); + for y in 0..height { + for x in 0..width { + let index = ((y * width + x) * 4) as usize; + let block = (height / 4..height / 2).contains(&y) && (x0..x0 + width / 4).contains(&x); + rgba[index] = if block { 240 } else { (x * 255 / width) as u8 }; + rgba[index + 1] = (y * 255 / height) as u8; + rgba[index + 2] = ((x + y) * 255 / (width + height)) as u8; + rgba[index + 3] = 255; + } + } + Surface::rgba(&rgba, size).expect("a valid RGBA frame") + }; + let mut encoder = crate::encode::Encoder::new(&crate::encode::Config { + kind: crate::encode::Kind::Software, + ..crate::encode::Config::new(width, height, crate::Rate::new(30, 1).unwrap()) + }) + .expect("a software H.264 encoder"); + + let config = Config { + usage: wgpu::TextureUsages::COPY_SRC, + ..Config::new() + }; + let mut importing = Renderer::new(&device, &queue, config.clone()).expect("a renderer"); + let mut uploading = Renderer::new(&device, &queue, config).expect("a renderer"); + + let mut buffers = std::collections::HashSet::new(); + let mut drawn = 0; + for index in 0..PICTURES { + if index == 0 { + encoder.cut().unwrap(); + } + let frame = Frame::new(picture(index), Timestamp::from_micros(index * 33_333).unwrap()); + for unit in encoder.encode(&frame).expect("encode a picture") { + let exported = exporting + .decode(unit.payload.clone(), unit.timestamp, index == 0) + .expect("decode to the GPU"); + let downloaded = downloading + .decode(unit.payload, unit.timestamp, index == 0) + .expect("decode to the CPU"); + assert_eq!(exported.len(), downloaded.len(), "the two decoders disagreed"); + + for (gpu, cpu) in exported.iter().zip(&downloaded) { + let Surface::DmaBuf(buffer) = &gpu.surface else { + panic!("picture {index} did not come back GPU-resident"); + }; + let export = buffer.export().expect("export the decoded surface"); + let file = std::fs::File::from(export.as_fd().try_clone_to_owned().expect("duplicate")); + buffers.insert(file.metadata().expect("stat the DMA-BUF").ino()); + drop((file, export)); + + let texture = importing.render(gpu).expect("draw the imported picture"); + assert_eq!(importing.strikes, 0, "picture {index} fell back to the CPU"); + let zero_copy = readback(&device, &queue, &texture).await; + let texture = uploading.render(cpu).expect("draw the downloaded picture"); + let reference = readback(&device, &queue, &texture).await; + + for (pixel, (&imported, &expected)) in zero_copy.iter().zip(&reference).enumerate() { + let drift = (0..4).map(|c| imported[c].abs_diff(expected[c])).max().unwrap_or(0); + let (x, y) = (pixel % width as usize, pixel / width as usize); + assert!( + drift <= 2, + "picture {index} at ({x}, {y}): imported {imported:?}, downloaded {expected:?}" + ); + } + drawn += 1; + } + } + } + + eprintln!("{drawn} pictures drawn from {} buffers", buffers.len()); + assert!(drawn > 0, "the decoder produced no pictures"); + assert!( + (buffers.len() as u64) < PICTURES, + "the decoder decoded every picture into a new buffer, so nothing here is reused" + ); + } + /// A pool-backed NV12 surface, shaped like a hardware decode's output. #[cfg(target_os = "macos")] fn pooled(size: Size, rgba: [u8; 4]) -> crate::Surface { From 7fbdba8178b2d0587803d884071128dcdceeca04 Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:10:59 +0000 Subject: [PATCH 07/85] chore: release (#4013) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 69 ++++++++++++++++++----------------- Cargo.toml | 34 ++++++++--------- rs/hang/CHANGELOG.md | 6 +++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 6 +++ rs/libmoq/Cargo.toml | 2 +- rs/moq-archive/CHANGELOG.md | 6 +++ rs/moq-archive/Cargo.toml | 2 +- rs/moq-audio/CHANGELOG.md | 6 +++ rs/moq-audio/Cargo.toml | 2 +- rs/moq-binary/CHANGELOG.md | 6 +++ rs/moq-binary/Cargo.toml | 2 +- rs/moq-boy/CHANGELOG.md | 6 +++ rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 10 +++++ rs/moq-cli/Cargo.toml | 2 +- rs/moq-e2ee/CHANGELOG.md | 6 +++ rs/moq-e2ee/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 6 +++ rs/moq-ffi/Cargo.toml | 2 +- rs/moq-gst/CHANGELOG.md | 6 +++ rs/moq-gst/Cargo.toml | 2 +- rs/moq-hls/CHANGELOG.md | 6 +++ rs/moq-hls/Cargo.toml | 2 +- rs/moq-json/CHANGELOG.md | 6 +++ rs/moq-json/Cargo.toml | 2 +- rs/moq-loc/CHANGELOG.md | 6 +++ rs/moq-loc/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 6 +++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 11 ++++++ rs/moq-net/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 6 +++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-room/CHANGELOG.md | 6 +++ rs/moq-room/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 +++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-rtmp/CHANGELOG.md | 6 +++ rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-srt/CHANGELOG.md | 6 +++ rs/moq-srt/Cargo.toml | 2 +- rs/moq-stats/CHANGELOG.md | 6 +++ rs/moq-stats/Cargo.toml | 2 +- rs/moq-tokio/CHANGELOG.md | 6 +++ rs/moq-tokio/Cargo.toml | 2 +- rs/moq-transcode/CHANGELOG.md | 6 +++ rs/moq-transcode/Cargo.toml | 2 +- rs/moq-uring/CHANGELOG.md | 6 +++ rs/moq-uring/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 6 +++ rs/moq-video/Cargo.toml | 2 +- 52 files changed, 236 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12d5ba6958..bd12cb5af0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,9 +1613,9 @@ dependencies = [ [[package]] name = "cudarc" -version = "0.19.9" +version = "0.19.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804764d10e844da09765a7b2ca9641a0851523d1702efb0d7299d73e31b86e80" +checksum = "7359bf1de037ddada72729c3acec9b040f3468400c6e9e740b01ba0cd2e89562" dependencies = [ "libloading 0.9.0", ] @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.1" +version = "0.21.2" dependencies = [ "anyhow", "bytes", @@ -3086,16 +3086,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -3883,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "bytes", @@ -4154,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.1" +version = "0.0.2" dependencies = [ "async-trait", "bytes", @@ -4171,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.0" +version = "0.1.1" dependencies = [ "block2 0.6.2", "bytes", @@ -4250,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bytes", "kio 0.6.0", @@ -4262,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "boytacean", @@ -4282,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.1" +version = "0.12.2" dependencies = [ "anyhow", "axum", @@ -4319,7 +4320,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.1" +version = "0.0.2" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4338,7 +4339,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4372,7 +4373,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "bytes", @@ -4390,7 +4391,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.1" +version = "0.5.2" dependencies = [ "axum", "bytes", @@ -4413,7 +4414,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bytes", "criterion", @@ -4430,7 +4431,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.9" +version = "0.2.10" dependencies = [ "bytes", "moq-net", @@ -4448,7 +4449,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.1" +version = "0.10.2" dependencies = [ "anyhow", "base64 0.23.1", @@ -4485,7 +4486,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.0" +version = "0.3.1" dependencies = [ "arrayvec", "bytes", @@ -4602,7 +4603,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.1" +version = "0.15.2" dependencies = [ "anyhow", "axum", @@ -4643,7 +4644,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.1" +version = "0.2.2" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4657,7 +4658,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.1" +version = "0.3.2" dependencies = [ "aws-lc-rs", "axum", @@ -4677,7 +4678,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "byteorder", @@ -4714,7 +4715,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.1" +version = "0.3.2" dependencies = [ "bytes", "futures", @@ -4730,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.1" +version = "0.2.2" dependencies = [ "futures", "moq-json", @@ -4745,7 +4746,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.12" +version = "0.19.13" dependencies = [ "anyhow", "bytes", @@ -4798,7 +4799,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "bytes", @@ -4817,7 +4818,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.2" +version = "0.0.3" dependencies = [ "anyhow", "bytes", @@ -4874,7 +4875,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "ash", @@ -10880,18 +10881,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 4bc55e9ece..d2d825ba63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,7 +119,7 @@ dispatch2 = "0.3.1" flate2 = "1.1" futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.1", path = "rs/hang" } +hang = { version = "0.21.2", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -137,16 +137,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.0", path = "rs/moq-audio", default-features = false } +moq-audio = { version = "0.1.1", path = "rs/moq-audio", default-features = false } moq-auth = { version = "0.1.0", path = "rs/moq-auth" } -moq-binary = { version = "0.1.0", path = "rs/moq-binary" } +moq-binary = { version = "0.1.1", path = "rs/moq-binary" } moq-flate = { version = "0.1.3", path = "rs/moq-flate" } -moq-hls = { version = "0.5.1", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.4.1", path = "rs/moq-json" } -moq-loc = { version = "0.2.9", path = "rs/moq-loc" } +moq-hls = { version = "0.5.2", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.4.2", path = "rs/moq-json" } +moq-loc = { version = "0.2.10", path = "rs/moq-loc" } moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.1", path = "rs/moq-mux" } -moq-net = { version = "0.3.0", path = "rs/moq-net" } +moq-mux = { version = "0.10.2", path = "rs/moq-mux" } +moq-net = { version = "0.3.1", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "1.3", default-features = false } @@ -156,23 +156,23 @@ moq-noq-udp = "1.3" # used by moq-video on Linux. moq-nvenc = { version = "0.1.0", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.1", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.1", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.1", path = "rs/moq-rtmp" } +moq-relay = { version = "0.15.2", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.2", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.2", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.1", path = "rs/moq-srt" } -moq-stats = { version = "0.2.1", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.12", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.2", path = "rs/moq-srt" } +moq-stats = { version = "0.2.2", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.13", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.0", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.1", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.2", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.3", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -185,7 +185,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.1.0", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.1", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 7a40b4ae56..026306e132 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.2](https://github.com/moq-dev/moq/compare/hang-v0.21.1...hang-v0.21.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.21.1](https://github.com/moq-dev/moq/compare/hang-v0.21.0...hang-v0.21.1) - 2026-09-23 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index d394d6188f..74de46bcd2 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.21.1" +version = "0.21.2" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index fc0d316356..eb8b6bce76 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.6.2](https://github.com/moq-dev/moq/compare/libmoq-v0.6.1...libmoq-v0.6.2) - 2026-09-24 + +### Other + +- rename in-repo smoke test to interop ([#3963](https://github.com/moq-dev/moq/pull/3963)) + ## [0.6.1](https://github.com/moq-dev/moq/compare/libmoq-v0.6.0...libmoq-v0.6.1) - 2026-09-23 ### Other diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 22c7c5be67..f3b5467f6c 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.6.1" +version = "0.6.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index 26ae4b7d4d..4d8b7ad185 100644 --- a/rs/moq-archive/CHANGELOG.md +++ b/rs/moq-archive/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.2](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.1...moq-archive-v0.0.2) - 2026-09-24 + +### Other + +- fill release doc gaps ([#4027](https://github.com/moq-dev/moq/pull/4027)) + ## [0.0.1](https://github.com/moq-dev/moq/releases/tag/moq-archive-v0.0.1) - 2026-09-23 ### Added diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index d95e48b1ce..d422e6a144 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.1" +version = "0.0.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index 9ef434eb60..79c06a4eea 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.0...moq-audio-v0.1.1) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.0.27](https://github.com/moq-dev/moq/compare/moq-audio-v0.0.26...moq-audio-v0.0.27) - 2026-09-23 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index bcd477c666..b4715f6524 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index e06f2f14fe..f331bd03f5 100644 --- a/rs/moq-binary/CHANGELOG.md +++ b/rs/moq-binary/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.0...moq-binary-v0.1.1) - 2026-09-24 + +### Other + +- fill release doc gaps ([#4027](https://github.com/moq-dev/moq/pull/4027)) + ## [0.1.0](https://github.com/moq-dev/moq/releases/tag/moq-binary-v0.1.0) - 2026-09-23 ### Added diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index b89cb730d3..6c335a8d89 100644 --- a/rs/moq-binary/Cargo.toml +++ b/rs/moq-binary/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 654cc2d2b2..bc4881d5d0 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.2](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.1...moq-boy-v0.5.2) - 2026-09-24 + +### Other + +- update Cargo.lock dependencies + ## [0.5.1](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.0...moq-boy-v0.5.1) - 2026-09-23 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 59dd41b808..95c2452551 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.5.1" +version = "0.5.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 629d7d38c1..6fe93efd76 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.2](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.1...moq-cli-v0.12.2) - 2026-09-24 + +### Added + +- *(moq-video)* capture cameras through PipeWire ([#4022](https://github.com/moq-dev/moq/pull/4022)) + +### Fixed + +- *(cli)* play a retired audio rendition's tail alongside its replacement ([#3966](https://github.com/moq-dev/moq/pull/3966)) + ## [0.12.1](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.0...moq-cli-v0.12.1) - 2026-09-23 ### Fixed diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index a4c291b1bc..c8beacf0cc 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-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.12.1" +version = "0.12.2" edition = "2024" # Depends on moq-relay, whose sysinfo 0.39 needs 1.95, above the 1.91 workspace # floor. This binary is an application, so the bump stays here rather than diff --git a/rs/moq-e2ee/CHANGELOG.md b/rs/moq-e2ee/CHANGELOG.md index e90efb6ba8..af08f37f1f 100644 --- a/rs/moq-e2ee/CHANGELOG.md +++ b/rs/moq-e2ee/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.2](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.1...moq-e2ee-v0.0.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net + ### Changed - Profile `moq-e2ee-00`: every derivation is scoped to a publisher-minted `Epoch`, the last segment of the opaque broadcast path from `Credential::path` diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index 2a21fc884a..fd238266bf 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.1" +version = "0.0.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index 7c9261010b..718e991f06 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.2](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.1...moq-ffi-v0.4.2) - 2026-09-24 + +### Other + +- rename in-repo smoke test to interop ([#3963](https://github.com/moq-dev/moq/pull/3963)) + ## [0.4.1](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.0...moq-ffi-v0.4.1) - 2026-09-23 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 7566002574..e4fa741ac7 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/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.4.1" +version = "0.4.2" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 42a5e473a6..13d738f63e 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.2](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.1...moq-gst-v0.4.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-tokio + ## [0.4.1](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.0...moq-gst-v0.4.1) - 2026-09-23 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index c4b090836c..46ad5d70c1 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.1" +version = "0.4.2" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index ded8cb909a..aaa7ab267e 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.2](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.1...moq-hls-v0.5.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.5.1](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.0...moq-hls-v0.5.1) - 2026-09-23 ### Other diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 706db30592..ea6507fcdd 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/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.1" +version = "0.5.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index 3516a63598..1ecd59b10d 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.2](https://github.com/moq-dev/moq/compare/moq-json-v0.4.1...moq-json-v0.4.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net + ## [0.4.1](https://github.com/moq-dev/moq/compare/moq-json-v0.4.0...moq-json-v0.4.1) - 2026-09-23 ### Added diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 1bfcc59da1..75dffd6f53 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.1" +version = "0.4.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index 4170099f98..a7f6ad94d5 100644 --- a/rs/moq-loc/CHANGELOG.md +++ b/rs/moq-loc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.10](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.9...moq-loc-v0.2.10) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net + ## [0.2.9](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.8...moq-loc-v0.2.9) - 2026-09-23 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index cf9dd3ab1b..c5b36454a9 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.9" +version = "0.2.10" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index c3dad9792c..e9e2e26db0 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.2](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.1...moq-mux-v0.10.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, moq-binary, moq-json, hang, moq-loc + ## [0.10.1](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.0...moq-mux-v0.10.1) - 2026-09-23 ### Other diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 46c6a9b997..6ed852fc6c 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/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.1" +version = "0.10.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index c88a8d546a..e276d9be20 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1](https://github.com/moq-dev/moq/compare/moq-net-v0.3.0...moq-net-v0.3.1) - 2026-09-24 + +### Fixed + +- *(relay)* keep only finished groups warm when a track goes idle ([#3977](https://github.com/moq-dev/moq/pull/3977)) +- *(net)* don't end in-flight tracks when their broadcast ends ([#4007](https://github.com/moq-dev/moq/pull/4007)) + +### Other + +- rename in-repo smoke test to interop ([#3963](https://github.com/moq-dev/moq/pull/3963)) + ## [0.3.0](https://github.com/moq-dev/moq/compare/moq-net-v0.2.22...moq-net-v0.3.0) - 2026-09-23 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index e72a06b42d..2673e10def 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.0" +version = "0.3.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 1400eaeeda..771158be80 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.2](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.1...moq-relay-v0.15.2) - 2026-09-24 + +### Fixed + +- *(relay)* fix the lease deadline on tokio's clock and pause the outage test ([#3969](https://github.com/moq-dev/moq/pull/3969)) + ## [0.15.1](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.0...moq-relay-v0.15.1) - 2026-09-23 ### Fixed diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 84372ade1c..3d5c133a27 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.15.1" +version = "0.15.2" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target diff --git a/rs/moq-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index 6b209706e4..5aac36a967 100644 --- a/rs/moq-room/CHANGELOG.md +++ b/rs/moq-room/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2](https://github.com/moq-dev/moq/compare/moq-room-v0.2.1...moq-room-v0.2.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.1](https://github.com/moq-dev/moq/compare/moq-room-v0.2.0...moq-room-v0.2.1) - 2026-09-23 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index 71121853d5..65e6d6a525 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.1" +version = "0.2.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index b0f4c7b2d5..d626ce1f26 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.2](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.1...moq-rtc-v0.3.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.1](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.0...moq-rtc-v0.3.1) - 2026-09-23 ### Other diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 59de7aeb0f..83843ef6a8 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.1" +version = "0.3.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index 2e92e9388b..baffca9e9d 100644 --- a/rs/moq-rtmp/CHANGELOG.md +++ b/rs/moq-rtmp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.2](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.1...moq-rtmp-v0.3.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.1](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.0...moq-rtmp-v0.3.1) - 2026-09-23 ### Other diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 44a6dab4b3..81e577fab9 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/moq-dev/moq" # src/rml/LICENSE and applies to that module regardless of which option you pick. license = "MIT OR Apache-2.0" -version = "0.3.1" +version = "0.3.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index 48892a18bb..cfaca50794 100644 --- a/rs/moq-srt/CHANGELOG.md +++ b/rs/moq-srt/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.2](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.1...moq-srt-v0.3.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, moq-mux + ## [0.3.1](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.0...moq-srt-v0.3.1) - 2026-09-23 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 26ca13b9be..d2bbeb46a0 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.1" +version = "0.3.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index 7d0f801207..648d0957d4 100644 --- a/rs/moq-stats/CHANGELOG.md +++ b/rs/moq-stats/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.1...moq-stats-v0.2.2) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.1](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.0...moq-stats-v0.2.1) - 2026-09-23 ### Other diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index b3d0600285..e7e73b60c8 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.1" +version = "0.2.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index 934839d8a1..24b14b662b 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.13](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.12...moq-tokio-v0.19.13) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net + ## [0.19.12](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.11...moq-tokio-v0.19.12) - 2026-09-23 ### Other diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 11442ac8ba..fc1c18a516 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.12" +version = "0.19.13" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index c4029f7d91..c1823056c1 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.0...moq-transcode-v0.1.1) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net, moq-video, hang, moq-mux + ## [0.0.21](https://github.com/moq-dev/moq/compare/moq-transcode-v0.0.20...moq-transcode-v0.0.21) - 2026-09-23 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index eafbea39d4..93498be324 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index 51a742faa2..94bcde13e8 100644 --- a/rs/moq-uring/CHANGELOG.md +++ b/rs/moq-uring/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.3](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.2...moq-uring-v0.0.3) - 2026-09-24 + +### Other + +- updated the following local packages: moq-net + ## [0.0.2](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.1...moq-uring-v0.0.2) - 2026-09-23 ### Fixed diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 4be6dd8895..a10bc20106 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.2" +version = "0.0.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 779968c0dc..a2dc35f1f1 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-video-v0.1.0...moq-video-v0.1.1) - 2026-09-24 + +### Added + +- *(moq-video)* one VAAPI render node for encode, decode and resize ([#4023](https://github.com/moq-dev/moq/pull/4023)) + ## [0.0.27](https://github.com/moq-dev/moq/compare/moq-video-v0.0.26...moq-video-v0.0.27) - 2026-09-23 ### Fixed diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 56a6d5c19e..e1b735dfc5 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true From d57810426dcacbf44e079f4189474eefcc453b6d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 13:37:28 -0700 Subject: [PATCH 08/85] build: ship the moq-cli crate as moq on Docker, Nix, and winget (#4030) Co-authored-by: Claude Opus 5.5 --- .github/workflows/cachix.yml | 6 ++-- .github/workflows/docker.yml | 45 +++++++++++++++++++--------- .github/workflows/release-winget.yml | 2 +- Dockerfile | 5 ++-- doc/setup/install.md | 14 +++++---- flake.nix | 14 +++++++-- rs/moq-cli/README.md | 4 +-- 7 files changed, 60 insertions(+), 30 deletions(-) diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 34f7198821..7b54d5f2e7 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -11,7 +11,7 @@ on: jobs: # Build and push only the package the tag names. The tag prefix - # (e.g. moq-relay) maps 1:1 to a flake attribute. + # (e.g. moq-relay) is the flake attribute, except moq-cli ships as moq. release: name: Release (${{ matrix.os }}) runs-on: ${{ matrix.runs-on }} @@ -48,7 +48,9 @@ jobs: echo "Tag format not recognized: $REF_NAME" >&2 exit 1 fi - echo "name=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + name="${BASH_REMATCH[1]}" + [[ "$name" == moq-cli ]] && name=moq + echo "name=${name}" >> "$GITHUB_OUTPUT" - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2a3c3df447..fc6748864b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,13 +11,13 @@ on: workflow_call: inputs: package: - description: "Flake package to build (moq-relay, moq-cli)" + description: "Crate to build (moq-relay, moq-cli)" required: true type: string workflow_dispatch: inputs: package: - description: "Flake package to build (moq-relay, moq-cli)" + description: "Crate to build (moq-relay, moq-cli)" required: true type: string default: moq-relay @@ -32,7 +32,7 @@ jobs: permissions: contents: read outputs: - target: ${{ steps.parse.outputs.target }} + package: ${{ steps.parse.outputs.package }} version: ${{ steps.parse.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -41,19 +41,24 @@ jobs: - id: parse env: - PACKAGE: ${{ inputs.package }} + CRATE: ${{ inputs.package }} run: | ref=${GITHUB_REF#refs/tags/} - if [[ -n "$PACKAGE" ]]; then - target="$PACKAGE" + if [[ -n "$CRATE" ]]; then + crate="$CRATE" elif [[ "$ref" =~ ^([a-z-]+)-v[0-9.]+$ ]]; then - target="${BASH_REMATCH[1]}" + crate="${BASH_REMATCH[1]}" else echo "Tag format not recognized." >&2 exit 1 fi - echo "target=${target}" >> "$GITHUB_OUTPUT" - .github/scripts/release.sh parse-version "$target" + # The flake package and image are named after the executable. + case "$crate" in + moq-cli) package=moq ;; + *) package="$crate" ;; + esac + echo "package=${package}" >> "$GITHUB_OUTPUT" + .github/scripts/release.sh parse-version "$crate" build: name: Build ${{ matrix.platform }} @@ -89,8 +94,8 @@ jobs: context: . platforms: ${{ matrix.platform }} build-args: | - package=${{ needs.parse.outputs.target }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.parse.outputs.target }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' }} + package=${{ needs.parse.outputs.package }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.parse.outputs.package }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' }} - name: Export digest if: github.ref_type == 'tag' @@ -132,6 +137,18 @@ jobs: run: | # shellcheck disable=SC2046 # intentional word-splitting: one arg per digest docker buildx imagetools create \ - -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}:${{ needs.parse.outputs.version }} \ - -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}:latest \ - $(printf "${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}@sha256:%s " *) + -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.package }}:${{ needs.parse.outputs.version }} \ + -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.package }}:latest \ + $(printf "${{ env.REGISTRY }}/${{ needs.parse.outputs.package }}@sha256:%s " *) + + # The image was moqdev/moq-cli through 0.12.2. Point `latest` there at a + # refusal naming the new image, so a user tracking it breaks instead of + # silently staying on 0.12.2. Pinned versions keep working. + - name: Refuse the old image name + if: needs.parse.outputs.package == 'moq' + run: | + docker buildx build --platform linux/amd64,linux/arm64 --push \ + -t ${{ env.REGISTRY }}/moq-cli:latest - <<'DOCKERFILE' + FROM busybox:stable + ENTRYPOINT ["sh", "-c", "echo 'error: moqdev/moq-cli is now moqdev/moq' >&2; exit 1"] + DOCKERFILE diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml index 4b0e62aba6..7e069b78d8 100644 --- a/.github/workflows/release-winget.yml +++ b/.github/workflows/release-winget.yml @@ -86,7 +86,7 @@ jobs: crate="${BASH_REMATCH[1]}" version="${BASH_REMATCH[2]}" case "$crate" in - moq-cli) identifier="moq-dev.moq-cli" ;; + moq-cli) identifier="moq-dev.moq" ;; moq-relay) identifier="moq-dev.moq-relay" ;; *) echo "No winget identifier mapped for crate: $crate" >&2 diff --git a/Dockerfile b/Dockerfile index c77df07b7a..e7626dbdfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,8 @@ RUN --mount=type=cache,target=/root/.cache --mount=type=cache,target=/nix,from=n ARG package="sh" # Create entry.sh script that knows which binary to run. Derive it from the -# single binary the package produced (so a crate whose `[[bin]]` name differs -# from its package name, e.g. `moq-cli` shipping as `moq`, just works); fall -# back to the package name when there's no binary (the `sh` default). +# single binary the package produced, so a package need not share its binary's +# name; fall back to the package name when there's no binary (the `sh` default). RUN binary="$(ls /output/result/bin 2>/dev/null | head -n1)"; \ [ -n "$binary" ] || binary="${package}"; \ printf '#!/bin/sh\nexec /bin/%s "$@"\n' "${binary}" > /output/entry.sh && \ diff --git a/doc/setup/install.md b/doc/setup/install.md index 7402b3fa6e..175687d6ef 100644 --- a/doc/setup/install.md +++ b/doc/setup/install.md @@ -14,10 +14,12 @@ Two binaries and two plugins ship prebuilt: | GStreamer plugin | `moqsink`, `moqsrc` | [GStreamer](/bin/gstreamer) elements | | OBS plugin | | [OBS Studio](/bin/obs) output and source | -Homebrew and Linux package names match the executables. Cargo crates and -Windows package IDs retain `moq-cli`. Existing Homebrew +Package names match the executables everywhere except crates.io, where the +crate is `moq-cli`. Existing Homebrew installs migrate through formula renames; apt upgrades use transitional -packages, and dnf replaces the old packages. +packages, and dnf replaces the old packages. The `moqdev/moq-cli` Docker image +and the `#moq-cli` flake package stop at 0.12.2 and now exit with an error +naming `moqdev/moq` and `#moq`. Use `moq auth` for keys and tokens; installing `moq` includes it. @@ -32,11 +34,11 @@ brew install moq-dev/tap/moq-relay moq-dev/tap/moq # Nix (pin a release tag to use the binary cache) nix run github:moq-dev/moq#moq-relay -- relay.toml -nix run github:moq-dev/moq#moq-cli -- --help +nix run github:moq-dev/moq#moq -- --help # Docker (linux/amd64 and linux/arm64) docker run -p 4443:4443/udp -p 4443:4443/tcp -v "$PWD/relay.toml:/app/relay.toml:ro" moqdev/moq-relay /app/relay.toml -docker run -i moqdev/moq-cli --help +docker run -i moqdev/moq --help ``` Static binaries for Linux (x86\_64, aarch64), macOS (Apple Silicon), and Windows @@ -87,7 +89,7 @@ works without root, and config edits survive upgrades. ```powershell winget install moq-dev.moq-relay -winget install moq-dev.moq-cli +winget install moq-dev.moq ``` The OBS plugin ships as a zip for Windows x64 and macOS arm64 on the diff --git a/flake.nix b/flake.nix index 48971bef39..9cee6d70e4 100644 --- a/flake.nix +++ b/flake.nix @@ -419,14 +419,24 @@ name = "moq-all"; paths = [ moq-relay - moq-cli + moq ]; }; + # Named after the executable. The overlay keeps `moq-cli` because + # nixpkgs already has an unrelated `moq`. + moq = overlayPkgs.moq-cli; + + # The package was `moq-cli` through 0.12.2. Refuse with the new name + # so `nix run` and `nix profile upgrade` break instead of going stale. + moq-cli = pkgs.writeShellScriptBin "moq" '' + echo "error: the moq-cli package is now moq: nix run github:moq-dev/moq#moq" >&2 + exit 1 + ''; + # Inherit packages from the overlay inherit (overlayPkgs) moq-relay - moq-cli moq-bench moq-boy libmoq diff --git a/rs/moq-cli/README.md b/rs/moq-cli/README.md index fa30ae44de..de0241a778 100644 --- a/rs/moq-cli/README.md +++ b/rs/moq-cli/README.md @@ -12,10 +12,10 @@ cargo install moq-cli ### Docker ```bash -docker pull moqdev/moq-cli +docker pull moqdev/moq ``` -Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-cli). +Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq). ## Usage From 2296ee5611e96a1c7dff788df61157ed49eb2102 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 13:44:13 -0700 Subject: [PATCH 09/85] ci: stop paying for low-signal test runs (#4048) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .config/nextest.toml | 5 +++++ .github/workflows/check.yml | 3 --- justfile | 1 + rs/justfile | 39 +++++++++++++++++++++++++++++++++---- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index c2abb88767..65691871bd 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -19,6 +19,11 @@ slow-timeout = { period = "30s", terminate-after = 2 } # race until proven otherwise (see Root Cause First in CLAUDE.md). retries = 0 +# `--all-targets` still compiles the criterion benches, which is what catches a +# bench that no longer builds. Running them in test mode only replays the +# workload once, and the large origin fan-outs take ~20s each for no signal. +default-filter = "not kind(bench)" + # CI has noisier neighbours and cold caches, so give a test longer before # calling it wedged. [profile.ci] diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 80b14d0090..a1b03fd00c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -81,9 +81,6 @@ jobs: env: MOQ_STRICT: 1 - - name: Media feature contracts - run: nix develop --command just rs media-features - test: name: Test if: github.event.action != 'closed' diff --git a/justfile b/justfile index a48833cafe..ad3ed7e431 100644 --- a/justfile +++ b/justfile @@ -510,6 +510,7 @@ check $BASE="" *args: just drafts check just rs check --workspace --exclude moq-net-fuzz {{ args }} just rs tokio-features + just rs media-features just --justfile bench/justfile check cargo run --quiet --locked --package quest -- check # Not covered by the line above: moq-wasm only exists on the wasm32 target. diff --git a/rs/justfile b/rs/justfile index dba4cbbf3d..0f5f2985cd 100644 --- a/rs/justfile +++ b/rs/justfile @@ -279,6 +279,16 @@ _wants-uring $PACKAGES: set -euo pipefail grep -qw 'moq-relay' <<< "$(just rs _names "$PACKAGES")" +# True when the media feature contracts have to run. The contract script is not +# a crate, so `_select` drops it; a diff that only edits the script must still +# reach the gate, or a broken command merges on shell lint alone. +[private] +_wants-media-features $PACKAGES $FILES: + #!/usr/bin/env bash + set -euo pipefail + grep -qwE '(moq-video|moq-audio|moq-transcode)' <<< "$(just rs _names "$PACKAGES")" \ + || grep -qx 'rs/scripts/media-features.sh' <<< "$FILES" + # Print the crate names behind a list of `--package` flags. [private] _names $PACKAGES: @@ -351,6 +361,24 @@ _select-test: [[ -z "$(just rs _select "rs/scripts/package-binary.sh")" ]] \ || fail "a non-crate seed must select nothing" + # The contract script is that same kind of seed, so selection stays empty. + # The gate still has to run it: this is the diff that can break the commands. + [[ -z "$(just rs _select "rs/scripts/media-features.sh")" ]] \ + || fail "the media contract script must not select a crate" + just rs _wants-media-features "" "rs/scripts/media-features.sh" \ + || fail "a media-features.sh diff must drive the contract" + if just rs _wants-media-features "" "rs/scripts/package-binary.sh"; then + fail "an unrelated script must not drive the media contract" + fi + video=$(just rs _select "rs/moq-video/src/lib.rs") + just rs _wants-media-features "$video" "" \ + || fail "a moq-video diff must drive the media contract: $(just rs _names "$video")" + just rs _wants-media-features "--package file:///tmp/rs/moq-transcode#0" "" \ + || fail "moq-transcode alone must drive the media contract" + if just rs _wants-media-features "$relay" ""; then + fail "a moq-relay diff must not drive the media contract: $(just rs _names "$relay")" + fi + # A crate nested inside another crate's directory is covered by its parent's # seed. Selecting it directly would compile libFuzzer on every moq-net diff, # and its directory name is not its crate name, so it would also mis-report. @@ -456,6 +484,13 @@ check-changed $FILES: just rs uring-check fi + # Each media feature shape compiles on its own (see rs/scripts/media-features.sh), + # about two minutes, so only a diff that reaches the media crates, or the + # contract script itself, pays for it. + if [[ "$packages" == "ALL" ]] || just rs _wants-media-features "$packages" "$FILES"; then + just rs media-features + fi + # Compile moq-tokio by itself at the feature extremes and with each crypto provider. # Its default-feature build is already part of the ordinary clippy pass above. tokio-features: @@ -813,10 +848,6 @@ test-changed $FILES: *) echo "rs: testing $(just rs _names "$packages")"; just rs test --no-tests=pass $packages ;; esac - if [[ "$packages" == "ALL" ]] || just rs _names "$packages" | grep -qw moq-relay; then - just rs relay-minimal - fi - # Compile and run the `/// ```` examples, which nextest skips. doctest *args: cargo test --locked --doc {{ args }} From b51e49baec295ebb079e569205b0d7c4c2e9d4d6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 14:23:23 -0700 Subject: [PATCH 10/85] docs(drafts): delete moq-pattern, fold request resolution into moq-cluster (#4066) Co-authored-by: Claude Opus 5.5 --- drafts/draft-lcurley-moq-cluster.md | 43 ++++++++----- drafts/draft-lcurley-moq-lite.md | 11 +--- drafts/draft-lcurley-moq-pattern.md | 98 ----------------------------- quest/m1/wildcard/README.md | 7 ++- 4 files changed, 34 insertions(+), 125 deletions(-) delete mode 100644 drafts/draft-lcurley-moq-pattern.md diff --git a/drafts/draft-lcurley-moq-cluster.md b/drafts/draft-lcurley-moq-cluster.md index 1119cde7e2..912be90e32 100644 --- a/drafts/draft-lcurley-moq-cluster.md +++ b/drafts/draft-lcurley-moq-cluster.md @@ -18,14 +18,6 @@ author: normative: moqt: I-D.ietf-moq-transport - I-D.lcurley-moq-pattern: - title: "MoQ Pattern Extension" - target: https://datatracker.ietf.org/doc/draft-lcurley-moq-pattern/ - author: - - - ins: L. Curley - name: Luke Curley - date: false informative: @@ -160,7 +152,7 @@ An endpoint MUST NOT append the block when nothing negotiated it, and MUST NOT i NAMESPACE_DONE ({{moqt}} Section 10.17) carries no state from this extension. An advertisement claims capability, not inventory: namespaces beneath the advertised one can be served, not that any exists. -Per-request refusals follow {{I-D.lcurley-moq-pattern}}. +A publisher that serves only some of them advertises the covering namespace and refuses the requests it will not serve ({{selection}}). ## HOP_PATH Parameter {#hop-path} HOP_PATH is the ordered list of Hop IDs an advertisement has passed through, from the original publisher to the peer sending it: @@ -243,13 +235,19 @@ The expected update is a ROUTE_COST change, which is how a relay signals that it # Path Selection {#selection} -A receiver resolving a request consults only the most specific advertisements covering it: the longest prefix. +A receiver resolving a SUBSCRIBE, FETCH, or track-status request consults only the most specific advertisements covering it: the longest prefix. +A refusal never falls through to a less specific tier. Within that tier, a receiver SHOULD prefer a HOP_PATH that contains no 0 entry over one that does, then the lowest ROUTE_COST, breaking ties toward the shorter HOP_PATH and then toward the most recently received. This is advisory: a receiver MAY apply local policy, such as measured RTT, instead. -NO_CAPACITY and its single re-resolution are defined by {{I-D.lcurley-moq-pattern}}. -Excluding the refusing advertiser excludes every route with its non-zero first Hop ID, or its session when that ID is 0. +NO_CAPACITY ({{iana}}) refuses a request the publisher could serve but has no capacity for now. +It permits ONE re-resolution within the same tier, excluding the refusing advertiser: every route with its non-zero first Hop ID, or its session when that ID is 0. +A receiver that has spent its retry, or has no other candidate, MUST refuse downstream with a code other than NO_CAPACITY, so retries cannot compound hop by hop. +Every other refusal, including an unrecognized code, is terminal. +A receiver SHOULD NOT cache refusals. + +A relay MUST NOT advertise a namespace merely because it resolved it: the covering advertisement stays the only one until the publisher advertises the concrete namespace, which it SHOULD do once producing, so a later request finds the running content by its exact namespace instead of resolving a second producer. Two advertisements whose HOP_PATH begins with the same non-zero Hop ID come from the same publisher and carry interchangeable content: a receiver MAY hold them as redundant paths and fail an active subscription over to the survivor at a group boundary. If the first entries differ, or either is 0, they are distinct publishers reusing a namespace ({{publishers}}). @@ -267,7 +265,8 @@ One rule for advertisement and dispatch keeps advertised paths truthful and prev {{moqt}} lets several publishers advertise one namespace and leaves to the relay how it serves a SUBSCRIBE among them. Under this extension an advertisement is a path, so a session advertises a namespace at most once, a relay forwards only the best path it knows ({{selection}}), and a subscription is served from one source at a time. -A receiver MAY still hold paths to several publishers of one namespace and choose between them as it sees fit: serve from the cheapest and move to the next when it fails or refuses the request, or try each in cost order until one accepts. +A receiver MAY still hold paths to several publishers of one namespace and choose between them as it sees fit: serve from the cheapest and move to the next when it fails. +A refusal moves to another publisher only as {{selection}} allows: once, and only for NO_CAPACITY. The advertised path and the served source stay the same publisher: a relay that moves to another MUST withdraw its advertisement and advertise the new path ({{updating}}), so the first Hop ID downstream always names the publisher whose Objects flow. Moving between distinct publishers is a discontinuity: their groups are not one sequence, so a subscriber sees an unrelated Location, and a FETCH that succeeds against one may fail against the other. @@ -284,10 +283,12 @@ Because a relay only appends to HOP_PATH, it cannot make a competing path look s ROUTE_COST has no such protection: it is a single value the sender chooses, so a relay can advertise 0 for content it is not carrying and attract subscriptions it then has to fetch. Both cost only a suboptimal path choice, and the latter is self-limiting, since the traffic won this way must then be served. +Implementations SHOULD bound the work started by requests beneath a broad advertisement, using NO_CAPACITY when capacity is exhausted. + A receiver MUST NOT make security decisions based on Hop IDs, and a deployment spanning a trust boundary SHOULD treat a peer's ROUTE_COST as a hint to clamp or ignore rather than an accounting figure. -# IANA Considerations +# IANA Considerations {#iana} This document requests the following registrations. High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions. @@ -313,11 +314,23 @@ Both are carried in PUBLISH_NAMESPACE, in REQUEST_UPDATE of a PUBLISH_NAMESPACE The Key-Value-Pair parity is load-bearing: HOP_PATH is odd, so its value is a length-prefixed byte string, while HOP_ID, RELAY_COST, and ROUTE_COST are even, so their values are bare varints. +## MOQT Error Codes + +This document requests one registration in the "REQUEST_ERROR Codes" registry. + +| Value | Name | Reference | +|:--------|:------------|:--------------| +| 0x40B5A | NO_CAPACITY | This Document | + --- back # Appendix A: Changelog +## moq-cluster-02 +- Defined request resolution against the longest covering prefix and the NO_CAPACITY refusal with its single re-resolution; any other refusal is terminal, including between several publishers of one namespace. +- A relay does not advertise a namespace because it resolved it; the publisher advertises the concrete namespace once producing. + ## moq-cluster-01 - Assigned identities are local selection state and MUST NOT be forwarded. - Bridging an upstream that sent no HOP_PATH writes 0 for that hop; a received 0 is forwarded unchanged. @@ -326,4 +339,4 @@ The Key-Value-Pair parity is load-bearing: HOP_PATH is odd, so its value is a le - A PUBLISH_NAMESPACE is updated with REQUEST_UPDATE on its request stream instead of a repeated PUBLISH_NAMESPACE; HOP_PATH and ROUTE_COST are registered for REQUEST_UPDATE. A NAMESPACE is still re-sent on its stream. - A session advertises a namespace at most once and a subscription is served from one source at a time. A receiver chooses among several publishers of one namespace; moving between them is a discontinuity unless they share a Hop ID. - Named the routing protocols whose single per-direction metric RELAY_COST follows. -- Path selection consults the most specific advertisement first, the longest prefix; an advertisement is always a prefix, and a request beneath it that the advertiser will not serve is refused ({{I-D.lcurley-moq-pattern}}). Standby seeds are bounded by deployment limits. +- Path selection consults the most specific advertisement first, the longest prefix; an advertisement is always a prefix, and a request beneath it that the advertiser will not serve is refused. Standby seeds are bounded by deployment limits. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index c86b6bcc46..dcac539e93 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -18,14 +18,6 @@ author: normative: moqt: I-D.ietf-moq-transport - I-D.lcurley-moq-pattern: - title: "MoQ Pattern Extension" - target: https://datatracker.ietf.org/doc/draft-lcurley-moq-pattern/ - author: - - - ins: L. Curley - name: Luke Curley - date: false qmux: I-D.ietf-quic-qmux qmuxws: title: "QMux over WebSocket" @@ -40,6 +32,7 @@ normative: RFC9002: informative: + I-D.lcurley-moq-cluster: --- abstract @@ -312,7 +305,7 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST | ------- | ------------- | ----------- | | 0x12 | MALFORMED_TRACK | The track's content could not be parsed. | | ------- | ------------- | ----------- | -| 0x30 | NO_CAPACITY | The publisher could serve this request but has no capacity for it now. Permits one re-resolution (see [Resolution](#resolution)); elsewhere it is terminal like any refusal. Bridges to NO_CAPACITY in {{I-D.lcurley-moq-pattern}}. | +| 0x30 | NO_CAPACITY | The publisher could serve this request but has no capacity for it now. Permits one re-resolution (see [Resolution](#resolution)); elsewhere it is terminal like any refusal. Bridges to NO_CAPACITY in {{I-D.lcurley-moq-cluster}}. | | ------- | ------------- | ----------- | | 0x31 | CONTROL_TIMEOUT | The peer took too long to answer a control request. Distinct from DELIVERY_TIMEOUT, which is content that missed its deadline; it has no moq-transport value and bridges to INTERNAL_ERROR. | | ------- | ------------- | ----------- | diff --git a/drafts/draft-lcurley-moq-pattern.md b/drafts/draft-lcurley-moq-pattern.md deleted file mode 100644 index 2f6a1dd127..0000000000 --- a/drafts/draft-lcurley-moq-pattern.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: "MoQ Pattern Extension" -abbrev: "moq-pattern" -category: info - -docname: draft-lcurley-moq-pattern-latest -submissiontype: IETF -number: -date: -v: 3 -area: wit -workgroup: moq - -author: - - - fullname: Luke Curley - email: kixelated@gmail.com - -normative: - moqt: I-D.ietf-moq-transport - -informative: - ---- abstract - -This document defines namespace patterns for MoQ Transport {{moqt}}: the matching and authorization rules an authorization token and a local filter share. -Advertisements stay literal prefixes; a pattern narrows what an endpoint may publish, subscribe to, or is told about, and never travels as an advertisement of its own. - ---- note_Note_to_Readers - -This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. -Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. - ---- middle - -# Conventions and Definitions -{::boilerplate bcp14-tagged} - -A pattern is a sequence of segments over a namespace's tuple fields. -A path-based protocol can use the same matching semantics by treating each path segment as one field. - -# Pattern Semantics {#patterns} - -Each segment is a literal matching one field exactly, a wildcard matching any one field, a partial matching one field with a known prefix and suffix without overlap, or a globstar matching zero or more fields. -A pattern contains at most one globstar. -A namespace matches when its fields can be assigned to the segments in order, the globstar taking any number of them. -Without a globstar, the number of fields must equal the number of segments. -A pattern is exact; a subtree is a literal prefix followed by a globstar. - -A pattern is never advertised. -An advertisement is a literal prefix, a route claiming that every namespace beneath it can be served; it claims capability, not inventory, and never asserts that any namespace exists. -A publisher that serves only some of the namespaces beneath a prefix, such as an archive or an on-demand processor, advertises the covering prefix and refuses the requests it will not serve as specified in {{resolution}}. -No message narrows an advertisement. - -## Interest and Filtering {#interest} - -A subscriber interested in the namespaces matching a pattern sends SUBSCRIBE_NAMESPACE for the pattern's literal head, the literal fields before its first wildcard, and filters the advertisements it receives locally: an advertisement is relevant when some namespace beneath its prefix matches the pattern. -A pattern whose head is empty subscribes to every advertisement. -What each wildcard stood for is derived from the advertised prefix against the pattern, not carried on the wire. - -## Authorization {#authorization} - -An authorization scope is a set of patterns. -A receiver MUST discard an advertisement whose prefix is disjoint from every pattern the sender may publish under, meaning no namespace beneath the prefix matches. -An advertisement is a hint and a request is the authority: an endpoint MUST refuse a request for a namespace outside the requester's scope, and MUST refuse one outside its own, whatever it advertised. -How a scope is expressed and exchanged is out of scope. - -# Request Resolution {#resolution} - -A SUBSCRIBE, FETCH, or track-status request is resolved against the advertisements covering its requested namespace. -Only the most specific covering tier is consulted: the advertisements with the longest prefix. -A refusal never falls through to a less specific tier. -Selection within the tier is local policy or the policy of another negotiated extension, such as clustering; this document requires neither a cost nor a Hop ID. - -NO_CAPACITY ({{iana}}) refuses a request the publisher could serve but has no capacity for now. -It permits ONE re-resolution within the same tier, excluding the refusing advertiser, identified by its incoming session unless another extension supplies an origin identity. -A receiver that has spent its retry, or has no other candidate, MUST refuse downstream with a code other than NO_CAPACITY, so retries cannot compound hop by hop. -Every other refusal, including an unrecognized code, is terminal. -A receiver SHOULD NOT cache refusals. -A relay MUST NOT advertise a namespace merely because it resolved it; the advertiser SHOULD announce the concrete namespace once producing it. - -# Security Considerations - -A pattern may cover an arbitrarily large set of namespaces, but does not authorize access to any of them; publishers MUST authorize each content request ({{authorization}}). -A pattern with an empty literal head subscribes to every advertisement and filters locally; implementations SHOULD bound the advertisements a session may receive. -Implementations SHOULD bound the work started by requests beneath a broad advertisement, using NO_CAPACITY when capacity is exhausted. - -# IANA Considerations {#iana} - -This document requests the following registration in the {{moqt}} registries. - -## MOQT Error Codes - -| Value | Name | Registry | Reference | -|:--------|:------------|:--------------------|:--------------| -| 0x40B5A | NO_CAPACITY | REQUEST_ERROR Codes | This Document | - ---- back diff --git a/quest/m1/wildcard/README.md b/quest/m1/wildcard/README.md index 9a07b0a66a..e7be970d69 100644 --- a/quest/m1/wildcard/README.md +++ b/quest/m1/wildcard/README.md @@ -189,11 +189,12 @@ field. declare two workers' output interchangeable and splice between them; a service that needs that guarantee has to carry it in its own media contract, not in routing. -- **Patterns are independent of clustering.** `draft-lcurley-moq-pattern` - owns the matching and authorization semantics tokens and filters share; +- **Patterns are independent of clustering.** The `moq-pattern` crate owns + the matching semantics tokens and filters share, with no draft of its own; no announce message carries a pattern on either protocol (AUTH grants on lite-06 do, per [Path patterns](/quest/m1/path-patterns.md)). moq-cluster adds hop - lists, costs, and pool selection to prefix advertisements. + lists, costs, pool selection, and request resolution to prefix + advertisements. ### Where derived output lives From cff7c7cc5e6b827057570cdba6f1aa4d6f2412a5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 14:23:51 -0700 Subject: [PATCH 11/85] fix(moq-hls): align cursor discontinuities across renditions (#4065) Co-authored-by: Claude Opus 5.5 --- rs/moq-hls/src/export/rendition.rs | 12 +-- rs/moq-hls/src/export/segments.rs | 160 +++++++++++++++++------------ 2 files changed, 97 insertions(+), 75 deletions(-) diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 862429c8c4..c07a0439a3 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -318,6 +318,7 @@ impl Rendition { duration: entry.duration, pts: entry.pts, end: Duration::from(entry.pts) + entry.duration, + discontinuity: 0, }; self.live.push(row, window); } @@ -407,19 +408,12 @@ impl Rendition { let program_date_time = window.segments.first().and_then(|first| self.wall_clock(first.pts)); - // A jump in content time between consecutive rows is a discontinuity: the next segment - // doesn't continue the previous one's timeline. Tolerate sub-millisecond drift from - // timescale rounding. - let mut previous_end: Option = None; + let mut previous: Option = None; let segments = window .segments .into_iter() .map(|s| { - let discontinuity = previous_end.is_some_and(|end| { - let start = Duration::from(s.pts); - start.saturating_sub(end).max(end.saturating_sub(start)) > Duration::from_millis(1) - }); - previous_end = Some(s.end); + let discontinuity = previous.replace(s.discontinuity).is_some_and(|p| p != s.discontinuity); Segment { segment: s.segment, duration: s.duration, diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index 2a5a14488a..96648583f5 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -44,6 +44,13 @@ struct State { sequence: u64, /// The timeline track ended: the broadcast is over (`EXT-X-ENDLIST`). ended: bool, + /// The discontinuity sequence stamped onto the next row, bumped whenever the content + /// timeline breaks. + discontinuity: u64, + /// The end of the last pushed row, to detect a jump even after its row left the window. + last_end: Option, + /// The rows were cleared, so the next row can't continue the previous timeline. + broken: bool, } /// One playlist segment: its aligned number, timing, and this rendition's group ranges. @@ -63,6 +70,10 @@ pub(crate) struct Row { /// The segment's ending presentation timestamp (`pts + duration`), for window eviction /// and discontinuity detection. pub end: Duration, + /// The discontinuity sequence, assigned by [`Producer::push`]. It changes wherever the + /// content timeline breaks, identically on every rendition fed the same records, so + /// renditions mark the same breaks however many segments each one skipped. + pub discontinuity: u64, } /// A consistent read of the window, for rendering one playlist (the serve path only). @@ -79,10 +90,8 @@ pub(crate) struct Window { /// The next segment a [`Consumer`] should emit, resolved from the window. enum Next { - /// A segment is ready to fetch, along with the segment number that follows it in the - /// window (`None` if it's the newest row). The successor lets a cursor notice a gap: if - /// the next segment it emits isn't this successor, rows were evicted unseen in between. - Ready { row: Row, successor: Option }, + /// A segment is ready to fetch. + Ready(Row), /// No further segment will ever appear (the timeline ended). Ended, /// Nothing new yet; wait for the next window change. @@ -106,16 +115,14 @@ impl State { /// window before the cursor reached them are skipped: the cursor resumes at the oldest /// row still in the window. fn next_after(&self, after: Option) -> Next { - let mut iter = self.rows.iter().enumerate().filter(|(_, r)| match after { + let next = self.rows.iter().find(|r| match after { Some(after) => r.segment > after, None => true, }); - let Some((index, row)) = iter.next() else { - return if self.ended { Next::Ended } else { Next::Pending }; - }; - Next::Ready { - row: row.clone(), - successor: self.rows.get(index + 1).map(|next| next.segment), + match next { + Some(row) => Next::Ready(row.clone()), + None if self.ended => Next::Ended, + None => Next::Pending, } } } @@ -128,12 +135,15 @@ impl Producer { rows: VecDeque::new(), sequence: 0, ended: false, + discontinuity: 0, + last_end: None, + broken: false, }), } } /// Append a row, evicting the front of the window past `window`. - pub fn push(&self, row: Row, window: Duration) { + pub fn push(&self, mut row: Row, window: Duration) { let Ok(mut state) = self.state.write() else { return; }; @@ -150,6 +160,18 @@ impl Producer { state.sequence = row.segment; } + // Tolerate sub-millisecond drift from timescale rounding. + let start = Duration::from(row.pts); + let jumped = state + .last_end + .is_some_and(|end| start.saturating_sub(end).max(end.saturating_sub(start)) > Duration::from_millis(1)); + if state.broken || jumped { + state.discontinuity += 1; + } + state.broken = false; + state.last_end = Some(row.end); + row.discontinuity = state.discontinuity; + state.rows.push_back(row); // Evict from the front while the remaining rows still cover the window. @@ -186,6 +208,7 @@ impl Producer { pub fn clear(&self) { if let Ok(mut state) = self.state.write() { state.rows.clear(); + state.broken = true; } } @@ -276,8 +299,7 @@ impl Producer { state: self.state.consume(), rendition, after: None, - expected: None, - gap: false, + emitted: None, } } } @@ -293,9 +315,10 @@ pub struct Segment { pub duration: Duration, /// Wall-clock start time, when the timeline advertises an anchor. pub program_date_time: Option, - /// The media timeline is broken before this segment: one or more segments were skipped - /// since the previous one (evicted from the window before they could be fetched, or gaps - /// with no content for this rendition). A recorder marks an `EXT-X-DISCONTINUITY` here. + /// The content timeline breaks before this segment (the source skipped or restarted), so a + /// recorder marks an `EXT-X-DISCONTINUITY` here. Every rendition marks the same breaks, as + /// HLS requires. Segments this cursor skipped (evicted, uncached, or gaps with no content + /// for this rendition) leave a hole on a continuous timeline, not a discontinuity. pub discontinuity: bool, } @@ -311,11 +334,8 @@ pub struct Consumer { /// advanced once a segment is fetched or skipped, so a transient fetch error re-tries the /// same segment on the next call instead of losing it. after: Option, - /// The successor segment recorded when the last one was emitted. If the next segment - /// isn't it, rows were evicted unseen in between (a gap). - expected: Option, - /// A gap opened since the last emitted segment (a skip); set on the next one's discontinuity. - gap: bool, + /// The discontinuity sequence of the last segment returned. + emitted: Option, } impl Consumer { @@ -328,47 +348,39 @@ impl Consumer { /// The next segment, with its media; `None` once the rendition ends. /// /// Waits for the next segment, then FETCHes and transmuxes its groups. A segment whose - /// groups already left the relay cache (or that is a gap for this rendition) is skipped - /// (this resumes at the next one, flagging [`Segment::discontinuity`]) rather than - /// surfaced as an error; a real fetch/transmux failure is returned, leaving the cursor to - /// retry it on the next call. + /// groups already left the relay cache (or that is a gap for this rendition) is skipped, + /// resuming at the next one, rather than surfaced as an error; a real fetch/transmux + /// failure is returned, leaving the cursor to retry it on the next call. pub async fn next(&mut self) -> Result> { loop { - let Some((row, successor)) = kio::wait(|waiter| self.poll_next(waiter)).await else { + let Some(row) = kio::wait(|waiter| self.poll_next(waiter)).await else { return Ok(None); }; - // A gap opened if the segment we're about to emit isn't the successor the previous - // one recorded (rows between them evicted unseen). - let gap = discontinuity(self.gap, self.after, self.expected, row.segment); - match self.rendition.segment(row.segment).await? { Some(media) => { - self.after = Some(row.segment); - self.expected = successor; - self.gap = false; return Ok(Some(Segment { segment: row.segment, media, duration: row.duration, program_date_time: self.rendition.wall_clock(row.pts), - discontinuity: gap, + discontinuity: self.emit(&row), })); } - // A gap for this rendition, or its groups aged out of the cache before we - // fetched them; skip to the next and carry the gap onto whichever segment we - // emit next. - None => { - self.after = Some(row.segment); - self.expected = successor; - self.gap = true; - } + None => self.after = Some(row.segment), } } } - fn poll_next(&self, waiter: &kio::Waiter) -> Poll)>> { + /// Advance past `row` as returned, reporting whether it starts a new discontinuity. + fn emit(&mut self, row: &Row) -> bool { + self.after = Some(row.segment); + let previous = self.emitted.replace(row.discontinuity); + previous.is_some_and(|previous| previous != row.discontinuity) + } + + fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { let poll = self.state.poll(waiter, |state| match state.next_after(self.after) { - Next::Ready { row, successor } => Poll::Ready(Some((row, successor))), + Next::Ready(row) => Poll::Ready(Some(row)), Next::Ended => Poll::Ready(None), Next::Pending => Poll::Pending, }); @@ -381,12 +393,6 @@ impl Consumer { } } -fn discontinuity(gap: bool, after: Option, expected: Option, segment: u64) -> bool { - gap || expected - .or_else(|| after.map(|after| after.saturating_add(1))) - .is_some_and(|expected| expected != segment) -} - #[cfg(test)] mod tests { use super::*; @@ -400,6 +406,7 @@ mod tests { duration: Duration::from_millis(duration_ms), pts, end: Duration::from(pts) + Duration::from_millis(duration_ms), + discontinuity: 0, } } @@ -474,12 +481,40 @@ mod tests { snapshot.segments.iter().map(|row| row.segment).collect::>(), vec![10] ); - assert!( - discontinuity(false, Some(4), None, snapshot.segments[0].segment), - "a cursor caught up before the skip marks the next segment discontinuous" + assert_eq!( + snapshot.segments[0].discontinuity, 1, + "the row after a skipped source range starts a new discontinuity" ); } + #[test] + fn a_skipped_row_on_a_continuous_timeline_is_not_a_discontinuity() { + let live = Producer::new(); + let window = Duration::from_secs(30); + for i in 0..3u64 { + live.push(row(i, i, i * 2_000, 2_000), window); + } + + // A cursor that emits segment 0 and skips segment 1 (uncached, or a gap for its + // rendition) must not mark segment 2: a sibling that fetched segment 1 wouldn't, and + // players require renditions to agree on discontinuities. + let snapshot = live.window(); + assert_eq!(snapshot.segments[0].discontinuity, snapshot.segments[2].discontinuity); + } + + #[test] + fn a_content_time_jump_starts_a_new_discontinuity() { + let live = Producer::new(); + let window = Duration::from_secs(30); + live.push(row(0, 0, 0, 2_000), window); + live.push(row(1, 1, 2_000, 2_000), window); + live.push(row(2, 2, 10_000, 2_000), window); + live.push(row(3, 3, 12_000, 2_000), window); + + let sequences: Vec<_> = live.window().segments.iter().map(|row| row.discontinuity).collect(); + assert_eq!(sequences, vec![0, 0, 1, 1]); + } + #[test] fn segment_ranges_and_gaps() { let live = Producer::new(); @@ -494,6 +529,7 @@ mod tests { duration: Duration::from_secs(1), pts: moq_net::Timestamp::from_millis(1_000).unwrap(), end: Duration::from_millis(2_000), + discontinuity: 0, }, window, ); @@ -531,20 +567,12 @@ mod tests { live.push(row(0, 0, 0, 2_000), window); live.push(row(1, 1, 2_000, 2_000), window); - let first = match live.state.read().next_after(None) { - Next::Ready { row, successor } => { - assert_eq!(successor, Some(1)); - row - } - _ => panic!("expected a segment"), + let Next::Ready(first) = live.state.read().next_after(None) else { + panic!("expected a segment"); }; assert_eq!(first.segment, 0); - let second = match live.state.read().next_after(Some(0)) { - Next::Ready { row, successor } => { - assert_eq!(successor, None); - row - } - _ => panic!("expected a segment"), + let Next::Ready(second) = live.state.read().next_after(Some(0)) else { + panic!("expected a segment"); }; assert_eq!(second.segment, 1); assert!( From a8c9d71bc57b1bba39451ef1c7906779083a6296 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 14:33:16 -0700 Subject: [PATCH 12/85] perf(moq-json): track the window decode path only to explain a failure (#4028) Co-authored-by: Claude Opus 5.5 --- rs/moq-json/Cargo.toml | 4 + rs/moq-json/benches/window.rs | 141 ++++++++++++++++++++++++ rs/moq-json/src/window/decoder.rs | 18 ++- rs/moq-json/tests/window_allocations.rs | 129 ++++++++++++++++++++++ 4 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 rs/moq-json/benches/window.rs create mode 100644 rs/moq-json/tests/window_allocations.rs diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 75dffd6f53..720c37bf1c 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -34,3 +34,7 @@ harness = false [[bench]] name = "allocations" harness = false + +[[bench]] +name = "window" +harness = false diff --git a/rs/moq-json/benches/window.rs b/rs/moq-json/benches/window.rs new file mode 100644 index 0000000000..e406cbc694 --- /dev/null +++ b/rs/moq-json/benches/window.rs @@ -0,0 +1,141 @@ +//! Cost of decoding window frames into typed records, swept over records per header. +//! +//! Each run decodes one group header holding `records` records, then `records` push frames. The +//! frames are built outside the measured section. +//! +//! Run `cargo bench -p moq-json --bench window` and compare the table across revisions. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use moq_json::window::{ConsumerConfig, Decoder}; + +struct Counter; + +static COUNTING: AtomicBool = AtomicBool::new(false); +static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +/// A stats-shaped record: flat counters plus one nested object, nothing owned on the heap. +#[derive(serde::Deserialize)] +#[allow(dead_code)] +struct Record { + bytes: u64, + frames: u64, + groups: u64, + subscriptions: u64, + nested: Nested, +} + +#[derive(serde::Deserialize)] +#[allow(dead_code)] +struct Nested { + bytes: u64, + frames: u64, +} + +/// Runs measured per configuration. +const RUNS: u32 = 16; + +fn record(index: usize) -> serde_json::Value { + serde_json::json!({ + "bytes": index * 1500, + "frames": index, + "groups": index / 30, + "subscriptions": 3, + "nested": { "bytes": index * 1200, "frames": index }, + }) +} + +fn measure(f: impl FnOnce() -> R) -> (usize, Duration) { + ALLOCATIONS.store(0, Ordering::Relaxed); + COUNTING.store(true, Ordering::Relaxed); + let start = Instant::now(); + let out = f(); + let elapsed = start.elapsed(); + COUNTING.store(false, Ordering::Relaxed); + drop(black_box(out)); + (ALLOCATIONS.load(Ordering::Relaxed), elapsed) +} + +/// Mean allocations and time for the header, and per push. +fn run(records: usize) -> ((usize, Duration), (usize, Duration)) { + let header = serde_json::to_vec(&serde_json::json!({ + "offset": 0, + "records": (0..records).map(record).collect::>(), + })) + .unwrap(); + let pushes: Vec<_> = (records..2 * records) + .map(|index| serde_json::to_vec(&serde_json::json!({ "push": record(index) })).unwrap()) + .collect(); + + let (mut header_allocs, mut header_time) = (0, Duration::ZERO); + let (mut push_allocs, mut push_time) = (0, Duration::ZERO); + for _ in 0..RUNS { + let mut decoder = Decoder::::new(ConsumerConfig::default()); + let mut group = decoder.group(); + + let (allocs, time) = measure(|| group.decode(&header).unwrap()); + header_allocs += allocs; + header_time += time; + + let (allocs, time) = measure(|| { + for push in &pushes { + group.decode(push).unwrap(); + } + }); + push_allocs += allocs; + push_time += time; + } + + let pushes = RUNS * records as u32; + ( + (header_allocs / RUNS as usize, header_time / RUNS), + (push_allocs / pushes as usize, push_time / pushes), + ) +} + +fn main() { + // Nextest lists all targets as potential test binaries. + if std::env::args().any(|arg| arg == "--list") { + return; + } + println!("records header_allocs header_us push_allocs push_ns"); + for records in [1, 16, 256, 4096] { + let ((header_allocs, header_time), (push_allocs, push_time)) = run(records); + let header_us = header_time.as_secs_f64() * 1e6; + let push_ns = push_time.as_nanos(); + println!("{records:>7} {header_allocs:>13} {header_us:>9.1} {push_allocs:>11} {push_ns:>7}"); + } +} diff --git a/rs/moq-json/src/window/decoder.rs b/rs/moq-json/src/window/decoder.rs index 7518f15123..429d534954 100644 --- a/rs/moq-json/src/window/decoder.rs +++ b/rs/moq-json/src/window/decoder.rs @@ -161,16 +161,13 @@ impl Decoder { let bytes = inflated.as_deref().unwrap_or(payload); if !group.positioned { - let header: Header = serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_slice(bytes)) - .map_err(|err| Error::Json(err.to_string()))?; + let header: Header = parse(bytes)?; self.apply_header(header.offset, header.start.unwrap_or(header.offset), header.records)?; group.positioned = true; return Ok(()); } - match serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_slice(bytes)) - .map_err(|err| Error::Json(err.to_string()))? - { + match parse(bytes)? { Op::Push(record) => self.apply_push(record), Op::Pop(count) => self.apply_pop(count), } @@ -292,6 +289,17 @@ impl Decoder { } } +/// Deserialize one frame, naming the JSON path of any failure. +fn parse(bytes: &[u8]) -> Result { + // Tracking the path allocates for every key walked, which dwarfed the decode itself, so it only + // runs again to explain a failure. + if let Ok(value) = T::deserialize(&mut serde_json::Deserializer::from_slice(bytes)) { + return Ok(value); + } + serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_slice(bytes)) + .map_err(|err| Error::Json(err.to_string())) +} + impl Group<'_, T> { /// Take the next event produced by this group's frames so far. pub fn next_event(&mut self) -> Option> { diff --git a/rs/moq-json/tests/window_allocations.rs b/rs/moq-json/tests/window_allocations.rs new file mode 100644 index 0000000000..590e1afd5a --- /dev/null +++ b/rs/moq-json/tests/window_allocations.rs @@ -0,0 +1,129 @@ +//! Decoding a window frame allocates only what the decoded records own. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use moq_json::window::{ConsumerConfig, Decoder, Event}; + +struct Counter; + +thread_local! { + // Per thread, so tests running alongside don't leak into the count. + static ALLOCATIONS: Cell> = const { Cell::new(None) }; +} + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +fn bump() { + // `try_with` because the allocator can run while the thread-local is being torn down. + let _ = ALLOCATIONS.try_with(|count| count.set(count.get().map(|n| n + 1))); +} + +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + bump(); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + bump(); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + bump(); + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +fn count(f: impl FnOnce() -> R) -> (R, usize) { + ALLOCATIONS.with(|count| count.set(Some(0))); + let out = f(); + let allocations = ALLOCATIONS.with(|count| count.take()).unwrap(); + (out, allocations) +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +struct Record { + bytes: u64, + frames: u64, + groups: u64, + nested: Nested, +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +struct Nested { + bytes: u64, + frames: u64, +} + +fn record(index: usize) -> serde_json::Value { + serde_json::json!({ "bytes": index, "frames": 2, "groups": 3, "nested": { "bytes": 4, "frames": 5 } }) +} + +/// The records own nothing on the heap, so a header costs only its `Vec`'s growth and a push costs +/// only the event queue's. Tracking the error path on every decode cost an allocation per key walked. +#[test] +fn decode_allocates_only_the_output() { + const RECORDS: usize = 1024; + + let header = serde_json::to_vec(&serde_json::json!({ + "offset": 0, + "records": (0..RECORDS).map(record).collect::>(), + })) + .unwrap(); + let pushes: Vec<_> = (RECORDS..2 * RECORDS) + .map(|index| serde_json::to_vec(&serde_json::json!({ "push": record(index) })).unwrap()) + .collect(); + + let mut decoder = Decoder::::new(ConsumerConfig::default()); + let mut group = decoder.group(); + + let ((), allocations) = count(|| group.decode(&header).unwrap()); + assert!( + allocations < 64, + "{allocations} allocations for a {RECORDS}-record header" + ); + + let ((), allocations) = count(|| { + for push in &pushes { + group.decode(push).unwrap(); + } + }); + assert!(allocations < 64, "{allocations} allocations for {RECORDS} pushes"); + + let mut events = std::iter::from_fn(|| group.next_event()); + assert!(matches!(events.next(), Some(Event::Push { index: 0, value }) if value.bytes == 0)); + assert!( + matches!(events.last(), Some(Event::Push { index, value }) if index == 2 * RECORDS as u64 - 1 && value.bytes == index) + ); +} + +/// A malformed record still names where it went wrong. +#[test] +fn decode_error_names_the_path() { + let mut decoder = Decoder::::new(ConsumerConfig::default()); + let mut group = decoder.group(); + + let header = serde_json::to_vec(&serde_json::json!({ + "offset": 0, + "records": [record(0), { "bytes": 1, "frames": 2, "groups": 3, "nested": { "bytes": "four", "frames": 5 } }], + })) + .unwrap(); + let err = group.decode(&header).unwrap_err().to_string(); + assert!(err.contains("records[1].nested.bytes"), "{err}"); + + group + .decode(&serde_json::to_vec(&serde_json::json!({ "offset": 0, "records": [] })).unwrap()) + .unwrap(); + let err = group + .decode(br#"{"push":{"bytes":1,"frames":"two","groups":3,"nested":{"bytes":4,"frames":5}}}"#) + .unwrap_err() + .to_string(); + assert!(err.contains("push.frames"), "{err}"); +} From b99ad331f7cc4c81d3a026eb50b992085025465f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 14:33:37 -0700 Subject: [PATCH 13/85] test(moq-tokio): fix websocket_forbidden port-collision flake (#4055) Co-authored-by: Claude Opus 5.5 --- rs/moq-tokio/tests/broadcast.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index ddc1db8b78..0af6214375 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -2482,12 +2482,20 @@ async fn reconnect_stops_on_websocket_unauthorized() { async fn websocket_forbidden_does_not_end_a_quic_connect() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let (mut server, addr) = test_server().await; - - // The same port over TCP, where the fallback dials. - let listener = tokio::net::TcpListener::bind(("::", addr.port())) - .await - .expect("failed to bind TCP listener"); + // The fallback dials the same port over TCP. Nothing reserves a port for both + // UDP and TCP at once, and the ephemeral UDP port may already be taken over TCP, + // so pick again until both bind. + let (mut server, addr, listener) = 'bind: { + for _ in 0..20 { + let (server, addr) = test_server().await; + match tokio::net::TcpListener::bind(("::", addr.port())).await { + Ok(listener) => break 'bind (server, addr, listener), + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => continue, + Err(err) => panic!("failed to bind TCP listener: {err}"), + } + } + panic!("no port was free over both UDP and TCP"); + }; let forbid = tokio::spawn(async move { let (mut stream, _) = listener.accept().await?; let mut buf = [0; 1024]; From 4d85382700d27a1b40888eab4fcc1dcac0f5d9b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 14:36:01 -0700 Subject: [PATCH 14/85] fix(net): a broadcast exists only while announced (#4021) Co-authored-by: Claude --- dart/moq/README.md | 1 + dart/moq/lib/src/client.dart | 2 +- dart/moq/test/moq_test.dart | 35 +- dart/moq_ffi/lib/src/moq.dart | 12 +- doc/concept/moq-lite.md | 9 + doc/lib/c/index.md | 2 +- doc/lib/dart/index.md | 3 +- doc/lib/go/index.md | 2 +- doc/lib/index.md | 2 +- doc/lib/js/net.md | 6 +- doc/lib/js/publish.md | 2 +- doc/lib/kt/index.md | 2 +- doc/lib/py/index.md | 2 +- doc/lib/rs/moq-net.md | 12 +- doc/lib/swift/index.md | 2 +- go/wrapper/client.go | 2 +- go/wrapper/moq_test.go | 33 +- go/wrapper/origin.go | 15 +- go/wrapper/publish.go | 7 +- go/wrapper/server.go | 2 +- js/net/src/broadcast.ts | 11 +- js/net/src/ietf/publisher.ts | 13 +- js/net/src/integration.test.ts | 27 + js/net/src/lite/publisher.ts | 21 +- js/net/src/origin.test.ts | 147 ++++- js/net/src/origin.ts | 213 ++++--- js/net/src/wire.ts | 2 + js/publish/src/broadcast.ts | 5 +- js/publish/src/element.ts | 2 +- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 2 +- .../kotlin/dev/moq/SmokeTest.kt | 28 +- py/moq-rs/README.md | 2 +- py/moq-rs/moq/client.py | 2 +- py/moq-rs/moq/origin.py | 19 +- py/moq-rs/moq/publish.py | 6 +- py/moq-rs/moq/server.py | 1 + py/moq-rs/tests/test_local.py | 29 +- quest/m1/README.md | 2 +- quest/m1/announce-to-serve.md | 83 --- quest/m1/broadcast-close/README.md | 1 - quest/m1/ietf-publish-done.md | 21 + rs/libmoq/src/api.rs | 23 +- rs/libmoq/src/origin.rs | 8 +- rs/libmoq/src/publish.rs | 2 +- rs/libmoq/src/test.rs | 45 +- rs/moq-cli/src/publish.rs | 5 +- rs/moq-ffi/src/origin.rs | 29 +- rs/moq-ffi/src/producer.rs | 11 +- rs/moq-ffi/src/test.rs | 115 ++-- rs/moq-hls/src/export/upstream.rs | 4 +- rs/moq-mux/src/catalog/hang/consumer.rs | 10 +- rs/moq-mux/src/source.rs | 6 +- rs/moq-net/benches/origin.rs | 6 +- rs/moq-net/src/ietf/publisher.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 145 ++++- rs/moq-net/src/lite/subscriber.rs | 17 +- rs/moq-net/src/model/broadcast.rs | 21 +- rs/moq-net/src/model/front.rs | 91 ++- rs/moq-net/src/model/origin.rs | 573 ++++++++++++++---- rs/moq-net/tests/announce_to_serve.rs | 233 +++++++ swift/README.md | 1 + swift/Sources/Moq/Broadcast.swift | 6 +- swift/Sources/Moq/Origin.swift | 14 +- swift/Tests/MoqTests/SmokeTests.swift | 35 +- 64 files changed, 1540 insertions(+), 652 deletions(-) delete mode 100644 quest/m1/announce-to-serve.md create mode 100644 quest/m1/ietf-publish-done.md create mode 100644 rs/moq-net/tests/announce_to_serve.rs diff --git a/dart/moq/README.md b/dart/moq/README.md index 8bd2f556aa..d1d2c02d9f 100644 --- a/dart/moq/README.md +++ b/dart/moq/README.md @@ -26,6 +26,7 @@ final server = await Server.listen( ), ); final broadcast = server.createBroadcast('live'); +broadcast.announce(route: MoqRoute()); // unannounced broadcasts are invisible await for (final request in server.requests()) { final session = await request.accept(); print(session.epoch()); diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 88b44596e1..bf659b758a 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -126,7 +126,7 @@ final class Moq { } } - /// Create an unadvertised broadcast at [path]. + /// Create an unannounced broadcast at [path], invisible to everyone until announced. /// /// Advertise it with `announce` after populating tracks. Create, `dynamic` /// if tracks are served on demand, populate, then announce. diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index e168ee7d8c..c437261396 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -138,32 +138,35 @@ void main() { ); }); - test('local discovery survives unannounce until finish', () async { + test('a broadcast is reachable only while announced', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); final broadcast = origin.createBroadcast(path: 'live'); broadcast.publishTrack(name: 'events', info: null); final consumer = origin.consume(); - final announced = consumer.announced(config: MoqAnnounceConfig()); - final created = await announced.next().timeout(timeout); - expect(created?.prefix(), 'live'); - expect(created?.active(), isTrue); - expect(created?.route().cost, 0); + await expectLater( + consumer.requestBroadcast(path: 'live').timeout(timeout), + throwsA(anything), + ); - broadcast.announce(route: MoqRoute(cost: 3)); - final advertised = await announced.next().timeout(timeout); - expect(advertised?.active(), isTrue); - expect(advertised?.route().cost, 3); + broadcast.announce(route: MoqRoute()); + final announced = consumer.announced(config: MoqAnnounceConfig()); + final first = await announced.next().timeout(timeout); + expect(first?.prefix(), 'live'); + expect(first?.active(), isTrue); broadcast.unannounce(); - final local = await announced.next().timeout(timeout); - expect(local?.active(), isTrue); - expect(local?.route().cost, 0); - await consumer.requestBroadcast(path: 'live').timeout(timeout); - - broadcast.finish(); final retracted = await announced.next().timeout(timeout); expect(retracted?.prefix(), 'live'); expect(retracted?.active(), isFalse); + await expectLater( + consumer.requestBroadcast(path: 'live').timeout(timeout), + throwsA(anything), + ); + + broadcast.announce(route: MoqRoute()); + final back = await announced.next().timeout(timeout); + expect(back?.active(), isTrue); + await consumer.requestBroadcast(path: 'live').timeout(timeout); announced.cancel(); announced.dispose(); }); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 40ad78d490..e733305163 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -12306,18 +12306,18 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced_broadcast() != - 16445) { + 8509) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_request_broadcast() != - 18586) { + 64026) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqorigindynamic_cancel() != 47453) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqorigindynamic_requested_broadcast() != - 53391) { + 54021) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqorigindynamic_update() != 27700) { @@ -12327,7 +12327,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast() != - 47748) { + 48971) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_dynamic() != 56233) { @@ -12348,7 +12348,7 @@ void _checkApiChecksums() { 47317) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 14026) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume() != 27634) { @@ -12405,7 +12405,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce() != - 39609) { + 63513) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqcontainerproducer_cut() != 17534) { diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6c1fd65b96..1423170ed6 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -54,6 +54,15 @@ anonymous mark and travels the chain unchanged. A route that passed through an anonymous hop at any depth ranks below every fully identified route, whatever the costs say; among anonymous routes, cost keeps ordering. +A broadcast exists only while it is announced, for consumers in the same +process and across a session alike: one that is created but never announced +can be neither discovered nor requested. A broadcast published locally +competes with remote routes to its path on cost like any other route, winning +only a tie. Retracting a route (an unannounce, or the peer's `ANNOUNCE_END`) +stops new requests from resolving through it but leaves subscriptions already +in flight alone: each track runs to its own end, the publisher's FIN or reset. +moq-transport sessions behave the same when a namespace is withdrawn. + ## Path patterns Rust's `moq_net::Pattern` and TypeScript's `Path.Pattern` from `@moq/net` diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 1503770533..94d1f64595 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -40,7 +40,7 @@ and `target/include/moq.h`. - **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (locally discoverable producer), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. ```c moq_client_config config; diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index a0405fa9e9..72998c4c28 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -55,6 +55,7 @@ final server = await Server.listen( ), ); final live = server.createBroadcast('live/camera'); +live.announce(route: MoqRoute()); // unannounced broadcasts are invisible await for (final request in server.requests()) { final session = await request.accept(); print(session.epoch()); @@ -62,7 +63,7 @@ await for (final request in server.requests()) { ``` The three advertising operations: `moq.createBroadcast(path)` (or -`origin.createBroadcast`) returns a locally discoverable producer; +`origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / `broadcast.unannounce()` own that exact-path advertisement; `origin.dynamic_(prefix:, route:)` claims `prefix` and every path beneath it (`''` for everything; Dart spells the origin method diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 81e3b1833b..f6c0ca09bc 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -70,7 +70,7 @@ broadcast.Finish() // keep the producer reachable while publishing, then finis ``` The three advertising operations: `client.CreateBroadcast(path)` (or -`origin.CreateBroadcast`) returns a locally discoverable producer; +`origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.Announce(route)` / `broadcast.Unannounce()` own that exact-path advertisement; `origin.Dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` diff --git a/doc/lib/index.md b/doc/lib/index.md index 24dcfb9cf2..27eaf5edd1 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -35,7 +35,7 @@ how it looks in that language: - **Connect** to a relay with TLS options (system roots, custom CA, fingerprint pinning, mTLS) and a JWT in the URL, or **serve** sessions yourself and accept or reject each request by path. - **Reconnect** automatically with backoff when the transport drops, with `status`/`epoch` reporting each (re)connect and backoff tunable down to retrying forever. The peer's inbound QUIC stream limit is configurable for subscribe-heavy clients. -- **Discover** broadcasts by prefix, wait for a specific one, or request an unannounced one. Create a locally discoverable exact path with `create_broadcast`, then advertise it to peers with `announce` / `unannounce`, or claim a path prefix with `dynamic(prefix, route)`. +- **Discover** broadcasts by prefix, wait for a specific one, or request one by path, including a path a prefix claim serves on demand. Advertise an exact path with `create_broadcast` then `announce` / `unannounce` (a broadcast is invisible to local consumers and peers alike until announced), or claim a path prefix with `dynamic(prefix, route)`. - **Publish and subscribe to media** with the hang catalog filled in from the bitstream, plus raw pixels or PCM in and out with the codec running inside the binding (VideoToolbox, Media Foundation, NVENC, openh264, Opus). A publisher follows the connection's send estimate through `session.bandwidth()`: reserve a share for an app-owned encoder, or pass the handle when encoding so the built-in video encoder follows the grant. - **Connection health.** `stats()` snapshots RTT, send/receive estimates, and byte/packet counters. `bandwidth()` divides that send estimate among tracks sharing the connection. - **Raw tracks** of arbitrary bytes with timestamps, sparse or replayed groups, per-subscriber priority and max age, and best-effort datagrams. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 988c05dbc8..0ea3b7c819 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -47,7 +47,7 @@ for (;;) { } ``` -- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` prefers a local broadcast, so a page that watches what it publishes reads its own copy with no round trip. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and announcing advertises the path to peers. +- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. - **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. Supplying WebTransport/WebSocket options, discovery, delay, or a caller-owned origin selects a private loop; explicit `share: true` refuses those options. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. @@ -86,14 +86,14 @@ Moq.Path.Pattern.parse("camera-*").rooted("room").text; // "room/camera-*" Three operations, on an origin: - `origin.createBroadcast(path)` returns a producer. The broadcast is - reachable and visible to local discovery immediately. Peers see it only after + invisible and unreachable, for local consumers and peers alike, until `broadcast.announce()`. - `broadcast.announce(route)` / `broadcast.unannounce()` own that advertisement. Announcing again re-prices the standing route. - `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `Origin.Dynamic` while the claim should stay advertised; `close()` retracts it. A request beneath it - with no local broadcast is an `Origin.Request` to `accept` or `reject`; + that no announced local broadcast wins is an `Origin.Request` to `accept` or `reject`; reject what you will not serve rather than narrowing the claim, since a route is always a prefix on every wire. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index fabf01206c..2af452cb20 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -31,7 +31,7 @@ WebCodecs, writes the catalog, and publishes a hang broadcast. | `source` | `camera`, `screen`, or `file`. | | `muted`, `invisible` | Disable audio or video capture. | | `preview` | What the nested element shows: the raw `source` (default), a decoded copy of the `encoded` stream to see what viewers get, or `none`. | -| `announce` | When to advertise: once a `source` is live (default), `always`, or `never`. A camera source waits for every enabled track. The broadcast is created while connected either way; this only flips discoverability. | +| `announce` | When to advertise: once a `source` is live (default), `always`, or `never`. A camera source waits for every enabled track. The broadcast is created while connected either way, but nobody can see or subscribe to it until it is announced. | A nested `