From c1331c3aa647fda73f46c68eae520750719a6542 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:41:04 -0700 Subject: [PATCH 01/15] chore(quest): claim quest/m1/api-audit/media-trim.md Quest-Claim: ab0ccdb72af45440 From d17fd9937cfcdd9b2adef4515eae2a0e6c1c4bae Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:18:02 -0700 Subject: [PATCH 02/15] refactor(video)!: trim the capture, preset report, and DMA-BUF surface `encode::Control`/`Driver` are crate-private: `publish_capture` is the only capture entry point, and its encode knobs move from the deleted `encode::Options` onto `encode::Capture`, so `encode::Options` names only audio's producer options. `encode::Applied` and the `applied()` accessors go private; the encoder still logs what a backend applied. `DmaBuf::new` and `DmaBufLayout` go private until an external producer needs them, and `color::Range` reuses `hang::catalog::Range` instead of duplicating it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- rs/hang/src/catalog/video/color.rs | 2 +- rs/moq-cli/src/publish.rs | 22 ++--- rs/moq-video/MIGRATE.md | 16 ++++ rs/moq-video/README.md | 10 -- rs/moq-video/examples/encode-presets.rs | 12 +-- rs/moq-video/src/color.rs | 10 +- rs/moq-video/src/encode/encoder.rs | 27 ++---- rs/moq-video/src/encode/mod.rs | 13 +-- rs/moq-video/src/encode/producer.rs | 119 ++++++++++++------------ rs/moq-video/src/encode/sink.rs | 45 +-------- rs/moq-video/src/frame.rs | 20 ++-- rs/moq-video/src/lib.rs | 4 +- 12 files changed, 121 insertions(+), 179 deletions(-) diff --git a/rs/hang/src/catalog/video/color.rs b/rs/hang/src/catalog/video/color.rs index f02e2bdb852..c90fe1dd365 100644 --- a/rs/hang/src/catalog/video/color.rs +++ b/rs/hang/src/catalog/video/color.rs @@ -32,7 +32,7 @@ pub struct Color { } /// The numeric range of the samples, as H.273's `VideoFullRangeFlag`. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum Range { /// Studio swing: 8-bit luma spans 16..235 and chroma 16..240. diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index f3b1fe85c40..f6148f093c3 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -289,7 +289,7 @@ enum Source { Capture { broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer, - video: Option<(moq_video::capture::Config, moq_video::encode::Options)>, + video: Option, audio: Option<(moq_audio::capture::Config, moq_audio::encode::Options)>, }, } @@ -457,7 +457,7 @@ impl Publish { let video = if args.no_video { None } else { - Some((args.video_config()?, args.video_encode(bandwidth.clone()))) + Some(args.video_capture(bandwidth.clone())?) }; let audio = (!args.no_audio).then(|| (args.audio_config(), args.audio_encode(bandwidth))); anyhow::ensure!(video.is_some() || audio.is_some(), "nothing to capture"); @@ -544,14 +544,9 @@ impl Publish { let catalog = catalog.clone(); async move { match video { - Some((config, encode)) => { - let mut options = moq_video::encode::Capture::default(); - options.capture = config; - options.encode = encode; - moq_video::encode::publish_capture(broadcast, catalog, options) - .await - .map_err(anyhow::Error::from) - } + Some(options) => moq_video::encode::publish_capture(broadcast, catalog, options) + .await + .map_err(anyhow::Error::from), None => Ok(()), } } @@ -740,8 +735,9 @@ impl CaptureArgs { Ok(config) } - fn video_encode(&self, bandwidth: moq_net::bandwidth::Allocator) -> moq_video::encode::Options { - let mut options = moq_video::encode::Options::default(); + fn video_capture(&self, bandwidth: moq_net::bandwidth::Allocator) -> anyhow::Result { + let mut options = moq_video::encode::Capture::default(); + options.capture = self.video_config()?; options.bitrate = self.bitrate.map(moq_net::bandwidth::Rate::from_bps); options.codec = self.codec.into(); options.kind = if self.software { @@ -752,7 +748,7 @@ impl CaptureArgs { moq_video::encode::Kind::Auto }; options.bandwidth = bandwidth; - options + Ok(options) } /// The audio source named by the flags, defaulting to the default diff --git a/rs/moq-video/MIGRATE.md b/rs/moq-video/MIGRATE.md index e8292cbf31f..c9634a5d606 100644 --- a/rs/moq-video/MIGRATE.md +++ b/rs/moq-video/MIGRATE.md @@ -4,6 +4,22 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Capture publishes through `publish_capture` only + +`encode::Control`, `encode::Driver`, and `encode::Options` are gone. +Set the encode fields directly on `encode::Capture` (`bitrate`, `codec`, `kind`, `bandwidth`) next to `capture`, and run it with `encode::publish_capture`; drop its future to stop the capture. +There is no public keyframe request for a capture publish; a joining subscriber still gets one through the track's refresh. + +### `encode::Applied` is gone + +`encode::Applied`, `Encoder::applied`, and `Sink::applied` are private. +The encoder logs the controls it applied for `Config::preset` at debug level when it opens. + +### External DMA-BUF construction is private + +`DmaBuf::new` and `DmaBufLayout` are private until an external producer needs them. +`DmaBuf` surfaces from capture and decode are unchanged. + ### `Control::cut` is not rate limited, and capture advertises refresh (#6048) `encode::Control::cut` no longer spaces keyframes 500 ms apart: every request forces one on the next frame, so frequent calls produce all-keyframe video. diff --git a/rs/moq-video/README.md b/rs/moq-video/README.md index 3249523cc26..7f55c6588cc 100644 --- a/rs/moq-video/README.md +++ b/rs/moq-video/README.md @@ -124,13 +124,6 @@ Public entry points: - `encode::publish_capture(...)` captures a webcam, encodes it, and publishes on demand: the track and catalog are advertised up front, but the camera opens only while a subscriber is watching and is released when the last one leaves. -- `encode::Control::new(...)` does the same but returns a `Control` handle with - the `Driver` that runs it, like `moq-audio`'s. `Control::cut()` asks for a - keyframe: requests coalesce, any keyframe serves them (the GOP cadence - included), and a forced one lands at least 500ms after any other. On a - backend that cannot force one it returns `Error::CutUnsupported` and the - publish carries on. Dropping the last `Control` ends the `Driver` promptly, - even mid-open. - `encode::Producer` publishes frames you encoded yourself (`publish(&[Encoded])`), handling the catalog and framing. Each is published at its own timestamp. @@ -168,9 +161,6 @@ good, since nothing signalled its timeline, so drop excess captures before publishing. Non-exportable application images need a GPU copy into an exportable slot. External Vulkan images have no CPU mapping or download fallback. -External DMA-BUF producers use `DmaBuf::new` with an owned FD, `DmaBufLayout`, -and a release guard; their buffers also refuse CPU download. - Run `just rs gpu` for the opt-in native hardware exercise. It creates a Vulkan image independently of Unreal, imports it once into CUDA, checks repeated slot reuse and held-reader ordering, and tears down through cancellation; a diff --git a/rs/moq-video/examples/encode-presets.rs b/rs/moq-video/examples/encode-presets.rs index 5be2de5a5d1..f932a5e65d7 100644 --- a/rs/moq-video/examples/encode-presets.rs +++ b/rs/moq-video/examples/encode-presets.rs @@ -64,7 +64,7 @@ fn main() -> anyhow::Result<()> { let pictures: Vec<&[u8]> = raw.chunks_exact(len).collect(); anyhow::ensure!(!pictures.is_empty(), "input holds no {size} I420 frame"); - println!("preset\tcontrols\tp50 ms\tp95 ms\tmax ms\tfps\tcpu ms/frame\theld\tskipped\tkbps"); + println!("preset\tencoder\tp50 ms\tp95 ms\tmax ms\tfps\tcpu ms/frame\theld\tskipped\tkbps"); for (preset, label) in PRESETS .into_iter() .filter(|(_, label)| only.is_none_or(|only| only == *label)) @@ -99,7 +99,7 @@ fn main() -> anyhow::Result<()> { let seconds = pictures.len() as f64 / fps as f64; println!( "{label}\t{}\t{:.2}\t{:.2}\t{:.2}\t{:.0}\t{:.2}\t{}\t{}\t{:.0}", - paced.controls, + paced.name, pick(0.5), pick(0.95), pick(1.0), @@ -114,7 +114,8 @@ fn main() -> anyhow::Result<()> { } struct Run { - controls: String, + /// The encoder backend that ran. + name: String, /// Per emitted frame, from `encode` being called to the packet stamped with it coming back. latency: Vec, /// Frames the codec dropped rather than encoded (openh264 skips to hold its rate). @@ -129,8 +130,7 @@ struct Run { /// Encode every picture, at real-time pacing when `paced` and back to back otherwise. fn run(config: &Config, pictures: &[&[u8]], paced: bool) -> anyhow::Result { let mut encoder = Encoder::new(config)?; - let applied = encoder.applied(); - let controls = format!("{} {:?}: {}", encoder.name(), applied.preset, applied.controls); + let name = encoder.name().to_owned(); let frame_time = Duration::from_micros(1_000_000 / config.framerate.rounded() as u64); let mut submitted: Vec = Vec::with_capacity(pictures.len()); @@ -179,7 +179,7 @@ fn run(config: &Config, pictures: &[&[u8]], paced: bool) -> anyhow::Result .unwrap_or(0); Ok(Run { - controls, + name, latency: emitted.into_iter().flatten().collect(), skipped, held, diff --git a/rs/moq-video/src/color.rs b/rs/moq-video/src/color.rs index 3fd708474bf..e7080b6ecda 100644 --- a/rs/moq-video/src/color.rs +++ b/rs/moq-video/src/color.rs @@ -54,14 +54,8 @@ pub enum Matrix { Bt2020, } -/// The numeric range of the samples. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Range { - /// Studio swing: 8-bit luma spans 16..235 and chroma 16..240. - Limited, - /// Full swing: every 8-bit code is used. - Full, -} +/// The numeric range of the samples, the same type the catalog's colour carries. +pub use hang::catalog::Range; /// The chromaticities of the RGB primaries. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/rs/moq-video/src/encode/encoder.rs b/rs/moq-video/src/encode/encoder.rs index ab11316afa1..ee46b5515fd 100644 --- a/rs/moq-video/src/encode/encoder.rs +++ b/rs/moq-video/src/encode/encoder.rs @@ -105,9 +105,8 @@ impl Gop { /// backend maps a preset onto the controls it actually has, so two presets can /// apply the same controls on one backend, and some backends cannot rule out /// frame reordering or queueing: V4L2 leaves both to the driver, and -/// MediaCodec's no-B-frame setting is only a hint. [`Encoder::applied`] reports -/// what took effect, and only a reported [`Applied::preset`] confirms it. A -/// preset describes the encoder alone, not keyframe join time, transport delay, +/// MediaCodec's no-B-frame setting is only a hint. The encoder logs the +/// controls that took effect at debug level when it opens. A preset describes the encoder alone, not keyframe join time, transport delay, /// or viewer playout. /// /// `#[non_exhaustive]` so a later policy can be added without breaking a @@ -124,21 +123,19 @@ pub enum Preset { Quality, } -/// The latency and compression controls an encoder actually applied, as -/// reported by [`Encoder::applied`]. +/// The latency and compression controls a backend actually applied at open. /// /// A report, not a request: a backend that has no distinct mapping for the /// requested [`Preset`] names the one whose controls it did apply, and one that /// could not confirm the controls a preset needs names none. #[derive(Clone, Debug, Default, PartialEq, Eq)] -#[non_exhaustive] -pub struct Applied { +pub(crate) struct Applied { /// The preset whose controls took effect, or `None` when the backend could /// not confirm them (a refused or advisory low-latency setting). - pub preset: Option, + pub(crate) preset: Option, /// The backend controls that took effect, for display, e.g. /// `"p1, low-latency tuning, no B-frames, CBR, 1-frame VBV"`. Not a parse target. - pub controls: String, + pub(crate) controls: String, } impl Applied { @@ -338,9 +335,6 @@ pub struct Encoder { codec: Codec, size: Size, bitrate: moq_net::bandwidth::Rate, - /// What the backend reported applying for [`Config::preset`], read once at - /// open: the controls are fixed for the session's lifetime. - applied: Applied, /// What the backend wrote into the bitstream's VUI, kept so a frame declaring /// a different space is caught rather than silently mislabeled. color: Color, @@ -371,7 +365,6 @@ impl Encoder { codec: config.codec, size, bitrate: config.resolved_bitrate(), - applied, color: config.resolved_color(), pending_cut: false, #[cfg(target_os = "linux")] @@ -386,9 +379,10 @@ impl Encoder { } /// The latency and compression controls the backend applied for - /// [`Config::preset`]. - pub fn applied(&self) -> &Applied { - &self.applied + /// [`Config::preset`], fixed for the session's lifetime. + #[cfg(test)] + pub(crate) fn applied(&self) -> Applied { + self.backend.applied() } /// The resolution this encoder emits; external Vulkan inputs may be larger. @@ -1110,7 +1104,6 @@ mod tests { codec: config.codec, size: config.size(), bitrate: config.resolved_bitrate(), - applied: Applied::default(), color: config.resolved_color(), pending_cut: false, #[cfg(target_os = "linux")] diff --git a/rs/moq-video/src/encode/mod.rs b/rs/moq-video/src/encode/mod.rs index 0e12c14a826..c5b4dd859e1 100644 --- a/rs/moq-video/src/encode/mod.rs +++ b/rs/moq-video/src/encode/mod.rs @@ -5,9 +5,7 @@ //! //! Entry points, high to low level: //! - `publish_capture` captures and publishes a webcam (turnkey). Requires the -//! `capture` feature. `Control::new` is the same with a handle kept for -//! controlling it while it runs (asking for a keyframe), plus the `Driver` -//! that runs it. +//! `capture` feature. //! - [`Encoder`] encodes raw [`Frame`](crate::Frame)s you supply into //! [`Encoded`] access units, and [`Producer`] publishes those (bring your own //! frames). Build both for the same [`Codec`]. @@ -21,10 +19,9 @@ //! discover a track nothing has encoded yet. That's what makes on-demand //! encoding possible at all. //! -//! `Capture` / `Options` (with `capture`) / [`Kind`] / [`Config`] configure them. +//! `Capture` (with `capture`) / [`Kind`] / [`Config`] configure them. //! [`Config::preset`] picks a [`Preset`], how the encoder trades latency for -//! compression at the configured bitrate, and [`Encoder::applied`] reports the -//! controls the backend actually applied for it. The decode/consume +//! compression at the configured bitrate. The decode/consume //! counterpart (mirror of `moq-audio`'s consumer) lives in the sibling //! [`decode`](crate::decode) module. //! @@ -42,10 +39,10 @@ mod cuts; pub use backend::NAMES; pub use encoded::Encoded; -pub use encoder::{Applied, Codec, Config, Encoder, Gop, Kind, Preset}; +pub use encoder::{Codec, Config, Encoder, Gop, Kind, Preset}; pub use producer::Producer; #[cfg(feature = "capture")] -pub use producer::{Capture, Control, Driver, Options, publish_capture}; +pub use producer::{Capture, publish_capture}; pub use sink::Sink; #[cfg(test)] diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index d71957a3dda..b4a6871ffff 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -254,17 +254,24 @@ impl Producer { } } -/// Source-agnostic encode knobs for a capture publish, where the geometry -/// (width / height / framerate) comes from the capture source, not the caller. -/// For the bring-your-own-frames [`Encoder`](super::Encoder) path, where you -/// must specify geometry, use [`Config`](super::Config) instead. +/// Capture and encode settings for [`publish_capture`]. /// -/// `#[non_exhaustive]`: construct via [`Options::default`] and set fields, so -/// new knobs can be added without breaking callers. +/// The encoded geometry (width / height / framerate) comes from the capture +/// source, not the caller. For the bring-your-own-frames +/// [`Encoder`](super::Encoder) path, where you specify geometry, use +/// [`Config`](super::Config) instead. +/// +/// Frames are stamped on the catalog's [`clock`](moq_mux::catalog::Producer::clock), the one +/// its consumers are told about, so a concurrent audio capture on the same catalog stays aligned. +/// +/// `#[non_exhaustive]`: construct via [`Capture::default`] and set fields, so +/// new settings can be added without breaking callers. #[derive(Clone, Debug, Default)] #[non_exhaustive] #[cfg(feature = "capture")] -pub struct Options { +pub struct Capture { + /// The source to capture. + pub capture: capture::Config, /// Target bitrate; `None` derives one from the resolution. /// /// This is a ceiling, not a fixed rate: with [`bandwidth`](Self::bandwidth) @@ -292,23 +299,6 @@ pub struct Options { pub bandwidth: moq_net::bandwidth::Allocator, } -/// Capture and encode settings for [`Control::new`] and [`publish_capture`]. -/// -/// Frames are stamped on the catalog's [`clock`](moq_mux::catalog::Producer::clock), the one -/// its consumers are told about, so a concurrent audio capture on the same catalog stays aligned. -/// -/// `#[non_exhaustive]`: construct via [`Capture::default`] and set -/// fields, so new settings can be added without changing [`Control::new`]. -#[derive(Clone, Debug, Default)] -#[non_exhaustive] -#[cfg(feature = "capture")] -pub struct Capture { - /// The source to capture. - pub capture: capture::Config, - /// The track's codec and encode settings. - pub encode: Options, -} - /// The keyframe requests shared between the [`Control`]s and the [`Driver`]. /// /// One lock covers both fields, so a request is either counted before the driver @@ -329,7 +319,7 @@ struct Requests { /// and ends the track, even while it is still opening the source or an encoder. #[derive(Clone, Debug)] #[cfg(feature = "capture")] -pub struct Control { +pub(crate) struct Control { requests: kio::Producer, } @@ -343,18 +333,18 @@ impl Control { /// and can be spawned; on macOS the capture session is `!Send`, so await /// [`Driver::run`] on a local task there. [`Control`] itself is `Send + Sync`, /// so it can live anywhere. - pub fn new( + pub(crate) fn new( broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer, options: Capture, ) -> Result<(Self, Driver), Error> { - let suffix = match options.encode.codec { + let suffix = match options.codec { Codec::H264 => ".avc3", Codec::H265 => ".hev1", }; // Advertised only when every backend the encoder may open honors a cut, since the info // is fixed before the driver learns which one it got. - let refresh = super::backend::always_cuts(options.encode.codec, &options.encode.kind).then_some(REFRESH); + let refresh = super::backend::always_cuts(options.codec, &options.kind).then_some(REFRESH); let info = catalog.track_info(hang::catalog::PRIORITY.video).with_refresh(refresh); let track = broadcast.unique_track(suffix, info)?; let requests = kio::Producer::new(Requests::default()); @@ -384,7 +374,9 @@ impl Control { /// reopens updates it, since a reopen can land on a different backend. Before the probe /// finishes nothing is known, and the request is accepted because it is served anyway: no /// frame has been published yet, and the first one out of any encoder is a keyframe. - pub fn cut(&self) -> Result<(), Error> { + // Only tests ask for a keyframe until a publisher needs to. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn cut(&self) -> Result<(), Error> { // Only a `close` fails the write, and nothing closes it; the last handle dropping does. let Ok(mut requests) = self.requests.write() else { return Ok(()); @@ -401,7 +393,7 @@ impl Control { /// /// Runs until the track ends, the capture fails, or the final [`Control`] drops. #[cfg(feature = "capture")] -pub struct Driver { +pub(crate) struct Driver { track: moq_net::track::Producer, catalog: moq_mux::catalog::Producer, options: Capture, @@ -427,7 +419,7 @@ impl Driver { /// /// Dropping the final [`Control`] ends it promptly wherever it is waiting, including a camera /// open or permission prompt still in flight, which is abandoned rather than finished. - pub async fn run(self) -> Result<(), Error> { + pub(crate) async fn run(self) -> Result<(), Error> { self.run_with(DeviceSource).await } @@ -438,7 +430,6 @@ impl Driver { options, requests, } = self; - let Capture { capture, encode } = options; let clock = catalog.clock(); let controls = requests.consume(); @@ -448,18 +439,19 @@ impl Driver { // rendition that says exactly what the stream will carry, rather than one every consumer has // to treat as provisional. let rendition = async { - let camera = source.open(&capture).await?; + let camera = source.open(&options.capture).await?; let mut probe_config = encoder::Config::new( camera.width(), camera.height(), - capture + options + .capture .framerate .or_else(|| camera.framerate()) .unwrap_or(DEFAULT_FRAMERATE), ); - probe_config.bitrate = encode.bitrate; - probe_config.codec = encode.codec; - probe_config.kind = encode.kind.clone(); + probe_config.bitrate = options.bitrate; + probe_config.codec = options.codec; + probe_config.kind = options.kind.clone(); probe_config.color = camera.color(); let (rendition, mut sink) = probe_config.probe_sink().await?; // Learned here so a caller finds out before the first viewer, not on its first cut. @@ -493,7 +485,7 @@ impl Driver { let result = tokio::select! { biased; () = controls.closed() => Ok(()), - result = capture_loop(&mut producer, &demand, &mut source, &capture, &encode, &clock, &requests) => result, + result = capture_loop(&mut producer, &demand, &mut source, &options, &clock, &requests) => result, }; // This runs only when the loop ends on its own (the track is usually already @@ -515,8 +507,9 @@ impl Driver { /// Capture a webcam and publish it as an on-demand video track. /// -/// This convenience function runs a [`Control`] from `options` without handing -/// back the handle. Use [`Control::new`] directly to retain controls. +/// The source is opened once at startup to probe the mode it negotiates, so the +/// catalog rendition is exact before a frame is published, then reopened only +/// while a subscriber is watching. Drop the future to stop it. /// /// Returns when the broadcast is dropped (the track stops being announced) or /// the capture fails. Frames are stamped on the catalog's clock, so a @@ -706,8 +699,7 @@ async fn capture_loop( producer: &mut Producer, demand: &moq_net::track::Demand, source: &mut S, - capture: &capture::Config, - encode: &Options, + options: &Capture, clock: &moq_mux::Clock, requests: &kio::ProducerWeak, ) -> Result<(), Error> { @@ -716,6 +708,7 @@ async fn capture_loop( // held across reopens so the claim doesn't lapse while the camera is closed. let mut reservation: Option = None; let counted = requests.consume(); + let capture = &options.capture; loop { // Idle until a viewer subscribes; the track ending is a clean exit. The @@ -742,9 +735,9 @@ async fn capture_loop( .or_else(|| camera.framerate()) .unwrap_or(DEFAULT_FRAMERATE); let mut encoder_config = encoder::Config::new(camera.width(), camera.height(), framerate); - encoder_config.bitrate = encode.bitrate; - encoder_config.codec = encode.codec; - encoder_config.kind = encode.kind.clone(); + encoder_config.bitrate = options.bitrate; + encoder_config.codec = options.codec; + encoder_config.kind = options.kind.clone(); encoder_config.color = camera.color(); // Off macOS and iOS this opens the encoder on a dedicated thread; see `sink`. // No cut on reopen: a fresh encoder opens with a keyframe on every backend, @@ -764,7 +757,7 @@ async fn capture_loop( // larger mode below what it can send, or keep claiming room a smaller one no // longer needs. let ceiling = encoder_config.resolved_bitrate(); - let reservation = reservation.get_or_insert_with(|| encode.bandwidth.reserve(demand, ceiling)); + let reservation = reservation.get_or_insert_with(|| options.bandwidth.reserve(demand, ceiling)); reservation.update(ceiling); // Rate control is per encoder: this one opened at the configured bitrate, @@ -1213,11 +1206,10 @@ mod tests { // Local: a capture stream is `!Send` on macOS, so each test runs on a `LocalSet`. let task = tokio::task::spawn_local(async move { let mut source = Opens(rx); - let options = Options { + let options = Capture { kind: encoder::Kind::Software, - ..Options::default() + ..Capture::default() }; - let config = capture::Config::default(); // Held, so the loop has a channel to report each encoder's cut support into. let controls = kio::Producer::new(Requests::default()); let requests = controls.weak(); @@ -1226,7 +1218,6 @@ mod tests { &mut producer, &demand, &mut source, - &config, &options, &clock, &requests, @@ -1610,10 +1601,12 @@ mod tests { let consumer = broadcast.consume(); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - let mut options = Capture::default(); - options.encode.kind = encoder::Kind::Software; - // Room for noise, which rate control would otherwise skip frames of. - options.encode.bitrate = Some(moq_net::bandwidth::Rate::from_mbps(4)); + let options = Capture { + kind: encoder::Kind::Software, + // Room for noise, which rate control would otherwise skip frames of. + bitrate: Some(moq_net::bandwidth::Rate::from_mbps(4)), + ..Capture::default() + }; let (control, driver) = Control::new(broadcast.clone(), catalog, options).unwrap(); // Ten nominal fps makes the default two-second GOP twenty frames, which every @@ -1743,8 +1736,10 @@ mod tests { let mut broadcast = moq_net::broadcast::Info::new().produce(); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - let mut options = Capture::default(); - options.encode.kind = kind; + let options = Capture { + kind, + ..Capture::default() + }; let (_control, driver) = Control::new(broadcast.clone(), catalog, options).unwrap(); assert_eq!(driver.track.info().refresh, refresh); } @@ -1789,8 +1784,10 @@ mod tests { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - let mut options = Capture::default(); - options.encode.kind = encoder::Kind::Software; + let options = Capture { + kind: encoder::Kind::Software, + ..Capture::default() + }; let (control, driver) = Control::new(broadcast.clone(), catalog.clone(), options).unwrap(); // The probe gets a camera; the subscriber's open after it never finishes. @@ -1848,21 +1845,19 @@ mod tests { let (camera, stream) = camera(); let mut source = Streams::new([stream]); - let options = Options { + let options = Capture { kind: encoder::Kind::Named(probe::NO_CUT.into()), - ..Options::default() + ..Capture::default() }; let control = Control { requests: kio::Producer::new(Requests::default()), }; let requests = control.requests.weak(); let clock = catalog.clock(); - let config = capture::Config::default(); let mut run = std::pin::pin!(capture_loop( &mut producer, &demand, &mut source, - &config, &options, &clock, &requests, diff --git a/rs/moq-video/src/encode/sink.rs b/rs/moq-video/src/encode/sink.rs index eeffb49c18a..5cf73872892 100644 --- a/rs/moq-video/src/encode/sink.rs +++ b/rs/moq-video/src/encode/sink.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use super::Encoded; -use super::encoder::{Applied, Config}; +use super::encoder::Config; use crate::{Error, Frame}; #[cfg(apple)] @@ -75,12 +75,6 @@ impl Sink { self.0.name() } - /// The latency and compression controls the backend applied, like - /// [`Encoder::applied`](super::Encoder::applied). - pub fn applied(&self) -> &Applied { - self.0.applied() - } - /// Cut a new group at the next frame, like /// [`Encoder::cut`](super::Encoder::cut). /// @@ -158,7 +152,7 @@ mod threaded { use tokio::sync::{mpsc, oneshot}; use super::super::Encoded; - use super::super::encoder::{Applied, Config, Encoder}; + use super::super::encoder::{Config, Encoder}; use crate::worker::{Ready, Worker}; use crate::{Error, Frame}; @@ -204,7 +198,6 @@ mod threaded { /// What the encode thread reports once its encoder is open. pub struct Opened { name: String, - applied: Applied, } /// Build an encoder for `config` and serve requests until the channel closes. @@ -217,7 +210,6 @@ mod threaded { // If the awaiting `open` was cancelled, give up before encoding. let opened = Opened { name: encoder.name().to_owned(), - applied: encoder.applied().clone(), }; if !ready.ok(opened) { return; @@ -274,10 +266,6 @@ mod threaded { &self.0.info().name } - pub fn applied(&self) -> &Applied { - &self.0.info().applied - } - pub async fn cut(&mut self) -> Result<(), Error> { self.0.request(|resp| Request::Cut { resp }).await } @@ -312,7 +300,7 @@ mod inline { use std::sync::Arc; use super::super::Encoded; - use super::super::encoder::{Applied, Config, Encoder}; + use super::super::encoder::{Config, Encoder}; use crate::{Error, Frame}; /// An [`Encoder`] driven inline on the calling thread (see the module docs). @@ -332,10 +320,6 @@ mod inline { self.0.name() } - pub fn applied(&self) -> &Applied { - self.0.applied() - } - /// Async only to match the threaded `Inner`; there's no thread to hand this /// to, so it runs inline. The same holds for the calls below. pub async fn cut(&mut self) -> Result<(), Error> { @@ -446,29 +430,6 @@ mod tests { ); } - /// What the backend applied has to survive the trip off the encode thread, - /// since that thread is the only place the encoder can be asked. - #[cfg(feature = "openh264")] - #[test] - fn the_applied_preset_crosses_the_thread() { - let mut config = Config::new(320, 240, crate::Rate::new(30, 1).unwrap()); - config.kind = Kind::Software; - config.preset = super::super::Preset::Quality; - let sink = pollster::block_on(Sink::open(&config)).unwrap(); - // openh264 has no Quality controls of its own, and says so. - assert_eq!(sink.applied().preset, Some(super::super::Preset::Balanced)); - assert!(!sink.applied().controls.is_empty()); - } - - /// A backend that reports nothing claims no preset, rather than echoing the - /// one that was asked for. - #[test] - fn an_unreported_backend_claims_no_preset() { - let _probe = probe::exclusive(); - let sink = pollster::block_on(Sink::open(&probe_config())).unwrap(); - assert_eq!(sink.applied().preset, None); - } - /// Regression: a queued request runs on the encode thread whether or not the /// caller is still waiting, so a cancelled `encode` leaves the codec a step /// ahead of the stream with output nobody received. Carrying on would publish diff --git a/rs/moq-video/src/frame.rs b/rs/moq-video/src/frame.rs index fcc6a81439b..add22a0fa86 100644 --- a/rs/moq-video/src/frame.rs +++ b/rs/moq-video/src/frame.rs @@ -281,7 +281,9 @@ impl DmaBuf { /// /// Clones share the descriptor and guard; exports duplicate the FD as needed. /// External buffers refuse CPU download, including linear allocations. - pub fn new(fd: OwnedFd, layout: DmaBufLayout, owner: T) -> Result { + // Only tests build one until an external producer needs it. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn new(fd: OwnedFd, layout: DmaBufLayout, owner: T) -> Result { Self::adopt(layout, Arc::new(ExternalDmaBuf { fd, _owner: owner })) } @@ -363,24 +365,24 @@ impl DmaBuf { /// The format, tiling, and plane layout of an external DMA-BUF. #[cfg(all(target_os = "linux", feature = "dmabuf"))] #[derive(Clone, Debug)] -#[non_exhaustive] -pub struct DmaBufLayout { +pub(crate) struct DmaBufLayout { /// DRM fourcc describing the pixel format. - pub format: DrmFormat, + pub(crate) format: DrmFormat, /// DRM modifier describing memory tiling; defaults to linear. - pub modifier: u64, + pub(crate) modifier: u64, /// Visible image dimensions. - pub size: Size, + pub(crate) size: Size, /// Plane offsets and row strides, in format order. - pub planes: Vec, + pub(crate) planes: Vec, /// Color space of YUV samples, when the producer knows it. - pub color: Option, + pub(crate) color: Option, } #[cfg(all(target_os = "linux", feature = "dmabuf"))] impl DmaBufLayout { /// Describe linear planes and set optional tiling and color fields afterward. - pub fn new(format: DrmFormat, size: Size, planes: Vec) -> Self { + #[cfg(test)] + pub(crate) fn new(format: DrmFormat, size: Size, planes: Vec) -> Self { Self { format, modifier: 0, diff --git a/rs/moq-video/src/lib.rs b/rs/moq-video/src/lib.rs index 9946478b057..95eb27f2445 100644 --- a/rs/moq-video/src/lib.rs +++ b/rs/moq-video/src/lib.rs @@ -25,8 +25,6 @@ //! and framing. The codec is chosen via [`encode::Codec`]: H.264 (openh264 / //! VideoToolbox / Media Foundation / NVENC / VAAPI / V4L2) or H.265 //! (VideoToolbox / Media Foundation / NVENC). Entry points: -//! - `encode::Control::new` returns a `Control` handle (ask for a keyframe -//! with `cut`) and the `Driver` that captures and publishes the webcam. //! - `encode::publish_capture` captures a webcam and publishes it (turnkey). //! It encodes strictly on demand: the track and catalog are advertised up //! front (the camera opens once at startup so they can be exact), and the @@ -103,7 +101,7 @@ mod v4l2; pub use color::Color; pub use error::Error; #[cfg(all(target_os = "linux", feature = "dmabuf"))] -pub use frame::{DmaBuf, DmaBufExport, DmaBufLayout, DmaBufPlane, DrmFormat}; +pub use frame::{DmaBuf, DmaBufExport, DmaBufPlane, DrmFormat}; pub use frame::{Frame, I420, Surface}; pub use output::Output; pub use rate::{MAX_FRAMES_PER_SECOND, Rate, RateError}; From 9b060c7ee329a754f319492cdb7f1b3c72fb02d2 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:05:42 -0700 Subject: [PATCH 03/15] refactor(audio): trim the encode surface and await playback observation - encode::Settings is encode::Config (Options::config, Encoder::config). - Remove encode::Preset, with_preset, preset(), Applied, and Encoder::applied: a preset only picked a frame duration. - moq-ffi drops MoqAudioPreset and MoqAudioEncoderOutput::preset, so preset plus frame_duration_us can no longer be refused at runtime. Wrappers drop their Preset alias; the OBS plugin maps its preset setting onto frame_duration_us. - Remove encode/decode Kind::Named, which only checked the codec. - playback::Observer::finish returns a future in place of finished() polling; the driver thread wakes it off the audio thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- cpp/obs/src/moq-audio-encoder.cpp | 19 +- dart/moq/lib/audio.dart | 5 +- dart/moq/test/moq_test.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 99 ----- doc/lib/rs/moq-audio.md | 4 +- go/wrapper/audio/audio.go | 18 +- go/wrapper/types.go | 2 +- .../kotlin/dev/moq/Aliases.kt | 2 +- .../kotlin/dev/moq/audio/Audio.kt | 4 +- py/moq-rs/moq/audio.py | 8 +- py/moq-rs/moq/types.py | 4 - py/moq-rs/tests/test_local.py | 8 +- rs/moq-audio/MIGRATE.md | 10 + rs/moq-audio/src/decode/backend/mod.rs | 68 +--- rs/moq-audio/src/decode/consumer.rs | 24 +- rs/moq-audio/src/decode/decoder.rs | 35 +- rs/moq-audio/src/encode/backend/libopus.rs | 28 +- rs/moq-audio/src/encode/backend/mod.rs | 103 ++--- rs/moq-audio/src/encode/backend/pcm.rs | 8 +- rs/moq-audio/src/encode/backend/stub.rs | 6 +- rs/moq-audio/src/encode/capture.rs | 2 +- rs/moq-audio/src/encode/encoder.rs | 362 +++++------------- rs/moq-audio/src/encode/mod.rs | 6 +- rs/moq-audio/src/encode/producer.rs | 56 ++- rs/moq-audio/src/opus.rs | 2 +- rs/moq-audio/src/playback.rs | 2 + rs/moq-audio/src/playback/driver.rs | 3 + rs/moq-audio/src/playback/observe.rs | 97 ++++- rs/moq-audio/src/playback/sink.rs | 9 +- rs/moq-audio/tests/integration/roundtrip.rs | 10 +- rs/moq-boy/src/audio.rs | 4 +- rs/moq-c/src/audio.rs | 11 +- rs/moq-cli/src/play/metrics.rs | 15 +- rs/moq-cli/src/publish.rs | 2 +- rs/moq-ffi/CHANGELOG.md | 1 - rs/moq-ffi/src/audio.rs | 109 ++---- rs/moq-ffi/src/test.rs | 4 - swift/Sources/Moq/Aliases.swift | 2 +- swift/Sources/Moq/Audio.swift | 4 +- 39 files changed, 377 insertions(+), 781 deletions(-) diff --git a/cpp/obs/src/moq-audio-encoder.cpp b/cpp/obs/src/moq-audio-encoder.cpp index fb69015eb48..3e745a60796 100644 --- a/cpp/obs/src/moq-audio-encoder.cpp +++ b/cpp/obs/src/moq-audio-encoder.cpp @@ -17,14 +17,15 @@ namespace { // Opus codes natively at 48 kHz, so OBS resamples its mix to it. constexpr uint32_t SAMPLE_RATE = 48000; -std::optional ParsePreset(const char *name) +// The preset picks the Opus packet duration in microseconds: low latency halves +// the packetization delay at twice the packet rate. Quality matches balanced, +// since libopus already runs at full complexity and longer packets only add delay. +std::optional ParsePreset(const char *name) { if (strcmp(name, "low_latency") == 0) - return moq::AudioPreset::kLowLatency; - if (strcmp(name, "balanced") == 0) - return moq::AudioPreset::kBalanced; - if (strcmp(name, "quality") == 0) - return moq::AudioPreset::kQuality; + return 10000; + if (strcmp(name, "balanced") == 0 || strcmp(name, "quality") == 0) + return 20000; return std::nullopt; } @@ -51,8 +52,8 @@ struct MoQAudioEncoder { void *Create(obs_data_t *settings, obs_encoder_t *encoder) { const char *preset_name = obs_data_get_string(settings, "preset"); - const auto preset = ParsePreset(preset_name); - if (!preset) { + const auto frame_duration_us = ParsePreset(preset_name); + if (!frame_duration_us) { LOG_ERROR("Unknown MoQ audio preset: %s", preset_name); return nullptr; } @@ -66,7 +67,7 @@ void *Create(obs_data_t *settings, obs_encoder_t *encoder) output.sample_rate = SAMPLE_RATE; output.channels = channels; output.bitrate = Bitrate(settings); - output.preset = *preset; + output.frame_duration_us = *frame_duration_us; auto codec = moq::AudioEncoder::init(output); if (!codec) { diff --git a/dart/moq/lib/audio.dart b/dart/moq/lib/audio.dart index 6eace5e0c6a..8266e632f76 100644 --- a/dart/moq/lib/audio.dart +++ b/dart/moq/lib/audio.dart @@ -22,7 +22,7 @@ typedef Consumer = MoqAudioConsumer; /// The PCM layout written to a [Producer]. typedef EncoderInput = MoqAudioEncoderInput; -/// The codec side: codec, track name, rate, channels, bitrate, preset, and frame duration (0 takes the codec's own frame). +/// The codec side: codec, track name, rate, channels, bitrate, and frame duration (0 takes the codec's own frame). typedef EncoderOutput = MoqAudioEncoderOutput; /// The PCM layout a [Consumer] delivers, how stale a group may get, and its playout delay. @@ -43,9 +43,6 @@ typedef Codec = MoqAudioCodec; /// A raw PCM sample layout. typedef SampleFormat = MoqAudioSampleFormat; -/// How the encoder trades latency for compression; null in [EncoderOutput] means `balanced`. -typedef Preset = MoqAudioPreset; - /// Duration view over a PCM frame's timestamp. extension AudioFrameDuration on MoqAudioFrame { /// Presentation timestamp of the first sample. diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 01e4836c8ec..b03c452f2f7 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -698,7 +698,7 @@ void main() { output: moq_audio.EncoderOutput( codec: moq_audio.Codec.opus(), track: 'mic', - preset: moq_audio.Preset.lowLatency, + frameDurationUs: 10000, ), ); producer.write( diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index a7f3a9a6bf6..3540a4d1d29 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -264,7 +264,6 @@ class MoqAudioEncoderOutput { final int? channels; final int? bitrate; final int frameDurationUs; - final MoqAudioPreset? preset; MoqAudioEncoderOutput({ required this.codec, this.track = null, @@ -272,7 +271,6 @@ class MoqAudioEncoderOutput { this.channels = null, this.bitrate = null, this.frameDurationUs = 0, - this.preset = null, }); } @@ -313,11 +311,6 @@ class FfiConverterMoqAudioEncoderOutput { ); final frameDurationUs = frameDurationUs_lifted.value; new_offset += frameDurationUs_lifted.bytesRead; - final preset_lifted = FfiConverterOptionalMoqAudioPreset.read( - Uint8List.view(buf.buffer, new_offset), - ); - final preset = preset_lifted.value; - new_offset += preset_lifted.bytesRead; return LiftRetVal( MoqAudioEncoderOutput( codec: codec, @@ -326,7 +319,6 @@ class FfiConverterMoqAudioEncoderOutput { channels: channels, bitrate: bitrate, frameDurationUs: frameDurationUs, - preset: preset, ), new_offset - buf.offsetInBytes, ); @@ -340,7 +332,6 @@ class FfiConverterMoqAudioEncoderOutput { FfiConverterOptionalUInt32.allocationSize(value.channels) + FfiConverterOptionalUInt32.allocationSize(value.bitrate) + FfiConverterUInt32.allocationSize(value.frameDurationUs) + - FfiConverterOptionalMoqAudioPreset.allocationSize(value.preset) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -373,10 +364,6 @@ class FfiConverterMoqAudioEncoderOutput { value.frameDurationUs, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalMoqAudioPreset.write( - value.preset, - Uint8List.view(buf.buffer, new_offset), - ); return new_offset - buf.offsetInBytes; } @@ -387,7 +374,6 @@ class FfiConverterMoqAudioEncoderOutput { FfiConverterOptionalUInt32.allocationSize(value.channels) + FfiConverterOptionalUInt32.allocationSize(value.bitrate) + FfiConverterUInt32.allocationSize(value.frameDurationUs) + - FfiConverterOptionalMoqAudioPreset.allocationSize(value.preset) + 0; } } @@ -4257,44 +4243,6 @@ class FfiConverterMoqVideoPacket { } } -enum MoqAudioPreset { lowLatency, balanced, quality } - -class FfiConverterMoqAudioPreset { - static LiftRetVal read(Uint8List buf) { - final index = buf.buffer.asByteData(buf.offsetInBytes).getInt32(0); - switch (index) { - case 1: - return LiftRetVal(MoqAudioPreset.lowLatency, 4); - case 2: - return LiftRetVal(MoqAudioPreset.balanced, 4); - case 3: - return LiftRetVal(MoqAudioPreset.quality, 4); - default: - throw UniffiInternalError( - UniffiInternalError.unexpectedEnumCase, - "Unable to determine enum variant", - ); - } - } - - static MoqAudioPreset lift(RustBuffer buffer) { - return FfiConverterMoqAudioPreset.read(buffer.asUint8List()).value; - } - - static RustBuffer lower(MoqAudioPreset input) { - return toRustBuffer(createUint8ListFromInt(input.index + 1)); - } - - static int allocationSize(MoqAudioPreset _value) { - return 4; - } - - static int write(MoqAudioPreset value, Uint8List buf) { - buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, value.index + 1); - return 4; - } -} - enum MoqAudioSampleFormat { u8, s16, @@ -13748,53 +13696,6 @@ class FfiConverterOptionalMoqAudioFrame { } } -class FfiConverterOptionalMoqAudioPreset { - static MoqAudioPreset? lift(RustBuffer buf) { - return FfiConverterOptionalMoqAudioPreset.read(buf.asUint8List()).value; - } - - static LiftRetVal read(Uint8List buf) { - if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { - return LiftRetVal(null, 1); - } - final result = FfiConverterMoqAudioPreset.read( - Uint8List.view(buf.buffer, buf.offsetInBytes + 1), - ); - return LiftRetVal(result.value, result.bytesRead + 1); - } - - static int allocationSize([MoqAudioPreset? value]) { - if (value == null) { - return 1; - } - return FfiConverterMoqAudioPreset.allocationSize(value) + 1; - } - - static RustBuffer lower(MoqAudioPreset? value) { - if (value == null) { - return toRustBuffer(Uint8List.fromList([0])); - } - final buf = Uint8List( - FfiConverterOptionalMoqAudioPreset.allocationSize(value), - ); - FfiConverterOptionalMoqAudioPreset.write(value, buf); - return toRustBuffer(buf); - } - - static int write(MoqAudioPreset? value, Uint8List buf) { - if (value == null) { - buf[0] = 0; - return 1; - } - buf[0] = 1; - return FfiConverterMoqAudioPreset.write( - value, - Uint8List.view(buf.buffer, buf.offsetInBytes + 1), - ) + - 1; - } -} - class FfiConverterOptionalMoqBandwidth { static MoqBandwidth? lift(RustBuffer buf) { return FfiConverterOptionalMoqBandwidth.read(buf.asUint8List()).value; diff --git a/doc/lib/rs/moq-audio.md b/doc/lib/rs/moq-audio.md index 3caa4561e73..31a1fefb092 100644 --- a/doc/lib/rs/moq-audio.md +++ b/doc/lib/rs/moq-audio.md @@ -23,8 +23,8 @@ capture or playback is enabled. The microphone opens only while someone listens, and can be swapped without changing the track subscribers know. Opus packs 20 ms per packet by default, -or 10 ms with the low-latency preset. Groups carry at least 20 ms by default, -so the low-latency preset pairs packets. `encode::Options::group_duration` +or 10 ms with a shorter `frame_duration`. Groups carry at least 20 ms by default, +so 10 ms packets pair up. `encode::Options::group_duration` changes that minimum; zero puts every packet in its own group. Longer groups mean fewer streams per relay but coarser loss. There is no AAC encoder, so an AAC encode request is refused; AAC decode is for broadcasts from the ingest gateways. diff --git a/go/wrapper/audio/audio.go b/go/wrapper/audio/audio.go index 2966e3ed39c..4a8b54f33c6 100644 --- a/go/wrapper/audio/audio.go +++ b/go/wrapper/audio/audio.go @@ -29,20 +29,7 @@ type Packet = ffi.MoqAudioPacket // EncoderFinish holds the packets Encoder.Finish drains and the padded samples to discard. type EncoderFinish = ffi.MoqAudioEncoderFinish -// Preset is how the encoder trades latency for compression; a nil EncoderOutput.Preset is PresetBalanced. -type Preset = ffi.MoqAudioPreset - -const ( - // PresetLowLatency packs 10 ms Opus packets, at twice the packet rate. - PresetLowLatency = ffi.MoqAudioPresetLowLatency - // PresetBalanced packs 20 ms Opus packets, the default. - PresetBalanced = ffi.MoqAudioPresetBalanced - // PresetQuality favors compression; 20 ms Opus packets today, the same as PresetBalanced. - PresetQuality = ffi.MoqAudioPresetQuality -) - -// EncoderOutput configures the codec side: codec, track name, rate, channels, bitrate, frame duration (0 takes the preset's or codec's own frame), and preset. -// Preset is refused alongside a nonzero FrameDuration. +// EncoderOutput configures the codec side: codec, track name, rate, channels, bitrate, and frame duration (0 takes the codec's own frame; 10 ms Opus halves the packetization delay). type EncoderOutput struct { Codec Codec Track *string @@ -50,7 +37,6 @@ type EncoderOutput struct { Channels *uint32 Bitrate *uint32 FrameDuration time.Duration - Preset *Preset } func (o EncoderOutput) ffi() (ffi.MoqAudioEncoderOutput, error) { @@ -58,7 +44,7 @@ func (o EncoderOutput) ffi() (ffi.MoqAudioEncoderOutput, error) { if err == nil && frame > math.MaxUint32 { err = fmt.Errorf("audio frame duration %v exceeds %d microseconds", o.FrameDuration, uint32(math.MaxUint32)) } - return ffi.MoqAudioEncoderOutput{Codec: o.Codec, Track: o.Track, SampleRate: o.SampleRate, Channels: o.Channels, Bitrate: o.Bitrate, FrameDurationUs: uint32(frame), Preset: o.Preset}, err + return ffi.MoqAudioEncoderOutput{Codec: o.Codec, Track: o.Track, SampleRate: o.SampleRate, Channels: o.Channels, Bitrate: o.Bitrate, FrameDurationUs: uint32(frame)}, err } // DecoderOutput configures the PCM a Consumer delivers, how stale a group may get before it is skipped, and the playout delay. diff --git a/go/wrapper/types.go b/go/wrapper/types.go index c55269463c2..b754a0f71aa 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -17,7 +17,7 @@ type ( AudioDecoderOutput = ffi.MoqAudioDecoderOutput // AudioEncoderInput declares the PCM sample format, sample rate, and channel count of frames written to an audio producer. AudioEncoderInput = ffi.MoqAudioEncoderInput - // AudioEncoderOutput configures the encoder: codec, optional sample rate, channels, bitrate, frame duration, and preset. + // AudioEncoderOutput configures the encoder: codec, optional sample rate, channels, bitrate, and frame duration. AudioEncoderOutput = ffi.MoqAudioEncoderOutput // AudioSampleFormat is a raw PCM sample layout, mirroring WebCodecs AudioData.format. AudioSampleFormat = ffi.MoqAudioSampleFormat diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 016772c6ba9..1b6d8e4d045 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -135,7 +135,7 @@ typealias VideoDecoderOutput = uniffi.moq.MoqVideoDecoderOutput typealias VideoDecodedFrame = uniffi.moq.MoqVideoDecodedFrame /** The PCM layout the caller feeds an audio producer. */ typealias AudioEncoderInput = uniffi.moq.MoqAudioEncoderInput -/** The codec-side encoder configuration: codec, output rate/channels, bitrate, frame duration, and preset. */ +/** The codec-side encoder configuration: codec, output rate/channels, bitrate, and frame duration. */ typealias AudioEncoderOutput = uniffi.moq.MoqAudioEncoderOutput /** One video frame: pixels in the configured layout plus a presentation timestamp. */ typealias VideoFrame = uniffi.moq.MoqVideoFrame diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt index bbead7c43fc..a024b8e6c31 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt @@ -8,7 +8,7 @@ typealias Encoder = uniffi.moq.MoqAudioEncoder typealias Consumer = uniffi.moq.MoqAudioConsumer /** The PCM layout written to a [Producer]. */ typealias EncoderInput = uniffi.moq.MoqAudioEncoderInput -/** The codec side: codec, track name, rate, channels, bitrate, frame duration (0 takes the preset's or codec's own frame), and preset. */ +/** The codec side: codec, track name, rate, channels, bitrate, and frame duration (0 takes the codec's own frame). */ typealias EncoderOutput = uniffi.moq.MoqAudioEncoderOutput /** The PCM layout a [Consumer] delivers, how stale a group may get, and its playout delay. */ typealias DecoderOutput = uniffi.moq.MoqAudioDecoderOutput @@ -20,7 +20,5 @@ typealias Packet = uniffi.moq.MoqAudioPacket typealias EncoderFinish = uniffi.moq.MoqAudioEncoderFinish /** The audio codec: `Codec.opus()` or `Codec.aac()`. */ typealias Codec = uniffi.moq.MoqAudioCodec -/** How the encoder trades latency for compression; `null` is `BALANCED`. */ -typealias Preset = uniffi.moq.MoqAudioPreset /** A raw PCM sample layout. */ typealias SampleFormat = uniffi.moq.MoqAudioSampleFormat diff --git a/py/moq-rs/moq/audio.py b/py/moq-rs/moq/audio.py index da3e5380b54..e25972e5987 100644 --- a/py/moq-rs/moq/audio.py +++ b/py/moq-rs/moq/audio.py @@ -24,7 +24,6 @@ from .subscribe import BroadcastConsumer from .types import AudioCodec as Codec from .types import AudioEncoderInput as EncoderInput -from .types import AudioPreset as Preset from .types import AudioSampleFormat as SampleFormat Packet = MoqAudioPacket @@ -53,8 +52,8 @@ def _from_ffi(value: MoqAudioFrame) -> Frame: class EncoderOutput: """Codec-side settings. - A zero ``frame_duration`` takes the ``preset``'s frame, or the codec's own. - ``preset`` defaults to ``Balanced`` and is refused alongside a nonzero ``frame_duration``. + A zero ``frame_duration`` takes the codec's own frame: 20 ms for Opus, 1024 + samples for AAC. 10 ms Opus packets halve the packetization delay. """ codec: Codec @@ -63,7 +62,6 @@ class EncoderOutput: channels: int | None = None bitrate: int | None = None frame_duration: timedelta = timedelta(0) - preset: Preset | None = None def _ffi(self) -> MoqAudioEncoderOutput: return MoqAudioEncoderOutput( @@ -73,7 +71,6 @@ def _ffi(self) -> MoqAudioEncoderOutput: channels=self.channels, bitrate=self.bitrate, frame_duration_us=_to_us(self.frame_duration, "frame_duration"), - preset=self.preset, ) @@ -220,7 +217,6 @@ def finish(self, pcm: list[float] | None = None) -> EncoderFinish: "Frame", "Codec", "Packet", - "Preset", "Producer", "SampleFormat", ] diff --git a/py/moq-rs/moq/types.py b/py/moq-rs/moq/types.py index 262b69d98ee..6d4426a6bd0 100644 --- a/py/moq-rs/moq/types.py +++ b/py/moq-rs/moq/types.py @@ -12,9 +12,6 @@ from moq_ffi import ( MoqAudioEncoderInput as AudioEncoderInput, ) -from moq_ffi import ( - MoqAudioPreset as AudioPreset, -) from moq_ffi import ( MoqAudioSampleFormat as AudioSampleFormat, ) @@ -70,7 +67,6 @@ "Anchor", "AudioCodec", "AudioEncoderInput", - "AudioPreset", "AudioSampleFormat", "Backoff", "ConnectionStats", diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index c3318208144..193915cad8e 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -1349,10 +1349,10 @@ def test_audio_encoder_returns_namespaced_records(): assert isinstance(finish, moq.audio.EncoderFinish) -def test_audio_encoder_preset_init_and_bitrate(): - """A preset sets the frame size, the encoder reports its OpusHead, and the bitrate retunes live.""" +def test_audio_encoder_frame_init_and_bitrate(): + """A frame duration sets the frame size, the encoder reports its OpusHead, and the bitrate retunes live.""" output = moq.audio.EncoderOutput( - codec=moq.AudioCodec.opus(), sample_rate=48000, channels=1, preset=moq.audio.Preset.LOW_LATENCY + codec=moq.AudioCodec.opus(), sample_rate=48000, channels=1, frame_duration=timedelta(milliseconds=10) ) encoder = moq.audio.Encoder(output) assert encoder.frame_size == 480 @@ -1363,7 +1363,7 @@ def test_audio_encoder_preset_init_and_bitrate(): assert encoder.encode([0.0] * encoder.frame_size).data with pytest.raises(moq.Error.Audio): # type: ignore[attr-defined] - moq.audio.Encoder(replace(output, frame_duration=timedelta(milliseconds=20))) + moq.audio.Encoder(replace(output, frame_duration=timedelta(milliseconds=15))) def test_video_namespace_names_its_configuration_types(): diff --git a/rs/moq-audio/MIGRATE.md b/rs/moq-audio/MIGRATE.md index 6efb57e67c9..8a86d79bb97 100644 --- a/rs/moq-audio/MIGRATE.md +++ b/rs/moq-audio/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `encode::Settings` is `encode::Config` + +`encode::Settings` is `encode::Config`, matching `decode::Config` and `moq_video::encode::Config`. +`encode::Options::settings` is `config`, and `encode::Encoder::settings()` is `config()`. + +### `Kind::Named` is removed + +`encode::Kind::Named` and `decode::Kind::Named` only checked the codec, which the encoder already takes from `Config::codec` and the decoder from the catalog. +Drop the variant and use `Kind::Auto`. + ### Decoded reads carry encoder ends (#5687) `decode::Consumer::read()` returns `decode::Read` events instead of bare frames: `Frame { discontinuity, frame }` and `End { discontinuity, end }`; `None` still means the track is finished. diff --git a/rs/moq-audio/src/decode/backend/mod.rs b/rs/moq-audio/src/decode/backend/mod.rs index 046e5b8fdd0..32c359facb5 100644 --- a/rs/moq-audio/src/decode/backend/mod.rs +++ b/rs/moq-audio/src/decode/backend/mod.rs @@ -6,8 +6,7 @@ //! //! [`open`] tries the platform decoders before the software ones, skipping any //! that does not advertise the catalog codec, and refuses when none opens the -//! track. [`Kind::Named`] names the codec, not a backend, so which backend -//! decodes it stays the library's choice. The software tier is libopus for Opus, a passthrough for PCM, and +//! track. Which backend decodes a codec stays the library's choice. The software tier is libopus for Opus, a passthrough for PCM, and //! symphonia for AAC-LC mono and stereo (behind the `aac` feature). No platform //! decoder is wired in yet. @@ -88,39 +87,19 @@ const SOFTWARE: &[Candidate] = &[ }, ]; -/// The name [`Kind::Named`] uses for a catalog codec, `None` for one this crate has no name for. -fn codec_name(codec: &AudioCodec) -> Option<&'static str> { - match codec { - AudioCodec::Opus => Some("opus"), - AudioCodec::Pcm => Some("pcm"), - AudioCodec::AAC(_) => Some("aac"), - _ => None, - } -} - /// Open the first backend that advertises the catalog codec and accepts the track. pub(crate) fn open(catalog: &AudioConfig, config: &Config) -> Result, Error> { - if let Kind::Named(requested) = &config.kind - && codec_name(&catalog.codec) != Some(requested) - { - return Err(Error::Unsupported(format!( - "audio decoder {requested:?} is unavailable for {}", - catalog.codec - ))); - } - - select(catalog, candidates(&config.kind, PLATFORM, SOFTWARE)) + select(catalog, candidates(config.kind, PLATFORM, SOFTWARE)) } /// The candidates `kind` allows, in the order to try them. /// /// Takes the tiers as arguments so a test can supply stubs instead of whatever /// this host compiles in. -fn candidates<'a>(kind: &Kind, platform: &'a [Candidate], software: &'a [Candidate]) -> Vec<&'a Candidate> { +fn candidates<'a>(kind: Kind, platform: &'a [Candidate], software: &'a [Candidate]) -> Vec<&'a Candidate> { match kind { Kind::Software => software.iter().collect(), - // `open` has already checked that a name matches the codec. - Kind::Auto | Kind::Named(_) => platform.iter().chain(software).collect(), + Kind::Auto => platform.iter().chain(software).collect(), } } @@ -217,7 +196,7 @@ mod tests { } fn pick(kind: Kind, platform: &[Candidate], software: &[Candidate]) -> Result { - let backend = select(&opus(), candidates(&kind, platform, software))?; + let backend = select(&opus(), candidates(kind, platform, software))?; Ok(backend.name().to_owned()) } @@ -239,13 +218,6 @@ mod tests { assert_eq!(name, "software"); } - /// A codec name is not a backend selector: it tries the same tiers as `Auto`. - #[test] - fn named_codec_picks_like_auto() { - let name = pick(Kind::Named("opus".into()), &[PLATFORM_STUB], &[SOFTWARE_STUB]).unwrap(); - assert_eq!(name, "platform"); - } - #[test] fn every_refusal_is_reported() { const ALSO_REFUSING: Candidate = Candidate { @@ -260,34 +232,4 @@ mod tests { "{message}" ); } - - fn named(name: &str) -> Config { - Config { - kind: Kind::Named(name.into()), - } - } - - /// The codec names published moq-audio accepts still open their codec. - #[test] - fn codec_names_open_their_codec() { - let pcm = AudioConfig::new(AudioCodec::Pcm, 48_000, 2); - assert_eq!(open(&pcm, &named("pcm")).unwrap().name(), pcm::NAME); - assert_eq!(open(&opus(), &named("opus")).unwrap().name(), libopus::NAME); - } - - /// A name for another codec is refused, not quietly swapped for one that decodes. - #[test] - fn another_codecs_name_is_refused() { - assert!(matches!(open(&opus(), &named("pcm")), Err(Error::Unsupported(_)))); - } - - /// Backend names are internal: asking for one is refused like any unknown name. - #[test] - fn backend_names_are_refused() { - let message = open(&opus(), &named(libopus::NAME)) - .err() - .expect("libopus is a backend, not a codec") - .to_string(); - assert!(message.contains("\"libopus\"") && message.contains("opus"), "{message}"); - } } diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index c39554a5641..e2cc020b587 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -702,7 +702,7 @@ mod tests { use moq_net::Timestamp; use super::*; - use crate::encode::{Encoder, Input, Options as EncodeOptions, Producer, Settings}; + use crate::encode::{Config as EncodeConfig, Encoder, Input, Options as EncodeOptions, Producer}; use crate::{Format, Layout}; async fn warmup_output(start: Start, declared: bool) -> (Vec, usize) { @@ -1053,11 +1053,11 @@ mod tests { }; let options = EncodeOptions { track: Some("audio".to_string()), - settings: Settings::new(48_000, Layout::Mono), + config: EncodeConfig::new(48_000, Layout::Mono), ..EncodeOptions::default() }; let mut producer = Producer::new(&mut broadcast, catalog, input.clone(), &options).unwrap(); - let mut catalog = Encoder::new(&Settings::new(input.sample_rate, input.layout)) + let mut catalog = Encoder::new(&EncodeConfig::new(input.sample_rate, input.layout)) .unwrap() .catalog(); // Every packet counts here; the warmup tests cover trimming. @@ -1372,11 +1372,11 @@ mod tests { #[tokio::test] async fn resampling_keeps_the_activity_boundary_on_its_source() { - let mut encoder = Encoder::new(&Settings { + let mut encoder = Encoder::new(&EncodeConfig { dtx: true, bitrate: Some(moq_net::bandwidth::Rate::from_bps(24_000)), frame_duration: std::time::Duration::from_millis(10), - ..Settings::new(48_000, Layout::Mono) + ..EncodeConfig::new(48_000, Layout::Mono) }) .unwrap(); let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 1); @@ -1589,10 +1589,10 @@ mod tests { /// before the jump and are labelled by the packet they came from. #[tokio::test] async fn a_terminal_jump_leaves_the_held_samples_alone() { - let mut encoder = Encoder::new(&Settings { + let mut encoder = Encoder::new(&EncodeConfig { dtx: true, bitrate: Some(moq_net::bandwidth::Rate::from_bps(24_000)), - ..Settings::new(48_000, Layout::Mono) + ..EncodeConfig::new(48_000, Layout::Mono) }) .unwrap(); let mut catalog = encoder.catalog(); @@ -1710,7 +1710,7 @@ mod tests { sample_rate: 48_000, layout: Layout::Mono, }; - let mut encoder = Encoder::new(&Settings::new(input.sample_rate, input.layout)).unwrap(); + let mut encoder = Encoder::new(&EncodeConfig::new(input.sample_rate, input.layout)).unwrap(); let mut catalog = encoder.catalog(); // Every packet counts here; the warmup tests cover trimming. catalog.warmup = None; @@ -1884,7 +1884,7 @@ mod tests { /// would shrink the term by exactly that much. #[tokio::test] async fn opus_states_its_own_frame_duration() { - let mut encoder = Encoder::new(&Settings::new(48_000, Layout::Mono)).unwrap(); + let mut encoder = Encoder::new(&EncodeConfig::new(48_000, Layout::Mono)).unwrap(); let mut catalog = encoder.catalog(); // Every packet counts here; the warmup tests cover trimming. catalog.warmup = None; @@ -1980,7 +1980,7 @@ mod tests { sample_rate: 48_000, layout: Layout::Mono, }; - let mut encoder = Encoder::new(&Settings::new(input.sample_rate, input.layout)).unwrap(); + let mut encoder = Encoder::new(&EncodeConfig::new(input.sample_rate, input.layout)).unwrap(); let mut catalog = encoder.catalog(); // Every packet counts here; the warmup tests cover trimming. catalog.warmup = None; @@ -2033,7 +2033,7 @@ mod tests { sample_rate: 48_000, layout: Layout::Mono, }; - let mut encoder = Encoder::new(&Settings::new(input.sample_rate, input.layout)).unwrap(); + let mut encoder = Encoder::new(&EncodeConfig::new(input.sample_rate, input.layout)).unwrap(); let mut catalog = encoder.catalog(); // Every packet counts here; the warmup tests cover trimming. catalog.warmup = None; @@ -2151,7 +2151,7 @@ mod tests { }; // One real Opus packet, so a mis-framed read can't accidentally decode. - let mut encoder = Encoder::new(&Settings::new(input.sample_rate, input.layout)).unwrap(); + let mut encoder = Encoder::new(&EncodeConfig::new(input.sample_rate, input.layout)).unwrap(); let mut catalog = encoder.catalog(); // Every packet counts here; the warmup tests cover trimming. catalog.warmup = None; diff --git a/rs/moq-audio/src/decode/decoder.rs b/rs/moq-audio/src/decode/decoder.rs index 7b600582fd6..5ed9969e4d5 100644 --- a/rs/moq-audio/src/decode/decoder.rs +++ b/rs/moq-audio/src/decode/decoder.rs @@ -9,7 +9,7 @@ use super::backend::{self, Backend}; use crate::{Error, Layout}; /// Decoder backend selection. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[non_exhaustive] pub enum Kind { /// Prefer a platform decoder, falling back to software. @@ -17,10 +17,6 @@ pub enum Kind { Auto, /// Require a software backend. Software, - /// Require the track to be this codec, by its lowercase name: `"opus"`, - /// `"pcm"`, or `"aac"`. Any other codec is refused. The library still picks - /// the backend, platform first. - Named(String), } /// Low-level decoder configuration. @@ -230,7 +226,7 @@ pub(crate) mod tests { /// Real Opus: 20 ms packets of a continuous 440 Hz sine, mono, from libopus /// with its 312-sample lookahead. pub(crate) fn opus_packets(count: usize) -> Vec { - let mut encoder = crate::encode::Encoder::new(&crate::encode::Settings::new(48_000, Layout::Mono)).unwrap(); + let mut encoder = crate::encode::Encoder::new(&crate::encode::Config::new(48_000, Layout::Mono)).unwrap(); let frames = encoder.frame_size(); (0..count) .map(|packet| { @@ -404,31 +400,4 @@ pub(crate) mod tests { Err(Error::Unsupported(_)) )); } - - /// Published moq-audio accepts the codec's name, not a backend's. - #[cfg(feature = "aac")] - #[test] - fn named_aac_decodes_aac() { - let config = Config { - kind: Kind::Named("aac".into()), - }; - assert!(Decoder::new(&aac_catalog(), &config).is_ok()); - - let config = Config { - kind: Kind::Named("opus".into()), - }; - assert!(matches!( - Decoder::new(&aac_catalog(), &config), - Err(Error::Unsupported(_)) - )); - } - - #[test] - fn refuses_unavailable_backend() { - let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2); - let config = Config { - kind: Kind::Named("missing".into()), - }; - assert!(matches!(Decoder::new(&catalog, &config), Err(Error::Unsupported(_)))); - } } diff --git a/rs/moq-audio/src/encode/backend/libopus.rs b/rs/moq-audio/src/encode/backend/libopus.rs index 118c701be46..b8fc8a7ffce 100644 --- a/rs/moq-audio/src/encode/backend/libopus.rs +++ b/rs/moq-audio/src/encode/backend/libopus.rs @@ -8,7 +8,7 @@ use unsafe_libopus::{ }; use super::Backend; -use crate::encode::{Encoded, Settings}; +use crate::encode::{Config, Encoded}; use crate::{Error, opus}; pub(super) const NAME: &str = "libopus"; @@ -32,16 +32,16 @@ pub(super) struct Libopus { unsafe impl Send for Libopus {} impl Libopus { - /// Opens at the settings' rate and layout, which the front end has already + /// Opens at the config's rate and layout, which the front end has already /// checked against what Opus codes. - pub(super) fn open(settings: &Settings) -> Result, Error> { - Ok(Box::new(Self::new(settings)?)) + pub(super) fn open(config: &Config) -> Result, Error> { + Ok(Box::new(Self::new(config)?)) } - fn new(settings: &Settings) -> Result { - let sample_rate = settings.sample_rate; - let channels = settings.layout.channels(); - let frame_size = opus::frame_size(sample_rate, settings.frame_duration)?; + fn new(config: &Config) -> Result { + let sample_rate = config.sample_rate; + let channels = config.layout.channels(); + let frame_size = opus::frame_size(sample_rate, config.frame_duration)?; let mut err = 0i32; // SAFETY: out-pointer `err` is valid; inner is checked for null below. @@ -68,10 +68,10 @@ impl Libopus { lookahead: 0, }; - if let Some(bitrate) = settings.bitrate { + if let Some(bitrate) = config.bitrate { backend.set_rate(bitrate.as_bps())?; } - backend.set_ctl(OPUS_SET_DTX_REQUEST, i32::from(settings.dtx), "OPUS_SET_DTX")?; + backend.set_ctl(OPUS_SET_DTX_REQUEST, i32::from(config.dtx), "OPUS_SET_DTX")?; let bitrate = backend.get_ctl(OPUS_GET_BITRATE_REQUEST, "OPUS_GET_BITRATE")?; backend.bitrate = u64::try_from(bitrate) @@ -180,9 +180,9 @@ mod tests { #[test] fn runtime_bitrate_reaches_libopus() { - let mut backend = Libopus::new(&Settings { + let mut backend = Libopus::new(&Config { bitrate: Some(moq_net::bandwidth::Rate::from_bps(64_000)), - ..Settings::default() + ..Config::default() }) .unwrap(); @@ -196,9 +196,9 @@ mod tests { #[test] fn applies_dtx_control() { - let backend = Libopus::new(&Settings { + let backend = Libopus::new(&Config { dtx: true, - ..Settings::default() + ..Config::default() }) .unwrap(); diff --git a/rs/moq-audio/src/encode/backend/mod.rs b/rs/moq-audio/src/encode/backend/mod.rs index 97f4dbdc025..7cd7eb031ba 100644 --- a/rs/moq-audio/src/encode/backend/mod.rs +++ b/rs/moq-audio/src/encode/backend/mod.rs @@ -2,21 +2,20 @@ //! //! The mirror of the decode backends. [`Backend`] is the seam between the codec //! and the [`Encoder`](super::Encoder) front end, which owns what every backend -//! of a codec shares: validating [`Settings`](super::Settings) against the +//! of a codec shares: validating [`Config`](super::Config) against the //! codec, framing the input, draining the startup delay at the end, and the -//! catalog entry, whose description is synthesized from the settings rather than +//! catalog entry, whose description is synthesized from the config rather than //! read back from the backend. //! //! [`open`] tries the platform encoders before the software ones, skipping any -//! that does not emit the codec, and refuses when none opens. [`Kind::Named`] -//! names the codec, not a backend, so which backend encodes it stays the -//! library's choice. The software tier -//! is libopus for Opus and a passthrough for PCM. AAC has no software encoder, +//! that does not emit the codec, and refuses when none opens. Which backend +//! encodes a codec stays the library's choice. The software tier is libopus for +//! Opus and a passthrough for PCM. AAC has no software encoder, //! so it is only as available as the platform's, and no platform encoder is //! wired in yet. use super::Encoded; -use super::encoder::{Codec, Kind, Settings}; +use super::encoder::{Codec, Config, Kind}; use crate::Error; mod libopus; @@ -27,8 +26,8 @@ pub(crate) mod stub; /// An opened encoder: one frame of interleaved `f32` PCM in, one packet out. /// -/// Input arrives at the settings' rate, in the crate's canonical channel order -/// for the settings' layout; a codec with another native order reorders it here. +/// Input arrives at the config's rate, in the crate's canonical channel order +/// for the config's layout; a codec with another native order reorders it here. /// /// One packet per frame is part of the contract: the producer stamps packets by /// counting frames. A codec that pipelines output (MediaCodec) needs a `flush` @@ -67,7 +66,7 @@ pub(crate) trait Backend: Send + std::panic::UnwindSafe + std::panic::RefUnwindS struct Candidate { name: &'static str, codecs: &'static [Codec], - open: fn(&Settings) -> Result, Error>, + open: fn(&Config) -> Result, Error>, } /// Operating-system encoders, in priority order. @@ -95,17 +94,8 @@ const STUB_PLATFORM: &[Candidate] = &[Candidate { open: stub::Stub::open, }]; -/// Open the first backend that emits the codec and accepts the settings. -pub(crate) fn open(settings: &Settings) -> Result, Error> { - if let Kind::Named(requested) = &settings.kind - && requested != settings.codec.as_str() - { - return Err(Error::Unsupported(format!( - "audio encoder {requested:?} is unavailable for {}", - settings.codec - ))); - } - +/// Open the first backend that emits the codec and accepts the config. +pub(crate) fn open(config: &Config) -> Result, Error> { #[cfg(test)] let platform = match stub::installed() { true => STUB_PLATFORM, @@ -114,30 +104,29 @@ pub(crate) fn open(settings: &Settings) -> Result, Error> { #[cfg(not(test))] let platform = PLATFORM; - select(settings, candidates(&settings.kind, platform, SOFTWARE)) + select(config, candidates(config.kind, platform, SOFTWARE)) } /// The candidates `kind` allows, in the order to try them. /// /// Takes the tiers as arguments so a test can supply stubs instead of whatever /// this host compiles in. -fn candidates<'a>(kind: &Kind, platform: &'a [Candidate], software: &'a [Candidate]) -> Vec<&'a Candidate> { +fn candidates<'a>(kind: Kind, platform: &'a [Candidate], software: &'a [Candidate]) -> Vec<&'a Candidate> { match kind { Kind::Software => software.iter().collect(), - // `open` has already checked that a name matches the codec. - Kind::Auto | Kind::Named(_) => platform.iter().chain(software).collect(), + Kind::Auto => platform.iter().chain(software).collect(), } } -fn select(settings: &Settings, candidates: Vec<&Candidate>) -> Result, Error> { - let codec = settings.codec; +fn select(config: &Config, candidates: Vec<&Candidate>) -> Result, Error> { + let codec = config.codec; let mut refused = Vec::new(); for candidate in candidates { if !candidate.codecs.contains(&codec) { continue; } - match (candidate.open)(settings) { + match (candidate.open)(config) { Ok(backend) => return Ok(backend), Err(err) => refused.push((candidate.name, err)), } @@ -163,9 +152,9 @@ fn select(settings: &Settings, candidates: Vec<&Candidate>) -> Result "none".to_owned(), false => available.join(", "), }; - Err(Error::Unsupported(match &settings.kind { + Err(Error::Unsupported(match config.kind { Kind::Software => format!("no software {codec} audio encoder (this build has: {available})"), - Kind::Auto | Kind::Named(_) => format!("no {codec} audio encoder (this build has: {available})"), + Kind::Auto => format!("no {codec} audio encoder (this build has: {available})"), })) } @@ -206,12 +195,12 @@ mod tests { open: |_| Ok(Box::new(Fake("platform"))), }; - /// Compiled in but refusing the settings, like a platform encoder asked for a + /// Compiled in but refusing the config, like a platform encoder asked for a /// layout its framework does not open. const REFUSING: Candidate = Candidate { name: "refusing", codecs: &[Codec::Aac], - open: |_| Err(Error::Unsupported("not these settings".into())), + open: |_| Err(Error::Unsupported("not these config".into())), }; const SOFTWARE_STUB: Candidate = Candidate { @@ -227,16 +216,16 @@ mod tests { open: |_| panic!("opened for a codec it does not emit"), }; - fn aac(kind: Kind) -> Settings { - Settings { + fn aac(kind: Kind) -> Config { + Config { kind, - ..Settings::from_input(Codec::Aac, &Default::default()) + ..Config::from_input(Codec::Aac, &Default::default()) } } fn pick(kind: Kind, platform: &[Candidate], software: &[Candidate]) -> Result { - let settings = aac(kind); - let backend = select(&settings, candidates(&settings.kind, platform, software))?; + let config = aac(kind); + let backend = select(&config, candidates(config.kind, platform, software))?; Ok(backend.name().to_owned()) } @@ -258,13 +247,6 @@ mod tests { assert_eq!(name, "software"); } - /// A codec name is not a backend selector: it tries the same tiers as `Auto`. - #[test] - fn named_codec_picks_like_auto() { - let name = pick(Kind::Named("aac".into()), &[PLATFORM_STUB], &[SOFTWARE_STUB]).unwrap(); - assert_eq!(name, "platform"); - } - #[test] fn every_refusal_is_reported() { const ALSO_REFUSING: Candidate = Candidate { @@ -300,37 +282,4 @@ mod tests { let err = open(&aac(Kind::Software)).err().expect("no software AAC encoder"); assert!(err.to_string().contains("no software aac"), "{err}"); } - - fn named(codec: Codec, name: &str) -> Settings { - Settings { - codec, - kind: Kind::Named(name.into()), - ..Settings::default() - } - } - - /// A codec's own name opens that codec, like `Auto`. - #[test] - fn codec_names_open_their_codec() { - let _stub = stub::install(); - assert_eq!(open(&named(Codec::Opus, "opus")).unwrap().name(), libopus::NAME); - assert_eq!(open(&named(Codec::Pcm, "pcm")).unwrap().name(), pcm::NAME); - assert_eq!(open(&named(Codec::Aac, "aac")).unwrap().name(), stub::NAME); - } - - /// A name for another codec is refused, not quietly swapped for the settings' codec. - #[test] - fn another_codecs_name_is_refused() { - assert!(matches!(open(&named(Codec::Opus, "pcm")), Err(Error::Unsupported(_)))); - } - - /// Backend names are internal: asking for one is refused like any unknown name. - #[test] - fn backend_names_are_refused() { - let message = open(&named(Codec::Opus, libopus::NAME)) - .err() - .expect("libopus is a backend, not a codec") - .to_string(); - assert!(message.contains("\"libopus\"") && message.contains("opus"), "{message}"); - } } diff --git a/rs/moq-audio/src/encode/backend/pcm.rs b/rs/moq-audio/src/encode/backend/pcm.rs index 62a2382a536..2892cb92cc5 100644 --- a/rs/moq-audio/src/encode/backend/pcm.rs +++ b/rs/moq-audio/src/encode/backend/pcm.rs @@ -1,7 +1,7 @@ //! Uncompressed little-endian `f32` PCM, which needs no codec at all. use super::Backend; -use crate::encode::{Encoded, Settings}; +use crate::encode::{Config, Encoded}; use crate::{Error, Layout, pcm}; pub(super) const NAME: &str = "pcm"; @@ -13,15 +13,15 @@ pub(super) struct Pcm { impl Pcm { /// The catalog carries only a channel count, so a named layout that count /// would decode as another one is refused rather than relabeled. - pub(super) fn open(settings: &Settings) -> Result, Error> { - let layout = settings.layout; + pub(super) fn open(config: &Config) -> Result, Error> { + let layout = config.layout; if !matches!(layout, Layout::Discrete(_)) && Layout::from_channels(layout.channels())? != layout { return Err(Error::Unsupported(format!( "pcm carries only a channel count, which decodes as {:?}, not {layout:?}", Layout::from_channels(layout.channels())? ))); } - let bitrate = pcm::bitrate(settings.sample_rate, settings.layout.channels())?; + let bitrate = pcm::bitrate(config.sample_rate, config.layout.channels())?; Ok(Box::new(Self { bitrate })) } } diff --git a/rs/moq-audio/src/encode/backend/stub.rs b/rs/moq-audio/src/encode/backend/stub.rs index a64cf664689..00cd1da758e 100644 --- a/rs/moq-audio/src/encode/backend/stub.rs +++ b/rs/moq-audio/src/encode/backend/stub.rs @@ -8,7 +8,7 @@ use bytes::Bytes; use super::Backend; use crate::Error; -use crate::encode::{Encoded, Settings}; +use crate::encode::{Config, Encoded}; pub(crate) const NAME: &str = "stub"; @@ -46,9 +46,9 @@ pub(crate) struct Stub { } impl Stub { - pub(super) fn open(settings: &Settings) -> Result, Error> { + pub(super) fn open(config: &Config) -> Result, Error> { Ok(Box::new(Self { - bitrate: settings.bitrate.map_or(128_000, |rate| rate.as_bps()), + bitrate: config.bitrate.map_or(128_000, |rate| rate.as_bps()), frames: 0, })) } diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 38300a6b750..e5c8c6c072e 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -1499,7 +1499,7 @@ mod tests { let mut options = Capture::default(); options.capture.source = capture::Source::Microphone(Some("first".into())); options.encode.track = Some("audio".into()); - options.encode.settings.layout = PcmLayout::from_channels(channels).unwrap(); + options.encode.config.layout = PcmLayout::from_channels(channels).unwrap(); let (control, driver) = Control::build(broadcast, catalog.clone(), options, Supervisor::exact()).unwrap(); let subscription = consumer.track("audio").unwrap().subscribe(None).await.unwrap(); (control, driver, source(opens, false), subscription, catalog) diff --git a/rs/moq-audio/src/encode/encoder.rs b/rs/moq-audio/src/encode/encoder.rs index 348a002825a..2578871d4af 100644 --- a/rs/moq-audio/src/encode/encoder.rs +++ b/rs/moq-audio/src/encode/encoder.rs @@ -1,6 +1,6 @@ //! Audio encoder front end. //! -//! [`Encoder`] checks [`Settings`] against the codec, opens a +//! [`Encoder`] checks [`Config`] against the codec, opens a //! [`Backend`](super::backend::Backend) for it, and owns what every backend of a //! codec shares: framing, the terminal drain, and the catalog entry. @@ -72,7 +72,7 @@ impl FromStr for Codec { } /// Encoder backend selection. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[non_exhaustive] pub enum Kind { /// Prefer a platform encoder, falling back to software. @@ -80,10 +80,6 @@ pub enum Kind { Auto, /// Require a software backend. Software, - /// Require [`Settings::codec`] to be this codec, by its lowercase name: - /// `"opus"`, `"pcm"`, or `"aac"`. Any other codec is refused. The library - /// still picks the backend, platform first. - Named(String), } /// PCM supplied to [`Producer::write`](super::Producer::write). @@ -115,83 +111,10 @@ impl Default for Input { } } -/// How an encoder trades latency for compression, applied with -/// [`Settings::with_preset`]. -/// -/// Mirrors `moq_video::encode::Preset`. A preset sets packetization only: -/// codec, sample rate, layout, bitrate, and DTX stay as configured. The -/// packet duration is a packetization setting, not a delay guarantee: Opus adds -/// its own 6.5 ms lookahead, and transport and playout buffering are separate. -/// [`Encoder::applied`] reports what took effect. -/// -/// `#[non_exhaustive]` so a later policy can be added without breaking a -/// `match`. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum Preset { - /// 10 ms packets: the shortest packetization Opus codes with its full - /// toolset, at twice the packet rate. - LowLatency, - /// 20 ms packets, the Opus default and what [`Settings::new`] builds. - #[default] - Balanced, - /// 20 ms packets. Identical to [`Balanced`](Self::Balanced) today: - /// libopus already runs at full complexity, and a longer packet would only - /// add delay. - Quality, -} - -impl Preset { - /// The packet duration this preset codes at. - fn frame_duration(self) -> Duration { - match self { - Self::LowLatency => Duration::from_millis(10), - Self::Balanced | Self::Quality => Duration::from_millis(20), - } - } -} - -/// The packetization an encoder actually applied, as reported by -/// [`Encoder::applied`]. -/// -/// Mirrors `moq_video::encode::Applied`. A report, not a request: it names the -/// preset whose packet duration the encoder runs at, so [`Preset::Quality`] -/// reports [`Preset::Balanced`], whose 20 ms it shares. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -#[non_exhaustive] -pub struct Applied { - /// The preset whose controls took effect, or `None` when the frame duration - /// matches none: a custom [`Settings::frame_duration`], or AAC's fixed - /// 1024-sample frames. - pub preset: Option, - /// The controls that took effect, for display, e.g. `"10ms packets"`. Not a - /// parse target. - pub controls: String, -} - -impl Applied { - /// What `settings` apply once validated. - fn new(settings: &Settings) -> Self { - if settings.codec == Codec::Aac { - return Self { - preset: None, - controls: format!("{AAC_FRAME_SIZE}-sample frames"), - }; - } - let preset = [Preset::LowLatency, Preset::Balanced] - .into_iter() - .find(|preset| preset.frame_duration() == settings.frame_duration); - Self { - preset, - controls: format!("{:?} packets", settings.frame_duration), - } - } -} - -/// Audio codec settings shared by [`Encoder`] and [`Producer`](super::Producer). +/// Audio codec config shared by [`Encoder`] and [`Producer`](super::Producer). #[derive(Clone, Debug)] #[non_exhaustive] -pub struct Settings { +pub struct Config { /// Output codec. Defaults to [`Codec::Opus`]. pub codec: Codec, /// Sample rate accepted by the codec. @@ -219,49 +142,23 @@ pub struct Settings { pub frame_duration: Duration, /// Which encoder implementation to use. pub kind: Kind, - /// The preset last given to [`with_preset`](Self::with_preset), read back - /// with [`preset`](Self::preset). Crate-visible so the crate's struct - /// literals can still end in `..Settings::default()`. - pub(crate) preset: Preset, } -impl Settings { - /// Build default Opus settings for `sample_rate` and `layout`, packetized - /// by the default [`Preset`]. +impl Config { + /// Build default Opus config for `sample_rate` and `layout`, in 20 ms packets. pub fn new(sample_rate: u32, layout: Layout) -> Self { - let preset = Preset::default(); Self { codec: Codec::default(), sample_rate, layout, bitrate: None, dtx: false, - frame_duration: preset.frame_duration(), + frame_duration: Duration::from_millis(20), kind: Kind::Auto, - preset, - } - } - - /// Store `preset` and apply its packetization, keeping every other setting. - /// - /// AAC frames are always 1024 samples, so AAC keeps its frame duration. - pub fn with_preset(mut self, preset: Preset) -> Self { - self.preset = preset; - if self.codec != Codec::Aac { - self.frame_duration = preset.frame_duration(); } - self } - /// The preset given to [`with_preset`](Self::with_preset), or the default. - /// - /// It echoes the request and goes stale if `codec` or `frame_duration` is - /// changed afterward; [`Encoder::applied`] reports what took effect. - pub fn preset(&self) -> Preset { - self.preset - } - - /// Derive concrete codec settings from source PCM. + /// Derive concrete codec config from source PCM. pub fn from_input(codec: Codec, input: &Input) -> Self { let sample_rate = match codec { Codec::Opus => opus::pick_rate(input.sample_rate), @@ -279,10 +176,10 @@ impl Settings { } } - /// Check the settings against the codec, returning its frame size. + /// Check the config against the codec, returning its frame size. /// /// Codec rules live here rather than in a backend, so every backend of a - /// codec refuses the same settings. + /// codec refuses the same config. fn frame_size(&self) -> Result { self.layout.validate()?; let (rate, channels) = (self.sample_rate, self.layout.channels()); @@ -298,7 +195,7 @@ impl Settings { Codec::Pcm => { if self.bitrate.is_some() { return Err(Error::Unsupported( - "pcm bitrate is fixed; leave Settings::bitrate unset".into(), + "pcm bitrate is fixed; leave Config::bitrate unset".into(), )); } if self.dtx { @@ -335,7 +232,7 @@ impl Settings { } } -impl Default for Settings { +impl Default for Config { fn default() -> Self { Self::new(48_000, Layout::Stereo) } @@ -350,14 +247,14 @@ fn aac_frame_duration(sample_rate: u32) -> Duration { Duration::from_nanos((AAC_FRAME_SIZE as u64 * 1_000_000_000 + rate / 2) / rate) } -/// The AudioSpecificConfig fields for AAC-LC at the settings' rate and layout. +/// The AudioSpecificConfig fields for AAC-LC at the config's rate and layout. /// /// Only layouts with a channelConfiguration are accepted, since synthesizing /// one from a bare count would mislabel the rest: config 3 is 3.0 where the /// count's default layout is 2.1, and 6.1 has no config the encoder writes. /// 7.1 takes config 7, the one every decoder reads as eight channels. -fn aac_config(settings: &Settings) -> Result { - let layout = settings.layout; +fn aac_config(config: &Config) -> Result { + let layout = config.layout; if !matches!( layout, Layout::Mono @@ -373,7 +270,7 @@ fn aac_config(settings: &Settings) -> Result ))); } - let sample_rate = settings.sample_rate; + let sample_rate = config.sample_rate; if !(1..=AAC_MAX_SAMPLE_RATE).contains(&sample_rate) { return Err(Error::Unsupported(format!( "aac sample rate must be between 1 and {AAC_MAX_SAMPLE_RATE} Hz (got {sample_rate})" @@ -395,11 +292,9 @@ fn aac_config(settings: &Settings) -> Result /// the terminal [`Finish::discard_padding`] when the container supports it. pub struct Encoder { backend: Box, - settings: Settings, - /// What the settings applied, fixed at open. - applied: Applied, + config: Config, frame_size: usize, - /// The catalog description, synthesized from the settings at construction so + /// The catalog description, synthesized from the config at construction so /// the rendition can be registered before the first packet exists. description: Option, /// Whether input has reached the codec, since a fresh encoder owes no drain. @@ -430,32 +325,31 @@ impl Finish { } impl Encoder { - /// Open an encoder for `settings`, refusing a codec no backend on this host + /// Open an encoder for `config`, refusing a codec no backend on this host /// encodes. - pub fn new(settings: &Settings) -> Result { - let frame_size = settings.frame_size()?; - let backend = backend::open(settings)?; + pub fn new(config: &Config) -> Result { + let frame_size = config.frame_size()?; + let backend = backend::open(config)?; - let description = match settings.codec { + let description = match config.codec { Codec::Opus => { // OpusHead carries the lookahead in the 48 kHz timebase. let lookahead = backend.delay() as u64; - let pre_skip = u16::try_from((lookahead * 48_000) / u64::from(settings.sample_rate)) + let pre_skip = u16::try_from((lookahead * 48_000) / u64::from(config.sample_rate)) .map_err(|_| Error::Unsupported(format!("Opus lookahead {lookahead} does not fit in OpusHead")))?; - let head = moq_mux::codec::opus::Config::new(settings.sample_rate, settings.layout.channels()) + let head = moq_mux::codec::opus::Config::new(config.sample_rate, config.layout.channels()) .with_pre_skip(pre_skip) .encode() .map_err(moq_mux::Error::from)?; Some(head) } - Codec::Aac => Some(aac_config(settings)?.encode().map_err(moq_mux::Error::from)?), + Codec::Aac => Some(aac_config(config)?.encode().map_err(moq_mux::Error::from)?), Codec::Pcm => None, }; Ok(Self { backend, - settings: settings.clone(), - applied: Applied::new(settings), + config: config.clone(), frame_size, description, started: false, @@ -467,32 +361,27 @@ impl Encoder { self.backend.name() } - /// The packetization applied for [`Settings::preset`]. - pub fn applied(&self) -> &Applied { - &self.applied - } - - /// The encoder settings, including the latest accepted runtime bitrate. - pub fn settings(&self) -> &Settings { - &self.settings + /// The encoder config, including the latest accepted runtime bitrate. + pub fn config(&self) -> &Config { + &self.config } /// The codec this encoder emits. A [`Producer`](super::Producer) must be /// built for the same codec to publish its packets. pub fn codec(&self) -> Codec { - self.settings.codec + self.config.codec } /// Sample rate the codec actually runs at, which is - /// [`Settings::sample_rate`]. + /// [`Config::sample_rate`]. pub fn codec_rate(&self) -> u32 { - self.settings.sample_rate + self.config.sample_rate } /// Channel count the codec actually runs at, which is - /// [`Settings::layout`]'s channel count. + /// [`Config::layout`]'s channel count. pub fn codec_channels(&self) -> u32 { - self.settings.layout.channels() + self.config.layout.channels() } /// Number of samples per channel the codec consumes per call to @@ -518,7 +407,7 @@ impl Encoder { let previous = self.backend.bitrate(); self.backend.set_bitrate(bitrate.as_bps())?; if bitrate.as_bps() != previous { - self.settings.bitrate = Some(bitrate); + self.config.bitrate = Some(bitrate); } Ok(()) } @@ -542,7 +431,7 @@ impl Encoder { /// AAC packet is stamped that much earlier and the priming lands before the /// first input sample rather than delaying it. pub(super) fn folded_delay(&self) -> usize { - match self.settings.codec { + match self.config.codec { Codec::Aac => self.backend.delay(), Codec::Opus | Codec::Pcm => 0, } @@ -636,10 +525,10 @@ impl Encoder { /// hang catalog entry describing this encoder's output stream. pub fn catalog(&self) -> hang::catalog::AudioConfig { let (rate, channels) = (self.codec_rate(), self.codec_channels()); - let (codec, bitrate): (hang::catalog::AudioCodec, _) = match self.settings.codec { + let (codec, bitrate): (hang::catalog::AudioCodec, _) = match self.config.codec { Codec::Opus => ( hang::catalog::AudioCodec::Opus, - self.settings.bitrate.map(moq_net::bandwidth::Rate::as_bps), + self.config.bitrate.map(moq_net::bandwidth::Rate::as_bps), ), Codec::Pcm => ( hang::catalog::AudioCodec::Pcm, @@ -647,7 +536,7 @@ impl Encoder { ), Codec::Aac => ( hang::catalog::AAC { profile: AAC_LC }.into(), - self.settings.bitrate.map(moq_net::bandwidth::Rate::as_bps), + self.config.bitrate.map(moq_net::bandwidth::Rate::as_bps), ), }; @@ -655,7 +544,7 @@ impl Encoder { config.bitrate = bitrate; config.description = self.description.clone(); config.container = hang::catalog::Container::Legacy; - if matches!(self.settings.codec, Codec::Opus) { + if matches!(self.config.codec, Codec::Opus) { config.warmup = Some(moq_mux::codec::opus::WARMUP); } config @@ -688,9 +577,9 @@ mod tests { #[test] fn opus_encode_then_decode_keeps_signal_close() { - let mut enc = Encoder::new(&Settings { + let mut enc = Encoder::new(&Config { bitrate: Some(moq_net::bandwidth::Rate::from_bps(96_000)), - ..Settings::default() + ..Config::default() }) .unwrap(); @@ -718,87 +607,44 @@ mod tests { #[test] fn opus_rejects_unsupported_frame_duration() { - let err = Encoder::new(&Settings { + let err = Encoder::new(&Config { frame_duration: Duration::from_millis(15), - ..Settings::default() + ..Config::default() }); assert!(matches!(err, Err(Error::Unsupported(_)))); } - /// A preset has to reach the codec as its packet duration, read back off the - /// packet's TOC rather than our own settings, and leave everything else alone. - /// The settings keep the requested preset; the encoder reports the applied one. + /// The frame duration has to reach the codec as its packet duration, read + /// back off the packet's TOC rather than our own config. #[test] - fn preset_sets_the_packet_duration_and_keeps_the_rest() { - for (preset, samples, applied) in [ - (Preset::LowLatency, 480, Preset::LowLatency), - (Preset::Balanced, 960, Preset::Balanced), - (Preset::Quality, 960, Preset::Balanced), - ] { - let settings = Settings { - bitrate: Some(moq_net::bandwidth::Rate::from_bps(96_000)), + fn frame_duration_reaches_the_packet() { + for (millis, samples) in [(10, 480), (20, 960)] { + let config = Config { layout: Layout::Mono, - ..Settings::default() - } - .with_preset(preset); - let mut enc = Encoder::new(&settings).unwrap(); - assert_eq!(enc.settings().layout, Layout::Mono); - assert_eq!(enc.settings().preset(), preset); - assert_eq!(enc.applied().preset, Some(applied), "{preset:?}"); - assert_eq!(enc.bitrate().as_bps(), 96_000); + frame_duration: Duration::from_millis(millis), + ..Config::default() + }; + let mut enc = Encoder::new(&config).unwrap(); assert_eq!(enc.frame_size(), samples); let packet = enc.encode(&vec![0.0f32; samples]).unwrap(); // SAFETY: a non-empty packet from the encoder above; the TOC is its first byte. let coded = unsafe { unsafe_libopus::opus_packet_get_samples_per_frame(packet.payload.as_ptr(), 48_000) }; - assert_eq!(coded as usize, samples, "{preset:?} packet"); - - let input = Input::new(48_000, Layout::Stereo); - let aac = Settings::from_input(Codec::Aac, &input).with_preset(preset); - assert_eq!(aac.preset(), preset); - assert_eq!( - aac.frame_duration, - Settings::from_input(Codec::Aac, &input).frame_duration - ); - assert_eq!(aac.frame_size().unwrap(), AAC_FRAME_SIZE, "{preset:?} aac"); - assert_eq!(stub(&aac).unwrap().applied().preset, None, "{preset:?} aac"); + assert_eq!(coded as usize, samples, "{millis} ms packet"); } } - /// Settings without a preset are the default preset, and say so. - #[test] - fn default_settings_apply_the_default_preset() { - let settings = Settings::default(); - assert_eq!(settings.preset(), Preset::default()); - let enc = Encoder::new(&settings).unwrap(); - assert_eq!(enc.applied().preset, Some(Preset::default())); - assert_eq!(enc.applied().controls, "20ms packets"); - } - - /// A frame duration no preset uses is reported as such, not as the request. - #[test] - fn custom_frame_duration_applies_no_preset() { - let settings = Settings { - frame_duration: Duration::from_micros(2_500), - ..Settings::default().with_preset(Preset::LowLatency) - }; - let enc = Encoder::new(&settings).unwrap(); - assert_eq!(enc.settings().preset(), Preset::LowLatency); - assert_eq!(enc.applied().preset, None); - assert_eq!(enc.applied().controls, "2.5ms packets"); - } - #[test] fn opus_rejects_misaligned_input() { - let mut enc = Encoder::new(&Settings::default()).unwrap(); + let mut enc = Encoder::new(&Config::default()).unwrap(); assert!(matches!(enc.encode(&[0.0f32; 100]), Err(Error::Misaligned { .. }))); } #[test] fn opus_catalog_includes_opushead() { - let enc = Encoder::new(&Settings { + let enc = Encoder::new(&Config { bitrate: Some(moq_net::bandwidth::Rate::from_bps(64_000)), - ..Settings::default() + ..Config::default() }) .unwrap(); let cfg = enc.catalog(); @@ -814,7 +660,7 @@ mod tests { #[test] fn opus_decoder_trims_encoder_lookahead_once() { - let mut enc = Encoder::new(&Settings::default()).unwrap(); + let mut enc = Encoder::new(&Config::default()).unwrap(); let mut dec = Decoder::new(&enc.catalog(), &DecodeConfig::default()).unwrap(); let frame = vec![0.0; enc.frame_size() * enc.codec_channels() as usize]; @@ -830,7 +676,7 @@ mod tests { #[test] fn opus_finish_accounts_for_partial_frame_padding() { - let enc = Encoder::new(&Settings::new(48_000, Layout::Mono)).unwrap(); + let enc = Encoder::new(&Config::new(48_000, Layout::Mono)).unwrap(); // The 360 frames of terminal padding exceed the 312-frame lookahead, // so the partial packet itself completes the drain. @@ -841,9 +687,9 @@ mod tests { #[test] fn opus_finish_drains_lookahead_across_multiple_short_packets() { - let mut enc = Encoder::new(&Settings { + let mut enc = Encoder::new(&Config { frame_duration: Duration::from_micros(2_500), - ..Settings::new(48_000, Layout::Mono) + ..Config::new(48_000, Layout::Mono) }) .unwrap(); let frame = vec![0.0; enc.frame_size()]; @@ -857,7 +703,7 @@ mod tests { #[test] fn reset_drops_pending_opus_lookahead() { - let mut enc = Encoder::new(&Settings::new(48_000, Layout::Mono)).unwrap(); + let mut enc = Encoder::new(&Config::new(48_000, Layout::Mono)).unwrap(); let mut old = vec![0.0; enc.frame_size()]; old[enc.frame_size() - 1] = 1.0; enc.encode(&old).unwrap(); @@ -881,20 +727,20 @@ mod tests { #[test] fn opus_runtime_bitrate_updates_encoder_state() { - let mut enc = Encoder::new(&Settings { + let mut enc = Encoder::new(&Config { bitrate: Some(moq_net::bandwidth::Rate::from_bps(64_000)), - ..Settings::default() + ..Config::default() }) .unwrap(); enc.set_bitrate(moq_net::bandwidth::Rate::from_bps(32_000)).unwrap(); assert_eq!(enc.bitrate(), moq_net::bandwidth::Rate::from_bps(32_000)); - assert_eq!(enc.settings().bitrate, Some(moq_net::bandwidth::Rate::from_bps(32_000))); + assert_eq!(enc.config().bitrate, Some(moq_net::bandwidth::Rate::from_bps(32_000))); } #[test] fn opus_runtime_bitrate_rejects_values_libopus_would_clamp() { - let mut enc = Encoder::new(&Settings::default()).unwrap(); + let mut enc = Encoder::new(&Config::default()).unwrap(); let original = enc.bitrate(); assert!(enc.set_bitrate(moq_net::bandwidth::Rate::from_bps(1)).is_err()); assert!(enc.set_bitrate(moq_net::bandwidth::Rate::from_bps(600_001)).is_err()); @@ -903,7 +749,7 @@ mod tests { #[test] fn settings_fix_the_codec_rate() { - let enc = Encoder::new(&Settings::new(24_000, Layout::Mono)).unwrap(); + let enc = Encoder::new(&Config::new(24_000, Layout::Mono)).unwrap(); assert_eq!(enc.codec_rate(), 24_000); let catalog = enc.catalog(); assert_eq!(catalog.sample_rate, 24_000); @@ -913,9 +759,9 @@ mod tests { #[test] fn pcm_roundtrip_is_lossless() { - let mut enc = Encoder::new(&Settings { + let mut enc = Encoder::new(&Config { codec: Codec::Pcm, - ..Settings::default() + ..Config::default() }) .unwrap(); let mut dec = Decoder::new(&enc.catalog(), &DecodeConfig::default()).unwrap(); @@ -932,17 +778,17 @@ mod tests { #[test] fn pcm_refuses_layouts_its_count_cannot_name() { for layout in [Layout::ThreePointZero, Layout::FourPointZero] { - let err = Encoder::new(&Settings { + let err = Encoder::new(&Config { codec: Codec::Pcm, - ..Settings::new(48_000, layout) + ..Config::new(48_000, layout) }); assert!(matches!(err, Err(Error::Unsupported(_))), "{layout:?}"); } for layout in [Layout::TwoPointOne, Layout::Quad, Layout::Discrete(3)] { - let mut enc = Encoder::new(&Settings { + let mut enc = Encoder::new(&Config { codec: Codec::Pcm, - ..Settings::new(48_000, layout) + ..Config::new(48_000, layout) }) .unwrap(); let mut dec = Decoder::new(&enc.catalog(), &DecodeConfig::default()).unwrap(); @@ -954,9 +800,9 @@ mod tests { #[test] fn pcm_catalog_declares_fixed_bitrate() { - let enc = Encoder::new(&Settings { + let enc = Encoder::new(&Config { codec: Codec::Pcm, - ..Settings::default() + ..Config::default() }) .unwrap(); let catalog = enc.catalog(); @@ -968,9 +814,9 @@ mod tests { #[test] fn pcm_rejects_runtime_bitrate_change() { - let mut enc = Encoder::new(&Settings { + let mut enc = Encoder::new(&Config { codec: Codec::Pcm, - ..Settings::default() + ..Config::default() }) .unwrap(); let bitrate = enc.bitrate(); @@ -981,43 +827,43 @@ mod tests { #[test] fn pcm_rejects_fractional_sample_frame_duration() { - let err = Encoder::new(&Settings { + let err = Encoder::new(&Config { codec: Codec::Pcm, frame_duration: Duration::from_micros(2_500), - ..Settings::new(44_100, Layout::Stereo) + ..Config::new(44_100, Layout::Stereo) }); assert!(matches!(err, Err(Error::Unsupported(_)))); } #[test] fn pcm_rejects_bitrate_overflow() { - let err = Encoder::new(&Settings { + let err = Encoder::new(&Config { codec: Codec::Pcm, frame_duration: Duration::from_secs(1), - ..Settings::new(u32::MAX, Layout::Discrete(u32::MAX)) + ..Config::new(u32::MAX, Layout::Discrete(u32::MAX)) }); assert!(matches!(err, Err(Error::Unsupported(_)))); } #[test] fn pcm_rejects_opus_only_settings() { - let settings = Settings { + let config = Config { codec: Codec::Pcm, dtx: true, - ..Settings::default() + ..Config::default() }; - assert!(matches!(Encoder::new(&settings), Err(Error::Unsupported(_)))); + assert!(matches!(Encoder::new(&config), Err(Error::Unsupported(_)))); } /// The catalog carries only a count, so a discrete layout comes back as the /// count's default one, with the samples untouched. #[test] fn pcm_passes_discrete_multichannel_samples_through() { - let settings = Settings { + let config = Config { codec: Codec::Pcm, - ..Settings::new(48_000, Layout::Discrete(3)) + ..Config::new(48_000, Layout::Discrete(3)) }; - let mut encoder = Encoder::new(&settings).unwrap(); + let mut encoder = Encoder::new(&config).unwrap(); let catalog = encoder.catalog(); let mut decoder = Decoder::new(&catalog, &DecodeConfig::default()).unwrap(); let input = [0.1, 0.2, 0.3].repeat(encoder.frame_size()); @@ -1029,18 +875,18 @@ mod tests { #[test] fn opus_refuses_discrete_layout() { - let settings = Settings::new(48_000, Layout::Discrete(2)); - assert!(matches!(Encoder::new(&settings), Err(Error::Unsupported(_)))); + let config = Config::new(48_000, Layout::Discrete(2)); + assert!(matches!(Encoder::new(&config), Err(Error::Unsupported(_)))); } - fn aac(layout: Layout) -> Settings { - Settings::from_input(Codec::Aac, &Input::new(48_000, layout)) + fn aac(layout: Layout) -> Config { + Config::from_input(Codec::Aac, &Input::new(48_000, layout)) } - /// Open `settings` on the test stub, since this host has no AAC encoder. - fn stub(settings: &Settings) -> Result { + /// Open `config` on the test stub, since this host has no AAC encoder. + fn stub(config: &Config) -> Result { let _stub = backend::stub::install(); - Encoder::new(settings) + Encoder::new(config) } /// Without the stub, the public constructor sees the real tiers and refuses. @@ -1052,7 +898,7 @@ mod tests { assert!(matches!(err, Error::Unsupported(_)), "{err}"); } - /// The ASC is synthesized from the settings, so it exists before any packet. + /// The ASC is synthesized from the config, so it exists before any packet. #[test] fn aac_catalog_carries_the_synthesized_asc() { let enc = stub(&aac(Layout::Stereo)).unwrap(); @@ -1105,33 +951,33 @@ mod tests { #[test] fn aac_frame_duration_is_the_codecs() { assert_eq!(aac(Layout::Stereo).frame_duration, Duration::from_nanos(21_333_333)); - let settings = Settings { + let config = Config { frame_duration: Duration::from_micros(21_333), ..aac(Layout::Stereo) }; - assert_eq!(stub(&settings).unwrap().frame_size(), 1024); + assert_eq!(stub(&config).unwrap().frame_size(), 1024); - let settings = Settings { + let config = Config { frame_duration: Duration::from_millis(20), ..aac(Layout::Stereo) }; - let err = stub(&settings).err().expect("20 ms is 960 samples"); + let err = stub(&config).err().expect("20 ms is 960 samples"); assert!(err.to_string().contains("1024"), "{err}"); } #[test] fn aac_refuses_dtx() { - let settings = Settings { + let config = Config { dtx: true, ..aac(Layout::Stereo) }; - assert!(matches!(stub(&settings), Err(Error::Unsupported(_)))); + assert!(matches!(stub(&config), Err(Error::Unsupported(_)))); } /// A backend that can't retune keeps its opening rate. #[test] fn fixed_rate_backend_keeps_its_opening_rate() { - let mut enc = stub(&Settings { + let mut enc = stub(&Config { bitrate: Some(moq_net::bandwidth::Rate::from_bps(96_000)), ..aac(Layout::Stereo) }) @@ -1140,7 +986,7 @@ mod tests { let err = enc.set_bitrate(moq_net::bandwidth::Rate::from_bps(64_000)); assert!(matches!(err, Err(Error::Unsupported(_)))); assert_eq!(enc.bitrate(), moq_net::bandwidth::Rate::from_bps(96_000)); - assert_eq!(enc.settings().bitrate, Some(moq_net::bandwidth::Rate::from_bps(96_000))); + assert_eq!(enc.config().bitrate, Some(moq_net::bandwidth::Rate::from_bps(96_000))); assert_eq!(enc.catalog().bitrate, Some(96_000)); } diff --git a/rs/moq-audio/src/encode/mod.rs b/rs/moq-audio/src/encode/mod.rs index d902d3b158c..9357a5ca4ca 100644 --- a/rs/moq-audio/src/encode/mod.rs +++ b/rs/moq-audio/src/encode/mod.rs @@ -11,8 +11,8 @@ //! [`Producer`] publishes them (bring your own PCM). //! - [`Producer`] alone publishes PCM you hand it, encoding as it goes. //! -//! [`Input`] declares the source PCM while [`Settings`] describes exactly what -//! the codec accepts. [`Options`] combines those settings with publication policy. +//! [`Input`] declares the source PCM while [`Config`] describes exactly what +//! the codec accepts. [`Options`] combines that config with publication policy. //! //! `publish_capture` is unlinked above because it only exists with the `capture` //! feature, so a default-feature rustdoc build has nothing to link to. @@ -26,7 +26,7 @@ mod producer; mod capture; pub use encoded::Encoded; -pub use encoder::{Applied, Codec, Encoder, Finish, Input, Kind, Preset, Settings}; +pub use encoder::{Codec, Config, Encoder, Finish, Input, Kind}; pub use producer::{Options, Producer}; #[cfg(feature = "capture")] diff --git a/rs/moq-audio/src/encode/producer.rs b/rs/moq-audio/src/encode/producer.rs index 5968c94baa6..a5e42cd51f0 100644 --- a/rs/moq-audio/src/encode/producer.rs +++ b/rs/moq-audio/src/encode/producer.rs @@ -7,7 +7,7 @@ use moq_mux::container::Frame as MuxFrame; use moq_net::Timestamp; use super::encoded::Encoded; -use super::encoder::{Encoder, Input, Settings}; +use super::encoder::{Config, Encoder, Input}; use crate::resample::{Remix, Resampler}; use crate::{Activity, Error, Frame}; @@ -22,8 +22,8 @@ pub struct Options { /// (`0.opus`, then `1.opus`, ...), matching how the video side names its /// track. Subscribers find it through the catalog either way. pub track: Option, - /// Codec input and encoded output settings. - pub settings: Settings, + /// Codec input and encoded output config. + pub config: Config, /// The connection's bandwidth, as an allocator over /// [`Session::send_bandwidth`](moq_net::Session::send_bandwidth). /// @@ -46,7 +46,7 @@ pub struct Options { /// loss: a viewer that falls behind skips a whole group, and a lost packet /// stalls the rest of its group until retransmitted. Packets still forward as /// they are encoded rather than buffered until the group fills. A longer - /// [`Settings::frame_duration`] (up to 60 ms for Opus) also packs more audio + /// [`Config::frame_duration`] (up to 60 ms for Opus) also packs more audio /// per group, at the cost of encoder latency. An unfinished group remains open /// across a pause or [`Producer::reanchor`] until the next write; use /// [`Producer::discontinuity`] to close it when capture stops. @@ -57,7 +57,7 @@ impl Default for Options { fn default() -> Self { Self { track: None, - settings: Settings::default(), + config: Config::default(), bandwidth: moq_net::bandwidth::Allocator::unlimited(), group_duration: std::time::Duration::from_millis(20), } @@ -67,7 +67,7 @@ impl Default for Options { /// Encode raw PCM and publish it as a moq-mux audio track. /// /// The input PCM layout is fixed at construction via [`Input`]; the codec -/// settings via [`Options`]. Subsequent [`write`](Self::write) calls just pass a +/// config via [`Options`]. Subsequent [`write`](Self::write) calls just pass a /// [`Frame`]: payload bytes and a timestamp. /// /// The catalog rendition is registered at construction (not on first write), so @@ -137,7 +137,7 @@ impl Reserved { // Mirrors the video side, which derives a unique name from the codec // rather than making every caller invent one. None => broadcast.unique_track( - &format!(".{}", options.settings.codec), + &format!(".{}", options.config.codec), catalog.track_info(hang::catalog::PRIORITY.audio), )?, }; @@ -158,10 +158,10 @@ impl Reserved { /// Separate from [`encode`](Self::encode), which cannot fail, so a layout the /// codec rejects leaves the reservation intact for another input. pub(crate) fn register(&mut self, input: Input, options: &Options) -> Result { - let remix = (input.layout != options.settings.layout) - .then(|| Remix::new(input.layout, options.settings.layout)) + let remix = (input.layout != options.config.layout) + .then(|| Remix::new(input.layout, options.config.layout)) .transpose()?; - let encoder = Encoder::new(&options.settings)?; + let encoder = Encoder::new(&options.config)?; let resampler = if input.sample_rate == encoder.codec_rate() { None @@ -169,7 +169,7 @@ impl Reserved { // Use microsecond precision so 2.5 ms frame_duration (supported by // libopus) doesn't truncate to 2 ms. let chunk_frames = - ((input.sample_rate as u128 * encoder.settings().frame_duration.as_micros()) / 1_000_000) as usize; + ((input.sample_rate as u128 * encoder.config().frame_duration.as_micros()) / 1_000_000) as usize; Some(Resampler::new( input.sample_rate, encoder.codec_rate(), @@ -643,14 +643,14 @@ mod tests { let options = Options { track: Some("audio".to_string()), group_duration: Duration::from_millis(group_ms), - settings: Settings { + config: Config { layout: Layout::Mono, bitrate: Some(moq_net::bandwidth::Rate::from_bps(128_000)), - ..Settings::default() + ..Config::default() }, ..Options::default() }; - let mut decoder_config = Encoder::new(&options.settings).unwrap().catalog(); + let mut decoder_config = Encoder::new(&options.config).unwrap().catalog(); // Every packet counts here; the consumer's warmup tests cover trimming. decoder_config.warmup = None; @@ -704,7 +704,7 @@ mod tests { let consumer = broadcast.consume(); let options = Options { track: Some("audio".to_string()), - settings: Settings::new(48_000, Layout::Mono), + config: Config::new(48_000, Layout::Mono), ..Options::default() }; let mut producer = Producer::new(&mut broadcast, catalog, input.clone(), &options).unwrap(); @@ -827,10 +827,10 @@ mod tests { }; let options = Options { track: Some("audio".to_string()), - settings: Settings::new(48_000, Layout::Mono), + config: Config::new(48_000, Layout::Mono), ..Options::default() }; - let mut decoder_config = Encoder::new(&options.settings).unwrap().catalog(); + let mut decoder_config = Encoder::new(&options.config).unwrap().catalog(); // Every packet counts here; the consumer's warmup tests cover trimming. decoder_config.warmup = None; @@ -879,15 +879,15 @@ mod tests { }; let options = Options { track: Some("audio".to_string()), - settings: Settings { + config: Config { layout: Layout::Mono, bitrate: Some(moq_net::bandwidth::Rate::from_bps(24_000)), dtx: true, - ..Settings::default() + ..Config::default() }, ..Options::default() }; - let mut decoder_config = Encoder::new(&options.settings).unwrap().catalog(); + let mut decoder_config = Encoder::new(&options.config).unwrap().catalog(); // Every packet counts here; the consumer's warmup tests cover trimming. decoder_config.warmup = None; @@ -1030,8 +1030,8 @@ mod tests { track: Some("audio".into()), ..Default::default() }; - options.settings.layout = Layout::Mono; - options.settings.frame_duration = Duration::from_micros(frame_us); + options.config.layout = Layout::Mono; + options.config.frame_duration = Duration::from_micros(frame_us); let mut producer = Producer::new(&mut broadcast, catalog, input, &options).unwrap(); let mut track = consumer .track("audio") @@ -1135,7 +1135,7 @@ mod tests { let input = Input::new(48_000, Layout::Mono); let options = Options { track: Some("audio".to_string()), - settings: Settings::from_input(crate::encode::Codec::Aac, &input), + config: Config::from_input(crate::encode::Codec::Aac, &input), ..Options::default() }; let stub = crate::encode::backend::stub::install(); @@ -1275,19 +1275,15 @@ mod tests { let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); let options = Options { track: Some("audio".into()), - settings: Settings { + config: Config { codec, - ..Settings::default() + ..Config::default() }, ..Options::default() }; let _producer = Producer::new(&mut broadcast, catalog.clone(), Input::default(), &options).unwrap(); let published = catalog.snapshot().audio.renditions.remove("audio").unwrap(); - assert_eq!( - published, - Encoder::new(&options.settings).unwrap().catalog(), - "{codec:?}" - ); + assert_eq!(published, Encoder::new(&options.config).unwrap().catalog(), "{codec:?}"); } } } diff --git a/rs/moq-audio/src/opus.rs b/rs/moq-audio/src/opus.rs index 8b744bc09ee..ae12c28d203 100644 --- a/rs/moq-audio/src/opus.rs +++ b/rs/moq-audio/src/opus.rs @@ -271,7 +271,7 @@ fn parse_size(data: &[u8]) -> Option<(usize, i32)> { /// either of the two paths that do so (SILK coding nothing, and the Opus-level /// silence detector). Nothing else in a conforming encoder produces it except a /// bitrate below libopus's floor, which codes nothing whatever the input; -/// [`Settings::bitrate`](crate::encode::Settings::bitrate) refuses those, so our own +/// [`Config::bitrate`](crate::encode::Config::bitrate) refuses those, so our own /// encoder cannot emit one and this stays exact on both sides. /// /// It deliberately says nothing about the periodic refresh that interrupts a diff --git a/rs/moq-audio/src/playback.rs b/rs/moq-audio/src/playback.rs index 7e241bc80e3..a7300a37e67 100644 --- a/rs/moq-audio/src/playback.rs +++ b/rs/moq-audio/src/playback.rs @@ -112,6 +112,8 @@ impl Engine { let (opened, ready) = tokio::sync::oneshot::channel(); let shared = Arc::new(driver::Shared::default()); + // The audio thread cannot wake the observer itself, so it asks the driver to. + shared.observe.wake_with(commands.clone()); // A canceller holds no `Handle`, so `Shared` is where it reaches the // driver to ask for a retry. #[cfg(feature = "aec")] diff --git a/rs/moq-audio/src/playback/driver.rs b/rs/moq-audio/src/playback/driver.rs index f902d20ce22..c4fa14fe49e 100644 --- a/rs/moq-audio/src/playback/driver.rs +++ b/rs/moq-audio/src/playback/driver.rs @@ -925,6 +925,9 @@ impl Driver { /// Catch the mixer up after a sink was added or dropped. fn sync(&mut self) { + // The audio thread posts a sync once it closes out an observer's measurement. + self.shared.observe.wake(); + if let Some(retired) = &self.retired { // Each of these frees a ring buffer, which is exactly why the mixer // handed it over instead of dropping it itself. diff --git a/rs/moq-audio/src/playback/observe.rs b/rs/moq-audio/src/playback/observe.rs index 852d8d4e959..5a3c8ea599d 100644 --- a/rs/moq-audio/src/playback/observe.rs +++ b/rs/moq-audio/src/playback/observe.rs @@ -10,9 +10,11 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, sync_channel}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::task::{Poll, Waker}; use std::time::Duration; +use super::driver::Commands; use crate::Error; /// Frames per quantum, the Web Audio render quantum. @@ -70,7 +72,7 @@ pub struct Observation { /// still waits for its first samples. pub stalled: bool, /// Gaps that closed since the last read. One still open is reported once a - /// full quantum ends it, or after [`Observer::finish`]. + /// full quantum ends it, or once [`Observer::finish`] resolves. pub gaps: Vec, /// Changes in [`stalled`](Self::stalled) since the last read. pub stalls: Vec, @@ -102,6 +104,11 @@ pub(super) struct Shared { finished: AtomicU64, /// The generation whose end a callback has closed out, or 0. acknowledged: AtomicU64, + /// The [`Observer::finish`] waiting on [`acknowledged`](Self::acknowledged). + /// Woken off the audio thread, which only posts a driver sync. + waker: Mutex>, + /// Posts that sync. Unset in tests that run a ledger without a driver. + driver: OnceLock, events: SyncSender<(u64, Event)>, received: Mutex>, } @@ -120,12 +127,29 @@ impl Default for Shared { overflowed: AtomicU64::new(0), finished: AtomicU64::new(0), acknowledged: AtomicU64::new(0), + waker: Mutex::new(None), + driver: OnceLock::new(), events, received: Mutex::new(received), } } } +impl Shared { + /// Route acknowledgements from the audio thread through `driver`. + pub(super) fn wake_with(&self, driver: Commands) { + let _ = self.driver.set(driver); + } + + /// Wake a [`Observer::finish`] whose measurement a callback closed out. Locks, + /// so the driver thread calls it, never the audio thread. + pub(super) fn wake(&self) { + if let Some(waker) = self.waker.lock().unwrap().take() { + waker.wake(); + } + } +} + /// Reads what an [`Engine`](super::Engine) played, quantum by quantum. /// /// Built by [`Engine::observe`](super::Engine::observe). An engine has at most @@ -184,16 +208,25 @@ impl Observer { Ok(observation) } - /// End the measurement: on the next callback, close a gap still open and - /// stop counting, so the read after it is final. Only the clock moves on. - pub fn finish(&self) { + /// End the measurement now, resolving once the next callback, or the stream + /// going away, has closed a gap still open and stopped counting. The read + /// after it resolves is final; only the clock moves on. + /// + /// Dropping the future still ends the measurement; it only stops waiting. + pub fn finish(&mut self) -> impl Future + '_ { self.shared.finished.store(self.generation, Ordering::Release); - } - - /// Whether a callback, or the stream going away, has closed out the - /// measurement since [`finish`](Self::finish), so the next read is final. - pub fn finished(&self) -> bool { - self.shared.acknowledged.load(Ordering::Acquire) == self.generation + std::future::poll_fn(|cx| { + let shared = &self.shared; + if shared.acknowledged.load(Ordering::Acquire) == self.generation { + return Poll::Ready(()); + } + *shared.waker.lock().unwrap() = Some(cx.waker().clone()); + // Checked again after registering, so an acknowledgement that raced it is not missed. + match shared.acknowledged.load(Ordering::Acquire) == self.generation { + true => Poll::Ready(()), + false => Poll::Pending, + } + }) } } @@ -281,6 +314,10 @@ impl Ledger { if let Some(observer) = self.finished() { // The measurement is over: report what was open, count nothing after it. self.acknowledge(observer); + // Lock-free: the driver thread wakes the waiting observer. + if let Some(driver) = self.shared.driver.get() { + driver.sync(); + } (self.filled, self.missing, self.first, self.power) = (0, 0, None, 0.0); self.frames += frames as u64; self.shared.clock.store(self.nanos(self.frames), Ordering::Relaxed); @@ -419,6 +456,7 @@ impl Drop for Ledger { fn drop(&mut self) { if let Some(observer) = self.finished() { self.acknowledge(observer); + self.shared.wake(); return; } self.close(); @@ -555,10 +593,16 @@ mod tests { let (mut ledger, mut observer) = observed(); add(&mut ledger, 128, 128); add(&mut ledger, 128, 0); - observer.finish(); - assert!(!observer.finished(), "no callback has seen it yet"); - add(&mut ledger, 128, 0); - assert!(observer.finished()); + { + let mut cx = std::task::Context::from_waker(Waker::noop()); + let mut finish = std::pin::pin!(observer.finish()); + assert!( + finish.as_mut().poll(&mut cx).is_pending(), + "no callback has seen it yet" + ); + add(&mut ledger, 128, 0); + assert!(finish.as_mut().poll(&mut cx).is_ready()); + } add(&mut ledger, 128, 128); add(&mut ledger, 128, 0); ledger.add(&buffer(128, 0), 2, 0, false); @@ -579,6 +623,29 @@ mod tests { assert_eq!(observation.clock, ms(128 * 6), "the clock still runs"); } + /// A stream going away closes out a waiting finish and wakes it, since no + /// callback is left to. + #[test] + fn finish_wakes_when_the_stream_goes_away() { + struct Flag(AtomicBool); + impl std::task::Wake for Flag { + fn wake(self: Arc) { + self.0.store(true, Ordering::Release); + } + } + + let (ledger, mut observer) = observed(); + let flag = Arc::new(Flag(AtomicBool::new(false))); + let waker = Waker::from(flag.clone()); + let mut cx = std::task::Context::from_waker(&waker); + let mut finish = std::pin::pin!(observer.finish()); + assert!(finish.as_mut().poll(&mut cx).is_pending()); + + drop(ledger); + assert!(flag.0.load(Ordering::Acquire), "the waiting finish was not woken"); + assert!(finish.as_mut().poll(&mut cx).is_ready()); + } + #[test] fn a_gap_open_across_observers_stays_with_the_first() { let shared = Arc::new(Shared::default()); diff --git a/rs/moq-audio/src/playback/sink.rs b/rs/moq-audio/src/playback/sink.rs index da549e69c70..586cbaf6455 100644 --- a/rs/moq-audio/src/playback/sink.rs +++ b/rs/moq-audio/src/playback/sink.rs @@ -635,8 +635,13 @@ mod tests { for _ in 0..4 { mixer.fill(&mut out, Duration::ZERO); } - observer.finish(); - mixer.fill(&mut out, Duration::ZERO); + { + let mut cx = Context::from_waker(std::task::Waker::noop()); + let mut finish = std::pin::pin!(observer.finish()); + assert!(finish.as_mut().poll(&mut cx).is_pending()); + mixer.fill(&mut out, Duration::ZERO); + assert!(finish.as_mut().poll(&mut cx).is_ready()); + } // Once the tail runs out nothing plays. The gap where it ran out lands in // the buffer before the stall, which the analyzer attributes to the stall. let observation = observer.read().unwrap(); diff --git a/rs/moq-audio/tests/integration/roundtrip.rs b/rs/moq-audio/tests/integration/roundtrip.rs index 09b397d820a..53ed95604d4 100644 --- a/rs/moq-audio/tests/integration/roundtrip.rs +++ b/rs/moq-audio/tests/integration/roundtrip.rs @@ -66,8 +66,8 @@ async fn opus_roundtrip(case: Case) { input.format = case.format; let mut options = encode::Options::default(); options.track = Some("audio".into()); - options.settings = encode::Settings::new(48_000, case.layout); - options.settings.codec = encode::Codec::Opus; + options.config = encode::Config::new(48_000, case.layout); + options.config.codec = encode::Codec::Opus; let mut producer = encode::Producer::new(&mut broadcast, catalog.clone(), input, &options).unwrap(); let channels = case.layout.channels(); let chunk_frames = case.rate as usize / 50; @@ -151,7 +151,7 @@ async fn pcm_round_trip_is_lossless() { let input = encode::Input::new(48_000, Layout::Stereo); let mut options = encode::Options::default(); options.track = Some("pcm".to_string()); - options.settings.codec = encode::Codec::Pcm; + options.config.codec = encode::Codec::Pcm; let mut producer = encode::Producer::new(&mut broadcast, catalog.clone(), input, &options).unwrap(); let samples = sine_f32_interleaved(440.0, 48_000, 2, 960); @@ -198,8 +198,8 @@ async fn pcm_five_one_reads_back_at_any_layout() { let input = encode::Input::new(48_000, Layout::FivePointOne); let mut options = encode::Options::default(); options.track = Some("pcm".to_string()); - options.settings = encode::Settings::new(48_000, Layout::FivePointOne); - options.settings.codec = encode::Codec::Pcm; + options.config = encode::Config::new(48_000, Layout::FivePointOne); + options.config.codec = encode::Codec::Pcm; // A level per speaker: left, right, center, LFE, side left, side right. let speakers = [0.1f32, 0.2, 0.3, 0.4, 0.05, 0.06]; diff --git a/rs/moq-boy/src/audio.rs b/rs/moq-boy/src/audio.rs index 7d9304c7496..1dc8bd3e668 100644 --- a/rs/moq-boy/src/audio.rs +++ b/rs/moq-boy/src/audio.rs @@ -29,8 +29,8 @@ impl AudioEncoder { input.format = moq_audio::Format::S16; let mut options = moq_audio::encode::Options::default(); options.track = Some("audio".to_string()); - options.settings = moq_audio::encode::Settings::from_input(moq_audio::encode::Codec::Opus, &input); - options.settings.bitrate = Some(OPUS_BITRATE); + options.config = moq_audio::encode::Config::from_input(moq_audio::encode::Codec::Opus, &input); + options.config.bitrate = Some(OPUS_BITRATE); let producer = moq_audio::encode::Producer::new(&mut broadcast, catalog, input, &options)?; Ok(Self { producer }) diff --git a/rs/moq-c/src/audio.rs b/rs/moq-c/src/audio.rs index 0fc94c55d41..a1b8e662761 100644 --- a/rs/moq-c/src/audio.rs +++ b/rs/moq-c/src/audio.rs @@ -348,17 +348,16 @@ pub unsafe extern "C" fn moq_encode_audio( let codec = codec_str .parse() .map_err(|_| Error::UnknownFormat(codec_str.to_string()))?; - options.settings = moq_audio::encode::Settings::from_input(codec, &encoder_input); + options.config = moq_audio::encode::Config::from_input(codec, &encoder_input); if let Some(sample_rate) = zeroable(raw_output.sample_rate) { - options.settings.sample_rate = sample_rate; + options.config.sample_rate = sample_rate; } if let Some(channels) = zeroable(raw_output.channels) { - options.settings.layout = moq_audio::Layout::from_channels(channels)?; + options.config.layout = moq_audio::Layout::from_channels(channels)?; } - options.settings.bitrate = - zeroable(raw_output.bitrate).map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); + options.config.bitrate = zeroable(raw_output.bitrate).map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); if let Some(micros) = zeroable(raw_output.frame_duration_us) { - options.settings.frame_duration = Duration::from_micros(micros.into()); + options.config.frame_duration = Duration::from_micros(micros.into()); } let bandwidth = ffi::parse_id_optional(bandwidth)?; diff --git a/rs/moq-cli/src/play/metrics.rs b/rs/moq-cli/src/play/metrics.rs index 645aeb7acd1..10c8d451d06 100644 --- a/rs/moq-cli/src/play/metrics.rs +++ b/rs/moq-cli/src/play/metrics.rs @@ -25,8 +25,8 @@ use super::timeline::Presentation; /// How often a sample is taken, the browser probe's interval. const INTERVAL: Duration = Duration::from_millis(250); -/// Intervals to wait for the speaker to close out the measurement. -const FINISH_TICKS: usize = 4; +/// How long to wait for the speaker to close out the measurement: four samples. +const FINISH_WAIT: Duration = INTERVAL.saturating_mul(4); #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -136,18 +136,11 @@ impl Metrics { if !self.observers.is_empty() { self.sample()?; } - if let Some((observer, _)) = &self.current { - observer.finish(); + if let Some((observer, _)) = &mut self.current { // The audio thread closes the gap on its next buffer. One that never // comes is a device that stopped pulling, which the render clock // already shows, so the wait is bounded rather than hung on it. - for _ in 0..FINISH_TICKS { - self.interval.tick().await; - if observer.finished() { - break; - } - } - if !observer.finished() { + if moq_clock::timeout(FINISH_WAIT, observer.finish()).await.is_err() { tracing::warn!("the speaker never closed out the measurement; the last gap may be missing"); } } diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index f6148f093c3..0c650404740 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -774,7 +774,7 @@ impl CaptureArgs { /// names its track; consumers find it through the catalog either way. fn audio_encode(&self, bandwidth: moq_net::bandwidth::Allocator) -> moq_audio::encode::Options { let mut options = moq_audio::encode::Options::default(); - options.settings.bitrate = self + options.config.bitrate = self .audio_bitrate .map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); options.bandwidth = bandwidth; diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index 9fd0b08a70a..89a1c5367a4 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `MoqAudioEncoderOutput::preset` takes a `MoqAudioPreset` (low latency, balanced, quality). - `MoqAudioEncoder::track_init` returns the init that publishes its packets, and `MoqAudioEncoder::set_bitrate` retunes it live. - `MoqAudioDecoderOutput::delay_us` fixes the audio playout delay, and diff --git a/rs/moq-ffi/src/audio.rs b/rs/moq-ffi/src/audio.rs index 6cc8110e20a..f1d2c305f4a 100644 --- a/rs/moq-ffi/src/audio.rs +++ b/rs/moq-ffi/src/audio.rs @@ -89,28 +89,6 @@ impl MoqAudioCodec { } } -/// How an audio encoder trades latency for compression, mirroring -/// `moq_audio::encode::Preset`. A preset sets packetization only; bitrate stays separate. -#[derive(Clone, Copy, uniffi::Enum)] -pub enum MoqAudioPreset { - /// 10 ms Opus packets, at twice the packet rate. - LowLatency, - /// 20 ms Opus packets, the default. - Balanced, - /// Favor compression; 20 ms Opus packets today, the same as `Balanced`. - Quality, -} - -impl From for moq_audio::encode::Preset { - fn from(preset: MoqAudioPreset) -> Self { - match preset { - MoqAudioPreset::LowLatency => Self::LowLatency, - MoqAudioPreset::Balanced => Self::Balanced, - MoqAudioPreset::Quality => Self::Quality, - } - } -} - /// PCM layout the caller will pass to [`MoqAudioProducer::write`]. #[derive(uniffi::Record)] pub struct MoqAudioEncoderInput { @@ -145,31 +123,20 @@ pub struct MoqAudioEncoderOutput { #[uniffi(default = None)] pub bitrate: Option, /// Encoded frame duration in microseconds. Opus accepts exactly - /// 2500/5000/10000/20000/40000/60000 us. 0 takes the preset's frame, or the - /// codec's own: 20 ms for Opus and 1024 samples for AAC. + /// 2500/5000/10000/20000/40000/60000 us; 10000 trades twice the packet rate + /// for lower latency. 0 takes the codec's own: 20 ms for Opus and 1024 + /// samples for AAC. #[uniffi(default = 0)] pub frame_duration_us: u32, - /// Packetization preset. `None` is `Balanced`. Refused alongside a nonzero - /// `frame_duration_us`; AAC keeps its 1024-sample frames either way. - #[uniffi(default = None)] - pub preset: Option, } impl MoqAudioEncoderOutput { - /// Apply the preset or explicit frame duration to `settings`. - fn packetize(&self, settings: moq_audio::encode::Settings) -> Result { - match (self.preset, self.frame_duration_us) { - (None, 0) => Ok(settings), - (Some(preset), 0) => Ok(settings.with_preset(preset.into())), - (None, us) => { - let mut settings = settings; - settings.frame_duration = Duration::from_micros(us.into()); - Ok(settings) - } - (Some(_), _) => { - Err(moq_audio::Error::Unsupported("set preset or frame_duration_us, not both".into()).into()) - } + /// Apply an explicit frame duration to `config`, keeping the codec's own for 0. + fn packetize(&self, mut config: moq_audio::encode::Config) -> moq_audio::encode::Config { + if self.frame_duration_us != 0 { + config.frame_duration = Duration::from_micros(self.frame_duration_us.into()); } + config } } @@ -239,13 +206,13 @@ pub struct MoqAudioEncoder { inner: std::sync::Mutex>, } -fn audio_settings(output: &MoqAudioEncoderOutput) -> Result { +fn encode_config(output: &MoqAudioEncoderOutput) -> Result { let sample_rate = output.sample_rate.unwrap_or(48_000); let layout = moq_audio::Layout::from_channels(output.channels.unwrap_or(2))?; let input = moq_audio::encode::Input::new(sample_rate, layout); - let mut settings = moq_audio::encode::Settings::from_input(output.codec.codec(), &input); - settings.bitrate = output.bitrate.map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); - output.packetize(settings) + let mut config = moq_audio::encode::Config::from_input(output.codec.codec(), &input); + config.bitrate = output.bitrate.map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); + Ok(output.packetize(config)) } fn audio_packets(packets: impl IntoIterator) -> Vec { @@ -262,7 +229,7 @@ impl MoqAudioEncoder { /// Create an encoder from codec-side output settings. #[uniffi::constructor] pub fn new(output: MoqAudioEncoderOutput) -> Result, MoqError> { - let encoder = moq_audio::encode::Encoder::new(&audio_settings(&output)?)?; + let encoder = moq_audio::encode::Encoder::new(&encode_config(&output)?)?; Ok(Arc::new(Self { inner: std::sync::Mutex::new(Some(encoder)), })) @@ -454,22 +421,22 @@ impl MoqAudioProducer { // than letting the codec derive one. let mut options = moq_audio::encode::Options::default(); options.track = output.track.clone(); - options.settings = moq_audio::encode::Settings::from_input(output.codec.codec(), &input); + options.config = moq_audio::encode::Config::from_input(output.codec.codec(), &input); if let Some(sample_rate) = output.sample_rate { - options.settings.sample_rate = sample_rate; + options.config.sample_rate = sample_rate; } if let Some(channels) = output.channels { - options.settings.layout = moq_audio::Layout::from_channels(channels)?; + options.config.layout = moq_audio::Layout::from_channels(channels)?; } - options.settings.bitrate = output.bitrate.map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); - options.settings = output.packetize(options.settings)?; + options.config.bitrate = output.bitrate.map(|bps| moq_net::bandwidth::Rate::from_bps(bps.into())); + options.config = output.packetize(options.config); if output.frame_duration_us == 0 && output.codec.codec() == moq_audio::encode::Codec::Aac { // from_input sized this at the input rate. The codec rate may be the // override above, and AAC's own frame is 1024 samples of that rate. let mut rated = input.clone(); - rated.sample_rate = options.settings.sample_rate; - options.settings.frame_duration = - moq_audio::encode::Settings::from_input(moq_audio::encode::Codec::Aac, &rated).frame_duration; + rated.sample_rate = options.config.sample_rate; + options.config.frame_duration = + moq_audio::encode::Config::from_input(moq_audio::encode::Codec::Aac, &rated).frame_duration; } if let Some(bandwidth) = &bandwidth { options.bandwidth = bandwidth.allocator().clone(); @@ -711,7 +678,6 @@ mod tests { channels: None, bitrate: None, frame_duration_us: 0, - preset: None, }; let encoder = MoqAudioEncoder::new(output).unwrap(); let samples = encoder.frame_size().unwrap() as usize * 2; @@ -720,7 +686,7 @@ mod tests { assert!(matches!(encoder.frame_size(), Err(MoqError::Closed))); } - fn opus_output(preset: Option, frame_duration_us: u32) -> MoqAudioEncoderOutput { + fn opus_output(frame_duration_us: u32) -> MoqAudioEncoderOutput { MoqAudioEncoderOutput { codec: MoqAudioCodec::opus(), track: None, @@ -728,39 +694,20 @@ mod tests { channels: Some(2), bitrate: None, frame_duration_us, - preset, } } #[test] - fn codec_only_encoder_packetizes_by_preset() { - for (preset, frame_size) in [ - (None, 960), - (Some(MoqAudioPreset::LowLatency), 480), - (Some(MoqAudioPreset::Balanced), 960), - (Some(MoqAudioPreset::Quality), 960), - ] { - let encoder = MoqAudioEncoder::new(opus_output(preset, 0)).unwrap(); - assert_eq!(encoder.frame_size().unwrap(), frame_size); + fn codec_only_encoder_packetizes_by_frame_duration() { + for (frame_duration_us, frame_size) in [(0, 960), (10_000, 480), (20_000, 960)] { + let encoder = MoqAudioEncoder::new(opus_output(frame_duration_us)).unwrap(); + assert_eq!(encoder.frame_size().unwrap(), frame_size, "{frame_duration_us} us"); } - assert_eq!( - MoqAudioEncoder::new(opus_output(None, 10_000)) - .unwrap() - .frame_size() - .unwrap(), - 480 - ); - } - - #[test] - fn codec_only_encoder_refuses_a_preset_with_a_frame_duration() { - let result = MoqAudioEncoder::new(opus_output(Some(MoqAudioPreset::LowLatency), 20_000)); - assert!(matches!(result, Err(MoqError::Audio { .. }))); } #[test] fn codec_only_encoder_reports_its_opus_head() { - let encoder = MoqAudioEncoder::new(opus_output(None, 0)).unwrap(); + let encoder = MoqAudioEncoder::new(opus_output(0)).unwrap(); let init = encoder.track_init().unwrap(); assert!(matches!(init.format, crate::media::MoqAudioFormat::Opus)); assert_eq!(&init.data[..8], b"OpusHead"); @@ -771,7 +718,7 @@ mod tests { #[test] fn codec_only_encoder_retunes_its_bitrate() { - let encoder = MoqAudioEncoder::new(opus_output(None, 0)).unwrap(); + let encoder = MoqAudioEncoder::new(opus_output(0)).unwrap(); encoder.set_bitrate(32_000).unwrap(); let samples = encoder.frame_size().unwrap() as usize * 2; assert!(!encoder.encode(vec![0.0; samples]).unwrap().data.is_empty()); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index cb5d8815996..6e2e378e7b6 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -556,7 +556,6 @@ async fn raw_audio_activity() { channels: None, bitrate: None, frame_duration_us: FRAME_DURATION_US, - preset: None, }, None, ) @@ -816,7 +815,6 @@ async fn raw_audio_decode_delay() { channels: None, bitrate: None, frame_duration_us: FRAME_US as u32, - preset: None, }, None, ) @@ -907,7 +905,6 @@ async fn raw_audio_frame_durations() { channels: None, bitrate: None, frame_duration_us, - preset: None, }; let audio = MoqAudioProducer::new(&catalog, input(), output("fine", 2_500), None).unwrap(); @@ -950,7 +947,6 @@ async fn raw_audio_codec_default_frame() { channels: None, bitrate: None, frame_duration_us: 0, - preset: None, }; let opus = MoqAudioProducer::new(&catalog, input(), output("opus", MoqAudioCodec::opus()), None).unwrap(); diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index 03a5fc8c37f..8338ebcd54b 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -16,7 +16,7 @@ public typealias AudioFrame = MoqFFI.MoqAudioFrame /// The PCM layout (format, sample rate, channels) written to an `Audio.Producer`. public typealias AudioEncoderInput = MoqFFI.MoqAudioEncoderInput /// The encoder-side config for a published audio track: codec, rate, channels, -/// bitrate, frame duration, and preset. +/// bitrate, and frame duration. public typealias AudioEncoderOutput = MoqFFI.MoqAudioEncoderOutput /// What a `VideoConsumer` decodes to: an optional resize, a max delay, and /// whether frames keep the decoder's native surface. diff --git a/swift/Sources/Moq/Audio.swift b/swift/Sources/Moq/Audio.swift index ab276250038..502c9686734 100644 --- a/swift/Sources/Moq/Audio.swift +++ b/swift/Sources/Moq/Audio.swift @@ -4,7 +4,7 @@ import MoqFFI public enum Audio { /// The PCM layout (format, sample rate, channels) written to a `Producer`. public typealias EncoderInput = AudioEncoderInput - /// The codec side: codec, track name, rate, channels, bitrate, frame duration, and preset. + /// The codec side: codec, track name, rate, channels, bitrate, and frame duration. public typealias EncoderOutput = AudioEncoderOutput /// The PCM layout a `Consumer` delivers, how stale a group may get, and its playout delay. public typealias DecoderOutput = AudioDecoderOutput @@ -16,8 +16,6 @@ public enum Audio { public typealias EncoderFinish = MoqAudioEncoderFinish /// Selects the audio encoder codec. Build one with `Codec.opus()` or `Codec.aac()`. public typealias Codec = AudioCodec - /// How the encoder trades latency for compression; `nil` is `.balanced`. - public typealias Preset = MoqAudioPreset /// The PCM sample layout written to a `Producer` or delivered by a `Consumer`. public typealias SampleFormat = AudioSampleFormat From cf2d744563e4ddfc9f1e78f6dd3f97daa532f539 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:58:53 -0700 Subject: [PATCH 04/15] refactor(media): trim json, mux, hang, and loc surfaces - moq-json: drop window `Checkpoint`, `Encoder::resume`, `Producer::resume`; window `Producer::cut` is test-only. Snapshot `mutate` takes `(f, at)` like `@moq/json`. - moq-mux: drop `timeline::Producer::{resume, flush}`; importers' `dropped_metadata` is test-only. - hang: `catalog::Section`, `deserialize_section`, and `Catalog::from_value` are crate-private. - @moq/loc: drop the test-only `Producer`. - @moq/hang: drop the `Container.Consumer` `maxAge` guard; export `Catalog.encode`, which @moq/publish uses instead of stamping `version`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- js/hang/MIGRATE.md | 6 +- js/hang/src/catalog/root.test.ts | 14 +-- js/hang/src/catalog/root.ts | 19 +++- js/hang/src/container/consumer.test.ts | 75 +++++++++----- js/hang/src/container/consumer.ts | 3 +- js/loc/MIGRATE.md | 5 + js/loc/README.md | 13 +-- js/loc/src/index.test.ts | 109 +------------------- js/loc/src/index.ts | 129 +----------------------- js/publish/src/catalog.ts | 10 +- rs/hang/MIGRATE.md | 5 + rs/hang/src/catalog/mod.rs | 4 +- rs/hang/src/catalog/root.rs | 2 +- rs/moq-hls/src/export/mod.rs | 11 +- rs/moq-json/MIGRATE.md | 2 +- rs/moq-json/src/snapshot/mod.rs | 21 ++-- rs/moq-json/src/snapshot/producer.rs | 6 +- rs/moq-json/src/window/encoder.rs | 33 ------ rs/moq-json/src/window/mod.rs | 60 ++--------- rs/moq-json/src/window/producer.rs | 20 +--- rs/moq-mux/src/container/flv/import.rs | 3 +- rs/moq-mux/src/container/fmp4/import.rs | 3 +- rs/moq-mux/src/error.rs | 3 +- rs/moq-mux/src/timeline.rs | 70 ------------- 24 files changed, 143 insertions(+), 483 deletions(-) diff --git a/js/hang/MIGRATE.md b/js/hang/MIGRATE.md index bce652a82da..641908f1a48 100644 --- a/js/hang/MIGRATE.md +++ b/js/hang/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Container.Loc.Producer` is removed + +`@moq/loc` dropped its producer, so the `Container.Loc` re-export has only the decoder; see the `@moq/loc` migration. + ### CMAF decoding is a separate module Replace `Container.Cmaf` with `import * as Cmaf from "@moq/hang/cmaf"`. @@ -53,7 +57,7 @@ Pass `frame.timestamp` after the payload. ### Container consumers take `maxDelay` -`Container.Consumer`'s `maxAge` prop is `maxDelay`, and passing `maxAge` throws a `TypeError`. +`Container.Consumer`'s `maxAge` prop is `maxDelay`; rename it, since an untyped `maxAge` is ignored. ### `cut` is `discontinuity` (#4141) diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index 1a20f9676e8..d81bb28059e 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -3,7 +3,7 @@ import * as z from "@zod/mini"; import { ARCHIVE_VERSION, ArchiveSchema } from "./archive.ts"; import { u53 } from "./integers.ts"; import type { ReferenceEpoch, RelativeBroadcast } from "./path.ts"; -import { checkRenditions, DuplicateRendition, minVersion, RootSchema, UnsupportedVersion, VERSION } from "./root.ts"; +import { checkRenditions, DuplicateRendition, encode, RootSchema, UnsupportedVersion, VERSION } from "./root.ts"; import { track } from "./track.ts"; // The base catalog carries the media sections (`video`/`audio`) and the data track sections @@ -324,12 +324,14 @@ test("the catalog version is needed only for misread fields", () => { const root = (hd: object) => RootSchema.parse({ video: { renditions: { hd } }, audio: { renditions: { en: audio } } }); - expect(minVersion(root(avc))).toBeUndefined(); - expect(minVersion(root({ ...avc, track: "hd" }))).toBeUndefined(); - expect(minVersion(root({ ...avc, track: "video" }))).toBe(4); - expect(minVersion(root({ ...avc, warmup: 500 }))).toBe(4); + expect(encode(root(avc)).version).toBeUndefined(); + expect(encode(root({ ...avc, track: "hd" })).version).toBeUndefined(); + expect(encode(root({ ...avc, track: "video" })).version).toBe(4); + expect(encode(root({ ...avc, warmup: 500 })).version).toBe(4); const data = RootSchema.parse({ json: { tracks: { chat: { mode: "stream", track: "chat.json" } } } }); - expect(minVersion(data)).toBe(4); + expect(encode(data).version).toBe(4); + // A carried version is recomputed, never trusted. + expect(encode({ ...root(avc), version: 4 }).version).toBeUndefined(); }); test("shared stats and echo discovery round trips without changing old catalogs", async () => { diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts index 1ed0d54a86e..1e6d7b2da67 100644 --- a/js/hang/src/catalog/root.ts +++ b/js/hang/src/catalog/root.ts @@ -59,7 +59,7 @@ const VersionSchema = z.number().check( * * The root `version` is the oldest moq-hang draft that reads the catalog correctly, absent while * that is 3. Parsing throws {@link UnsupportedVersion} when it is above {@link VERSION}; a - * publisher sets it with {@link minVersion}. + * publisher sets it with {@link encode}. */ export const RootSchema = z.looseObject({ // First, so it is checked before any section. @@ -93,7 +93,7 @@ export type Root = z.infer; * * Only a field an older reader would misread raises it; one it would merely ignore does not. */ -export function minVersion(root: Root): number | undefined { +function minVersion(root: Root): number | undefined { // moq-hang-04: a reader that ignores `track` subscribes to the ID, a different track. const renamed = [ root.video?.renditions, @@ -150,6 +150,21 @@ export function checkRenditions(root: Root): Root { return root; } +/** + * The catalog as a publisher writes it: refused as every reader refuses it, with the root + * `version` computed rather than carried, so an edit can't claim less than the catalog uses. + * + * Throws {@link TooManyRenditions} or {@link DuplicateRendition}. Mirrors Rust's catalog serializer. + */ +export function encode(root: Root): Root { + checkRenditions(root); + const value = { ...root }; + delete value.version; + const version = minVersion(root); + if (version !== undefined) value.version = version; + return value; +} + /** A catalog update carried a `broadcast` reference that walks above its reader's root. */ export class EscapingBroadcast extends Error { /** The offending reference, normalized. */ diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index b4c1e38b25d..2e3542f04b0 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { readFileSync } from "node:fs"; import { Manual, type Micro, Milli, Nano } from "@moq/clock"; -import { Format as LocFormat, Producer as LocProducer } from "@moq/loc"; +import { Format as LocFormat } from "@moq/loc"; import { Group, Error as NetError, SessionCode, StreamCode, Time, Track, Varint } from "@moq/net"; import { decodeInitSegment, type InitSegment } from "../cmaf/decode.ts"; import { Format as CmafFormat } from "../cmaf/format.ts"; @@ -32,6 +32,27 @@ function encodeLegacyFrame(timestamp: Micro, payload: Uint8Array): Uint8Array { return data; } +/** A LOC frame carrying only the timestamp property (0x10). */ +function encodeLocFrame(timestamp: Micro, payload: Uint8Array): Uint8Array { + const props = new Uint8Array([...Varint.encode(0x10), ...Varint.encode(timestamp)]); + return new Uint8Array([...Varint.encode(props.byteLength), ...props, ...payload]); +} + +/** Write each group of `[timestamp, payload]` LOC frames to `track`, then close it. */ +function writeLoc(track: Track.Producer, groups: [Micro, number[]][][]) { + for (const frames of groups) { + const group = track.appendGroup(); + for (const [timestamp, payload] of frames) { + group.writeFrame({ + payload: encodeLocFrame(timestamp, new Uint8Array(payload)), + timestamp: Time.Timestamp.fromMicros(timestamp), + }); + } + group.close(); + } + track.close(); +} + /** A moq-net timestamp of `ticks` at the test track's timescale. */ function at(ticks: number): Time.Timestamp { return new Time.Timestamp(ticks, Time.Timescale(TIMESCALE)); @@ -62,18 +83,6 @@ function replay(track: Track.Producer): Track.Subscriber { return track.subscribe({ maxDelay: Milli(30_000) }); } -test("Consumer refuses maxAge before taking ownership of the track", () => { - const producer = new Track.Producer("test"); - const track = producer.subscribe(); - for (const maxAge of [Milli(500), undefined]) { - const props = { format: new LegacyFormat("data"), maxAge }; - expect(() => new Consumer(track, props)).toThrow("maxDelay"); - expect(track.closed.peek()).toBeUndefined(); - } - track.close(); - producer.close(); -}); - // --- LegacyFormat --- test("LegacyFormat decodes a valid frame", () => { @@ -992,10 +1001,13 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra test("Consumer reads an empty LOC payload as the frame's duration", async () => { const track = new Track.Producer("test"); - const producer = new LocProducer(track); - producer.encode(new Uint8Array([0xde, 0xad]), 0 as Micro, true); - producer.encode(new Uint8Array(), 33_000 as Micro, false); - producer.close(); + writeLoc(track, [ + [ + [0 as Micro, [0xde, 0xad]], + [33_000 as Micro, []], + [66_000 as Micro, []], + ], + ]); const consumer = new Consumer(replay(track), { format: new LocFormat("video"), maxDelay: 500 as Milli }); await settle(); @@ -1006,13 +1018,19 @@ test("Consumer reads an empty LOC payload as the frame's duration", async () => consumer.close(); }); -test("Consumer passes over the duration markers the LOC producer writes", async () => { +test("Consumer passes over LOC duration markers", async () => { const track = new Track.Producer("test"); - const producer = new LocProducer(track); - producer.encode(new Uint8Array([0xde, 0xad]), 0 as Micro, true); - producer.encode(new Uint8Array([0xbe, 0xef]), 10_000 as Micro, false); - producer.encode(new Uint8Array([0xca, 0xfe]), 20_000 as Micro, true); - producer.close(); + writeLoc(track, [ + [ + [0 as Micro, [0xde, 0xad]], + [10_000 as Micro, [0xbe, 0xef]], + [20_000 as Micro, []], + ], + [ + [20_000 as Micro, [0xca, 0xfe]], + [30_000 as Micro, []], + ], + ]); const consumer = new Consumer(replay(track), { format: new LocFormat("video"), maxDelay: 500 as Milli }); await settle(); @@ -1030,10 +1048,13 @@ test("Consumer passes over the duration markers the LOC producer writes", async test("Consumer preserves empty Legacy and LOC data frames", async () => { for (const kind of ["legacy", "loc"]) { const track = new Track.Producer("data"); - const producer = - kind === "legacy" ? new LegacyProducer(track, new LegacyFormat("data")) : new LocProducer(track); - producer.encode(new Uint8Array(), 0 as Micro, true); - producer.close(); + if (kind === "legacy") { + const producer = new LegacyProducer(track, new LegacyFormat("data")); + producer.encode(new Uint8Array(), 0 as Micro, true); + producer.close(); + } else { + writeLoc(track, [[[0 as Micro, []]]]); + } const format = kind === "legacy" ? new LegacyFormat("data") : new LocFormat(); const consumer = new Consumer(replay(track), { format, maxDelay: 500 as Milli }); const frame = frameOf(await consumer.next()); diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index d2495c9c868..fd3d1723161 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -5,7 +5,7 @@ import type { Format } from "./format"; import { Jitter } from "./jitter"; import type { BufferedRanges, Frame, ReceivedFrame } from "./types"; -/** Options for constructing a {@link Consumer}; the obsolete `maxAge` prop is refused. */ +/** Options for constructing a {@link Consumer}. */ export interface ConsumerProps { /** The container format used to decode each MoQ frame. */ format: Format; @@ -153,7 +153,6 @@ export class Consumer { /** Start consuming the given track, decoding frames with `props.format`. */ constructor(track: Moq.Track.Subscriber, props: ConsumerProps) { - if ("maxAge" in props) throw new TypeError("Consumer maxAge is unsupported; use maxDelay"); this.#track = track; this.#format = props.format; this.#maxDelay = getter(props.maxDelay ?? Milli.zero); diff --git a/js/loc/MIGRATE.md b/js/loc/MIGRATE.md index 9c775bae813..823437f4be8 100644 --- a/js/loc/MIGRATE.md +++ b/js/loc/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Producer` is removed + +`Producer` and `Source` are gone; the package decodes LOC only. +Publish media through `@moq/publish`, or write LOC frames yourself: a varint property length, the `0x10` timestamp property in microseconds, then the codec payload. + ### Timestamps are `@moq/clock` units (#5706) Frame timestamps are typed as `Micro` from `@moq/clock` instead of `Time.Micro` from `@moq/net`. diff --git a/js/loc/README.md b/js/loc/README.md index 8f18232e172..05229bb22a4 100644 --- a/js/loc/README.md +++ b/js/loc/README.md @@ -2,7 +2,7 @@ Encode and decode frames for the [Low Overhead Container (LOC)](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/), a lightweight container format defined by the MoQ working group. -Each LOC frame is a small property block (timestamp, optional per-frame timescale) followed by a raw codec bitstream payload. This package provides `Format` (decoder) and `Producer` (encoder) classes that plug into `@moq/hang`'s container dispatch. +Each LOC frame is a small property block (timestamp, optional per-frame timescale) followed by a raw codec bitstream payload. This package provides the `Format` decoder that plugs into `@moq/hang`'s container dispatch. ## Installation @@ -16,17 +16,12 @@ npm add @moq/loc import * as Loc from "@moq/loc"; // Decode incoming LOC frames -const format = new Loc.Format(); +const format = new Loc.Format("video"); const frames = format.decode(rawFrameBytes); - -// Encode outgoing LOC frames. A keyframe starts a group. -const producer = new Loc.Producer(track); -producer.encode(payload, timestampMicros, keyframe); ``` -The producer is for video tracks. -An ordered group ends with an empty duration frame, at the next keyframe's timestamp or one interval after the last sample when the track closes. -`Format.end()` identifies that frame for audio and video, and the hang consumer skips it. +An audio or video group may end with an empty duration frame at the exclusive end of its last sample. +`Format.end()` identifies that frame, and the hang consumer skips it. ## License diff --git a/js/loc/src/index.test.ts b/js/loc/src/index.test.ts index 4d3c71e8d95..3c68644ebf7 100644 --- a/js/loc/src/index.test.ts +++ b/js/loc/src/index.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; -import { type Micro, Milli } from "@moq/clock"; -import { Track, Varint } from "@moq/net"; -import { Format, Producer } from "./index.ts"; +import type { Micro } from "@moq/clock"; +import { Varint } from "@moq/net"; +import { Format } from "./index.ts"; const PROP_TIMESCALE = 0x08; const PROP_TIMESTAMP = 0x10; @@ -155,106 +155,3 @@ test("Format rejects a timestamp past 2^53 - 1 instead of rounding", () => { const props = concat(Varint.encode(PROP_TIMESTAMP), Varint.encode(2n ** 53n)); expect(() => new Format().decode(buildFrame(props, new Uint8Array()))).toThrow(/larger than 53-bits/); }); - -/** Each group's (LOC timestamp, payload size, net timestamp) after the producer has closed the track. */ -async function readGroups(track: Track.Producer) { - const subscriber = track.subscribe({ maxDelay: Milli(30_000) }); - const format = new Format("video"); - const groups: { timestamp: number; size: number; net: number | undefined }[][] = []; - for (;;) { - const group = await subscriber.recvGroup(); - if (!group) break; - const frames: { timestamp: number; size: number; net: number | undefined }[] = []; - for (;;) { - const frame = await group.readFrame(); - if (!frame) break; - const [decoded] = format.decode(frame.payload); - frames.push({ - timestamp: decoded.timestamp, - size: decoded.payload.byteLength, - net: frame.timestamp?.asMicros(), - }); - } - groups.push(frames); - } - return groups; -} - -test("Producer ends a group with an empty frame at the next keyframe", async () => { - const track = new Track.Producer("video"); - const producer = new Producer(track); - producer.encode(new Uint8Array([0xde, 0xad]), 0 as Micro, true); - producer.encode(new Uint8Array([0xbe, 0xef]), 10_000 as Micro, false); - producer.encode(new Uint8Array([0xca, 0xfe]), 20_000 as Micro, true); - producer.close(); - - const groups = await readGroups(track); - expect(groups[0]).toEqual([ - { timestamp: 0, size: 2, net: 0 }, - { timestamp: 10_000, size: 2, net: 10_000 }, - { timestamp: 20_000, size: 0, net: 20_000 }, - ]); - // The tail has no successor, so the marker is one interval after the last sample. - expect(groups[1]).toEqual([ - { timestamp: 20_000, size: 2, net: 20_000 }, - { timestamp: 30_000, size: 0, net: 30_000 }, - ]); - expect(groups).toHaveLength(2); - - const marker = groups[0][2]; - expect( - new Format("video").end({ - payload: new Uint8Array(marker.size), - timestamp: marker.timestamp as Micro, - keyframe: false, - }), - ).toBe(20_000 as Micro); -}); - -test("Producer omits the marker when a group has no interval to close", async () => { - const track = new Track.Producer("video"); - const producer = new Producer(track); - producer.encode(new Uint8Array([1]), 0 as Micro, true); - producer.close(); - - expect(await readGroups(track)).toEqual([[{ timestamp: 0, size: 1, net: 0 }]]); -}); - -test("Producer estimates the marker when the next keyframe overlaps an ordered tail", async () => { - const track = new Track.Producer("video"); - const producer = new Producer(track); - producer.encode(new Uint8Array([1]), 0 as Micro, true); - producer.encode(new Uint8Array([1]), 10_000 as Micro, false); - producer.encode(new Uint8Array([1]), 5_000 as Micro, true); - producer.close(); - - expect(await readGroups(track)).toEqual([ - [ - { timestamp: 0, size: 1, net: 0 }, - { timestamp: 10_000, size: 1, net: 10_000 }, - { timestamp: 20_000, size: 0, net: 20_000 }, - ], - [{ timestamp: 5_000, size: 1, net: 5_000 }], - ]); -}); - -test("Producer omits a reordered group's marker and a keyframe that overlaps the tail", async () => { - const track = new Track.Producer("video"); - const producer = new Producer(track); - for (const [index, timestamp] of [0, 120_000, 40_000, 80_000].entries()) { - producer.encode(new Uint8Array([1]), timestamp as Micro, index === 0); - } - // Overlaps the previous frame, so it is not that group's end, and the cadence does not carry over. - producer.encode(new Uint8Array([1]), 50_000 as Micro, true); - producer.close(); - - expect(await readGroups(track)).toEqual([ - [ - { timestamp: 0, size: 1, net: 0 }, - { timestamp: 120_000, size: 1, net: 120_000 }, - { timestamp: 40_000, size: 1, net: 40_000 }, - { timestamp: 80_000, size: 1, net: 80_000 }, - ], - [{ timestamp: 50_000, size: 1, net: 50_000 }], - ]); -}); diff --git a/js/loc/src/index.ts b/js/loc/src/index.ts index d50c8e26a8b..bb954622062 100644 --- a/js/loc/src/index.ts +++ b/js/loc/src/index.ts @@ -1,5 +1,5 @@ /** - * Low Overhead Container (LOC): encode and decode codec bitstreams framed with + * Low Overhead Container (LOC): decode codec bitstreams framed with * per-frame timestamp and timescale metadata for MoQ. * * @module @@ -7,7 +7,6 @@ import type { Micro } from "@moq/clock"; import * as Moq from "@moq/net"; -import { Time } from "@moq/net"; /** A decoded LOC frame: the codec bitstream plus its timing metadata. */ export interface Frame { @@ -106,129 +105,3 @@ export class Format { return [{ payload, timestamp: micros, keyframe: false }]; } } - -/** A payload that can be copied into a buffer without first materializing a Uint8Array. */ -export interface Source { - /** Size in bytes of the payload. */ - byteLength: number; - /** Copy the payload into the provided buffer. */ - copyTo(buffer: Uint8Array): void; -} - -/** - * Encoder that packages frames as LOC and writes them to a moq-net track. - * - * Each call to {@link encode} produces one moq-net frame containing a - * property block with the 0x10 timestamp (in microseconds) and the codec - * bitstream payload. A keyframe starts a group. The group it closes, and the - * group still open at {@link close}, ends with an empty frame at the exclusive - * end of the last sample: the same duration marker `moq-mux` writes. - * Use it for video only: a data reader would see the marker as an empty object. - */ -export class Producer { - #track: Moq.Track.Producer; - #group?: Moq.Group.Producer; - // Last timestamp in the open group. The next keyframe is this group's end unless it lands earlier. - #previous?: Micro; - // Furthest timestamp in the open group. The tail marker estimates from this, not decode order. - #end?: Micro; - // Gap between the last two increasing timestamps. Kept across groups so a clean close can bound the tail. - #interval?: Micro; - // A frame's timestamp preceded the one before it. That group's presentation end does not bound its tail. - #reordered = false; - - constructor(track: Moq.Track.Producer) { - this.#track = track; - } - - /** - * Encode one frame and write it to the track. - * A keyframe ends the open group with a duration marker, then starts the next. - */ - encode(data: Uint8Array | Source, timestamp: Micro, keyframe: boolean) { - if (keyframe) { - // A keyframe before the previous frame overlaps that frame, so it is not the group's end. - const overlaps = this.#previous !== undefined && timestamp < this.#previous; - this.#closeGroup(overlaps ? undefined : timestamp); - if (overlaps) this.#interval = undefined; - this.#group = this.#track.appendGroup(); - } else if (!this.#group) { - throw new Error("must start with a keyframe"); - } - - this.#group?.writeFrame({ - payload: this.#encode(data, timestamp), - timestamp: Time.Timestamp.fromMicros(timestamp), - }); - - this.#reordered ||= this.#previous !== undefined && timestamp < this.#previous; - if (this.#previous !== undefined && timestamp > this.#previous) { - this.#interval = (timestamp - this.#previous) as Micro; - } - this.#previous = timestamp; - if (this.#end === undefined || timestamp > this.#end) this.#end = timestamp; - } - - #encode(source: Uint8Array | Source, timestamp: Micro): Uint8Array { - const propTypeBytes = Moq.Varint.encode(PROP_TIMESTAMP); - const propValueBytes = Moq.Varint.encode(timestamp); - const propsLen = propTypeBytes.byteLength + propValueBytes.byteLength; - - const propsLenBytes = Moq.Varint.encode(propsLen); - - const payloadSize = source.byteLength; - const total = propsLenBytes.byteLength + propsLen + payloadSize; - const out = new Uint8Array(total); - - let offset = 0; - out.set(propsLenBytes, offset); - offset += propsLenBytes.byteLength; - out.set(propTypeBytes, offset); - offset += propTypeBytes.byteLength; - out.set(propValueBytes, offset); - offset += propValueBytes.byteLength; - - const payloadView = out.subarray(offset); - if (source instanceof Uint8Array) { - payloadView.set(source); - } else { - source.copyTo(payloadView); - } - - return out; - } - - // End the open group. `end` is the next keyframe when one is already in hand; otherwise the - // last gap estimates where the tail stops. A reordered group omits the marker: its presentation - // end does not bound the last frame in decode order. - #closeGroup(end?: Micro) { - const group = this.#group; - if (!group) return; - - let marker = this.#reordered ? undefined : end; - if (marker === undefined && !this.#reordered && this.#end !== undefined && this.#interval !== undefined) { - marker = (this.#end + this.#interval) as Micro; - } - if (marker !== undefined) { - group.writeFrame({ - payload: this.#encode(new Uint8Array(), marker), - timestamp: Time.Timestamp.fromMicros(marker), - }); - } - group.close(); - this.#group = undefined; - this.#previous = undefined; - this.#end = undefined; - this.#reordered = false; - } - - /** - * Close the current group and the underlying track. - * A clean close writes the duration marker; an error does not. - */ - close(err?: Error) { - if (!err) this.#closeGroup(); - this.#group?.close(); - this.#track.close(err); - } -} diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 636b4f93c02..8b0470116ed 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -31,14 +31,10 @@ export class CatalogProducer { /** Edit the catalog; estimate-only changes publish at most once a second with a trailing update. */ mutate(fn: (catalog: Catalog.Root) => void): void { - const value = structuredClone(this.#value); - fn(value); + const draft = structuredClone(this.#value); + fn(draft); // Every consumer refuses a catalog that fails this, so refuse the edit before it reaches one. - Catalog.checkRenditions(value); - // Computed, never carried, so an edit can't claim less than the catalog uses. - const version = Catalog.minVersion(value); - if (version === undefined) delete value.version; - else value.version = version; + const value = Catalog.encode(draft); // Estimates only rise for a track; a renamed rendition starts its new track over. for (const field of ["jitter", "delay"] as const) { const previous = new Map(advertised(this.#value, field)); diff --git a/rs/hang/MIGRATE.md b/rs/hang/MIGRATE.md index 33cd6fd87b4..304d8bc991b 100644 --- a/rs/hang/MIGRATE.md +++ b/rs/hang/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `catalog::Section` is private + +`catalog::Section` and `catalog::deserialize_section` are internal to the catalog's own sections. +An application section decodes through its own serde impl; copy the fallback if it needs one. + ### Priorities are signed (#6142) `catalog::Priorities` fields and `container::track_info`'s priority are `i8`, higher first, with 0 unset. diff --git a/rs/hang/src/catalog/mod.rs b/rs/hang/src/catalog/mod.rs index c6106892571..0b2be0e5fdb 100644 --- a/rs/hang/src/catalog/mod.rs +++ b/rs/hang/src/catalog/mod.rs @@ -69,7 +69,7 @@ where /// /// The associated [`MAP`](Self::MAP) is what tells a real section apart from an application's own /// value under the same name. See [`deserialize_section`]. -pub trait Section: Default + serde::de::DeserializeOwned { +pub(crate) trait Section: Default + serde::de::DeserializeOwned { /// The key holding the section's map of tracks. const MAP: &'static str; } @@ -85,7 +85,7 @@ pub trait Section: Default + serde::de::DeserializeOwned { /// else's. A value that *is* a section but carries a malformed entry still fails, since swallowing /// that would hide a publisher bug (a data track with no `mode`) behind the silence that exists to /// protect an unrelated key. -pub fn deserialize_section<'de, D, T>(deserializer: D) -> Result +pub(crate) fn deserialize_section<'de, D, T>(deserializer: D) -> Result where D: serde::Deserializer<'de>, T: Section, diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index d9c5a740ca0..fe7285fd79e 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -379,7 +379,7 @@ impl Catalog { /// /// Fails with [`Error::UnsupportedVersion`](crate::Error::UnsupportedVersion) if the catalog /// needs a newer moq-hang draft than this crate reads. - pub fn from_value(value: serde_json::Value) -> Result { + pub(crate) fn from_value(value: serde_json::Value) -> Result { check_version(value.get("version").and_then(serde_json::Value::as_u64))?; Ok(serde_json::from_value(value)?) } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index b14cc878bf7..be13f07d71d 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -4946,11 +4946,16 @@ mod tests { video0.push(&span(sequence, sequence * 40, 40, sequence)).unwrap(); settle().await; } - // The publisher rolls its timeline group, so a joiner reads only the checkpoint and on. - video0.flush().unwrap(); + // A joiner wants a fresh group, so the next push restates the window and the joiner reads + // only that checkpoint and on. + let late = early.join(); + settle().await; video0.push(&span(1_000, 40_000, 40, 1_000)).unwrap(); + assert!( + fresh_join_reads(&early, "video0").await < 1_000, + "the push rolled the group" + ); - let late = early.join(); let joined = late.listed("video0", 1_000).await; let followed = early.listed("video0", 1_000).await; assert_eq!(joined.segments.len(), segments::MAX_SEGMENTS); diff --git a/rs/moq-json/MIGRATE.md b/rs/moq-json/MIGRATE.md index 507f510cf72..fc2f77ac7c4 100644 --- a/rs/moq-json/MIGRATE.md +++ b/rs/moq-json/MIGRATE.md @@ -40,7 +40,7 @@ Call `producer.demand().is_used()` instead of `producer.is_used()`. `moq_net::Timed

` drops its clock type parameter. Snapshot, stream, and window producers publish `at: None` untimed, so their track must be untimed; pass a timestamp on a timed track. -Snapshot `modify(at)` and `mutate(at, f)` carry the caller's optional time through the edit guard. +Snapshot `modify(at)` and `mutate(f, at)` carry the caller's optional time through the edit guard, in the same order as `@moq/json`. Wrap each update as `Timed::from(&value).at(timestamp)`, and pass `Some(timestamp)` to `modify`. ## 0.3 -> 0.4 diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 00e3b2fa8d0..d9d4062faac 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -97,7 +97,7 @@ mod test { Err(crate::Error::Net(moq_net::Error::TimestampMismatch)) )); let at = moq_net::Timestamp::from_millis(100).unwrap(); - producer.mutate(Some(at), |value| value["n"] = json!(2)).unwrap(); + producer.mutate(|value| value["n"] = json!(2), Some(at)).unwrap(); let waiter = kio::Waiter::noop(); let mut groups = producer.consume(); let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { @@ -806,14 +806,14 @@ mod test { let mut consumer = track.subscribe(None); let mut producer = Producer::::new(track, cfg(false)); - producer.mutate(None, |doc| doc.video = Some("v1".to_string())).unwrap(); + producer.mutate(|doc| doc.video = Some("v1".to_string()), None).unwrap(); // The second owner starts from the latest value and adds its own field without clobbering. - producer.mutate(None, |doc| doc.scte35 = Some(42)).unwrap(); + producer.mutate(|doc| doc.scte35 = Some(42), None).unwrap(); // A closure that changes nothing publishes nothing: deltas are off, so each publish would // otherwise open a group of its own. - producer.mutate(None, |_| {}).unwrap(); + producer.mutate(|_| {}, None).unwrap(); assert_eq!( consumer .largest() @@ -847,9 +847,12 @@ mod test { producer.update(&json!({ "keep": true })).unwrap(); assert!(matches!( - producer.mutate(None, |value| { - *value = json!({ "big": "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize + 1) }); - }), + producer.mutate( + |value| { + *value = json!({ "big": "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize + 1) }); + }, + None + ), Err(crate::Error::Net(moq_net::Error::FrameTooLarge)) )); } @@ -956,12 +959,12 @@ mod test { let ms = |n| moq_net::Timestamp::from_millis(n).unwrap(); let (mut producer, _track) = producer_on(cfg(true), timed()); producer - .mutate(Some(ms(100)), |value| *value = json!({ "video": "v1" })) + .mutate(|value| *value = json!({ "video": "v1" }), Some(ms(100))) .unwrap(); // Untimed on a timed track, so the frame is rejected before it reaches a group. assert!(matches!( - producer.mutate(None, |value| value["scte35"] = json!(42)), + producer.mutate(|value| value["scte35"] = json!(42), None), Err(crate::Error::Net(moq_net::Error::TimestampMismatch)) )); diff --git a/rs/moq-json/src/snapshot/producer.rs b/rs/moq-json/src/snapshot/producer.rs index e8a49f56272..177c5a6257e 100644 --- a/rs/moq-json/src/snapshot/producer.rs +++ b/rs/moq-json/src/snapshot/producer.rs @@ -122,9 +122,9 @@ impl Producer { /// /// ```no_run /// # fn example(producer: &mut moq_json::snapshot::Producer) -> moq_json::Result<()> { - /// producer.mutate(None, |catalog| { + /// producer.mutate(|catalog| { /// catalog["scte35"] = serde_json::json!({ "id": 1 }); - /// }) + /// }, None) /// # } /// ``` /// @@ -132,7 +132,7 @@ impl Producer { /// from the latest value, so their sections compose instead of clobbering one another. This is /// [`modify`](Self::modify) opened and committed for you; take the guard to hold the lock across /// several edits, and [`update`](Self::update) to replace the whole value. - pub fn mutate(&mut self, at: Option, f: impl FnOnce(&mut T)) -> Result<()> + pub fn mutate(&mut self, f: impl FnOnce(&mut T), at: Option) -> Result<()> where T: Default + DeserializeOwned, { diff --git a/rs/moq-json/src/window/encoder.rs b/rs/moq-json/src/window/encoder.rs index 3364532499f..60af70538e1 100644 --- a/rs/moq-json/src/window/encoder.rs +++ b/rs/moq-json/src/window/encoder.rs @@ -83,15 +83,6 @@ impl ProducerConfig { } } -/// A retained window to continue, such as one replayed from stored groups. -#[derive(Debug, Clone, PartialEq)] -pub struct Checkpoint { - /// Absolute index of the oldest retained record, and of the next to be pushed. - pub range: std::ops::Range, - /// The newest retained records, oldest first, ending just before `range.end`. - pub records: Vec, -} - /// One encoded frame, and the group boundary it implies. #[derive(Clone, Debug)] #[non_exhaustive] @@ -415,30 +406,6 @@ impl Encoder { } impl Encoder { - /// Create an encoder continuing `checkpoint`, so the first edit opens a group restating it. - /// - /// Fails when the records outnumber the range, or the range exceeds the safe integer range. - pub fn resume(config: ProducerConfig, checkpoint: &Checkpoint) -> Result { - let Checkpoint { range, records } = checkpoint; - if range.start > range.end || range.end > MAX_INDEX || records.len() as u64 > range.end - range.start { - return Err(Error::Json("invalid window checkpoint".into())); - } - - let mut encoder = Self::new(config); - let skip = encoder - .config - .checkpoint_records - .map(|limit| records.len().saturating_sub(limit)) - .unwrap_or_default(); - encoder.window = records[skip..] - .iter() - .map(serde_json::to_value) - .collect::>()?; - encoder.offset = range.start; - encoder.start = range.end - encoder.window.len() as u64; - Ok(encoder) - } - /// Append one record to the back of the window. /// /// Emits a push into the open group, or a header restating the window (the new record included) diff --git a/rs/moq-json/src/window/mod.rs b/rs/moq-json/src/window/mod.rs index 54eda8b8757..79fcd9c1229 100644 --- a/rs/moq-json/src/window/mod.rs +++ b/rs/moq-json/src/window/mod.rs @@ -28,12 +28,11 @@ //! [`snapshot`](crate::snapshot) does: on a track created with a //! [`refresh`](moq_net::track::Info::refresh) ratio, a subscriber joining once the open group's ops //! outweigh that ratio times its header asks for one, and [`Producer::refresh`] restates the window -//! in it. Otherwise a group runs to the frame and byte caps. A [`Consumer`] never surfaces a roll. -//! A caller that stores complete groups can also end one with [`Producer::cut`]; the next edit -//! opens a new group with a header. A header -//! restating records a reader already has yields nothing, so however often the publisher rolls, the -//! reader sees one continuous stream of [`Event`]s. [`ProducerConfig::checkpoint_records`] bounds -//! the suffix repeated on each roll for a long-lived window. +//! in it. Otherwise a group runs to the frame and byte caps. A [`Consumer`] never surfaces a roll: +//! a header restating records a reader already has yields nothing, so however often the publisher +//! rolls, the reader sees one continuous stream of [`Event`]s. +//! [`ProducerConfig::checkpoint_records`] bounds the suffix repeated on each roll for a long-lived +//! window. //! //! # What a reader is told //! @@ -60,7 +59,7 @@ mod properties; pub use consumer::Consumer; pub use decoder::{ConsumerConfig, Decoder, Event, Group}; -pub use encoder::{Checkpoint, Encoded, Encoder, Pending, ProducerConfig}; +pub use encoder::{Encoded, Encoder, Pending, ProducerConfig}; pub use producer::Producer; #[cfg(test)] @@ -457,53 +456,6 @@ mod test { ); } - #[test] - fn a_resumed_encoder_continues_the_window() { - let config = ProducerConfig::default().with_ops(false).with_checkpoint_records(2); - let mut original = Encoder::::new(config.clone()); - let mut decoder = Decoder::::new(ConsumerConfig::default()); - for n in 0..4 { - let frame = original.push(&rec(n)).unwrap(); - decoder.group().decode(&frame.payload).unwrap(); - frame.commit(); - } - let frame = original.pop(1).unwrap().unwrap(); - decoder.group().decode(&frame.payload).unwrap(); - frame.commit(); - std::iter::from_fn(|| decoder.next_event()).for_each(drop); - - let checkpoint = Checkpoint { - range: 1..4, - records: vec![rec(1), rec(2), rec(3)], - }; - let mut resumed = Encoder::resume(config, &checkpoint).unwrap(); - assert_eq!(resumed.range(), 1..4); - assert_eq!( - resumed.window(), - vec![rec(2), rec(3)], - "trimmed to the checkpoint bound" - ); - - let frame = resumed.push(&rec(4)).unwrap(); - assert!(frame.keyframe, "the first edit restates the window"); - decoder.group().decode(&frame.payload).unwrap(); - frame.commit(); - assert_eq!( - std::iter::from_fn(|| decoder.next_event()).collect::>(), - vec![Event::Push { - index: 4, - value: rec(4) - }], - "a reader that kept up sees only the new record" - ); - - let invalid = Checkpoint { - range: 3..4, - records: vec![rec(2), rec(3)], - }; - assert!(Encoder::resume(ProducerConfig::default(), &invalid).is_err()); - } - #[test] fn pops_cross_the_omitted_checkpoint_prefix() { let config = ProducerConfig::default().with_checkpoint_records(2); diff --git a/rs/moq-json/src/window/producer.rs b/rs/moq-json/src/window/producer.rs index 00c51ef2e67..2eb4c522ada 100644 --- a/rs/moq-json/src/window/producer.rs +++ b/rs/moq-json/src/window/producer.rs @@ -7,7 +7,7 @@ use moq_net::Timed; use serde::Serialize; use serde_json::Value; -use super::{Checkpoint, Encoded, Encoder, ProducerConfig}; +use super::{Encoded, Encoder, ProducerConfig}; use crate::Result; use crate::refresh::Refresh; @@ -38,10 +38,6 @@ impl Clone for Producer { impl Producer { /// Create a producer that publishes to the given track. pub fn new(track: moq_net::track::Producer, config: ProducerConfig) -> Self { - Self::with_encoder(track, Encoder::new(config)) - } - - fn with_encoder(track: moq_net::track::Producer, encoder: Encoder) -> Self { Self { inner: Arc::new(Mutex::new(Inner { refresh: Refresh::new(&track), @@ -50,7 +46,7 @@ impl Producer { group: None, largest: None, }, - encoder, + encoder: Encoder::new(config), at: None, finished: false, })), @@ -87,7 +83,8 @@ impl Producer { /// /// Idempotent: cutting when no group is open does nothing. A caller that stores complete groups /// cuts after its edits, so every edit so far sits in a group no later frame can extend. - pub fn cut(&mut self) -> Result<()> { + #[cfg(test)] + pub(crate) fn cut(&mut self) -> Result<()> { self.inner.lock().unwrap().cut() } @@ -121,14 +118,6 @@ impl Producer { } impl Producer { - /// Create a producer continuing `checkpoint` on the given track. - /// - /// The first edit opens a group restating the checkpoint, so a reader that already holds those - /// records sees only what follows. Fails like [`Encoder::resume`]. - pub fn resume(track: moq_net::track::Producer, config: ProducerConfig, checkpoint: &Checkpoint) -> Result { - Ok(Self::with_encoder(track, Encoder::resume(config, checkpoint)?)) - } - /// Append a record at its timestamp, or untimed when absent. pub fn push<'a>(&mut self, value: impl Into>) -> Result<()> where @@ -187,6 +176,7 @@ impl Inner { std::task::Poll::Ready(Ok(())) } + #[cfg(test)] fn cut(&mut self) -> Result<()> { let Some(mut group) = self.track.group.take() else { return Ok(()); diff --git a/rs/moq-mux/src/container/flv/import.rs b/rs/moq-mux/src/container/flv/import.rs index 2d5e6df58e6..9e0bd639f25 100644 --- a/rs/moq-mux/src/container/flv/import.rs +++ b/rs/moq-mux/src/container/flv/import.rs @@ -118,7 +118,8 @@ impl Import { } /// Return the number of metadata units discarded by this importer. - pub fn dropped_metadata(&self) -> u64 { + #[cfg(test)] + pub(crate) fn dropped_metadata(&self) -> u64 { self.dropped_metadata } diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 881fd9fd8fd..4458f95e57e 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -247,7 +247,8 @@ impl Import { } /// Return the number of metadata units discarded by this importer. - pub fn dropped_metadata(&self) -> u64 { + #[cfg(test)] + pub(crate) fn dropped_metadata(&self) -> u64 { self.dropped_metadata } diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 3358abdf0a7..cb0e630ece5 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -180,8 +180,7 @@ pub enum Error { composition_time_ms: i32, }, - /// [`timeline::Producer::resume`](crate::timeline::Producer::resume) received a checkpoint - /// whose record at this window index has a different sequence. + /// A timeline record at this window index carries a different sequence. #[error("timeline checkpoint record at index {0} has a different sequence")] TimelineCheckpoint(u64), diff --git a/rs/moq-mux/src/timeline.rs b/rs/moq-mux/src/timeline.rs index 4b4721195eb..517b2bff00b 100644 --- a/rs/moq-mux/src/timeline.rs +++ b/rs/moq-mux/src/timeline.rs @@ -51,7 +51,6 @@ use std::time::Duration; use hang::catalog::{Archive, Timeline}; use hang::timeline::{Position, Record, RecordExt}; -use moq_json::window::Checkpoint; use moq_net::{Timescale, Timestamp}; @@ -453,23 +452,6 @@ impl Producer { } } - /// Publish onto `track`, continuing `checkpoint`, such as a window recovered from storage. - /// - /// The track's first group restates the checkpoint, and the next record is sequence - /// `checkpoint.range.end`. Fails when a checkpoint record is not the sequence at its index or the - /// checkpoint is malformed. - pub fn resume(track: moq_net::track::Producer, checkpoint: &Checkpoint) -> crate::Result { - let sink = moq_json::window::Producer::resume(track, Self::config(), checkpoint).map_err(json)?; - // The encoder accepted the checkpoint, so the records fit before `range.end`. - let start = checkpoint.range.end - checkpoint.records.len() as u64; - for (index, record) in (start..).zip(&checkpoint.records) { - if record.sequence != index { - return Err(crate::Error::TimelineCheckpoint(index)); - } - } - Ok(Self { sink }) - } - fn config() -> moq_json::window::ProducerConfig { moq_json::window::ProducerConfig::default() .with_compression(true) @@ -504,14 +486,6 @@ impl Producer { .map_err(json) } - /// Close the track's open group, so every push and pop so far sits in a complete group. - /// - /// A recorder stores those groups before committing the next record. The next edit opens a - /// new group restating the window. - pub fn flush(&mut self) -> crate::Result<()> { - self.sink.cut().map_err(json) - } - /// Finish the timeline track. pub fn finish(&mut self) -> crate::Result<()> { self.sink.finish().map_err(json) @@ -1483,50 +1457,6 @@ mod test { )); } - #[tokio::test] - async fn a_resumed_producer_continues_the_checkpoint() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let track = broadcast.create_track("video0.timeline.z", Producer::info()).unwrap(); - let checkpoint = Checkpoint { - range: 1..3, - records: vec![ - Record::new(1, 1_000, 1_000, at(1), at(2)), - Record::new(2, 2_000, 1_000, at(2), at(3)), - ], - }; - let mut producer = Producer::resume(track, &checkpoint).unwrap(); - assert!(matches!( - producer.push(&Record::new(4, 3_000, 1_000, at(3), at(4))), - Err(crate::Error::TimelineSequence { expected: 3, actual: 4 }) - )); - producer.push(&Record::new(3, 3_000, 1_000, at(3), at(4))).unwrap(); - producer.finish().unwrap(); - - let mut section = Archive::new(); - section - .timelines - .insert("video0".to_string(), Timeline::new("video0.timeline.z", 4_000)); - let entries = read(&broadcast, §ion, "video0").await; - assert_eq!( - entries.iter().map(|entry| entry.sequence).collect::>(), - vec![1, 2, 3] - ); - } - - #[test] - fn a_checkpoint_record_must_be_the_sequence_at_its_index() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let track = broadcast.create_track("video0.timeline.z", Producer::info()).unwrap(); - let checkpoint = Checkpoint { - range: 0..1, - records: vec![Record::new(5, 0, 1_000, at(0), at(1))], - }; - assert!(matches!( - Producer::resume(track, &checkpoint), - Err(crate::Error::TimelineCheckpoint(0)) - )); - } - #[tokio::test] async fn a_missing_timeline_is_an_error() { let broadcast = moq_net::broadcast::Info::new().produce(); From 754f27a5d4a4386c49444a825d5a384b57e27937 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:50:05 -0700 Subject: [PATCH 05/15] refactor(hls)!: drop history and range playlists, expose routes, trim archive - moq-hls: remove `Config::history`, range playlists, and their errors; the CLI never set history, so `?start=` only ever answered 416. - moq-hls: `Route`/`Resource` move to `export` without the `server` feature; `Rendition::segment` takes the parsed `segments::Id` and `init` the hash. - moq-hls: `export::master` is private. - moq-archive: `Key`, `segment`, `store` internals, and most `Store` methods are private; `Store` keeps `new`, `prefix`, and `timelines`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- Cargo.lock | 3 - doc/bin/hls.md | 18 +- quest/m1/archive/catalog-hls.md | 3 - quest/m1/archive/consumer.md | 6 +- rs/moq-archive/MIGRATE.md | 8 +- rs/moq-archive/src/error.rs | 4 - rs/moq-archive/src/lib.rs | 10 +- rs/moq-archive/src/mock.rs | 48 +- rs/moq-archive/src/segment.rs | 7 +- rs/moq-archive/src/store.rs | 447 +----------- rs/moq-hls/Cargo.toml | 5 +- rs/moq-hls/MIGRATE.md | 36 +- rs/moq-hls/src/error.rs | 12 - rs/moq-hls/src/export/archive_tests.rs | 969 ------------------------- rs/moq-hls/src/export/history.rs | 90 --- rs/moq-hls/src/export/mod.rs | 459 ++---------- rs/moq-hls/src/export/mpd.rs | 37 +- rs/moq-hls/src/export/rendition.rs | 185 ++--- rs/moq-hls/src/export/renditions.rs | 53 +- rs/moq-hls/src/export/route.rs | 239 ++++++ rs/moq-hls/src/export/segments.rs | 75 +- rs/moq-hls/src/server/mod.rs | 5 +- rs/moq-hls/src/server/routes.rs | 430 +---------- 23 files changed, 519 insertions(+), 2630 deletions(-) delete mode 100644 rs/moq-hls/src/export/archive_tests.rs delete mode 100644 rs/moq-hls/src/export/history.rs create mode 100644 rs/moq-hls/src/export/route.rs diff --git a/Cargo.lock b/Cargo.lock index b8cb020c2fd..4468307feb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4860,15 +4860,12 @@ dependencies = [ name = "moq-hls" version = "0.5.11" dependencies = [ - "async-trait", "axum", "bytes", - "futures", "hang", "humantime", "kio 0.6.2", "m3u8-rs", - "moq-archive", "moq-clock", "moq-json", "moq-mux", diff --git a/doc/bin/hls.md b/doc/bin/hls.md index a58221c3688..8830e692e86 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -35,7 +35,7 @@ transcoded, so the player must support the publisher's codecs. What that means for you: - **Idle renditions cost nothing**, and a segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it. Keep `--window` (the playlist length, default 16 s) within that retention. -- **Live joins cost the window.** The default exporter reads the timeline checkpoint and lists at most 256 segments. `export::Config::history` reads a durable recording's retained timeline groups from the beginning instead of joining only the checkpoint tail. +- **Live joins cost the window.** The exporter reads the timeline checkpoint and lists at most 256 segments. - **Publish timelines.** A broadcast whose catalog advertises no timelines is skipped, and a rendition is listed only once its own timeline is advertised. Without a duration hint, a publisher declares that timeline after the rendition's first segment. - **Align GOPs across video renditions.** Segment boundaries come from one reference rendition. Other video renditions snap to their nearest keyframe, and a segment with none nearby becomes an `EXT-X-GAP`. - **Disabled renditions are hidden.** A rendition the catalog marks `enabled: false` leaves the multivariant playlist and DASH manifest until it re-enables. Its media playlist keeps serving, so a player already on it is not ended with `EXT-X-ENDLIST`. If it supplied segment boundaries, an enabled sibling takes over at its first record numbered past the last advertised one, and DASH starts a new period. The media sequence is the record number, so every edge agrees: a sibling numbered ahead restarts the playlist with a discontinuity, and one numbered behind (such as a re-enabled rung) holds every playlist until its numbering catches up. `EXT-X-DISCONTINUITY-SEQUENCE` and DASH periods are counted per edge, so edges agree on them only when no discontinuity or handoff happened while each was running. @@ -44,21 +44,7 @@ What that means for you: A [`moq-archive`](https://docs.rs/moq-archive) recording replayed through its `Reader` is served the same way, with no second stored copy, and lists the -same window as a live broadcast by default. `export::Config::history` includes -earlier timeline groups even after replay-cache eviction: the exporter FETCHes -missing groups from the Reader's stored timeline objects before following live -edits. Missing or unreadable stored history fails that export with an error, -and so does a recording whose start was pruned by retention. - -With `export::Config::history` enabled, a durable recording's media playlists -also accept `start` and `end` query parameters in decimal media-time seconds. -Both are required and name a half-open range, for example -`media.m3u8?start=12.5&end=30`. The playlist includes every overlapping -segment, widening its start to the preceding video sync point, and adds -`EXT-X-ENDLIST` once media covers the requested end. It remains an event -playlist while that end is still ahead of the live edge. A malformed range -returns HTTP 400. A live-only timeline or range past a finished recording -returns HTTP 416 instead of a partial clip, and a failed timeline returns 500. +same window as a live broadcast. `--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS, and `--cors-origin` opens it to browsers. See `moq export hls --help`. diff --git a/quest/m1/archive/catalog-hls.md b/quest/m1/archive/catalog-hls.md index 1728ed95792..f9c259242a5 100644 --- a/quest/m1/archive/catalog-hls.md +++ b/quest/m1/archive/catalog-hls.md @@ -48,9 +48,6 @@ Decided by the maintainer on 2026-10-17: Guidance: -- `moq-hls` history mode becomes explicit configuration only: today the - catalog's `store` marks a timeline durable (`durable` in - `rs/moq-hls/src/export/mod.rs`). - Fold in the old README's fix: `moq-mux`'s consumer refuses a record whose `sequence` differs from its window index, as its `Producer::resume` already does diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index a356218ea04..ee3f0764c54 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -65,8 +65,10 @@ Guidance: `track::Dynamic` handler resolves a `Time(pts)` end by picking the object from the key listing, then the group containing pts from that object's group timestamps, whichever quest lands second. -- Move `rs/moq-hls/src/export/archive_tests.rs` onto a consumer broadcast with - its archived catalog, deleting the hand-built catalog. +- Cover HLS export over a consumer broadcast with its archived catalog: + playlists read only the timelines, and a segment GETs only its own + rendition's objects. The Reader-based `moq-hls` archive tests went with + `moq-archive`'s public segment API. - Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, and HLS example. diff --git a/rs/moq-archive/MIGRATE.md b/rs/moq-archive/MIGRATE.md index c08212303ca..7b684d5f0ff 100644 --- a/rs/moq-archive/MIGRATE.md +++ b/rs/moq-archive/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Store internals are private + +`Key`, the `segment` module (`Frame`, `Group`, `Object`), the `store` module and its `list` queries, `Error::Pagination`, and every `Store` method but `new`, `prefix`, and `timelines` are private or removed. +`Store` is now only the handle a `Producer` records into and a `Reader` serves from: build it with `Store::new(inner, prefix)` and hand it over. +Record objects with `Producer`, not by writing them yourself, and keep your own `ObjectStore` handle for any listing or cleanup outside the archive layout. + ### Recording format version 3 stores a signed priority (#6142) `Info::priority`, `Info::new`, and `Error::Priority` use `i8`, higher first, with 0 unset. @@ -23,8 +29,6 @@ The new layout is not readable by `Reader`, which still replays timeline recordi ### Untimed tracks record untimed (#5720) `Info::new` and `Info::timescale` take and return `Option`, and `Error::TimescaleMismatch` carries `Option` fields. -`segment::Frame::timestamp` is `Option`. -`Object::decode(buf, timed)` and `Store::get_segments(track, segment, timed)` take whether the track is timed. Mismatched timing is `Error::Timing`. Untimed recordings use the version 2 format, which an older version 2 reader refuses. Pass `Some(timescale)` to `Info::new`, and `info.timescale.is_some()` as `timed`. diff --git a/rs/moq-archive/src/error.rs b/rs/moq-archive/src/error.rs index 9a9b075965c..db44446b2ea 100644 --- a/rs/moq-archive/src/error.rs +++ b/rs/moq-archive/src/error.rs @@ -72,10 +72,6 @@ pub enum Error { #[error("directory listing is unsupported: {0}")] Directory(String), - /// A pagination continuation was passed to streaming list. - #[error("continuation query requires list_paginated")] - Pagination, - /// A create collided with different object bytes. #[error("conflicting object: {0}")] Conflict(String), diff --git a/rs/moq-archive/src/lib.rs b/rs/moq-archive/src/lib.rs index 12e47d66db3..1aea80ccf2f 100644 --- a/rs/moq-archive/src/lib.rs +++ b/rs/moq-archive/src/lib.rs @@ -69,16 +69,16 @@ mod path; pub mod producer; pub mod reader; /// The binary segment object holding a record's groups and frames. -pub mod segment; -/// Put, get, list, and delete recording objects on an object store. -pub mod store; +mod segment; +/// Recording objects on an object store. +mod store; pub use error::{Error, Result}; pub use info::Info; -pub use path::Key; +pub(crate) use path::Key; pub use producer::Producer; pub use reader::Reader; -pub use segment::{Frame, Group, Object}; +pub(crate) use segment::Object; pub use store::Store; /// Recording format version written into `.info` and the binary envelope. diff --git a/rs/moq-archive/src/mock.rs b/rs/moq-archive/src/mock.rs index 76d1f43adc7..003ef4e5882 100644 --- a/rs/moq-archive/src/mock.rs +++ b/rs/moq-archive/src/mock.rs @@ -1,11 +1,9 @@ -//! A test object store: any inner store plus an operation log, injected failures, and -//! S3-style paginated listing. +//! A test object store: any inner store plus an operation log and injected failures. use std::sync::{Arc, Mutex}; use futures::stream::BoxStream; use futures::{StreamExt, TryStreamExt}; -use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore}; use object_store::memory::InMemory; use object_store::path::Path; use object_store::{ @@ -13,16 +11,13 @@ use object_store::{ PutOptions, PutPayload, PutResult, }; -/// S3's ListObjectsV2 page limit. -const MAX_KEYS: usize = 1000; - /// One call made against a [`Mock`]. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Op { Get(String), Put(String), Delete(String), - /// A streaming or paginated listing, with its prefix and exclusive offset. + /// A streaming listing, with its prefix and exclusive offset. List { prefix: String, offset: Option, @@ -306,42 +301,3 @@ impl ObjectStore for Mock { self.inner.copy_opts(from, to, options).await } } - -/// ListObjectsV2 semantics: a raw string prefix, lexical order, an exclusive `start-after` -/// offset, at most 1000 keys, and an opaque continuation token that resumes after the last key. -#[async_trait::async_trait] -impl PaginatedListStore for Mock { - async fn list_paginated( - &self, - prefix: Option<&str>, - opts: PaginatedListOptions, - ) -> object_store::Result { - if opts.delimiter.is_some() { - return Err(unsupported("delimiter")); - } - self.log(Op::List { - prefix: prefix.unwrap_or_default().to_string(), - offset: opts.offset.clone(), - }); - - let mut metas: Vec = self.inner.list(None).try_collect().await?; - metas.sort_by(|a, b| a.location.as_ref().cmp(b.location.as_ref())); - let after = opts.page_token.or(opts.offset); - metas.retain(|meta| { - let key = meta.location.as_ref(); - prefix.is_none_or(|prefix| key.starts_with(prefix)) && after.as_deref().is_none_or(|after| key > after) - }); - - let take = opts.max_keys.unwrap_or(MAX_KEYS).clamp(1, MAX_KEYS); - let page_token = (metas.len() > take).then(|| metas[take - 1].location.to_string()); - metas.truncate(take); - Ok(PaginatedListResult { - result: ListResult { - objects: metas, - common_prefixes: Vec::new(), - extensions: Default::default(), - }, - page_token, - }) - } -} diff --git a/rs/moq-archive/src/segment.rs b/rs/moq-archive/src/segment.rs index 7f69c463e0b..0411392dd80 100644 --- a/rs/moq-archive/src/segment.rs +++ b/rs/moq-archive/src/segment.rs @@ -1,4 +1,6 @@ -use bytes::{Buf, BufMut, Bytes, BytesMut}; +use bytes::{Buf, Bytes}; +#[cfg(test)] +use bytes::{BufMut, BytesMut}; use hang::timeline::Position; use moq_net::varint; @@ -35,6 +37,7 @@ pub struct Object { impl Object { /// An object whose first group starts at frame zero. + #[cfg(test)] pub fn new(groups: Vec) -> Self { Self { frame_start: 0, groups } } @@ -75,6 +78,7 @@ impl Object { /// Encode the binary envelope. The first sequence is absolute; later ones are `current - previous - 1`. /// /// Frame timestamps are stored only when the frames have them, so every frame must agree. + #[cfg(test)] pub fn encode(&self) -> Result { validate(&self.groups)?; @@ -235,6 +239,7 @@ fn validate(groups: &[Group]) -> Result<()> { Ok(()) } +#[cfg(test)] fn write_varint(buf: &mut impl BufMut, value: u64) -> Result<()> { varint::encode_quic(value, buf).map_err(|_| Error::Overflow) } diff --git a/rs/moq-archive/src/store.rs b/rs/moq-archive/src/store.rs index 3e8b3d8b1a1..281fe9aa223 100644 --- a/rs/moq-archive/src/store.rs +++ b/rs/moq-archive/src/store.rs @@ -1,36 +1,16 @@ -use std::num::NonZeroUsize; - use bytes::Bytes; use futures::StreamExt; use futures::stream::BoxStream; -use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore}; use object_store::path::Path; -use object_store::{ListResult, ObjectMeta, ObjectStore, ObjectStoreExt, PutMode, PutPayload}; +use object_store::{ObjectMeta, ObjectStore, ObjectStoreExt, PutMode, PutPayload}; use crate::info::Info; use crate::path::Key; use crate::segment::Object; use crate::{Error, Result}; -/// Recording object listing types. -/// -/// Listing scopes and offsets are relative to the recording prefix supplied to -/// [`super::Store::new`]. The same [`Query`] can drive streaming and paginated -/// listing. A page's [`Page::next`] query retains the opaque backend token and -/// must be passed to [`super::Store::list_paginated`], not [`super::Store::list`]. -/// -/// ``` -/// use std::num::NonZeroUsize; -/// -/// use moq_archive::store::list::Query; -/// -/// let query = Query::segments("video.timeline.z")? -/// .page_size(NonZeroUsize::new(100).unwrap()); -/// # Ok::<(), moq_archive::Error>(()) -/// ``` -pub mod list { - use std::num::NonZeroUsize; - +/// Recording object listing types, relative to the recording prefix supplied to [`Store::new`]. +pub(crate) mod list { use object_store::path::Path; use crate::path; @@ -41,54 +21,21 @@ pub mod list { pub struct Query { pub(crate) prefix: Option, pub(crate) offset: Option, - pub(crate) max_keys: Option, - pub(crate) page_token: Option, } impl Query { - /// List every object in the recording. - pub fn new() -> Self { - Self::default() - } - - fn prefix(mut self, prefix: impl Into) -> Self { - self.prefix = Some(prefix.into()); - self.page_token = None; - self - } - - fn offset(mut self, offset: impl Into) -> Self { - self.offset = Some(offset.into()); - self.page_token = None; - self - } - - /// Request at most this many entries per page. - pub fn page_size(mut self, max_keys: NonZeroUsize) -> Self { - self.max_keys = Some(max_keys); - self.page_token = None; - self - } - - /// List every object for one track. - pub fn track(track: &str) -> Result { - Ok(Self::new().prefix(path::track_prefix(track)?)) - } - /// List segment objects for one track. pub fn segments(track: &str) -> Result { - Ok(Self::new().prefix(path::segments_prefix(&Path::ROOT, track)?)) + Ok(Self { + prefix: Some(path::segments_prefix(&Path::ROOT, track)?), + ..Self::default() + }) } /// Start strictly after this recording object. - pub fn after(self, key: &Key) -> Result { - Ok(self.offset(key.path(&Path::ROOT)?)) - } - - pub(crate) fn next(&self, page_token: String) -> Self { - let mut next = self.clone(); - next.page_token = Some(page_token); - next + pub fn after(mut self, key: &Key) -> Result { + self.offset = Some(key.path(&Path::ROOT)?); + Ok(self) } } @@ -100,19 +47,9 @@ pub mod list { /// Object size in bytes. pub size: u64, } - - /// One page of recording objects. - #[derive(Debug, Clone)] - pub struct Page { - /// Entries in the backend's order. - pub entries: Vec, - /// The same query advanced to the next page, if any. - /// Pass it to `Store::list_paginated`; `Store::list` rejects it. - pub next: Option, - } } -use list::{Entry, Page, Query}; +use list::{Entry, Query}; /// Versioned recording objects on a generic [`ObjectStore`]. #[derive(Clone, Debug)] @@ -131,7 +68,7 @@ impl Store { } /// The underlying object store. - pub fn inner(&self) -> &T { + pub(crate) fn inner(&self) -> &T { &self.inner } @@ -141,7 +78,7 @@ impl Store { } /// Encode `key` under this store's prefix. - pub fn path(&self, key: &Key) -> Result { + pub(crate) fn path(&self, key: &Key) -> Result { key.path(&self.prefix) } @@ -149,7 +86,7 @@ impl Store { /// /// An existing recording in an older format is refused with [`Error::Version`]: a reader /// still reads it, but nothing appends to it. - pub async fn put_info(&self, track: &str, info: &Info) -> Result { + pub(crate) async fn put_info(&self, track: &str, info: &Info) -> Result { let key = Key::info(track)?; let path = self.path(&key)?; let bytes = info.encode()?; @@ -179,13 +116,14 @@ impl Store { } /// Fetch and validate a track's `.info`. - pub async fn get_info(&self, track: &str) -> Result { + pub(crate) async fn get_info(&self, track: &str) -> Result { let path = self.path(&Key::info(track)?)?; Info::decode(&self.get_bytes(&path).await?) } /// Create the object at `segments/`. A collision is accepted only when the bytes match. - pub async fn put_segments(&self, track: &str, segment: u64, object: &Object) -> Result { + #[cfg(test)] + pub(crate) async fn put_segments(&self, track: &str, segment: u64, object: &Object) -> Result { let key = Key::segments(track, segment)?; self.put_segment(&key, object.encode()?).await?; Ok(key) @@ -193,7 +131,7 @@ impl Store { /// Fetch and decode the object at `segments/`, whose frames store timestamps when /// `timed`, as the track's `.info` says. - pub async fn get_segments(&self, track: &str, segment: u64, timed: bool) -> Result { + pub(crate) async fn get_segments(&self, track: &str, segment: u64, timed: bool) -> Result { let path = self.path(&Key::segments(track, segment)?)?; Object::decode(self.get_bytes(&path).await?, timed) } @@ -225,19 +163,8 @@ impl Store { Ok(timelines) } - /// Delete the object at `key`. - pub async fn delete(&self, key: &Key) -> Result<()> { - let path = self.path(key)?; - self.inner.delete(&path).await?; - Ok(()) - } - - /// Stream matching entries. Order is unspecified and `max_keys` is ignored. - /// Continuation queries from `Page::next` fail; pass them to `list_paginated`. - pub fn list(&self, query: &Query) -> BoxStream<'static, Result> { - if query.page_token.is_some() { - return futures::stream::once(async { Err(Error::Pagination) }).boxed(); - } + /// Stream matching entries, in unspecified order. + pub(crate) fn list(&self, query: &Query) -> BoxStream<'static, Result> { let prefix = self.list_path(query.prefix.as_ref()); let offset = query.offset.as_ref().map(|offset| self.recording_path(offset)); let store_prefix = self.prefix.clone(); @@ -248,6 +175,7 @@ impl Store { stream.map(move |item| Entry::from_meta(&store_prefix, item?)).boxed() } + #[cfg(test)] async fn put_segment(&self, key: &Key, bytes: Bytes) -> Result<()> { let path = self.path(key)?; match self.create(&path, bytes.clone()).await? { @@ -289,50 +217,6 @@ impl Store { let path = relative.map_or_else(|| self.prefix.clone(), |relative| self.recording_path(relative)); (!path.as_ref().is_empty()).then_some(path) } - - fn paginated_prefix(&self, relative: Option<&Path>) -> Option { - self.list_path(relative).map(|path| format!("{path}/")) - } - - fn paginated_options(&self, query: &Query) -> PaginatedListOptions { - PaginatedListOptions { - offset: query - .offset - .as_ref() - .map(|offset| self.recording_path(offset).to_string()), - max_keys: query.max_keys.map(NonZeroUsize::get), - page_token: query.page_token.clone(), - ..Default::default() - } - } - - fn page(&self, query: &Query, result: PaginatedListResult) -> Result { - let PaginatedListResult { result, page_token } = result; - let entries = self.entries(result)?; - let next = page_token.map(|token| query.next(token)); - Ok(Page { entries, next }) - } - - fn entries(&self, result: ListResult) -> Result> { - if let Some(prefix) = result.common_prefixes.first() { - return Err(Error::Directory(prefix.to_string())); - } - result - .objects - .into_iter() - .map(|meta| Entry::from_meta(&self.prefix, meta)) - .collect() - } -} - -impl Store { - /// List one page while keeping the backend continuation token inside the returned query. - pub async fn list_paginated(&self, query: &Query) -> Result { - let prefix = self.paginated_prefix(query.prefix.as_ref()); - let opts = self.paginated_options(query); - let result = self.inner.list_paginated(prefix.as_deref(), opts).await?; - self.page(query, result) - } } impl Entry { @@ -350,7 +234,6 @@ enum Create { #[cfg(test)] mod tests { use std::collections::BTreeSet; - use std::num::NonZeroUsize; use futures::TryStreamExt; use object_store::ObjectStoreExt; @@ -506,19 +389,6 @@ mod tests { )); } - #[tokio::test] - async fn segments_put_get_and_delete() { - let store = memory(); - let object = one_group(0, b"tl"); - store.put_segments("timeline.z", 0, &object).await.unwrap(); - assert_eq!(store.get_segments("timeline.z", 0, true).await.unwrap(), object); - store.delete(&Key::segments("timeline.z", 0).unwrap()).await.unwrap(); - assert!(matches!( - store.get_segments("timeline.z", 0, true).await, - Err(Error::NotFound(_)) - )); - } - #[tokio::test] async fn listing_names_every_object() { let store = memory(); @@ -527,7 +397,7 @@ mod tests { store.put_segments("video", 3, &one_group(3, b"b")).await.unwrap(); store.put_segments("timeline.z", 2, &one_group(0, b"t")).await.unwrap(); - let listed: Vec<_> = store.list(&Query::new()).try_collect().await.unwrap(); + let listed: Vec<_> = store.list(&Query::default()).try_collect().await.unwrap(); let mut keys: Vec<_> = listed.into_iter().map(|listed| listed.key).collect(); keys.sort_by(|a, b| store.path(a).unwrap().as_ref().cmp(store.path(b).unwrap().as_ref())); assert_eq!( @@ -571,254 +441,10 @@ mod tests { .put_info("catalog.json", &Info::new(0, Some(1)).unwrap()) .await .unwrap(); - let locations = names(&store, &Query::new()).await; + let locations = names(&store, &Query::default()).await; assert!(locations.contains(&format!("{}/.info", encode_track("catalog.json").unwrap()))); } - #[tokio::test] - async fn listing_query_is_recording_relative() { - let store = memory(); - store.put_info("video", &Info::new(0, Some(1)).unwrap()).await.unwrap(); - store.put_segments("video", 1, &one_group(1, b"a")).await.unwrap(); - store - .put_info("video-alt", &Info::new(0, Some(1)).unwrap()) - .await - .unwrap(); - - let query = Query::track("video").unwrap(); - let paths = names(&store, &query).await; - assert_eq!( - paths, - BTreeSet::from([ - "video/.info".to_string(), - "video/segments/0000000000000000001".to_string(), - ]) - ); - assert_eq!( - store.paginated_prefix(query.prefix.as_ref()).as_deref(), - Some("rec/video/") - ); - } - - #[tokio::test] - async fn paginated_recording_prefix_excludes_siblings() { - let store = memory(); - store.put_info("video", &Info::new(0, Some(1)).unwrap()).await.unwrap(); - store - .inner() - .put( - &Path::from("rec-other/video/.info"), - Bytes::from_static(b"sibling").into(), - ) - .await - .unwrap(); - - let prefix = store.paginated_prefix(None).unwrap(); - assert_eq!(prefix, "rec/"); - let objects = store - .inner() - .list(None) - .try_filter(|meta| futures::future::ready(meta.location.as_ref().starts_with(&prefix))) - .try_collect() - .await - .unwrap(); - let entries = store - .entries(ListResult { - objects, - common_prefixes: Vec::new(), - extensions: Default::default(), - }) - .unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].key, Key::info("video").unwrap()); - } - - #[tokio::test] - async fn paginated_query_preserves_every_supported_option() { - let store = memory(); - let query = Query::segments("timeline.z") - .unwrap() - .after(&Key::segments("timeline.z", 1).unwrap()) - .unwrap() - .page_size(NonZeroUsize::new(2).unwrap()); - let options = store.paginated_options(&query); - assert_eq!( - store.paginated_prefix(query.prefix.as_ref()).as_deref(), - Some("rec/timeline%2Ez/segments/") - ); - assert_eq!( - options.offset.as_deref(), - Some("rec/timeline%2Ez/segments/0000000000000000001") - ); - assert_eq!(options.max_keys, Some(2)); - assert_eq!(options.page_token, None); - - let page = store - .page( - &query, - PaginatedListResult { - result: ListResult { - objects: Vec::new(), - common_prefixes: Vec::new(), - extensions: Default::default(), - }, - page_token: Some("opaque".to_string()), - }, - ) - .unwrap(); - let next = page.next.unwrap(); - assert_eq!(next.prefix, query.prefix); - assert_eq!(next.offset, query.offset); - assert_eq!(next.max_keys, query.max_keys); - assert_eq!(store.paginated_options(&next).page_token.as_deref(), Some("opaque")); - - let changed = next.page_size(NonZeroUsize::new(3).unwrap()); - assert_eq!(store.paginated_options(&changed).page_token, None); - } - - #[tokio::test] - async fn paginated_pages_match_streaming_results() { - let store = memory(); - for segment in 0..3 { - store - .put_segments("timeline.z", segment, &one_group(segment, b"t")) - .await - .unwrap(); - } - let query = Query::segments("timeline.z") - .unwrap() - .after(&Key::segments("timeline.z", 0).unwrap()) - .unwrap() - .page_size(NonZeroUsize::new(1).unwrap()); - let streamed = names(&store, &query).await; - let mut metas: Vec<_> = store - .inner() - .list(store.list_path(query.prefix.as_ref()).as_ref()) - .try_collect() - .await - .unwrap(); - metas.sort_by(|a, b| a.location.cmp(&b.location)); - metas.retain(|meta| meta.location > store.recording_path(query.offset.as_ref().unwrap())); - - let mut paged = BTreeSet::new(); - let mut current = query; - for (index, meta) in metas.iter().cloned().enumerate() { - let page = store - .page( - ¤t, - PaginatedListResult { - result: ListResult { - objects: vec![meta], - common_prefixes: Vec::new(), - extensions: Default::default(), - }, - page_token: (index + 1 < metas.len()).then(|| format!("page-{index}")), - }, - ) - .unwrap(); - paged.extend( - page.entries - .into_iter() - .map(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()), - ); - if let Some(next) = page.next { - current = next; - } - } - assert_eq!(paged, streamed); - } - - #[tokio::test] - async fn streaming_rejects_continuation_queries() { - let store = memory(); - store.put_segments("timeline.z", 0, &one_group(0, b"t")).await.unwrap(); - let query = Query::segments("timeline.z") - .unwrap() - .page_size(NonZeroUsize::new(1).unwrap()); - let next = query.next("0".to_string()); - let err = store.list(&next).try_collect::>().await.unwrap_err(); - assert_eq!(err, Error::Pagination); - } - - #[tokio::test] - async fn paginated_listing_walks_pages_and_excludes_siblings() { - let store = Store::new(Mock::memory(), "rec"); - for segment in 0..3 { - store - .put_segments("timeline.z", segment, &one_group(segment, b"t")) - .await - .unwrap(); - } - store - .inner() - .put( - &Path::from("rec-other/video/.info"), - Bytes::from_static(b"sibling").into(), - ) - .await - .unwrap(); - - let query = Query::segments("timeline.z") - .unwrap() - .after(&Key::segments("timeline.z", 0).unwrap()) - .unwrap() - .page_size(NonZeroUsize::new(1).unwrap()); - let streamed: BTreeSet = store - .list(&query) - .map_ok(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()) - .try_collect() - .await - .unwrap(); - assert_eq!(streamed.len(), 2); - - let mut paged = BTreeSet::new(); - let mut current = Some(query); - let mut pages = 0; - while let Some(query) = current { - let page = store.list_paginated(&query).await.unwrap(); - assert_eq!(page.entries.len(), 1); - paged.extend( - page.entries - .into_iter() - .map(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()), - ); - current = page.next; - pages += 1; - assert!(pages <= 2, "pagination did not terminate"); - } - assert_eq!(pages, 2); - assert_eq!(paged, streamed); - - // A recording-wide walk uses the trailing-slash scope, so the - // `rec-other` sibling must not appear in any page. - let query = Query::new().page_size(NonZeroUsize::new(1).unwrap()); - let streamed: BTreeSet = store - .list(&query) - .map_ok(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()) - .try_collect() - .await - .unwrap(); - assert_eq!(streamed.len(), 3); - - let mut paged = BTreeSet::new(); - let mut current = Some(query); - let mut pages = 0; - while let Some(query) = current { - let page = store.list_paginated(&query).await.unwrap(); - assert_eq!(page.entries.len(), 1); - paged.extend( - page.entries - .into_iter() - .map(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()), - ); - current = page.next; - pages += 1; - assert!(pages <= 3, "pagination did not terminate"); - } - assert_eq!(pages, 3); - assert_eq!(paged, streamed); - } - #[tokio::test] async fn empty_prefix_lists_the_whole_store() { let store = Store::new(Mock::memory(), ""); @@ -827,41 +453,16 @@ mod tests { .await .unwrap(); store.put_segments("video", 4, &one_group(4, b"a")).await.unwrap(); - assert_eq!(store.paginated_prefix(None), None); let expected = std::collections::HashSet::from([Key::info("catalog.json").unwrap(), Key::segments("video", 4).unwrap()]); let streamed: std::collections::HashSet = store - .list(&Query::new()) + .list(&Query::default()) .map_ok(|entry| entry.key) .try_collect() .await .unwrap(); assert_eq!(streamed, expected); - let page = store.list_paginated(&Query::new()).await.unwrap(); - assert_eq!( - page.entries - .into_iter() - .map(|entry| entry.key) - .collect::>(), - expected - ); - assert!(page.next.is_none()); - let page = store.list_paginated(&Query::segments("video").unwrap()).await.unwrap(); - assert_eq!(page.entries.len(), 1); - } - - #[test] - fn directory_results_are_not_silently_dropped() { - let store = memory(); - let err = store - .entries(ListResult { - objects: Vec::new(), - common_prefixes: vec![Path::from("rec/video")], - extensions: Default::default(), - }) - .unwrap_err(); - assert_eq!(err, Error::Directory("rec/video".to_string())); } #[tokio::test] diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index a38a3a6030c..74ade03962d 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -25,7 +25,6 @@ humantime = { workspace = true } kio = { workspace = true } m3u8-rs = "6" moq-clock = { workspace = true } -moq-json = { workspace = true } moq-mux = { workspace = true } moq-net = { workspace = true } percent-encoding = { workspace = true } @@ -38,9 +37,7 @@ tracing = { workspace = true } url = { workspace = true } [dev-dependencies] -async-trait = { workspace = true } -futures = { workspace = true } -moq-archive = { path = "../moq-archive" } +moq-json = { path = "../moq-json" } moq-tokio = { path = "../moq-tokio", default-features = false, features = ["tcp", "quic", "aws-lc-rs"] } tokio = { workspace = true, features = ["test-util"] } tower = { version = "0.5", features = ["util"] } diff --git a/rs/moq-hls/MIGRATE.md b/rs/moq-hls/MIGRATE.md index 93bf229f475..b9bb3f00f34 100644 --- a/rs/moq-hls/MIGRATE.md +++ b/rs/moq-hls/MIGRATE.md @@ -4,6 +4,29 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Routes parse without the server, and media takes the parsed id + +`server::Route` and `server::Resource` are `export::Route` and `export::Resource`, available without the `server` feature. +`Resource::Segment` and `Resource::SegmentAt` are one `Resource::Segment { kind, rendition, segment }`, whose `segments::Id` carries the generation, the reference tag, and `segments::At::Sequence` or `At::Pts`. +`Rendition::segment(sequence)` is `segment(&id)`, and refuses a segment URL from another generation. +`Rendition::init()` is `init(hash)`, answering only the init `Resource::Init` names. +Parse the request path with `Route::parse` instead of splitting it yourself, so an encoded `/` cannot smuggle a broadcast past your policy check, and pass the parsed id through: + +```rust +let Some(route) = moq_hls::export::Route::parse(path) else { return not_found() }; +match route.resource { + Resource::Init { hash, .. } => rendition.init(&hash).await, + Resource::Segment { segment, .. } => rendition.segment(&segment).await, + _ => return not_found(), +} +``` + +To warm the init before a media playlist, call `Rendition::playlist(query)`, which builds it. + +### The master playlist renderer is private + +`export::master` is private. Render the multivariant playlist with `Broadcaster::master_playlist`. + ### HLS import unwraps fMP4 to Legacy (#6250) `import::Import` now publishes one `Legacy` frame per sample instead of one `Cmaf` frame per fragment. @@ -23,7 +46,7 @@ import.finish()?.wait().await; `export::Broadcaster::manifest` and `export::Rendition::media_playlist` return `Result>`. Answer `Err(Error::TimelineFailed { .. })` with an uncacheable error (the server router uses 500 with `Cache-Control: no-store`) instead of serving a playlist. -`Rendition::playlist` fails the same way, including for a range it used to refuse with `Error::RangeUnavailable`. +`Rendition::playlist` fails the same way. A failed timeline no longer ends a playlist with `EXT-X-ENDLIST` or turns the DASH manifest `static`; only a clean finish does. A rendition whose own timeline failed is left out of the manifest and master playlist, while its siblings keep serving. @@ -47,11 +70,6 @@ A placed timebase only made redundant importers share timestamps; each importer `export::Broadcaster::rendition` and the `renditions()` cursor's `Event::Added` see a rendition only once the catalog advertises its own timeline, not as soon as its init is known. An unhinted publisher declares that timeline after the rendition's first record, so feed media until then instead of waiting for `Added` or `Rendition::init` first. -### Playlists take an optional range (#5705) - -`export::Rendition::playlist(query)` is `playlist(range, query)`. -Pass `None` for the live or history playlist. - ### Discontinuities are absolute sequences `export::segments::Segment::discontinuity` is a `u64` sequence, absolute within one `Broadcaster`, instead of a `bool` or a per-cursor count. @@ -61,14 +79,8 @@ A new `Broadcaster` starts a new sequence namespace: start a new recording, or m ### Playlists are bounded (#4978) Every timeline, durable ones included, lists a capped window of 256 segments. -Set `Config::history` to list a durable timeline past the window. `master.m3u8` lists a video rendition only once a segment starts at a sync point, and answers 404 until one can start. -```rust -let mut config = moq_hls::export::Config::default(); -config.history = true; -``` - ### `Broadcaster::new` is synchronous `Broadcaster::new` no longer awaits or fails. diff --git a/rs/moq-hls/src/error.rs b/rs/moq-hls/src/error.rs index aea371cb853..b7363fe731d 100644 --- a/rs/moq-hls/src/error.rs +++ b/rs/moq-hls/src/error.rs @@ -39,18 +39,6 @@ pub enum Error { #[error("mux: {0}")] Mux(#[from] moq_mux::Error), - /// History mode found no first group on this timeline, as in a retained (DVR) recording. - #[error("history needs timeline {0} from its first group; retained recordings are not supported")] - HistoryPruned(String), - - /// A range playlist was requested from a timeline whose media is not durable. - #[error("range playlists require a durable timeline")] - RangeNotDurable, - - /// A requested range is invalid or no longer fully retained. - #[error("requested range is unavailable: {0}")] - RangeUnavailable(String), - /// A `file://` URL could not be turned back into a filesystem path. #[error("invalid file URL")] InvalidFileUrl, diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs deleted file mode 100644 index 8025a454d6c..00000000000 --- a/rs/moq-hls/src/export/archive_tests.rs +++ /dev/null @@ -1,969 +0,0 @@ -//! Serving a `moq-archive` recording as HLS. -//! -//! A [`moq_archive::Reader`] replays each track's timeline onto a broadcast and answers FETCH from -//! its record objects, so the exporter serves it exactly like a live broadcast. These tests pin -//! the storage traffic that composition produces: playlists read only the timelines, and a segment -//! GETs only objects of the requested rendition. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use bytes::{Bytes, BytesMut}; -use futures::stream::BoxStream; -use hang::timeline::{Position, Record}; -use moq_archive::object_store::memory::InMemory; -use moq_archive::object_store::path::Path; -use moq_archive::object_store::{ - self, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, - PutMultipartOptions, PutOptions, PutPayload, PutResult, -}; -use moq_archive::{Frame, Group, Info, Object, Store, reader}; -use moq_json::window; - -use super::*; - -/// In-memory store that records every GET path and counts listings. -#[derive(Debug, Clone, Default)] -struct Counting { - inner: Arc, - gets: Arc>>, - lists: Arc, - /// Fail every media GET, so nothing can quietly depend on one. - reject_media: Arc, -} - -impl Counting { - /// Every GET since the last call. - fn take(&self) -> Vec { - std::mem::take(&mut *self.gets.lock().unwrap()) - } - - /// Listings since the store was created. - fn lists(&self) -> usize { - self.lists.load(Ordering::SeqCst) - } - - fn reject_media(&self, reject: bool) { - self.reject_media.store(reject, Ordering::SeqCst); - } -} - -impl std::fmt::Display for Counting { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Counting") - } -} - -#[async_trait::async_trait] -impl ObjectStore for Counting { - async fn put_opts( - &self, - location: &Path, - payload: PutPayload, - opts: PutOptions, - ) -> object_store::Result { - self.inner.put_opts(location, payload, opts).await - } - - async fn put_multipart_opts( - &self, - location: &Path, - opts: PutMultipartOptions, - ) -> object_store::Result> { - self.inner.put_multipart_opts(location, opts).await - } - - async fn get_opts(&self, location: &Path, options: GetOptions) -> object_store::Result { - self.gets.lock().unwrap().push(location.to_string()); - if self.reject_media.load(Ordering::SeqCst) && is_media(location.as_ref()) { - return Err(object_store::Error::NotImplemented { - operation: "media GET".into(), - implementer: "Counting".into(), - }); - } - self.inner.get_opts(location, options).await - } - - fn delete_stream( - &self, - locations: BoxStream<'static, object_store::Result>, - ) -> BoxStream<'static, object_store::Result> { - self.inner.delete_stream(locations) - } - - fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result> { - self.lists.fetch_add(1, Ordering::SeqCst); - self.inner.list(prefix) - } - - async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result { - self.inner.list_with_delimiter(prefix).await - } - - async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> object_store::Result<()> { - self.inner.copy_opts(from, to, options).await - } -} - -/// One track's timeline encoder, as `moq_archive::Writer` drives it. -struct Encoder { - encoder: window::Encoder, - /// Next timeline group sequence. - group: u64, - /// Next record sequence. - record: u64, -} - -/// Writes recording objects the way `moq_archive::Writer` lays them out, with full control over -/// each record's timing and groups. -struct Recording { - store: Store, - encoders: HashMap, -} - -impl Recording { - async fn new(tracks: &[&str]) -> Self { - let store = Store::new(Counting::default(), "rec"); - let mut encoders = HashMap::new(); - for track in tracks { - // Legacy media is stamped in microseconds. - store - .put_info(track, &Info::new(1, Some(1_000_000)).unwrap()) - .await - .unwrap(); - store - .put_info(&timeline(track), &Info::new(0, Some(1000)).unwrap()) - .await - .unwrap(); - let config = window::ProducerConfig::default().with_compression(true).with_ops(false); - let encoder = Encoder { - encoder: window::Encoder::new(config), - group: 0, - record: 0, - }; - encoders.insert(track.to_string(), encoder); - } - Self { store, encoders } - } - - /// Store and commit `track`'s next record: `groups`, each a list of frame timestamps in micros, - /// spanning `pts..pts + duration` in milliseconds, popping `pop` older records. - async fn add(&mut self, track: &str, pts: u64, duration: u64, groups: &[(u64, &[u64])], pop: u64) { - let encoder = self.encoders.get_mut(track).unwrap(); - let sequence = encoder.record; - encoder.record += 1; - let first = groups.first().unwrap().0; - let last = groups.last().unwrap().0; - let record = Record::new( - sequence, - pts, - duration, - Position::group(first), - Position::group(last + 1), - ); - - let groups = groups - .iter() - .map(|&(sequence, frames)| Group { - sequence, - frames: frames - .iter() - .enumerate() - .map(|(index, µs)| legacy(track, micros, index == 0)) - .collect(), - }) - .collect(); - self.store - .put_segments(track, sequence, &Object::new(groups)) - .await - .unwrap(); - - let mut payloads = Vec::new(); - let pending = encoder.encoder.push(&record).unwrap(); - payloads.push((pending.keyframe, pending.payload.clone())); - pending.commit(); - if let Some(pending) = encoder.encoder.pop(pop).unwrap() { - payloads.push((pending.keyframe, pending.payload.clone())); - pending.commit(); - } - let mut groups: Vec = Vec::new(); - for (keyframe, payload) in payloads { - if keyframe || groups.is_empty() { - groups.push(Group { - sequence: encoder.group, - frames: Vec::new(), - }); - encoder.group += 1; - } - let frame = Frame { - timestamp: record.pts, - payload, - }; - groups.last_mut().unwrap().frames.push(frame); - } - self.store - .put_segments(&timeline(track), sequence, &Object::new(groups)) - .await - .unwrap(); - } - - /// Every GET since the last call. - fn gets(&self) -> Vec { - self.store.inner().take() - } - - fn timelines(&self) -> std::collections::BTreeMap { - self.encoders - .keys() - .map(|track| (track.clone(), timeline(track))) - .collect() - } -} - -fn timeline(track: &str) -> String { - hang::timeline::default_name(track) -} - -/// One Legacy frame: a VP8 keyframe for video (geometry the muxer can parse), filler otherwise. -fn legacy(track: &str, micros: u64, keyframe: bool) -> Frame { - let payload: &'static [u8] = match (track.starts_with("audio"), keyframe) { - (true, _) => &[0xFC, 0xFF, 0xFE], - (false, true) => &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], - (false, false) => &[0x31, 0x00, 0x00], - }; - let frame = hang::container::Frame { - timestamp: moq_net::Timestamp::from_micros(micros).unwrap(), - payload: Bytes::from_static(payload), - }; - let mut encoded = BytesMut::new(); - frame.encode(&mut encoded).unwrap(); - Frame { - timestamp: Some(micros), - payload: encoded.freeze(), - } -} - -/// A live-style archive entry naming every rendition's timeline. -fn live() -> hang::catalog::Archive { - let mut archive = hang::catalog::Archive::new(); - for track in ["360p", "1080p", "audio"] { - archive - .timelines - .insert(track.to_string(), hang::catalog::Timeline::new(timeline(track), 4_000)); - } - archive -} - -/// The archive entry a replay advertises: the recording's timelines, durable in its store. -fn durable() -> hang::catalog::Archive { - let mut archive = live(); - archive.store = Some("memory:///rec/".parse().unwrap()); - archive.version = Some(hang::catalog::Archive::VERSION); - archive -} - -/// The catalog an exporter is handed: every rendition's config, plus `archive`. Out-of-band -/// configs, so no init needs media. -fn catalog(archive: hang::catalog::Archive) -> hang::Catalog { - let mut catalog = hang::Catalog::default(); - catalog.archive = Some(archive); - for (name, width, height) in [("360p", 640, 360), ("1080p", 1920, 1080)] { - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.coded_width = Some(width); - config.coded_height = Some(height); - catalog.video.renditions.insert(name.to_string(), config); - } - let audio = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); - catalog.audio.renditions.insert("audio".to_string(), audio); - catalog -} - -/// A recording served back through an origin: the reader's broadcast carries the supplied -/// catalog, and a broadcaster exports it. -struct Replay { - /// Taken to supply finality. - reader: Option>, - broadcaster: Arc, - _catalog: moq_json::snapshot::Producer, - _broadcast: moq_net::broadcast::Producer, - _origin: moq_net::origin::Producer, -} - -impl Replay { - async fn open(recording: &Recording, cache: u64, archive: hang::catalog::Archive) -> Self { - Self::open_with(recording, cache, archive, Config::default()).await - } - - async fn open_with(recording: &Recording, cache: u64, archive: hang::catalog::Archive, config: Config) -> Self { - let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); - tokio::spawn(moq_net::time::run(driver)); - let broadcast = origin.create_broadcast("rec").unwrap(); - - let track = broadcast - .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) - .unwrap(); - let mut json = moq_json::snapshot::Config::default(); - json.deltas = false; - let mut catalog = moq_json::snapshot::Producer::new(track, json); - catalog - .update(moq_net::Timed::from(&self::catalog(archive)).at(moq_net::Timestamp::now())) - .unwrap(); - - let serving = reader::Config::new(recording.timelines()).with_cache(cache); - let reader = moq_archive::Reader::open(recording.store.clone(), &broadcast, serving) - .await - .unwrap(); - tokio::spawn(reader.serve()); - broadcast.announce(Default::default()).unwrap(); - for _ in 0..10 { - tokio::task::yield_now().await; - } - - let broadcaster = Broadcaster::new(crate::resolved(&origin, "rec").await, config); - tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()) - .await - .expect("a rendition becomes playable"); - - Self { - reader: Some(reader), - broadcaster, - _catalog: catalog, - _broadcast: broadcast, - _origin: origin, - } - } - - fn rendition(&self, kind: Kind, name: &str) -> Arc { - self.broadcaster - .rendition(kind, name) - .expect("rendition in the catalog") - } - - async fn playlist(&self, kind: Kind, name: &str) -> String { - let rendition = self.rendition(kind, name); - tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None, None)) - .await - .expect("playlist renders") - .unwrap() - .expect("playlist is servable") - } - - /// Wait until `name`'s playlist satisfies `ready`, then return it. - async fn playlist_until(&self, kind: Kind, name: &str, ready: impl Fn(&str) -> bool) -> String { - for _ in 0..500 { - let playlist = self.playlist(kind, name).await; - if ready(&playlist) { - return playlist; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("{name} playlist never became ready"); - } - - /// Wait until `name`'s playlist answers a failed timeline, then return that error. - async fn failure(&self, kind: Kind, name: &str) -> crate::Error { - let rendition = self.rendition(kind, name); - for _ in 0..500 { - let answer = tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None, None)) - .await - .expect("playlist answers"); - if let Err(err) = answer { - assert!(matches!(err, crate::Error::TimelineFailed { .. }), "{name}: {err}"); - return err; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("{name} playlist never failed"); - } -} - -fn is_media(path: &str) -> bool { - path.contains("/segments/") && !path.contains("timeline") -} - -/// Three 2s segments: one keyframe group per video record, four audio groups per audio record. -/// The first video rendition by name, 1080p, is the reference. -async fn three_segments() -> Recording { - segments(3).await -} - -/// `count` 2s segments, laid out like [`three_segments`]. -async fn segments(count: u64) -> Recording { - segments_with(count, None).await -} - -async fn bounded_segments(count: u64) -> Recording { - segments_with(count, Some(4)).await -} - -async fn segments_with(count: u64, checkpoint: Option) -> Recording { - let mut recording = Recording::new(&["360p", "1080p", "audio"]).await; - if let Some(limit) = checkpoint { - for encoder in recording.encoders.values_mut() { - encoder.encoder = window::Encoder::new( - window::ProducerConfig::default() - .with_compression(true) - .with_checkpoint_records(limit) - .with_ops(false), - ); - } - } - for segment in 0..count { - let pts = segment * 2_000_000; - for video in ["360p", "1080p"] { - recording - .add(video, segment * 2000, 2000, &[(segment, &[pts, pts + 1_000_000])], 0) - .await; - } - let audio: Vec<(u64, [u64; 1])> = (0..4).map(|i| (segment * 4 + i, [pts + i * 500_000])).collect(); - let audio: Vec<(u64, &[u64])> = audio - .iter() - .map(|(sequence, frames)| (*sequence, &frames[..])) - .collect(); - recording.add("audio", segment * 2000, 2000, &audio, 0).await; - } - recording -} - -#[tokio::test] -async fn playlists_read_only_timelines_and_segments_their_own_objects() { - let recording = three_segments().await; - let replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; - - let master = replay.broadcaster.master_playlist(None); - assert!(master.contains("video/360p/media.m3u8") && master.contains("video/1080p/media.m3u8")); - - // Render and reload every playlist with media GETs refused: shared numbering, and not one - // media GET. The reference lists every segment; the others hold back the newest until their - // own timeline passes its end, or ends. - recording.store.inner().reject_media(true); - for _ in 0..2 { - for (kind, name, listed) in [ - (Kind::Video, "1080p", 3), - (Kind::Video, "360p", 2), - (Kind::Audio, "audio", 3), - ] { - let playlist = replay.playlist(kind, name).await; - for segment in 0..3 { - let line = format!("seg/29b1cd67.{segment}.m4s\n"); - assert_eq!(playlist.contains(&line), segment < listed, "{name}: {playlist}"); - } - assert!(!playlist.contains("#EXT-X-ENDLIST"), "no finality was supplied"); - } - } - let gets = recording.gets(); - for rendition in ["360p", "1080p", "audio"] { - assert!( - gets.contains(&format!("rec/{rendition}/.info")), - "track metadata binds before End: {gets:?}" - ); - } - assert!(gets.iter().any(|path| path.contains("timeline")), "{gets:?}"); - assert!( - !gets.iter().any(|path| is_media(path)), - "playlists must not GET media: {gets:?}" - ); - recording.store.inner().reject_media(false); - // A segment resolves its objects from the timelines: no listing, no index object. - let lists = recording.store.inner().lists(); - - // Switching renditions downloads only the selected rendition's object. - let low = replay - .rendition(Kind::Video, "360p") - .listed_segment(1) - .await - .unwrap() - .unwrap(); - assert_eq!(&low[4..8], b"moof"); - assert_eq!(recording.gets(), ["rec/360p/segments/0000000000000000001"]); - let high = replay - .rendition(Kind::Video, "1080p") - .listed_segment(2) - .await - .unwrap() - .unwrap(); - assert_eq!(&high[4..8], b"moof"); - assert_eq!(recording.gets(), ["rec/1080p/segments/0000000000000000002"]); - - // An audio segment spans four groups but still costs one object GET. - let audio = replay - .rendition(Kind::Audio, "audio") - .listed_segment(1) - .await - .unwrap() - .unwrap(); - assert_eq!(&audio[4..8], b"moof"); - assert_eq!(recording.gets(), ["rec/audio/segments/0000000000000000001"]); - - // A repeated request hits the reader's cache, including the immutable `.info`. - replay - .rendition(Kind::Video, "360p") - .listed_segment(1) - .await - .unwrap() - .unwrap(); - assert_eq!(recording.gets(), Vec::::new()); - assert_eq!(recording.store.inner().lists(), lists, "segments never list"); -} - -#[tokio::test] -async fn a_bounded_cache_rereads_evicted_objects() { - let recording = three_segments().await; - // Too small for any object, so nothing stays cached. - let replay = Replay::open(&recording, 1, durable()).await; - replay.playlist(Kind::Audio, "audio").await; - recording.gets(); - - // Each of the segment's four groups misses the cache and GETs the same object again. - let audio = replay - .rendition(Kind::Audio, "audio") - .listed_segment(1) - .await - .unwrap() - .unwrap(); - assert_eq!(&audio[4..8], b"moof"); - let gets: Vec = recording.gets().into_iter().filter(|path| is_media(path)).collect(); - assert_eq!(gets, vec!["rec/audio/segments/0000000000000000001"; 4]); -} - -#[tokio::test] -async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities() { - let mut recording = Recording::new(&["360p", "1080p", "audio"]).await; - recording.add("1080p", 0, 2000, &[(0, &[0])], 0).await; - recording.add("360p", 0, 8000, &[(0, &[0])], 0).await; - // 360p stored nothing for segment 1. - recording.add("1080p", 2000, 2000, &[(1, &[2_000_000])], 0).await; - // Content time jumps from 4s to 10s. - recording.add("1080p", 10_000, 2000, &[(2, &[10_000_000])], 0).await; - recording.add("360p", 10_000, 2000, &[(1, &[10_000_000])], 0).await; - - let mut replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; - // Finality resolves each rendition's newest segment. - replay.reader.take().unwrap().finish().unwrap(); - let low = replay - .playlist_until(Kind::Video, "360p", |playlist| playlist.contains("#EXT-X-ENDLIST")) - .await; - let expected = concat!( - "#EXTINF:2.00000,\nseg/29b1cd67.0.m4s\n", - "#EXT-X-GAP\n#EXTINF:2.00000,\nseg/29b1cd67.1.m4s\n", - "#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\nseg/29b1cd67.2.m4s\n", - ); - assert!(low.contains(expected), "{low}"); - let high = replay.playlist(Kind::Video, "1080p").await; - assert!(!high.contains("#EXT-X-GAP"), "{high}"); - assert!( - high.contains("#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\nseg/29b1cd67.2.m4s\n"), - "{high}" - ); - - // A gap is never fetched. - recording.gets(); - let rendition = replay.rendition(Kind::Video, "360p"); - assert!(rendition.listed_segment(1).await.unwrap().is_none()); - assert!(!recording.gets().iter().any(|path| is_media(path))); - let after = rendition.listed_segment(2).await.unwrap().unwrap(); - assert_eq!(&after[4..8], b"moof"); -} - -#[tokio::test] -async fn a_growing_recording_ends_only_on_caller_finality() { - let mut recording = three_segments().await; - let mut replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; - replay.playlist(Kind::Video, "1080p").await; - - // A DVR commit: segment 3 arrives and segment 0 expires. - recording.add("1080p", 6000, 2000, &[(3, &[6_000_000])], 1).await; - recording.gets(); - let mut reader = replay.reader.take().unwrap(); - reader.refresh().await.unwrap(); - let playlist = replay - .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.3.m4s\n") - }) - .await; - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:1\n"), "{playlist}"); - assert!(!playlist.contains("seg/29b1cd67.0.m4s"), "{playlist}"); - assert!( - !playlist.contains("#EXT-X-ENDLIST"), - "a recording without finality stays live" - ); - let gets = recording.gets(); - assert!( - !gets.iter().any(|path| is_media(path)), - "following reads only the timelines: {gets:?}" - ); - - // The store holds no completion marker; the caller supplies finality. - reader.finish().unwrap(); - let playlist = replay - .playlist_until(Kind::Video, "1080p", |playlist| playlist.contains("#EXT-X-ENDLIST")) - .await; - assert!(playlist.contains("seg/29b1cd67.3.m4s\n#EXT-X-ENDLIST\n"), "{playlist}"); -} - -/// A durable timeline still lists only the default 16s window: a live playlist stays bounded -/// however much the store retains. -#[tokio::test] -async fn a_durable_timeline_lists_the_window() { - let recording = segments(12).await; - let replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; - let playlist = replay - .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.11.m4s\n") - }) - .await; - // Eight 2s segments cover the window. - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:4\n"), "{playlist}"); - assert!(!playlist.contains("seg/29b1cd67.3.m4s\n"), "{playlist}"); -} - -/// History mode lists a durable timeline's whole recording, and DASH offers the whole listed -/// span. A live-style entry, or a `replay` path that moves the durable ranges to another -/// broadcast, keeps the window even then. -#[tokio::test] -async fn history_mode_lists_a_durable_timeline_past_the_window() { - let recording = bounded_segments(12).await; - let config = Config { - history: true, - ..Config::default() - }; - let replay = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config.clone()).await; - let playlist = replay - .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.11.m4s\n") - }) - .await; - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n"), "{playlist}"); - assert!(playlist.contains("seg/29b1cd67.0.m4s\n"), "{playlist}"); - - for (kind, name) in [(Kind::Video, "360p"), (Kind::Video, "1080p"), (Kind::Audio, "audio")] { - replay.rendition(kind, name).init().await.unwrap(); - } - let manifest = replay.broadcaster.manifest(None).unwrap().expect("manifest renders"); - assert!(manifest.contains("timeShiftBufferDepth=\"PT24.000S\""), "{manifest}"); - - let mut elsewhere = durable(); - elsewhere.replay = Some(moq_net::path::RelativeOwned::new("./recording")); - for archive in [live(), elsewhere] { - let windowed = Replay::open_with(&recording, 64 * 1024 * 1024, archive, config.clone()).await; - let playlist = windowed - .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.11.m4s\n") - }) - .await; - assert!(!playlist.contains("seg/29b1cd67.0.m4s\n"), "{playlist}"); - } -} - -/// A media-time range lists the overlapping durable segments, uses their source sequence, and -/// becomes a VOD playlist once the requested end is covered. Independent edges agree. -#[tokio::test(start_paused = true)] -async fn range_playlist_clips_durable_history() { - let recording = bounded_segments(12).await; - let config = Config { - history: true, - ..Config::default() - }; - let first = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config.clone()).await; - let second = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config).await; - for replay in [&first, &second] { - replay - .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.11.m4s\n") - }) - .await; - } - let range = super::Range::new(Duration::from_secs(5), Duration::from_secs(9)).unwrap(); - let playlist = |replay: &Replay| { - replay - .rendition(Kind::Video, "1080p") - .range_playlist(range, Some("jwt=clip")) - .unwrap() - .unwrap() - }; - let first_playlist = playlist(&first); - assert_eq!(first_playlist, playlist(&second)); - assert!(first_playlist.contains("#EXT-X-MEDIA-SEQUENCE:2\n"), "{first_playlist}"); - assert!( - first_playlist.contains("seg/29b1cd67.2.m4s?jwt=clip\n"), - "{first_playlist}" - ); - assert!( - first_playlist.contains("seg/29b1cd67.4.m4s?jwt=clip\n"), - "{first_playlist}" - ); - assert!(!first_playlist.contains("seg/29b1cd67.5.m4s"), "{first_playlist}"); - assert!(first_playlist.ends_with("#EXT-X-ENDLIST\n"), "{first_playlist}"); -} - -/// An unsupported range is refused before waiting for media, so a server answers 416 rather than -/// timing out. -#[tokio::test(start_paused = true)] -async fn range_playlist_refuses_non_durable_or_windowed_timelines() { - use futures::FutureExt; - - let recording = bounded_segments(3).await; - let range = super::Range::new(Duration::ZERO, Duration::from_secs(2)).unwrap(); - let windowed = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; - assert!(matches!( - windowed - .rendition(Kind::Video, "1080p") - .playlist(Some(range), None) - .now_or_never(), - Some(Err(crate::Error::RangeUnavailable(_))) - )); - - let config = Config { - history: true, - ..Config::default() - }; - let live = Replay::open_with(&recording, 64 * 1024 * 1024, live(), config.clone()).await; - assert!(matches!( - live.rendition(Kind::Video, "1080p") - .playlist(Some(range), None) - .now_or_never(), - Some(Err(crate::Error::RangeNotDurable)) - )); - - // A rendition without its own timeline would list only gaps, so it is not served at all. - let mut archive = durable(); - archive.timelines.remove("360p"); - let untimed = Replay::open_with(&recording, 64 * 1024 * 1024, archive, config).await; - assert!(untimed.broadcaster.rendition(Kind::Video, "360p").is_none()); - assert!(untimed.broadcaster.rendition(Kind::Video, "1080p").is_some()); -} - -#[tokio::test(start_paused = true)] -async fn history_mode_recovers_evicted_timeline_groups() { - history_after_expiry(false).await; -} - -#[tokio::test(start_paused = true)] -async fn history_mode_recovers_a_finished_timeline() { - history_after_expiry(true).await; -} - -async fn history_after_expiry(finished: bool) { - let recording = bounded_segments(12).await; - let expired = Expired::open(&recording, finished, &[]).await; - let replay = &expired.replay; - // Every rendition recovers its own evicted timeline, not only the reference: otherwise the - // reference's early rows resolve as gaps on its siblings. - for (kind, name) in [(Kind::Video, "1080p"), (Kind::Video, "360p"), (Kind::Audio, "audio")] { - let playlist = replay - .playlist_until(kind, name, |playlist| playlist.contains(".10.m4s\n")) - .await; - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n"), "{name}: {playlist}"); - assert!(playlist.contains(".0.m4s\n"), "{name}: {playlist}"); - assert!(!playlist.contains("#EXT-X-GAP"), "{name}: {playlist}"); - assert!(replay.rendition(kind, name).listed_segment(0).await.unwrap().is_some()); - } -} - -/// How a stored timeline object is damaged. -#[derive(Clone, Copy, Debug)] -enum Fault { - /// The object is gone, so its group fails to FETCH. - Missing, - /// The object's frames are garbage, so its group fails to decode. - Corrupt, -} - -/// A history-mode export of a recording whose publisher cache expired first, so every timeline -/// group is FETCHed from the store. -struct Expired { - replay: Replay, - serving: tokio::task::JoinHandle<()>, - driver: tokio::task::JoinHandle, -} - -impl Expired { - /// Expire the cache, apply each `(track, record, fault)` to that record's stored timeline - /// object, then start exporting. `finished` supplies finality first. - async fn open(recording: &Recording, finished: bool, faults: &[(&str, u64, Fault)]) -> Self { - let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); - let driver = tokio::spawn(moq_net::time::run(driver)); - let broadcast = origin.create_broadcast("rec").unwrap(); - let track = broadcast - .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) - .unwrap(); - let mut catalog = moq_json::snapshot::Producer::new(track, moq_json::snapshot::Config::default()); - catalog - .update(moq_net::Timed::from(&self::catalog(durable())).at(moq_net::Timestamp::now())) - .unwrap(); - let reader = moq_archive::Reader::open( - recording.store.clone(), - &broadcast, - reader::Config::new(recording.timelines()), - ) - .await - .unwrap(); - let serving = tokio::spawn(reader.serve()); - let reader = if finished { - reader.finish().unwrap(); - None - } else { - Some(reader) - }; - // Expire the publisher cache before any HLS consumer can retain a copy. - let now = moq_net::time::Instant::now(); - broadcast.info().pool.gc(now, &kio::Waiter::noop()); - broadcast - .info() - .pool - .gc(now + Duration::from_secs(31), &kio::Waiter::noop()); - let subscription = moq_net::track::Subscription::default() - .with_groups(0..) - .with_max_delay(Duration::from_millis((1_u64 << 62) - 1)); - let mut retained = broadcast - .consume() - .track(&timeline("1080p")) - .unwrap() - .subscribe(subscription) - .await - .unwrap() - .ordered(); - let retained = retained.next_group().await.unwrap(); - assert!( - retained.is_none_or(|group| group.sequence > 0), - "the initial timeline groups must really be evicted" - ); - for &(track, record, fault) in faults { - let track = timeline(track); - let mut object = recording.store.get_segments(&track, record, true).await.unwrap(); - let key = moq_archive::Key::segments(&track, record).unwrap(); - recording.store.delete(&key).await.unwrap(); - if let Fault::Corrupt = fault { - for frame in object.groups.iter_mut().flat_map(|group| group.frames.iter_mut()) { - frame.payload = Bytes::from_static(b"garbage"); - } - recording.store.put_segments(&track, record, &object).await.unwrap(); - } - } - broadcast.announce(Default::default()).unwrap(); - let broadcaster = Broadcaster::new( - crate::resolved(&origin, "rec").await, - Config { - history: true, - ..Config::default() - }, - ); - tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()) - .await - .expect("a rendition becomes playable or fails after cache expiry"); - let replay = Replay { - reader, - broadcaster, - _catalog: catalog, - _broadcast: broadcast, - _origin: origin, - }; - Self { - replay, - serving, - driver, - } - } -} - -impl Drop for Expired { - fn drop(&mut self) { - self.serving.abort(); - self.driver.abort(); - } -} - -/// Retention prunes a recording's first timeline objects, so history mode refuses it before -/// listing anything rather than starting from a later checkpoint. -#[tokio::test(start_paused = true)] -async fn history_mode_refuses_a_retained_recording() { - let recording = bounded_segments(12).await; - for track in recording.timelines().values() { - for segment in 0..4 { - let key = moq_archive::Key::segments(track, segment).unwrap(); - recording.store.delete(&key).await.unwrap(); - } - } - let replay = Replay::open_with( - &recording, - 64 * 1024 * 1024, - durable(), - Config { - history: true, - ..Config::default() - }, - ) - .await; - for (kind, name) in [(Kind::Video, "360p"), (Kind::Video, "1080p"), (Kind::Audio, "audio")] { - let err = replay.failure(kind, name).await; - assert!( - err.to_string().contains("retained recordings are not supported"), - "{name}: {err}" - ); - } - - let broadcast = replay._origin.consume().request_broadcast("rec", None).await.unwrap(); - let err = history::read(&broadcast, &durable(), "1080p", |_| panic!("nothing is listed")) - .await - .unwrap_err(); - assert!(matches!(err, crate::Error::HistoryPruned(_)), "{err}"); -} - -#[tokio::test(start_paused = true)] -async fn a_missing_mid_history_reference_group_fails_every_listing() { - mid_history_reference_fault(Fault::Missing).await; -} - -#[tokio::test(start_paused = true)] -async fn a_corrupt_mid_history_reference_group_fails_every_listing() { - mid_history_reference_fault(Fault::Corrupt).await; -} - -/// A finished recording missing part of its reference timeline would otherwise list a prefix -/// and end with `EXT-X-ENDLIST`, so every listing answers the failure instead, a clip of the -/// readable prefix included. -async fn mid_history_reference_fault(fault: Fault) { - let recording = bounded_segments(12).await; - let expired = Expired::open(&recording, true, &[("1080p", 6, fault)]).await; - let replay = &expired.replay; - for (kind, name) in [(Kind::Video, "1080p"), (Kind::Video, "360p"), (Kind::Audio, "audio")] { - replay.failure(kind, name).await; - } - assert!(matches!( - replay.broadcaster.manifest(None), - Err(crate::Error::TimelineFailed { .. }) - )); - let range = super::Range::new(Duration::ZERO, Duration::from_secs(4)).unwrap(); - assert!(matches!( - replay.rendition(Kind::Video, "1080p").playlist(Some(range), None).await, - Err(crate::Error::TimelineFailed { .. }) - )); -} - -/// A rendition missing part of its own history fails alone: its siblings finish, and the DASH -/// manifest and master playlist leave it out. -#[tokio::test(start_paused = true)] -async fn a_corrupt_mid_history_rendition_group_fails_only_its_playlist() { - let recording = bounded_segments(12).await; - let expired = Expired::open(&recording, true, &[("360p", 6, Fault::Corrupt)]).await; - let replay = &expired.replay; - replay.failure(Kind::Video, "360p").await; - for (kind, name) in [(Kind::Video, "1080p"), (Kind::Audio, "audio")] { - let playlist = replay - .playlist_until(kind, name, |playlist| playlist.contains("#EXT-X-ENDLIST")) - .await; - assert!(playlist.contains(".11.m4s\n"), "{name}: {playlist}"); - } - let manifest = replay - .broadcaster - .manifest(None) - .unwrap() - .expect("the siblings stay listed"); - assert!(manifest.contains("id=\"video/1080p\""), "{manifest}"); - assert!(!manifest.contains("id=\"video/360p\""), "{manifest}"); - let master = replay.broadcaster.master_playlist(None); - assert!(master.contains("video/1080p/"), "{master}"); - assert!(!master.contains("video/360p/"), "{master}"); -} diff --git a/rs/moq-hls/src/export/history.rs b/rs/moq-hls/src/export/history.rs deleted file mode 100644 index 5586906f824..00000000000 --- a/rs/moq-hls/src/export/history.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::ops::Range; -use std::time::Duration; - -use hang::timeline::Record; -use moq_json::window; -use moq_mux::timeline::Entry; - -/// One edit to a durable timeline's window. -pub(super) enum Edit { - /// A record became visible at this window index, which equals its sequence. - Push { index: u64, entry: Entry }, - /// These window indices were removed. - Pop(Range), -} - -/// Read `track`'s durable timeline from group 0, FETCHing groups the cache evicted, then follow -/// live edits. Every reader of a history-mode timeline (the reference and each rendition's spans) -/// shares this, so all of them recover the same evicted history. -pub(super) async fn read( - broadcast: &moq_net::broadcast::Consumer, - section: &hang::catalog::Archive, - track: &str, - mut apply: impl FnMut(Edit), -) -> crate::Result<()> { - let name = section - .timelines - .get(track) - .ok_or_else(|| moq_mux::Error::TimelineMissing(track.to_string()))? - .track - .as_str(); - let scale = moq_net::Timescale::new(section.timescale as u64) - .map_err(|_| moq_mux::Error::InvalidTimescale(section.timescale))?; - let track = broadcast.track(name)?; - // Effectively unbounded, so no history group is skipped as late: 2^53 - 1 ms is the largest - // delay every implementation decodes (js/net reads it as a safe integer). - let subscription = moq_net::track::Subscription::default() - .with_groups(0..) - .with_max_delay(Duration::from_millis((1_u64 << 53) - 1)); - let mut live = track.subscribe(subscription).await?.ordered(); - let mut decoder = window::Decoder::::new(window::ConsumerConfig::default().with_compression(true)); - let mut next = 0; - loop { - // The live group only marks how far to read. Each group is fetched when reached instead: - // a held consumer does not pin its frames, so the backfill before it could evict it. - let head = live.next_group().await?; - let finished = head.is_none(); - // A finished track can have no groups left in cache. Its high-water mark - // still tells us which stored groups must be fetched before returning. - let end = match head { - Some(group) => Some(group.sequence), - None => live.largest().await.map(|position| position.group), - }; - while end.is_some_and(|end| next <= end) { - let mut group = match track.fetch_group(next, None).await { - Ok(group) => group, - // Retention prunes a recording's start, which this path cannot list from yet. - Err(moq_net::Error::NotFound | moq_net::Error::Stream(moq_net::StreamError::NotFound)) if next == 0 => { - return Err(crate::Error::HistoryPruned(name.to_string())); - } - Err(err) => return Err(err.into()), - }; - let mut decoded = decoder.group(); - while let Some(frame) = group.read_frame().await? { - decoded.decode(&frame.payload).map_err(moq_mux::Error::from)?; - while let Some(event) = decoded.next_event() { - match event { - window::Event::Push { index, value } => { - if index != value.sequence { - return Err(moq_mux::Error::TimelineCheckpoint(index).into()); - } - apply(Edit::Push { - index, - entry: Entry::decode(value, scale)?, - }); - } - window::Event::Pop(range) => apply(Edit::Pop(range)), - // A durable path must not present missing history as a complete - // recording. Cached/live consumption may skip; this path cannot. - window::Event::Skip(_) => return Err(moq_net::Error::NotFound.into()), - _ => unreachable!("unknown timeline window event"), - } - } - } - next += 1; - } - if finished { - return Ok(()); - } - } -} diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index be13f07d71d..69966249177 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -19,22 +19,20 @@ //! //! The same machinery serves two kinds of consumer: //! -//! * the HTTP serve path (pull): [`Broadcaster::rendition`] / -//! [`Broadcaster::master_playlist`] / [`Broadcaster::manifest`] and -//! [`Rendition::playlist`] / [`Rendition::init`] / [`Rendition::segment`], rendered or -//! fetched per request, with or without the `server` feature's router; and +//! * the HTTP serve path (pull): [`Route::parse`] names the resource a request path addresses, +//! and [`Broadcaster::rendition`] / [`Broadcaster::master_playlist`] / +//! [`Broadcaster::manifest`] and [`Rendition::playlist`] / [`Rendition::init`] / +//! [`Rendition::segment`] render or fetch it per request, with or without the `server` +//! feature's router; and //! * a recorder (push): the [`renditions::Consumer`] and [`segments::Consumer`] cursors, which //! yield every rendition and every finalized segment in order, for mirroring a broadcast to //! storage. -#[cfg(test)] -#[allow(clippy::disallowed_methods, clippy::disallowed_types)] -mod archive_tests; -mod history; -pub mod master; +pub(crate) mod master; mod mpd; mod playlist; mod rendition; +mod route; mod spans; pub mod renditions; @@ -48,29 +46,11 @@ use rand::RngExt; pub(crate) use playlist::render_media; pub use rendition::{Kind, Rendition}; +pub use route::{Resource, Route}; use segments::Reference; -type TimelineWatchers = - Arc)>>>; - -/// A half-open media-time range for an HLS playlist. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Range { - /// The first requested media time. - start: Duration, - /// The first media time outside the request. - end: Duration, -} - -impl Range { - /// Build a non-empty media-time range. - pub fn new(start: Duration, end: Duration) -> crate::Result { - if start >= end { - return Err(crate::Error::RangeUnavailable("start must be before end".into())); - } - Ok(Self { start, end }) - } -} +/// The selected reference's timeline reader. +type TimelineWatcher = Arc>>>; /// Backoff bounds for the initial catalog subscription. /// @@ -100,29 +80,14 @@ pub struct Config { /// segments are fetched from there on request. It also caps segment `Cache-Control: max-age`. /// /// A durable timeline gets the same window: a live playlist stays bounded however much its - /// store retains. See [`history`](Self::history) to list all of it. + /// store retains. pub window: Duration, - - /// List a durable timeline past the window, trimmed only by the timeline's own pops, so a - /// viewer can seek back through a recording. Off by default. - /// - /// A timeline is durable when its catalog `archive` entry names a `store` and no `replay` - /// path, so every range it lists stays FETCHable from this broadcast. Other timelines keep - /// the window, since their segments leave the relay cache. - /// - /// Durable history reads retained timeline groups from the beginning, including records older - /// than the checkpoint a live join receives. Live playlists keep their bounded join. A - /// recording whose start was pruned by retention is refused with [`crate::Error::HistoryPruned`]. - /// Across reference changes, retention retires a common playlist prefix so remaining - /// segments keep their HLS sequence positions. - pub history: bool, } impl Default for Config { fn default() -> Self { Self { window: Duration::from_secs(16), - history: false, } } } @@ -139,9 +104,9 @@ pub struct Broadcaster { /// renditions themselves (see [`renditions::Producer::clear`]). Set once, right after /// construction. watcher: Mutex>>, - /// Reference timeline readers. History mode retains past references to follow their pops. - /// On drop they are aborted only when no recording cursor still needs their records. - timeline_watchers: TimelineWatchers, + /// The reference timeline reader. On drop it is aborted only when no recording cursor still + /// needs its records. + timeline_watcher: TimelineWatcher, } impl Broadcaster { @@ -149,16 +114,16 @@ impl Broadcaster { pub fn new(source: moq_mux::Source, config: Config) -> Arc { let broadcast = source.broadcast(); let renditions = renditions::Producer::new(config.window); - let timeline_watchers = Arc::new(Mutex::new(std::collections::BTreeMap::new())); + let timeline_watcher = TimelineWatcher::default(); let broadcaster = Arc::new(Self { broadcast, renditions: renditions.clone(), watcher: Mutex::new(None), - timeline_watchers: timeline_watchers.clone(), + timeline_watcher: timeline_watcher.clone(), }); // The watcher owns its own producer clone; the `Broadcaster`'s `Drop` aborts it so the // standing catalog subscription stops when nobody's serving from this broadcaster. - let watcher = tokio::spawn(watch_catalog(source, renditions, timeline_watchers, config.history)); + let watcher = tokio::spawn(watch_catalog(source, renditions, timeline_watcher)); *broadcaster.watcher.lock().unwrap() = Some(watcher); broadcaster } @@ -352,7 +317,7 @@ impl Broadcaster { #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn build_inits(&self) { for rendition in self.renditions.snapshot() { - if let Err(err) = rendition.init().await { + if let Err(err) = rendition.latest_init().await { tracing::warn!(rendition = %rendition.name, %err, "failed to build init segment"); } } @@ -389,10 +354,8 @@ impl Drop for Broadcaster { // Release the map either way: a surviving `renditions::Consumer` would otherwise pin // every `Rendition` forever. self.renditions.clear(); - if !held { - for (_, (_, watcher)) in std::mem::take(&mut *self.timeline_watchers.lock().unwrap()) { - watcher.abort(); - } + if !held && let Some(watcher) = self.timeline_watcher.lock().unwrap().take() { + watcher.abort(); } } } @@ -400,8 +363,7 @@ impl Drop for Broadcaster { async fn watch_catalog( source: moq_mux::Source, renditions: renditions::Producer, - timeline_watchers: TimelineWatchers, - history: bool, + timeline_watcher: TimelineWatcher, ) { // The source's catalog broadcast, so the catalog, the timeline subscription, and the closed // check below all refer to the one broadcast it was built from. @@ -425,44 +387,27 @@ async fn watch_catalog( } }; - let mut unbound = false; let mut reference: Option = None; loop { match kio::wait(|waiter| consumer.poll_next(waiter)).await { Ok(Some(catalog)) => { - // Decide the playlists' retention before any rendition starts following its timeline. - if history && !unbound && catalog.archive.as_ref().is_some_and(durable) { - unbound = true; - renditions.fanout().unbound(); - } renditions.sync(&source, &catalog); // Keep an enabled reference, preserving every row already advertised on a handoff. let next = self::reference(&catalog, reference.as_ref()); if next != reference { - let mut watchers = timeline_watchers.lock().unwrap(); + let mut watcher = timeline_watcher.lock().unwrap(); renditions.fanout().select(next.clone()); - if !unbound { - for (_, (_, watcher)) in std::mem::take(&mut *watchers) { - watcher.abort(); - } + if let Some(watcher) = watcher.take() { + watcher.abort(); } - // History rows stay listed until their original timeline pops them, so retain - // those readers after a handoff. Selection suppresses their pushes and finish. - // A reselected reference gets a fresh reader: the retained one consumed records - // while unselected, and only a replay restates them. if let (Some(next), Some(archive)) = (&next, catalog.archive.clone()) { - if let Some((_, watcher)) = watchers.remove(next.as_ref()) { - watcher.abort(); - } - let watcher = tokio::spawn(watch_timeline( + *watcher = Some(tokio::spawn(watch_timeline( broadcast.clone(), archive, next.clone(), renditions.fanout(), - unbound, - )); - watchers.insert(next.as_ref().clone(), (next.clone(), watcher)); + ))); } reference = next; } @@ -537,14 +482,6 @@ fn reference(catalog: &moq_mux::catalog::hang::Catalog, current: Option<&Referen .map(|(kind, id, track)| Arc::new((kind, id.clone(), track.to_string()))) } -/// Whether every range `archive` advertises stays FETCHable from this broadcast until the -/// timeline pops it: a store makes the ranges durable, and no `replay` path means this -/// broadcast serves them. The catalog states this, so [`Config::history`] can follow the -/// timeline's own retention rather than a window sized for relay caches. -fn durable(archive: &hang::catalog::Archive) -> bool { - archive.store.is_some() && archive.replay.is_none() -} - /// The segment watcher: read the reference rendition's timeline and fan each record out to every /// rendition's window as a segment. async fn watch_timeline( @@ -552,13 +489,11 @@ async fn watch_timeline( section: hang::catalog::Archive, reference: Reference, renditions: renditions::Fanout, - history: bool, ) { // A failed timeline is never retried: the origin already rides out transient source // failures, so an error here is final. It fails every playlist rather than ending it, so a - // truncated recording never reads as complete, nor freezes live with no signal. A retained - // history reader fails them too: its rows stay listed, and it can no longer report their pops. - match watch(&broadcast, §ion, &reference, &renditions, history).await { + // truncated recording never reads as complete, nor freezes live with no signal. + match watch(&broadcast, §ion, &reference, &renditions).await { // A superseded watcher cannot finish the newly selected reference's windows. Ok(()) => { renditions.finish(Some(&reference)); @@ -580,16 +515,8 @@ async fn watch( section: &hang::catalog::Archive, reference: &Reference, renditions: &renditions::Fanout, - history: bool, ) -> crate::Result<()> { let track = &reference.2; - if history { - return history::read(broadcast, section, track, |edit| match edit { - history::Edit::Push { index, entry } => renditions.push(index, entry, reference), - history::Edit::Pop(range) => renditions.pop(range, reference), - }) - .await; - } let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(broadcast, section, track).await?; while let Some(event) = timeline.next().await? { match event { @@ -924,7 +851,7 @@ mod tests { let video = renditions.get(Kind::Video, "video0").expect("video rendition"); let audio = renditions.get(Kind::Audio, "audio0").expect("audio rendition"); - let audio_init = audio.init().await.unwrap().expect("AAC init segment"); + let audio_init = audio.latest_init().await.unwrap().expect("AAC init segment"); assert_eq!( aac_avg_bitrate(&audio_init), 96_000, @@ -963,7 +890,7 @@ mod tests { ); assert_eq!(audio.bandwidth(), 110_000); assert_eq!( - audio.init().await.unwrap().expect("cached AAC init segment"), + audio.latest_init().await.unwrap().expect("cached AAC init segment"), audio_init, "an estimate update preserves the cached init segment" ); @@ -1051,7 +978,7 @@ mod tests { Arc::ptr_eq(&audio, &renditions.get(Kind::Audio, "audio0").unwrap()), "the estimate updates the existing rendition" ); - let init = audio.init().await.unwrap().expect("AAC init segment"); + let init = audio.latest_init().await.unwrap().expect("AAC init segment"); assert_eq!(audio.bandwidth(), 110_000); assert_eq!( @@ -1251,20 +1178,11 @@ mod tests { #[tokio::test(start_paused = true)] async fn disabled_reference_handoff_continues_the_source_media_sequence() { - handoff(false).await; - } - - #[tokio::test(start_paused = true)] - async fn history_reference_handoff_preserves_the_retained_window() { - handoff(true).await; - } - - async fn handoff(history: bool) { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").unwrap(); broadcast.announce(Default::default()).unwrap(); settle().await; - let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); let reserved = catalog.reserve(); let mut first = catalog.test_video("video0"); first.set(video_config()).unwrap(); @@ -1289,16 +1207,8 @@ mod tests { for at in [0, 2, 4, 6, 8] { sibling_media.write(vp8_frame(at * 1_000_000, true)).unwrap(); } - if history { - catalog - .mutate(|catalog| { - catalog.archive.as_mut().unwrap().store = Some("https://example.com/recording/".parse().unwrap()) - }) - .unwrap(); - } let config = Config { window: Duration::from_secs(4), - history, }; let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, config); broadcaster.advertised().await; @@ -1315,11 +1225,11 @@ mod tests { let new = segments::tag(&(Kind::Video, "video1".into(), "video1".into())); let before = first_rendition.snapshot(); let mut cursor = sibling_rendition.segments(); - // History lists the whole timeline; the live window starts at 2s. - for segment in u64::from(!history)..=2 { + // The live window starts at 2s. + for segment in 1..=2 { assert_eq!(cursor.next().await.unwrap().unwrap().segment, segment); } - let bytes = first_rendition.segment(&old, 2).await.unwrap().unwrap(); + let bytes = first_rendition.numbered(&old, 2).await.unwrap().unwrap(); let mut disabled = video_config(); disabled.enabled = false; first.set(disabled).unwrap(); @@ -1341,11 +1251,11 @@ mod tests { 3, "the recorder advances once into the new reference" ); - assert_eq!(after.media_sequence, before.media_sequence + u64::from(!history)); + assert_eq!(after.media_sequence, before.media_sequence + 1); assert_eq!(after.segments.last().unwrap().segment, 3, "the next source record"); assert!(after.segments.iter().any(|s| s.tag == old)); assert!(after.segments.iter().all(|s| !s.discontinuity)); - assert_eq!(first_rendition.segment(&old, 2).await.unwrap().unwrap(), bytes); + assert_eq!(first_rendition.numbered(&old, 2).await.unwrap().unwrap(), bytes); let mpd = broadcaster.manifest(None).unwrap().unwrap(); assert_eq!(mpd.matches(" ( + /// A broadcast advertising timelines for `video0` and `video1`. + async fn timeline_pair() -> ( moq_net::origin::Producer, moq_net::broadcast::Producer, moq_mux::catalog::Producer, @@ -1739,9 +1533,7 @@ mod tests { for name in ["video0", "video1"] { catalog.video.renditions.insert(name.into(), video_config()); } - let mut section = archive(&["video0", "video1"]); - section.store = Some("https://example.com/recording/".parse().unwrap()); - catalog.archive = Some(section); + catalog.archive = Some(archive(&["video0", "video1"])); }) .unwrap(); (origin, broadcast, catalog) @@ -1757,101 +1549,11 @@ mod tests { (track.clone(), moq_mux::timeline::Producer::new(track)) } - async fn history_export(origin: &moq_net::origin::Producer) -> Arc { - let config = Config { - history: true, - ..Default::default() - }; - let broadcaster = Broadcaster::new(crate::resolved(origin, "live").await, config); - broadcaster.advertised().await; - broadcaster - } - - // The retained reader of a reference consumed its records while another was selected, so - // reselecting the reference replays its timeline instead of skipping past them. - #[tokio::test(start_paused = true)] - async fn a_reselected_history_reference_replays_its_timeline() { - let (origin, broadcast, mut catalog) = durable_pair().await; - let (_old_track, mut old) = timeline_track(&broadcast, "video0"); - let (_new_track, mut new) = timeline_track(&broadcast, "video1"); - for sequence in 0..3 { - old.push(&gop(sequence)).unwrap(); - new.push(&gop(sequence)).unwrap(); - } - let broadcaster = history_export(&origin).await; - let video0 = broadcaster.rendition(Kind::Video, "video0").unwrap(); - let video1 = broadcaster.rendition(Kind::Video, "video1").unwrap(); - let old_tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - let new_tag = segments::tag(&(Kind::Video, "video1".into(), "video1".into())); - listed_under(&video0, &old_tag, 2).await; - catalog - .mutate(|catalog| catalog.video.renditions.get_mut("video0").unwrap().enabled = false) - .unwrap(); - new.push(&gop(3)).unwrap(); - listed_under(&video1, &new_tag, 3).await; - old.push(&gop(3)).unwrap(); - old.push(&gop(4)).unwrap(); - settle().await; - catalog - .mutate(|catalog| { - catalog.video.renditions.get_mut("video0").unwrap().enabled = true; - catalog.video.renditions.get_mut("video1").unwrap().enabled = false; - }) - .unwrap(); - let snapshot = listed_under(&video0, &old_tag, 4).await; - assert_eq!(numbers(&snapshot), [0, 1, 2, 3, 4], "history survives both handoffs"); - assert!(snapshot.segments.iter().all(|segment| !segment.discontinuity)); - } - - // A failed past reference still lists rows whose pops it can no longer report, so every - // listing answers the error rather than losing that prefix and still ending as complete. - #[tokio::test(start_paused = true)] - async fn a_failed_past_history_reference_fails_every_listing() { - let (origin, broadcast, mut catalog) = durable_pair().await; - let (old_track, mut old) = timeline_track(&broadcast, "video0"); - let (_new_track, mut new) = timeline_track(&broadcast, "video1"); - for sequence in 0..4 { - if sequence < 3 { - old.push(&gop(sequence)).unwrap(); - } - new.push(&gop(sequence)).unwrap(); - } - let broadcaster = history_export(&origin).await; - let video1 = broadcaster.rendition(Kind::Video, "video1").unwrap(); - let old_tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - let new_tag = segments::tag(&(Kind::Video, "video1".into(), "video1".into())); - listed_under(&video1, &old_tag, 2).await; - catalog - .mutate(|catalog| catalog.video.renditions.get_mut("video0").unwrap().enabled = false) - .unwrap(); - assert_eq!(numbers(&listed_under(&video1, &new_tag, 3).await), [0, 1, 2, 3]); - cut(old_track, old); - failed(&video1).await; - new.push(&gop(4)).unwrap(); - settle().await; - assert!( - video1.try_snapshot().is_err(), - "the selected reference cannot recover it" - ); - let video0 = broadcaster.rendition(Kind::Video, "video0").unwrap(); - failed(&video0).await; - assert!(matches!( - broadcaster.manifest(None), - Err(crate::Error::TimelineFailed { .. }) - )); - // A clip of the history answers the failure too, rather than refusing the range. - let range = Range::new(Duration::ZERO, Duration::from_secs(4)).unwrap(); - assert!(matches!( - video1.playlist(Some(range), None).await, - Err(crate::Error::TimelineFailed { .. }) - )); - } - // A rendition whose own timeline failed answers the error alone. Its siblings keep serving, // and the broadcast-wide listings leave it out so new players start on a sibling. #[tokio::test(start_paused = true)] async fn a_failed_rendition_leaves_the_manifest_and_master() { - let (origin, broadcast, _catalog) = durable_pair().await; + let (origin, broadcast, _catalog) = timeline_pair().await; let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); let (video1_track, mut video1) = timeline_track(&broadcast, "video1"); for sequence in 0..4 { @@ -1884,7 +1586,7 @@ mod tests { // still end every playlist and recording cursor. #[tokio::test(start_paused = true)] async fn a_source_ending_with_every_rendition_disabled_ends_the_playlists() { - let (origin, broadcast, mut catalog) = durable_pair().await; + let (origin, broadcast, mut catalog) = timeline_pair().await; let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); let (_video1_track, mut video1) = timeline_track(&broadcast, "video1"); for sequence in 0..4 { @@ -1999,7 +1701,7 @@ mod tests { while !rendition.snapshot().finished { tokio::task::yield_now().await; } - let init = tokio::time::timeout(Duration::from_secs(1), rendition.init()) + let init = tokio::time::timeout(Duration::from_secs(1), rendition.latest_init()) .await .expect("init must not wait for the unfinished trailing group") .unwrap() @@ -2027,7 +1729,7 @@ mod tests { while !rendition.snapshot().finished { tokio::task::yield_now().await; } - let mut blocked = std::pin::pin!(rendition.init()); + let mut blocked = std::pin::pin!(rendition.latest_init()); assert!( tokio::time::timeout(Duration::from_millis(10), blocked.as_mut()) .await @@ -2037,7 +1739,7 @@ mod tests { let segment = rendition.segments().next().await.unwrap().expect("completed segment"); assert!(!segment.media.is_empty()); - tokio::time::timeout(Duration::from_secs(1), rendition.init()) + tokio::time::timeout(Duration::from_secs(1), rendition.latest_init()) .await .expect("init must not wait behind the blocked bootstrap") .unwrap() @@ -2080,13 +1782,13 @@ mod tests { ); tokio::time::sleep(Duration::from_millis(10)).await; }; - let new_init = new.init().await.unwrap().expect("init segment"); + let new_init = new.latest_init().await.unwrap().expect("init segment"); let _ = tokio::time::timeout(Duration::from_secs(5), new.playable()).await; let new_playlist = new.media_playlist(None).unwrap().expect("playable"); let new_hash = init_hash(&new_playlist); assert_ne!(new_hash, old_hash, "different init bytes get a different URL"); - assert_eq!(new.init_versioned(new_hash).await.unwrap(), Some(new_init)); - assert!(new.init_versioned(&old_hash).await.unwrap().is_none()); + assert_eq!(new.init(new_hash).await.unwrap(), Some(new_init)); + assert!(new.init(&old_hash).await.unwrap().is_none()); } // A generation rides every segment URL. The first one labels the run already listed; @@ -2176,7 +1878,7 @@ mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - let old = rendition.init().await.unwrap().expect("init segment"); + let old = rendition.latest_init().await.unwrap().expect("init segment"); broadcaster.set_generation(Some("run-1")).unwrap(); broadcaster.set_generation(Some("run-2")).unwrap(); @@ -2192,10 +1894,10 @@ mod tests { .unwrap(); } let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - let new = rendition.init().await.unwrap().expect("init segment"); + let new = rendition.latest_init().await.unwrap().expect("init segment"); assert_ne!(new, old, "the new run's init is built from its own media"); let playlist = rendition.media_playlist(None).unwrap().expect("playable"); - assert_eq!(rendition.init_versioned(init_hash(&playlist)).await.unwrap(), Some(new)); + assert_eq!(rendition.init(init_hash(&playlist)).await.unwrap(), Some(new)); } #[tokio::test] @@ -2252,15 +1954,15 @@ mod tests { // Without coded dimensions VP8 learns its init from a keyframe, and the playlist names // the init by hash, so nothing renders until the init is built. assert!(rendition.media_playlist(None).unwrap().is_none()); - let init = rendition.init().await.unwrap().expect("init segment"); + let init = rendition.latest_init().await.unwrap().expect("init segment"); assert_eq!(&init[4..8], b"ftyp"); let rendered = rendition.media_playlist(None).unwrap().expect("playable"); let hash = init_hash(&rendered); assert!(rendered.contains("seg/656e3d1b.0.m4s\n")); assert!(rendered.contains("seg/656e3d1b.1.m4s\n")); - assert_eq!(rendition.init_versioned(hash).await.unwrap(), Some(init)); - assert!(rendition.init_versioned("0000000000000000").await.unwrap().is_none()); + assert_eq!(rendition.init(hash).await.unwrap(), Some(init)); + assert!(rendition.init("0000000000000000").await.unwrap().is_none()); // The same render, but carrying a credential into every child URL. let signed = rendition @@ -2405,8 +2107,8 @@ mod tests { .unwrap() .expect("manifest renders"); let hash = between(&signed, "initialization=\"video/video0/init.", ".mp4?jwt=abc.def\""); - let init = video_rendition.init().await.unwrap().expect("init segment"); - assert_eq!(video_rendition.init_versioned(hash).await.unwrap(), Some(init)); + let init = video_rendition.latest_init().await.unwrap().expect("init segment"); + assert_eq!(video_rendition.init(hash).await.unwrap(), Some(init)); assert!(signed.contains("media=\"video/video0/seg/656e3d1b.t$Time$.m4s?jwt=abc.def\"")); // $Time$ resolves to the same bytes the aligned number does; unknown times miss. @@ -2600,7 +2302,7 @@ mod tests { .next() .expect("a video rendition"); let rendition = broadcaster.rendition(Kind::Video, &video).expect("video discovered"); - let playlist = tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None, None)) + let playlist = tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None)) .await .expect("a listed segment") .unwrap() @@ -2726,7 +2428,7 @@ mod tests { ), ] { let rendition = broadcaster.rendition(kind, &name).unwrap(); - tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None, None)) + tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None)) .await .unwrap() .unwrap() @@ -2735,7 +2437,7 @@ mod tests { assert!(rows.len() >= 2, "multiple completed segments are listed"); for row in rows { assert!(!row.gap, "{kind:?} segment {} has a timeline", row.segment); - let media = rendition.segment(&row.tag, row.segment).await.unwrap(); + let media = rendition.numbered(&row.tag, row.segment).await.unwrap(); assert!(media.is_some(), "listed segment {} must have media", row.segment); } let mut cursor = rendition.segments(); @@ -3167,7 +2869,6 @@ mod tests { archive, reference, renditions.fanout(), - false, )); let rendition = renditions.get(Kind::Video, "video0").expect("rendition synced"); (rendition, watcher) @@ -3229,7 +2930,7 @@ mod tests { async fn init_bootstraps_after_the_catalog_broadcast_ends() { tokio::time::pause(); let ended = ended_rendition(false).await; - let result = ended.rendition.init().await; + let result = ended.rendition.latest_init().await; ended.watcher.abort(); let init = result.unwrap().expect("inline init bootstraps after End"); assert!(contains(&init, b"ftyp")); @@ -3248,7 +2949,7 @@ mod tests { async fn init_bootstraps_after_the_sibling_broadcast_ends() { tokio::time::pause(); let ended = ended_rendition(true).await; - let result = ended.rendition.init().await; + let result = ended.rendition.latest_init().await; ended.watcher.abort(); assert!(result.unwrap().is_some(), "sibling init bootstraps after End"); } @@ -3295,7 +2996,7 @@ mod tests { let frames = muxer.read(&mut group).await.unwrap(); assert!(frames.iter().any(|frame| frame.payload.as_ref() == NEW)); let next_init = muxer.init().unwrap().unwrap(); - let old_init = ended.rendition.init().await.unwrap().unwrap(); + let old_init = ended.rendition.latest_init().await.unwrap().unwrap(); assert_ne!( old_init, next_init, "the old inline init must not acquire replacement geometry" @@ -3604,7 +3305,6 @@ mod tests { archive, reference, fanout.clone(), - false, )); let rendition = renditions.get(Kind::Video, "video0").unwrap(); accept_sibling(&old_server, &old_media).await; @@ -3691,7 +3391,6 @@ mod tests { archive, reference, fanout.clone(), - false, )); let rendition = renditions.get(Kind::Video, "video0").unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) @@ -3714,7 +3413,7 @@ mod tests { .unwrap() .expect("the old run's rows were captured before the restart"); assert!( - rendition.init().await.unwrap().is_none(), + rendition.latest_init().await.unwrap().is_none(), "the new run has no media of its own yet" ); watcher.abort(); @@ -4190,7 +3889,6 @@ mod tests { catalog.archive.clone().unwrap(), reference(catalog, None).unwrap(), renditions.fanout(), - false, )); (renditions, watcher) } @@ -4313,12 +4011,6 @@ mod tests { let video1 = test.rendition("video1"); assert!(video1.media_playlist(None).is_err()); assert!(video1.representations().is_err()); - // A range the failed timeline covers still answers the failure, not a refused range. - let range = Range::new(Duration::ZERO, Duration::from_secs(4)).unwrap(); - assert!(matches!( - video1.snapshot_range(range, None), - Err(crate::Error::TimelineFailed { .. }) - )); // Segments already listed stay fetchable while the listing refuses. assert_eq!( video1.listed_content(0), @@ -4414,7 +4106,7 @@ mod tests { let (_reference, _video0) = test.publish("video0"); let (track, video1) = test.publish("video1"); let video1_rendition = test.rendition("video1"); - let pending = tokio::spawn(async move { video1_rendition.playlist(None, None).await }); + let pending = tokio::spawn(async move { video1_rendition.playlist(None).await }); tokio::time::sleep(Duration::from_secs(1)).await; assert!(!pending.is_finished(), "nothing is listed yet"); @@ -4431,7 +4123,7 @@ mod tests { let (track, video0) = test.publish("video0"); let (_track, _video1) = test.publish("video1"); let video1 = test.rendition("video1"); - let pending = tokio::spawn(async move { video1.playlist(None, None).await }); + let pending = tokio::spawn(async move { video1.playlist(None).await }); tokio::time::sleep(Duration::from_secs(1)).await; assert!(!pending.is_finished(), "nothing is listed yet"); @@ -4441,23 +4133,6 @@ mod tests { assert!(matches!(err, crate::Error::TimelineFailed { .. }), "{err}"); } - // A range over a recording that ended without media is refused rather than reported missing. - #[tokio::test(start_paused = true)] - async fn a_range_over_an_empty_ended_recording_is_refused() { - let test = Timelines::new(); - let (track, video0) = test.publish("video0"); - let (_track, _video1) = test.publish("video1"); - drop(video0); - track.finish().unwrap(); - assert!(test.finished("video1").await.segments.is_empty()); - - let range = Range::new(Duration::ZERO, Duration::from_secs(2)).unwrap(); - assert!(matches!( - test.rendition("video1").snapshot_range(range, None), - Err(crate::Error::RangeUnavailable(_)) - )); - } - // A rendition running far ahead of a stalled reference keeps the records its listed segments // resolve to, so a listed segment never turns into a gap. #[tokio::test(start_paused = true)] @@ -4778,7 +4453,6 @@ mod tests { archive(&["video0", "video1"]), Arc::new((Kind::Video, "video1".to_string(), "video1".to_string())), test.renditions.fanout(), - false, )); let after = test.listed("video1", 2).await; let new = segments::tag(&(Kind::Video, "video1".to_string(), "video1".to_string())); @@ -4786,7 +4460,7 @@ mod tests { assert!(after.segments.iter().all(|s| s.tag == new && s.tag != old)); let video0 = test.rendition("video0"); assert!( - video0.segment(&old, 0).await.unwrap().is_none(), + video0.numbered(&old, 0).await.unwrap().is_none(), "the old URL is no longer served" ); } @@ -4825,7 +4499,6 @@ mod tests { archive(&["main", "video-b", "audio0"]), Arc::new((Kind::Video, "main".to_string(), "video-b".to_string())), test.renditions.fanout(), - false, )); let after = test.listed("audio0", 2).await; let new = segments::tag(&(Kind::Video, "main".to_string(), "video-b".to_string())); diff --git a/rs/moq-hls/src/export/mpd.rs b/rs/moq-hls/src/export/mpd.rs index 54b2f6ce69a..e5057ff739a 100644 --- a/rs/moq-hls/src/export/mpd.rs +++ b/rs/moq-hls/src/export/mpd.rs @@ -70,9 +70,8 @@ pub(crate) struct Manifest { pub availability_start: Option, /// When this render happened (`MPD@publishTime`, dynamic only). pub publish: SystemTime, - /// The playlist window (`MPD@timeShiftBufferDepth`, dynamic only), or `None` when the - /// timeline bounds itself and the depth is the span it lists. - pub window: Option, + /// The playlist window (`MPD@timeShiftBufferDepth`, dynamic only). + pub window: Duration, /// The broadcast ended: render a `static` presentation instead of a `dynamic` one. pub finished: bool, /// Video representations, in catalog order. @@ -122,20 +121,6 @@ fn duration(units: u64, timescale: u32) -> Duration { Duration::from_nanos((u128::from(units) * 1_000_000_000 / u128::from(timescale.max(1))) as u64) } -/// The retained presentation span across representations and reference periods. -fn listed_span<'a>(representations: impl Iterator) -> Duration { - let spans: Vec<_> = representations - .filter_map(|rep| { - let (first, _) = rep.segments.first()?; - let (last, d) = rep.segments.last()?; - Some((duration(*first, rep.timescale), duration(last + d, rep.timescale))) - }) - .collect(); - let first = spans.iter().map(|(first, _)| *first).min().unwrap_or_default(); - let last = spans.iter().map(|(_, last)| *last).max().unwrap_or_default(); - last.saturating_sub(first) -} - /// The largest listed segment duration in whole seconds, for `MPD@maxSegmentDuration` (and the /// update cadence). Like HLS's target duration, derived from the segments when the publisher /// declared no bound. @@ -290,7 +275,7 @@ pub(crate) fn render_manifest(manifest: &Manifest, query: Option<&str>) -> Strin // Reload cadence and live delay follow HLS conventions: players refresh about once // per segment and sit a few segments behind the live edge (bounded by the window). let update = Duration::from_secs(target); - let window = manifest.window.unwrap_or_else(|| listed_span(representations())); + let window = manifest.window; let delay = Duration::from_secs(3 * target).min(window.max(update)); let _ = write!( out, @@ -370,7 +355,7 @@ mod tests { let manifest = Manifest { availability_start: Some(SystemTime::UNIX_EPOCH), publish: SystemTime::UNIX_EPOCH, - window: None, + window: Duration::from_secs(4), finished: false, video: vec![old, next], audio: Vec::new(), @@ -395,7 +380,7 @@ mod tests { let manifest = Manifest { availability_start: Some(SystemTime::UNIX_EPOCH), publish: SystemTime::UNIX_EPOCH, - window: None, + window: Duration::from_secs(16), finished: false, video: vec![next], audio: Vec::new(), @@ -430,7 +415,7 @@ mod tests { let manifest = Manifest { availability_start: Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_751_846_400_123)), publish: SystemTime::UNIX_EPOCH + Duration::from_millis(1_751_846_410_000), - window: Some(Duration::from_secs(16)), + window: Duration::from_secs(16), finished: false, video: vec![video(vec![(0, 2_000), (2_000, 2_000)], false)], audio: vec![audio(vec![(0, 2_000), (2_000, 2_000)], false)], @@ -470,7 +455,7 @@ mod tests { let manifest = Manifest { availability_start: None, publish: SystemTime::UNIX_EPOCH, - window: Some(Duration::from_secs(16)), + window: Duration::from_secs(16), finished: true, // The window starts mid-broadcast: presentation time stays anchored at pts 0 (no // presentationTimeOffset), so the duration spans the lead-in and a live session @@ -494,7 +479,7 @@ mod tests { let manifest = Manifest { availability_start: Some(SystemTime::UNIX_EPOCH), publish: SystemTime::UNIX_EPOCH, - window: Some(Duration::from_secs(16)), + window: Duration::from_secs(16), finished: false, video: vec![video(vec![(0, 2_000)], false)], audio: Vec::new(), @@ -512,7 +497,7 @@ mod tests { let manifest = Manifest { availability_start: Some(SystemTime::UNIX_EPOCH), publish: SystemTime::UNIX_EPOCH, - window: Some(Duration::from_secs(16)), + window: Duration::from_secs(16), finished: false, video: vec![rep], audio: Vec::new(), @@ -535,7 +520,7 @@ mod tests { let manifest = Manifest { availability_start: Some(SystemTime::UNIX_EPOCH), publish: SystemTime::UNIX_EPOCH, - window: Some(Duration::from_secs(16)), + window: Duration::from_secs(16), finished: false, video: vec![video(vec![(0, 2_000)], false)], audio: vec![audio(Vec::new(), false)], @@ -553,7 +538,7 @@ mod tests { &Manifest { availability_start: Some(SystemTime::UNIX_EPOCH), publish: SystemTime::UNIX_EPOCH, - window: Some(Duration::from_secs(16)), + window: Duration::from_secs(16), finished: false, video: vec![rep], audio: Vec::new(), diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index baed6031b62..1c8f11e18c5 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -246,8 +246,6 @@ pub struct Rendition { /// [`refresh`](Self::refresh). A disabled rendition keeps its window and media playlist but /// is left out of the master playlist and the DASH manifest. enabled: AtomicBool, - /// Whether this rendition follows its durable timeline from the beginning. - history: AtomicBool, /// Coded width, for the master playlist `RESOLUTION` (video only). pub width: Option, /// Coded height, for the master playlist `RESOLUTION` (video only). @@ -356,7 +354,6 @@ impl Rendition { bitrate: RwLock::new(config.bitrate), framerate: RwLock::new(config.framerate), enabled: AtomicBool::new(config.enabled), - history: AtomicBool::new(false), width: config.coded_width, height: config.coded_height, codec: config.codec.to_string(), @@ -389,7 +386,6 @@ impl Rendition { bitrate: RwLock::new(config.bitrate), framerate: RwLock::new(None), enabled: AtomicBool::new(config.enabled), - history: AtomicBool::new(false), width: None, height: None, codec: config.codec.to_string(), @@ -406,24 +402,20 @@ impl Rendition { }) } - /// Start following this rendition's own timeline, trimmed to the listed segments. `history` - /// reads it from the start, as the reference does in history mode. - pub(crate) fn watch(&self, history: bool) { - self.history.store(history, Ordering::Relaxed); + /// Start following this rendition's own timeline, trimmed to the listed segments. + pub(crate) fn watch(&self) { let watcher = tokio::spawn(watch_spans( self.catalog.clone(), self.section.clone(), self.track_name().to_string(), self.spans.clone(), self.live.clone(), - history, )); *self.watcher.lock().expect("watcher lock poisoned") = Some(watcher); } - /// Feed one reference timeline record into this rendition's window as a segment. With no - /// `window`, only source timeline pops trim it. - pub(crate) fn push(&self, row: segments::Row, window: Option) { + /// Feed one reference timeline record into this rendition's window as a segment. + pub(crate) fn push(&self, row: segments::Row, window: Duration) { if !self.media.admits(&self.live) { return; } @@ -508,7 +500,6 @@ impl Rendition { } /// The generation every segment URL currently carries. - #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) fn generation(&self) -> Option> { self.run().generation } @@ -590,20 +581,6 @@ impl Rendition { .then(|| super::render_media(&snapshot, &init.hash, query))) } - /// Render a clip playlist over a durable timeline's media time. - /// - /// The requested start is widened to the preceding independently decodable segment. The - /// playlist grows while `range.end` is ahead of the live edge and becomes VOD once covered. - pub(crate) fn range_playlist(&self, range: super::Range, query: Option<&str>) -> Result> { - self.check_range()?; - let run = self.run(); - let snapshot = self.snapshot_range(range, run.generation.clone())?; - let Some(init) = self.built_init(&run) else { - return Ok(None); - }; - Ok((!snapshot.segments.is_empty()).then(|| super::render_media(&snapshot, &init.hash, query))) - } - /// Wait until the media playlist is servable, then render it: at least one segment is /// listed and the `EXT-X-MAP` init segment it names is built, so a player never loads a /// map that 404s. `None` when the init cannot be built yet. @@ -611,31 +588,18 @@ impl Rendition { /// Waits without bound for the first segment; bounding it is the caller's policy. `query` /// propagates exactly as in [`media_playlist`](Self::media_playlist). /// - /// A `range` lists only the segments covering that media time, and needs a durable timeline - /// exported with [`Config::history`](super::Config::history). Its start widens to the - /// preceding sync point, and the playlist ends with `EXT-X-ENDLIST` once media covers its - /// end. A range the timeline cannot fully serve fails with - /// [`RangeNotDurable`](crate::Error::RangeNotDurable) or - /// [`RangeUnavailable`](crate::Error::RangeUnavailable) rather than listing a partial clip. /// A failed timeline fails it with [`TimelineFailed`](crate::Error::TimelineFailed), as in /// [`media_playlist`](Self::media_playlist). - pub async fn playlist(&self, range: Option, query: Option<&str>) -> Result> { - // Refuse an unsupported range up front rather than wait for media it can never list. - if range.is_some() { - self.check_range()?; - } + pub async fn playlist(&self, query: Option<&str>) -> Result> { self.playable().await; // A failed timeline may never supply the keyframe an inline init waits for. self.check()?; - // An inline-codec init needs a keyframe group fetched first. init() caches, so the + // An inline-codec init needs a keyframe group fetched first. Building it caches, so the // player's follow-up GET of the init is free. - if self.init().await?.is_none() { + if self.load_init().await?.is_none() { return Ok(None); } - match range { - Some(range) => self.range_playlist(range, query), - None => self.media_playlist(query), - } + self.media_playlist(query) } /// Snapshot the media playlist from the current timeline window. @@ -698,69 +662,6 @@ impl Rendition { self.live.failure().is_none() && self.check().is_err() } - /// Whether this rendition can serve range playlists: a durable timeline followed from its start. - fn check_range(&self) -> Result<()> { - if !super::durable(&self.section) { - return Err(crate::Error::RangeNotDurable); - } - if !self.history.load(Ordering::Relaxed) { - return Err(crate::Error::RangeUnavailable( - "range playlists require Config::history".into(), - )); - } - Ok(()) - } - - pub(super) fn snapshot_range(&self, range: super::Range, generation: Option>) -> Result { - self.media.sync(&self.live); - let mut window = self.live.window(); - // A failed timeline answers its error, not a refusal of the range the client asked for. - if let Some(err) = self.failure(&window) { - return Err(err); - } - let Some(first) = window.segments.first() else { - if window.ended { - return Err(crate::Error::RangeUnavailable("the recording is empty".into())); - } - return Ok(self.snapshot_from_window(window, false, generation)); - }; - if range.start < Duration::from(first.pts) { - return Err(crate::Error::RangeUnavailable("start is before retained media".into())); - } - - let first_overlap = window.segments.iter().position(|row| row.end > range.start); - let Some(first_overlap) = first_overlap else { - if window.ended { - return Err(crate::Error::RangeUnavailable("start is after the recording".into())); - } - window.segments.clear(); - return Ok(self.snapshot_from_window(window, false, generation)); - }; - let start = window.segments[..=first_overlap] - .iter() - .rposition(segments::Row::starts) - .ok_or_else(|| crate::Error::RangeUnavailable("no retained sync point precedes start".into()))?; - let reference_covered = window.segments.iter().any(|row| row.end >= range.end); - if window.ended && !reference_covered { - return Err(crate::Error::RangeUnavailable("end is after the recording".into())); - } - window.segments = window - .segments - .into_iter() - .skip(start) - .take_while(|row| Duration::from(row.pts) < range.end) - .collect(); - window.sequence = window.segments.first().map_or(window.sequence, |row| row.segment); - window.discontinuity = window - .segments - .first() - .map_or(window.discontinuity, |row| row.discontinuity); - let resolved = self.resolved(&window.segments).count(); - let covered = - resolved == window.segments.len() && window.segments.last().is_some_and(|row| row.end >= range.end); - Ok(self.snapshot_from_window(window, covered, generation)) - } - fn snapshot_from_window(&self, window: segments::Window, finished: bool, generation: Option>) -> Snapshot { // HLS requires EXT-X-TARGETDURATION to be at least every EXTINF, rounded up to whole // seconds. The catalog duration is nominal, while actual records can be longer. @@ -939,16 +840,18 @@ impl Rendition { .collect()) } - /// Fetch and transmux the segment of the reference tagged `tag` whose timeline `pts` is - /// `time`, in the timeline's timescale (DASH `$Time$` addressing of the same bytes - /// [`segment`](Self::segment) serves by aligned number). - #[cfg_attr(not(feature = "server"), allow(dead_code))] - pub(crate) async fn segment_at(&self, tag: &str, time: u64) -> Result> { + /// Fetch and transmux the segment of the reference tagged `tag` at `at`: its aligned + /// number, or its timeline pts in the timeline's timescale (DASH `$Time$`). + async fn segment_at(&self, tag: &str, at: segments::At) -> Result> { self.media.sync(&self.live); - let Some(segment) = self.live.segment_number_at(tag, time, self.timescale()) else { - return Ok(None); + let sequence = match at { + segments::At::Sequence(sequence) => sequence, + segments::At::Pts(pts) => match self.live.segment_number_at(tag, pts, self.timescale()) { + Some(sequence) => sequence, + None => return Ok(None), + }, }; - self.segment(tag, segment).await + self.numbered(tag, sequence).await } /// The timeline's timescale; a declared 0 is clamped rather than trusted. @@ -1015,23 +918,24 @@ impl Rendition { } } - /// The rendition's CMAF init segment, built on first request and cached. + /// The CMAF init segment served at `init.{hash}.mp4`, built on first request and cached. + /// + /// `None` when it cannot be built yet, or `hash` (a [`Resource::Init`](super::Resource::Init) + /// hash) names any other bytes, so a URL never serves an init it does not describe. /// /// For inline-parameter-set codecs (no catalog `description`), the parameter sets are /// resolved by fetching the newest keyframe group first. A bound track's retained media /// remains usable after its broadcast ends or is replaced; replacement media is never read. - pub async fn init(&self) -> Result> { - Ok(self.load_init().await?.map(|init| init.bytes.clone())) - } - - /// The init segment served at `init.{hash}.mp4`: `None` when `hash` names any other bytes, - /// so a URL never serves an init it does not describe. - #[cfg_attr(not(feature = "server"), allow(dead_code))] - pub(crate) async fn init_versioned(&self, hash: &str) -> Result> { + pub async fn init(&self, hash: &str) -> Result> { let init = self.load_init().await?; Ok(init.filter(|init| init.hash == hash).map(|init| init.bytes.clone())) } + /// The current CMAF init segment, whatever its hash, built on first request and cached. + pub(crate) async fn latest_init(&self) -> Result> { + Ok(self.load_init().await?.map(|init| init.bytes.clone())) + } + /// `run`'s init if it is already built, or can be without media: an out-of-band codec /// builds its init straight from the catalog. fn built_init(&self, run: &Run) -> Option> { @@ -1098,15 +1002,30 @@ impl Rendition { Ok(Some(self.cache_init(run.epoch, bytes))) } - /// Fetch and transmux the segment numbered `segment` by the reference tagged `tag` (the - /// `seg/{tag}.{segment}.m4s` URI). + /// Fetch and transmux the segment a [`Resource::Segment`](super::Resource::Segment) URL names. /// /// Fetches every frame the segment resolves to on this rendition (an audio segment packs many /// short groups) and encodes them as a single CMAF fragment. `None` when the segment isn't in /// the playlist window, is a gap or not yet resolved for this rendition, or its groups already /// left the relay cache. After End or Restart, only the bound instance's retained media /// drains; a replacement's media is never read under these rows. - pub async fn segment(&self, tag: &str, segment: u64) -> Result> { + /// + /// Only the current run's URLs are served: `None` when `id` names another + /// [generation](super::Broadcaster::set_generation), since a restart reuses segment numbers + /// and another run's URL would name different bytes. + pub async fn segment(&self, id: &segments::Id) -> Result> { + let current = || self.generation().as_deref() == id.generation.as_deref(); + if !current() { + return Ok(None); + } + let segment = self.segment_at(&id.reference, id.at).await?; + // A generation change while fetching may have swapped the rows under the lookup. + Ok(segment.filter(|_| current())) + } + + /// Fetch and transmux the segment numbered `segment` by the reference tagged `tag`, + /// whatever the run; see [`segment`](Self::segment). + pub(crate) async fn numbered(&self, tag: &str, segment: u64) -> Result> { self.media.sync(&self.live); let Some(row) = self.live.row(tag, segment) else { return Ok(None); @@ -1211,7 +1130,7 @@ impl Rendition { /// Fetch the listed segment numbered `segment`, under the tag its rows carry. pub(crate) async fn listed_segment(&self, segment: u64) -> Result> { let tag = listed_tag(&self.live.window().segments); - self.segment(&tag, segment).await + self.numbered(&tag, segment).await } /// What the listed segment numbered `segment` holds on this rendition. @@ -1223,7 +1142,7 @@ impl Rendition { /// Fetch the listed segment starting at `time`, under the tag its rows carry. pub(crate) async fn listed_segment_at(&self, time: u64) -> Result> { let tag = listed_tag(&self.live.window().segments); - self.segment_at(&tag, time).await + self.segment_at(&tag, segments::At::Pts(time)).await } } @@ -1242,16 +1161,8 @@ async fn watch_spans( track: String, spans: Arc, live: Arc, - history: bool, ) { let result: Result<()> = async { - if history { - return super::history::read(&broadcast, §ion, &track, |edit| match edit { - super::history::Edit::Push { entry, .. } => spans.push(entry), - super::history::Edit::Pop(range) => spans.pop(range), - }) - .await; - } let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&broadcast, §ion, &track).await?; while let Some(event) = timeline.next().await? { match event { diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index 31693476378..76a34db104c 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -76,10 +76,8 @@ struct Feed { /// The publisher run every segment URL carries; late-created renditions inherit it. generation: Option>, /// The playlist window duration applied on every push (see - /// [`Config::window`](super::Config::window)), or `None` in - /// [`Config::history`](super::Config::history) mode, where only the source timeline's pops - /// trim the playlists. - window: Option, + /// [`Config::window`](super::Config::window)). + window: Duration, } impl Feed { @@ -138,7 +136,7 @@ impl Producer { closed: false, anchor: None, generation: None, - window: Some(window), + window, })), }, } @@ -149,9 +147,8 @@ impl Producer { self.fanout.clone() } - /// The playlist window duration every rendition's window is trimmed to, or `None` when - /// only the source timeline trims them. - pub fn window(&self) -> Option { + /// The playlist window duration every rendition's window is trimmed to. + pub fn window(&self) -> Duration { self.fanout.feed.lock().unwrap().window } @@ -237,12 +234,6 @@ impl Producer { } impl Fanout { - /// List every record the source timeline retains, trimming only on its pops, instead of - /// a window. Call before the first record. - pub fn unbound(&self) { - self.feed.lock().unwrap().window = None; - } - /// Select the next reference, whose records list only past the last advertised one. pub fn select(&self, reference: Option) { let mut feed = self.feed.lock().unwrap(); @@ -318,7 +309,7 @@ impl Fanout { feed.last = Some(row.clone()); feed.history.push_back(row.clone()); let window = feed.window; - while let Some(window) = window { + loop { let rest = feed.history.get(1).map_or(Duration::ZERO, |second| { feed.history.back().unwrap().end.saturating_sub(second.pts.into()) }); @@ -438,14 +429,11 @@ impl Fanout { true } - /// Fail every window with `err` when `reference`'s timeline failed, so no playlist reads as - /// finished. A superseded reader counts only in history mode while rows it must pop are still - /// listed; a live window trims a superseded reference's rows on its own. + /// Fail every window with `err` when the selected `reference`'s timeline failed, so no + /// playlist reads as finished. A live window trims a superseded reference's rows on its own. pub fn fail(&self, reference: &Reference, err: crate::Error) -> bool { let mut feed = self.feed.lock().unwrap(); - let listed = feed.accepts(reference) - || (feed.window.is_none() && feed.history.iter().any(|row| row.reference == *reference)); - if feed.failed.is_some() || !listed { + if feed.failed.is_some() || !feed.accepts(reference) { return false; } feed.failed = Some(err.clone()); @@ -508,8 +496,7 @@ impl Producer { fn register(&self, rendition: &Arc) { let mut feed = self.fanout.feed.lock().unwrap(); rendition.label(feed.generation.clone()); - // An unwindowed feed is history mode: every rendition reads its timeline from the start. - rendition.watch(feed.window.is_none()); + rendition.watch(); for row in &feed.history { rendition.push(row.clone(), feed.window); } @@ -798,30 +785,14 @@ mod tests { } #[test] - fn only_a_reader_of_listed_rows_fails_the_feed() { + fn only_the_selected_reader_fails_the_feed() { let err = || crate::Error::NoVariants; - // Live: a superseded reader's rows trim with the window, so its failure changes nothing. + // A superseded reader's rows trim with the window, so its failure changes nothing. let (fanout, new) = handed_off(5); let old = fanout.feed.lock().unwrap().history[0].reference.clone(); assert!(!fanout.fail(&old, err())); assert!(fanout.fail(&new, err())); assert!(!fanout.finish(Some(&new)), "a failed feed never finishes"); - - // History: a retained reader fails the feed while rows it must pop stay listed. - let history = |popped: bool| { - let fanout = Producer::new(Duration::from_secs(30)).fanout(); - fanout.unbound(); - let old = Arc::new((Kind::Video, "old".into(), "old".into())); - fanout.select(Some(old.clone())); - fanout.push(0, entry(0, 0), &old); - fanout.select(Some(Arc::new((Kind::Video, "new".into(), "new".into())))); - if popped { - fanout.pop(0..1, &old); - } - fanout.fail(&old, err()) - }; - assert!(history(false)); - assert!(!history(true)); } #[test] diff --git a/rs/moq-hls/src/export/route.rs b/rs/moq-hls/src/export/route.rs new file mode 100644 index 00000000000..17e5279da7f --- /dev/null +++ b/rs/moq-hls/src/export/route.rs @@ -0,0 +1,239 @@ +//! The request paths an export serves, parsed. + +use percent_encoding::percent_decode_str; + +use super::Kind; +use super::segments; + +/// A parsed request path: the broadcast, and the resource under it. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Route { + /// The broadcast path, percent-decoded. An embedder that scopes its origin rewrites + /// this relative to that scope before looking it up. + pub broadcast: String, + /// The resource requested under the broadcast. + pub resource: Resource, +} + +/// A resource under a broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Resource { + /// `master.m3u8`: the HLS multivariant playlist. + Master, + /// `manifest.mpd`: the DASH manifest. + Manifest, + /// `{kind}/{rendition}/media.m3u8`: a rendition's HLS media playlist. + #[non_exhaustive] + Media { + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. + rendition: String, + }, + /// `{kind}/{rendition}/init.{hash}.mp4`: a rendition's CMAF init segment, answered by + /// [`Rendition::init`](super::Rendition::init). + #[non_exhaustive] + Init { + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. + rendition: String, + /// The hash of the init bytes the URL names. + hash: String, + }, + /// `{kind}/{rendition}/seg/{segment}.m4s`: a media segment, answered by + /// [`Rendition::segment`](super::Rendition::segment). + #[non_exhaustive] + Segment { + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. + rendition: String, + /// The segment the file name addresses. + segment: segments::Id, + }, +} + +impl Route { + /// Parse a request path such as `/project/live/video/hd/media.m3u8`, or `None` when it + /// addresses nothing. + /// + /// The broadcast occupies every segment before the resource suffix. Each segment is + /// percent-decoded, and one that decodes to a `/` is refused, so an encoded separator + /// cannot smuggle a different broadcast past a policy check on [`broadcast`](Self::broadcast). + pub fn parse(path: &str) -> Option { + let parts = path + .strip_prefix('/')? + .split('/') + .map(|part| { + percent_decode_str(part) + .decode_utf8() + .ok() + .map(|part| part.into_owned()) + }) + .collect::>>()?; + if parts.iter().any(String::is_empty) { + return None; + } + + let (broadcast, resource) = match parts.as_slice() { + [broadcast @ .., file] if file == "master.m3u8" => (broadcast, Resource::Master), + [broadcast @ .., file] if file == "manifest.mpd" => (broadcast, Resource::Manifest), + [broadcast @ .., kind, rendition, file] if file == "media.m3u8" => ( + broadcast, + Resource::Media { + kind: Kind::parse(kind)?, + rendition: rendition.clone(), + }, + ), + [broadcast @ .., kind, rendition, file] if init_hash(file).is_some() => ( + broadcast, + Resource::Init { + kind: Kind::parse(kind)?, + rendition: rendition.clone(), + hash: init_hash(file)?.to_string(), + }, + ), + [broadcast @ .., kind, rendition, directory, file] if directory == "seg" => ( + broadcast, + Resource::Segment { + kind: Kind::parse(kind)?, + rendition: rendition.clone(), + segment: segments::Id::parse(file.strip_suffix(".m4s")?)?, + }, + ), + _ => return None, + }; + + if broadcast.is_empty() || broadcast.iter().any(|part| part.contains('/')) { + return None; + } + Some(Self { + broadcast: broadcast.join("/"), + resource, + }) + } +} + +/// The content hash in an `init.{hash}.mp4` file name. +fn init_hash(file: &str) -> Option<&str> { + file.strip_prefix("init.")? + .strip_suffix(".mp4") + .filter(|hash| !hash.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use segments::{At, Id}; + + #[test] + fn parses_multisegment_broadcast_and_encoded_rendition() { + assert_eq!( + Route::parse("/project/live/video/cam%231%2Fmain%3Falt/media.m3u8"), + Some(Route { + broadcast: "project/live".to_string(), + resource: Resource::Media { + kind: Kind::Video, + rendition: "cam#1/main?alt".to_string(), + }, + }) + ); + } + + #[test] + fn parses_encoded_dash_rendition_as_one_segment() { + let parsed = |path| { + let route = Route::parse(path)?; + let rendition = match &route.resource { + Resource::Media { rendition, .. } + | Resource::Init { rendition, .. } + | Resource::Segment { rendition, .. } => rendition.clone(), + Resource::Master | Resource::Manifest => return None, + }; + Some((route.broadcast, rendition)) + }; + + // The manifest for broadcast `live` and rendition `video/1080p` emits these paths. + // Unencoded, the same name is broadcast `live/video`, rendition `1080p`. + let cases = [ + ("/live/video/video%2F1080p/init.0123abcd.mp4", "video/1080p"), + ("/live/video/video%2F1080p/seg/ab12cd34.t2000.m4s", "video/1080p"), + ("/live/video/cam%3F1/init.0123abcd.mp4", "cam?1"), + ("/live/video/cam%231/media.m3u8", "cam#1"), + ("/live/audio/100%25/init.0123abcd.mp4", "100%"), + ("/live/video/price%249/seg/ab12cd34.t0.m4s", "price$9"), + ]; + for (path, rendition) in cases { + assert_eq!(parsed(path), Some(("live".into(), rendition.into())), "{path}"); + } + assert_eq!( + parsed("/live/video/video/1080p/init.0123abcd.mp4"), + Some(("live/video".into(), "1080p".into())) + ); + } + + #[test] + fn parses_all_resource_routes() { + let resource = |path| Route::parse(path).map(|route| route.resource); + let segment = |generation: Option<&str>, at| Resource::Segment { + kind: Kind::Video, + rendition: "main".to_string(), + segment: Id { + generation: generation.map(str::to_string), + reference: "ab12cd34".to_string(), + at, + }, + }; + assert_eq!(resource("/project/live/master.m3u8"), Some(Resource::Master)); + assert_eq!(resource("/project/live/manifest.mpd"), Some(Resource::Manifest)); + assert_eq!( + resource("/project/live/audio/main/init.0123abcd.mp4"), + Some(Resource::Init { + kind: Kind::Audio, + rendition: "main".to_string(), + hash: "0123abcd".to_string(), + }) + ); + assert_eq!( + resource("/project/live/video/main/seg/ab12cd34.42.m4s"), + Some(segment(None, At::Sequence(42))) + ); + assert_eq!( + resource("/project/live/video/main/seg/run-1.ab12cd34.42.m4s"), + Some(segment(Some("run-1"), At::Sequence(42))) + ); + assert_eq!( + resource("/project/live/video/main/seg/ab12cd34.t2000.m4s"), + Some(segment(None, At::Pts(2000))) + ); + assert_eq!( + resource("/project/live/video/main/seg/init.ab12cd34.t2000.m4s"), + Some(segment(Some("init"), At::Pts(2000))) + ); + assert!(Route::parse("/master.m3u8").is_none()); + assert!(Route::parse("/project/live/video/main/unknown").is_none()); + assert!(Route::parse("/project/live/data/main/media.m3u8").is_none()); + assert!(Route::parse("/project/live/video/main/init.mp4").is_none()); + assert!(Route::parse("/project/live/video/main/init..mp4").is_none()); + assert!(Route::parse("/project/live/video/main/seg/ab12cd34.tx.m4s").is_none()); + assert!( + Route::parse("/project/live/video/main/seg/42.m4s").is_none(), + "a segment URL names its reference" + ); + assert!(Route::parse("/project/live/video/main/seg/1.ts").is_none()); + } + + #[test] + fn rejects_empty_path_segments() { + assert!(Route::parse("/project//live/master.m3u8").is_none()); + } + + #[test] + fn rejects_encoded_broadcast_separators() { + assert!(Route::parse("/project/private%2F/master.m3u8").is_none()); + assert!(Route::parse("/project%2Fprivate/master.m3u8").is_none()); + } +} diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index 0f64ed8f438..8b6a08d2a75 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -29,6 +29,49 @@ use sha2::{Digest, Sha256}; use super::{Kind, Rendition}; use crate::Result; +/// A segment as its URL names it: `seg/[{generation}.]{reference}.{sequence}.m4s`, or +/// `t{pts}` in place of the sequence for the DASH `$Time$` form. Parsed by +/// [`Route::parse`](super::Route::parse) and answered by [`Rendition::segment`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Id { + /// The publisher run the URL names, if the broadcaster carries one. + pub generation: Option, + /// The tag of the reference rendition whose records number the segment. + pub reference: String, + /// Where the segment sits on that reference. + pub at: At, +} + +/// Where a segment sits on its reference rendition: two names for the same bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum At { + /// The segment's aligned number, as HLS playlists address it. + Sequence(u64), + /// The segment's timeline pts, as the DASH manifest addresses it. + Pts(u64), +} + +impl Id { + /// Parse a segment file name without its `.m4s` extension. + pub(crate) fn parse(stem: &str) -> Option { + let (generation, reference, at) = match stem.split('.').collect::>().as_slice() { + [reference, at] => (None, reference.to_string(), *at), + [generation, reference, at] => (Some(generation.to_string()), reference.to_string(), *at), + _ => return None, + }; + let at = match at.strip_prefix('t') { + Some(pts) => At::Pts(pts.parse().ok()?), + None => At::Sequence(at.parse().ok()?), + }; + Some(Self { + generation, + reference, + at, + }) + } +} + /// The producing side of a rendition's timeline window. /// /// The catalog watcher appends rows via [`push`](Self::push) and marks the stream @@ -250,10 +293,9 @@ impl Producer { } } - /// Append a row, evicting the front of the window past `window` (see [`evicts`]). With no - /// `window`, only source timeline pops remove rows. Returns the window's + /// Append a row, evicting the front of the window past `window` (see [`evicts`]). Returns the window's /// [`resets`](Self::resets) after the push. - pub fn push(&self, row: Row, window: Option) -> u64 { + pub fn push(&self, row: Row, window: Duration) -> u64 { let Ok(mut state) = self.state.write() else { return u64::MAX; }; @@ -274,7 +316,7 @@ impl Producer { state.discontinuity = row.discontinuity; state.rows.push_back(row); - while let Some(window) = window { + loop { let rest = match state.rows.get(1) { Some(second) => state.rows.back().unwrap().end.saturating_sub(second.pts.into()), None => Duration::ZERO, @@ -373,7 +415,6 @@ impl Producer { /// (DASH `$Time$` addressing), or `None` when no listed row starts there. Exact match is /// safe because the rendered `S@t` and this lookup convert the same [`Row::pts`] the same /// way. - #[cfg_attr(not(feature = "server"), allow(dead_code))] pub fn segment_number_at(&self, tag: &str, time: u64, timescale: moq_net::Timescale) -> Option { let state = self.state.read(); state @@ -503,7 +544,7 @@ impl Consumer { /// The rendition's CMAF init segment, built once and cached; `None` until it can be built /// (an inline-parameter-set codec needs the first segment first). pub async fn init(&self) -> Result> { - self.rendition.init().await + self.rendition.latest_init().await } /// The next segment, with its media; `None` once the rendition ends, including when its @@ -614,8 +655,8 @@ mod tests { #[test] fn every_row_is_listed() { let live = Producer::new(); - live.push(row(0, 0, 0, 2_000), Some(Duration::from_secs(30))); - live.push(row(1, 1, 2_000, 2_000), Some(Duration::from_secs(30))); + live.push(row(0, 0, 0, 2_000), Duration::from_secs(30)); + live.push(row(1, 1, 2_000, 2_000), Duration::from_secs(30)); let window = live.window(); assert_eq!(window.sequence, 0); @@ -631,7 +672,7 @@ mod tests { #[test] fn window_evicts_and_advances_sequence() { let live = Producer::new(); - let window = Some(Duration::from_secs(4)); + let window = Duration::from_secs(4); for i in 0..6u64 { live.push(row(i, i, i * 2_000, 2_000), window); } @@ -648,7 +689,7 @@ mod tests { #[test] fn retiring_a_prefix_advances_the_playlist_sequence() { let live = Producer::new(); - let window = Some(Duration::from_secs(30)); + let window = Duration::from_secs(30); for i in 0..4u64 { live.push(row(i, i, i * 2_000, 2_000), window); } @@ -670,9 +711,9 @@ mod tests { #[test] fn a_skipped_source_range_clears_rows_before_the_next_segment() { let live = Producer::new(); - live.push(row(4, 4, 8_000, 2_000), Some(Duration::from_secs(10))); + live.push(row(4, 4, 8_000, 2_000), Duration::from_secs(10)); live.clear(); - live.push(row(10, 10, 20_000, 2_000), Some(Duration::from_secs(10))); + live.push(row(10, 10, 20_000, 2_000), Duration::from_secs(10)); let snapshot = live.window(); assert_eq!(snapshot.sequence, 10); @@ -719,7 +760,7 @@ mod tests { #[test] fn rows_and_the_keyframe_group() { let live = Producer::new(); - let window = Some(Duration::from_secs(30)); + let window = Duration::from_secs(30); live.push(row(0, 0, 0, 1_000), window); live.push( Row { @@ -751,7 +792,7 @@ mod tests { #[test] fn backwards_jump_resets_the_window() { let live = Producer::new(); - let window = Some(Duration::from_secs(30)); + let window = Duration::from_secs(30); assert_eq!(live.push(row(0, 0, 10_000, 2_000), window), 0); assert_eq!(live.push(row(1, 1, 12_000, 2_000), window), 0); assert_eq!(live.push(row(2, 2, 1_000, 2_000), window), 1); // restart: pts rewound @@ -768,7 +809,7 @@ mod tests { #[test] fn next_after_walks_segments() { let live = Producer::new(); - let window = Some(Duration::from_secs(30)); + 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); @@ -795,7 +836,7 @@ mod tests { #[test] fn a_new_reference_restarts_the_cursor() { let live = Producer::new(); - let window = Some(Duration::from_secs(30)); + let window = Duration::from_secs(30); live.push(row(5, 5, 10_000, 2_000), window); let Next::Ready(old) = live.state.read().next_after(None) else { panic!("expected a segment"); @@ -818,7 +859,7 @@ mod tests { #[test] fn a_handoff_continues_the_source_numbering_under_another_tag() { let live = Producer::new(); - let window = Some(Duration::from_secs(30)); + let window = Duration::from_secs(30); let old = row(5, 5, 10_000, 2_000); let old_reference = old.reference.clone(); live.push(old, window); diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index 5aed7aa6dc1..b1a3496e333 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -30,7 +30,8 @@ //! //! An embedder that authorizes each request itself (a token scoping the broadcast, a //! stats-metered origin per tenant) skips the router instead: parse the path with -//! [`Route::parse`], check [`Route::broadcast`], rewrite it relative to the scope, and +//! [`Route::parse`](crate::export::Route::parse), check +//! [`Route::broadcast`](crate::export::Route::broadcast), rewrite it relative to the scope, and //! answer with [`Server::respond`] on a `Server` built from that scope's origin. The //! decoded broadcast is what a policy should check. //! @@ -54,8 +55,6 @@ mod routes; -pub use routes::{Resource, Route}; - use std::collections::HashMap; use std::sync::{Arc, Mutex, Weak}; use std::task::Poll; diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 19ac4c6e9d7..cc04935a9ee 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -9,10 +9,9 @@ use axum::http::{StatusCode, Uri, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use bytes::Bytes; -use percent_encoding::percent_decode_str; use super::Server; -use crate::export::{Kind, Rendition}; +use crate::export::{Kind, Rendition, Resource, Route, segments}; const M3U8: &str = "application/vnd.apple.mpegurl"; const MPD: &str = "application/dash+xml"; @@ -26,164 +25,6 @@ pub fn router(server: Server) -> Router { Router::new().route("/{*path}", get(request)).with_state(server) } -/// A parsed request path: the broadcast, and the resource under it. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub struct Route { - /// The broadcast path, percent-decoded. An embedder that scopes its origin rewrites - /// this relative to that scope before calling [`Server::respond`]. - pub broadcast: String, - /// The resource requested under the broadcast. - pub resource: Resource, -} - -/// A resource under a broadcast. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum Resource { - /// `master.m3u8`: the HLS multivariant playlist. - Master, - /// `manifest.mpd`: the DASH manifest. - Manifest, - /// `{kind}/{rendition}/media.m3u8`: a rendition's HLS media playlist. - #[non_exhaustive] - Media { - /// The rendition's kind. - kind: Kind, - /// The rendition's name, percent-decoded. - rendition: String, - }, - /// `{kind}/{rendition}/init.{hash}.mp4`: a rendition's CMAF init segment. - #[non_exhaustive] - Init { - /// The rendition's kind. - kind: Kind, - /// The rendition's name, percent-decoded. - rendition: String, - /// The hash of the init bytes the URL names. - hash: String, - }, - /// `{kind}/{rendition}/seg/[{generation}.]{reference}.{sequence}.m4s`: a segment by its HLS - /// number. - #[non_exhaustive] - Segment { - /// The rendition's kind. - kind: Kind, - /// The rendition's name, percent-decoded. - rendition: String, - /// The publisher run the URL names, if the broadcaster carries one. - generation: Option, - /// The tag of the reference rendition whose records number the segment. - reference: String, - /// The segment's aligned number. - sequence: u64, - }, - /// `{kind}/{rendition}/seg/[{generation}.]{reference}.t{pts}.m4s`: the same bytes, addressed - /// by the DASH timeline pts (`$Time$`). - #[non_exhaustive] - SegmentAt { - /// The rendition's kind. - kind: Kind, - /// The rendition's name, percent-decoded. - rendition: String, - /// The publisher run the URL names, if the broadcaster carries one. - generation: Option, - /// The tag of the reference rendition whose records number the segment. - reference: String, - /// The segment's timeline pts. - pts: u64, - }, -} - -impl Route { - /// Parse a request path such as `/project/live/video/hd/media.m3u8`, or `None` when it - /// addresses nothing. - /// - /// The broadcast occupies every segment before the resource suffix. Each segment is - /// percent-decoded, and one that decodes to a `/` is refused, so an encoded separator - /// cannot smuggle a different broadcast past a policy check on [`broadcast`](Self::broadcast). - pub fn parse(path: &str) -> Option { - let parts = path - .strip_prefix('/')? - .split('/') - .map(|part| { - percent_decode_str(part) - .decode_utf8() - .ok() - .map(|part| part.into_owned()) - }) - .collect::>>()?; - if parts.iter().any(String::is_empty) { - return None; - } - - let (broadcast, resource) = match parts.as_slice() { - [broadcast @ .., file] if file == "master.m3u8" => (broadcast, Resource::Master), - [broadcast @ .., file] if file == "manifest.mpd" => (broadcast, Resource::Manifest), - [broadcast @ .., kind, rendition, file] if file == "media.m3u8" => ( - broadcast, - Resource::Media { - kind: Kind::parse(kind)?, - rendition: rendition.clone(), - }, - ), - [broadcast @ .., kind, rendition, file] if init_hash(file).is_some() => ( - broadcast, - Resource::Init { - kind: Kind::parse(kind)?, - rendition: rendition.clone(), - hash: init_hash(file)?.to_string(), - }, - ), - [broadcast @ .., kind, rendition, directory, file] if directory == "seg" => { - let kind = Kind::parse(kind)?; - let rendition = rendition.clone(); - let stem = file.strip_suffix(".m4s")?; - // `{generation}.{reference}.{segment}` when the export carries a generation, - // `{reference}.{segment}` otherwise. - let (generation, reference, stem) = match stem.split('.').collect::>().as_slice() { - [reference, stem] => (None, reference.to_string(), *stem), - [generation, reference, stem] => (Some(generation.to_string()), reference.to_string(), *stem), - _ => return None, - }; - let resource = match stem.strip_prefix('t') { - Some(pts) => Resource::SegmentAt { - kind, - rendition, - generation, - reference, - pts: pts.parse().ok()?, - }, - None => Resource::Segment { - kind, - rendition, - generation, - reference, - sequence: stem.parse().ok()?, - }, - }; - (broadcast, resource) - } - _ => return None, - }; - - if broadcast.is_empty() || broadcast.iter().any(|part| part.contains('/')) { - return None; - } - Some(Self { - broadcast: broadcast.join("/"), - resource, - }) - } -} - -/// The content hash in an `init.{hash}.mp4` file name. -fn init_hash(file: &str) -> Option<&str> { - file.strip_prefix("init.")? - .strip_suffix(".mp4") - .filter(|hash| !hash.is_empty()) -} - async fn request(State(server): State, uri: Uri, RawQuery(query): RawQuery) -> Response { match Route::parse(uri.path()) { Some(route) => server.respond(&route, query.as_deref()).await, @@ -209,23 +50,8 @@ impl Server { Resource::Segment { kind, rendition, - generation, - reference, - sequence, - } => { - let at = SegmentAt::Sequence(reference, *sequence); - segment(self, broadcast, *kind, rendition, generation.as_deref(), at).await - } - Resource::SegmentAt { - kind, - rendition, - generation, - reference, - pts, - } => { - let at = SegmentAt::Pts(reference, *pts); - segment(self, broadcast, *kind, rendition, generation.as_deref(), at).await - } + segment: id, + } => segment(self, broadcast, *kind, rendition, id).await, } } } @@ -265,100 +91,28 @@ async fn manifest(server: &Server, broadcast: &str, query: Option<&str>) -> Resp } async fn media(server: &Server, broadcast: &str, kind: Kind, rendition: &str, query: Option<&str>) -> Response { - let (range, child_query) = match playlist_range(query) { - Ok(parsed) => parsed, - Err(()) => return bad_request(), - }; let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else { return not_found(); }; - match moq_clock::timeout(READY_TIMEOUT, rendition.playlist(range, child_query.as_deref())).await { + match moq_clock::timeout(READY_TIMEOUT, rendition.playlist(query)).await { Ok(Ok(Some(playlist))) => m3u8(playlist), Ok(Ok(None)) | Err(_) => not_found(), - Ok(Err(crate::Error::RangeNotDurable | crate::Error::RangeUnavailable(_))) => range_not_satisfiable(), Ok(Err(err)) => server_error(err), } } -/// Parse `start` and `end` as decimal media-time seconds, returning the remaining query for -/// child resources. Both are required together and may occur only once. Other parameters pass -/// through byte for byte, so a signed token is never re-encoded. -fn playlist_range(query: Option<&str>) -> Result<(Option, Option), ()> { - let Some(query) = query else { - return Ok((None, None)); - }; - let mut start = None; - let mut end = None; - let mut child = Vec::new(); - for pair in query.split('&') { - let Some((key, value)) = url::form_urlencoded::parse(pair.as_bytes()).next() else { - continue; - }; - match key.as_ref() { - "start" if start.is_none() => start = Some(media_time(&value)?), - "end" if end.is_none() => end = Some(media_time(&value)?), - "start" | "end" => return Err(()), - _ => child.push(pair), - } - } - let range = match (start, end) { - (None, None) => None, - (Some(start), Some(end)) => Some(crate::export::Range::new(start, end).map_err(|_| ())?), - _ => return Err(()), - }; - let child = child.join("&"); - Ok((range, (!child.is_empty()).then_some(child))) -} - -fn media_time(value: &str) -> Result { - let seconds = value.parse::().map_err(|_| ())?; - if !seconds.is_finite() || seconds < 0.0 { - return Err(()); - } - Duration::try_from_secs_f64(seconds).map_err(|_| ()) -} - async fn init(server: &Server, broadcast: &str, kind: Kind, rendition: &str, hash: &str) -> Response { let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else { return not_found(); }; - media_result(rendition.init_versioned(hash).await, server) -} - -/// How a segment URL addresses its bytes under its reference's tag: HLS by aligned number -/// (`seg/{tag}.0.m4s`), DASH by timeline pts (`seg/{tag}.t2000.m4s`, the SegmentTemplate's -/// `$Time$`). -enum SegmentAt<'a> { - Sequence(&'a str, u64), - Pts(&'a str, u64), + media_result(rendition.init(hash).await, server) } -async fn segment( - server: &Server, - broadcast: &str, - kind: Kind, - rendition: &str, - generation: Option<&str>, - at: SegmentAt<'_>, -) -> Response { +async fn segment(server: &Server, broadcast: &str, kind: Kind, rendition: &str, id: &segments::Id) -> Response { let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else { return not_found(); }; - // Only the current run's URLs are served: a restart reuses segment numbers, so another - // generation's URL would name different bytes than the ones it once served. - let current = || rendition.generation().as_deref() == generation; - if !current() { - return not_found(); - } - let result = match at { - SegmentAt::Sequence(reference, sequence) => rendition.segment(reference, sequence).await, - SegmentAt::Pts(reference, pts) => rendition.segment_at(reference, pts).await, - }; - // A generation change while fetching may have swapped the rows under the lookup. - if !current() { - return not_found(); - } - media_result(result, server) + media_result(rendition.segment(id).await, server) } /// Resolve a rendition, waiting for the catalog to populate. @@ -413,14 +167,6 @@ fn not_found() -> Response { (StatusCode::NOT_FOUND, [(header::CACHE_CONTROL, "no-store")]).into_response() } -fn bad_request() -> Response { - (StatusCode::BAD_REQUEST, [(header::CACHE_CONTROL, "no-store")]).into_response() -} - -fn range_not_satisfiable() -> Response { - (StatusCode::RANGE_NOT_SATISFIABLE, [(header::CACHE_CONTROL, "no-store")]).into_response() -} - fn server_error(err: crate::Error) -> Response { tracing::warn!(%err, "hls request failed"); (StatusCode::INTERNAL_SERVER_ERROR, [(header::CACHE_CONTROL, "no-store")]).into_response() @@ -431,160 +177,11 @@ fn server_error(err: crate::Error) -> Response { mod tests { use super::*; - #[test] - fn parses_playlist_ranges_and_preserves_other_query_parameters() { - let (range, query) = playlist_range(Some("jwt=a%2Eb&start=1.25&sig=x~y*&end=4")).unwrap(); - assert_eq!( - range, - Some(crate::export::Range::new(Duration::from_millis(1250), Duration::from_secs(4)).unwrap()) - ); - assert_eq!(query.as_deref(), Some("jwt=a%2Eb&sig=x~y*")); - assert_eq!( - playlist_range(Some("jwt=x&flag")).unwrap(), - (None, Some("jwt=x&flag".into())) - ); - - for invalid in [ - "start=1", - "end=2", - "start=2&end=1", - "start=-1&end=2", - "start=wat&end=2", - "start=1&start=2&end=3", - ] { - assert!(playlist_range(Some(invalid)).is_err(), "accepted {invalid}"); - } - } - /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) } - #[test] - fn parses_multisegment_broadcast_and_encoded_rendition() { - assert_eq!( - Route::parse("/project/live/video/cam%231%2Fmain%3Falt/media.m3u8"), - Some(Route { - broadcast: "project/live".to_string(), - resource: Resource::Media { - kind: Kind::Video, - rendition: "cam#1/main?alt".to_string(), - }, - }) - ); - } - - #[test] - fn parses_encoded_dash_rendition_as_one_segment() { - let parsed = |path| { - let route = Route::parse(path)?; - let rendition = match &route.resource { - Resource::Media { rendition, .. } - | Resource::Init { rendition, .. } - | Resource::Segment { rendition, .. } - | Resource::SegmentAt { rendition, .. } => rendition.clone(), - Resource::Master | Resource::Manifest => return None, - }; - Some((route.broadcast, rendition)) - }; - - // The manifest for broadcast `live` and rendition `video/1080p` emits these paths. - // Unencoded, the same name is broadcast `live/video`, rendition `1080p`. - let cases = [ - ("/live/video/video%2F1080p/init.0123abcd.mp4", "video/1080p"), - ("/live/video/video%2F1080p/seg/ab12cd34.t2000.m4s", "video/1080p"), - ("/live/video/cam%3F1/init.0123abcd.mp4", "cam?1"), - ("/live/video/cam%231/media.m3u8", "cam#1"), - ("/live/audio/100%25/init.0123abcd.mp4", "100%"), - ("/live/video/price%249/seg/ab12cd34.t0.m4s", "price$9"), - ]; - for (path, rendition) in cases { - assert_eq!(parsed(path), Some(("live".into(), rendition.into())), "{path}"); - } - assert_eq!( - parsed("/live/video/video/1080p/init.0123abcd.mp4"), - Some(("live/video".into(), "1080p".into())) - ); - } - - #[test] - fn parses_all_resource_routes() { - let resource = |path| Route::parse(path).map(|route| route.resource); - assert_eq!(resource("/project/live/master.m3u8"), Some(Resource::Master)); - assert_eq!(resource("/project/live/manifest.mpd"), Some(Resource::Manifest)); - assert_eq!( - resource("/project/live/audio/main/init.0123abcd.mp4"), - Some(Resource::Init { - kind: Kind::Audio, - rendition: "main".to_string(), - hash: "0123abcd".to_string(), - }) - ); - assert_eq!( - resource("/project/live/video/main/seg/ab12cd34.42.m4s"), - Some(Resource::Segment { - kind: Kind::Video, - rendition: "main".to_string(), - generation: None, - reference: "ab12cd34".to_string(), - sequence: 42, - }) - ); - assert_eq!( - resource("/project/live/video/main/seg/run-1.ab12cd34.42.m4s"), - Some(Resource::Segment { - kind: Kind::Video, - rendition: "main".to_string(), - generation: Some("run-1".to_string()), - reference: "ab12cd34".to_string(), - sequence: 42, - }) - ); - assert_eq!( - resource("/project/live/video/main/seg/ab12cd34.t2000.m4s"), - Some(Resource::SegmentAt { - kind: Kind::Video, - rendition: "main".to_string(), - generation: None, - reference: "ab12cd34".to_string(), - pts: 2000, - }) - ); - assert_eq!( - resource("/project/live/video/main/seg/init.ab12cd34.t2000.m4s"), - Some(Resource::SegmentAt { - kind: Kind::Video, - rendition: "main".to_string(), - generation: Some("init".to_string()), - reference: "ab12cd34".to_string(), - pts: 2000, - }) - ); - assert!(Route::parse("/master.m3u8").is_none()); - assert!(Route::parse("/project/live/video/main/unknown").is_none()); - assert!(Route::parse("/project/live/data/main/media.m3u8").is_none()); - assert!(Route::parse("/project/live/video/main/init.mp4").is_none()); - assert!(Route::parse("/project/live/video/main/init..mp4").is_none()); - assert!(Route::parse("/project/live/video/main/seg/ab12cd34.tx.m4s").is_none()); - assert!( - Route::parse("/project/live/video/main/seg/42.m4s").is_none(), - "a segment URL names its reference" - ); - assert!(Route::parse("/project/live/video/main/seg/1.ts").is_none()); - } - - #[test] - fn rejects_empty_path_segments() { - assert!(Route::parse("/project//live/master.m3u8").is_none()); - } - - #[test] - fn rejects_encoded_broadcast_separators() { - assert!(Route::parse("/project/private%2F/master.m3u8").is_none()); - assert!(Route::parse("/project%2Fprivate/master.m3u8").is_none()); - } - const TIMEOUT: Duration = Duration::from_secs(10); fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { @@ -890,7 +487,6 @@ mod tests { archive .timelines .insert("video0".into(), hang::catalog::Timeline::new(name.clone(), 1_000)); - archive.store = Some("https://example.com/recording/".parse().unwrap()); catalog.archive = Some(archive); }) .unwrap(); @@ -905,21 +501,13 @@ mod tests { timeline.push(&record).unwrap(); } - let config = crate::export::Config { - history: true, - ..Default::default() - }; - let app = Server::new(origin.consume(), config).router(); + let app = Server::new(origin.consume(), crate::export::Config::default()).router(); wait_listed(&app, "/live/video/video0/media.m3u8", ".2.m4s").await; assert_eq!(status(&app, "/live/manifest.mpd").await, StatusCode::OK); drop(timeline); track.abort(moq_net::Error::SessionClosed).unwrap(); - for uri in [ - "/live/video/video0/media.m3u8", - "/live/video/video0/media.m3u8?start=0&end=2", - "/live/manifest.mpd", - ] { + for uri in ["/live/video/video0/media.m3u8", "/live/manifest.mpd"] { let deadline = tokio::time::Instant::now() + TIMEOUT; let response = loop { let response = oneshot(app.clone(), uri).await; From 6b7977fb78fcbdd8244402af347e7e1b5e87bfbe Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:51:03 -0700 Subject: [PATCH 06/15] refactor(mux): drop the unreachable TimelineCheckpoint error Its only producer was timeline::Producer::resume. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- quest/m1/archive/catalog-hls.md | 3 +-- rs/moq-mux/src/error.rs | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/quest/m1/archive/catalog-hls.md b/quest/m1/archive/catalog-hls.md index f9c259242a5..cc333c9e7d8 100644 --- a/quest/m1/archive/catalog-hls.md +++ b/quest/m1/archive/catalog-hls.md @@ -49,8 +49,7 @@ Decided by the maintainer on 2026-10-17: Guidance: - Fold in the old README's fix: `moq-mux`'s consumer refuses a record whose - `sequence` differs from its window index, as its `Producer::resume` already - does + `sequence` differs from its window index ([review](https://github.com/moq-dev/moq/pull/4280#pullrequestreview-5332725290)). - `js/hang` updates its schema and consumer; the JS producer and the `@moq/publish` opt-in are [JS HLS field](/quest/m2/js-hls.md). diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index cb0e630ece5..d31f3a7640b 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -180,10 +180,6 @@ pub enum Error { composition_time_ms: i32, }, - /// A timeline record at this window index carries a different sequence. - #[error("timeline checkpoint record at index {0} has a different sequence")] - TimelineCheckpoint(u64), - /// [`timeline::Producer::push`](crate::timeline::Producer::push) received a record out of order. #[error("timeline record {actual} pushed where {expected} was next")] TimelineSequence { From d7fe30eff70bf5d17c0ae8cd987248f21ddd91b8 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:01:28 -0700 Subject: [PATCH 07/15] fix: format hls export and drop stale ffi preset fields Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- rs/moq-ffi/src/test.rs | 2 -- rs/moq-hls/src/export/mod.rs | 20 +++----------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 6e2e378e7b6..4e215ad70f3 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -682,7 +682,6 @@ async fn audio_raw_publish_from_many_threads() { channels: None, bitrate: None, frame_duration_us: 20_000, - preset: None, }; let audio = MoqAudioProducer::new( &catalog, @@ -767,7 +766,6 @@ async fn audio_raw_publish_rejects_bad_frames() { channels: None, bitrate: None, frame_duration_us: 20_000, - preset: None, }, None, ) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 69966249177..c2a86719020 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -360,11 +360,7 @@ impl Drop for Broadcaster { } } -async fn watch_catalog( - source: moq_mux::Source, - renditions: renditions::Producer, - timeline_watcher: TimelineWatcher, -) { +async fn watch_catalog(source: moq_mux::Source, renditions: renditions::Producer, timeline_watcher: TimelineWatcher) { // The source's catalog broadcast, so the catalog, the timeline subscription, and the closed // check below all refer to the one broadcast it was built from. let broadcast = &source.broadcast(); @@ -3300,12 +3296,7 @@ mod tests { renditions.sync(&upstream, &snapshot); let fanout = renditions.fanout(); let reference = Arc::new((Kind::Video, "video0".to_string(), "video0".to_string())); - let watcher = tokio::spawn(watch_timeline( - upstream.broadcast(), - archive, - reference, - fanout.clone(), - )); + let watcher = tokio::spawn(watch_timeline(upstream.broadcast(), archive, reference, fanout.clone())); let rendition = renditions.get(Kind::Video, "video0").unwrap(); accept_sibling(&old_server, &old_media).await; tokio::time::timeout(Duration::from_secs(5), rendition.playable()) @@ -3386,12 +3377,7 @@ mod tests { renditions.sync(&upstream, &snapshot); let fanout = renditions.fanout(); let reference = Arc::new((Kind::Video, "video0".to_string(), "video0".to_string())); - let watcher = tokio::spawn(watch_timeline( - upstream.broadcast(), - archive, - reference, - fanout.clone(), - )); + let watcher = tokio::spawn(watch_timeline(upstream.broadcast(), archive, reference, fanout.clone())); let rendition = renditions.get(Kind::Video, "video0").unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await From 42cc76b52ba6517fc9b2edc6547b85add4d20de6 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:23:47 -0700 Subject: [PATCH 08/15] fix(video): allow Control::cut outside the openh264 capture tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- rs/moq-video/src/encode/producer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index b4a6871ffff..c7921805dcf 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -375,7 +375,7 @@ impl Control { /// finishes nothing is known, and the request is accepted because it is served anyway: no /// frame has been published yet, and the first one out of any encoder is a keyframe. // Only tests ask for a keyframe until a publisher needs to. - #[cfg_attr(not(test), allow(dead_code))] + #[cfg_attr(not(all(test, feature = "openh264")), allow(dead_code))] pub(crate) fn cut(&self) -> Result<(), Error> { // Only a `close` fails the write, and nothing closes it; the last handle dropping does. let Ok(mut requests) = self.requests.write() else { From d71bede59ca832553eaff52729d4e40a0561c3ce Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 09:07:02 -0700 Subject: [PATCH 09/15] quest: drop media-trim and the history-floor quest it obsoletes Co-Authored-By: Claude Opus 5.5 --- quest/m1/api-audit/README.md | 1 - quest/m1/api-audit/media-trim.md | 49 ------------------------------- quest/m1/archive/README.md | 1 - quest/m1/archive/consumer.md | 3 +- quest/m1/archive/history-floor.md | 41 -------------------------- 5 files changed, 1 insertion(+), 94 deletions(-) delete mode 100644 quest/m1/api-audit/media-trim.md delete mode 100644 quest/m1/archive/history-floor.md diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 250bb6bc3fa..271de14e659 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -84,7 +84,6 @@ Rejected in planning: - [Wrapper config names](/quest/m1/api-audit/wrapper-config.md) - Python, Go, and Dart client options use the FFI's names and nesting - [One decode read](/quest/m1/api-audit/decode-read.md) - audio and video decoders yield the same `Frame`, `Gap`, `End` read as moq-mux containers - [Watch and publish trim](/quest/m1/api-audit/watch-trim.md) - the clock is re-exported, unused watch outputs are gone, and derived publish state is read-only -- [Media surface trim](/quest/m1/api-audit/media-trim.md) - media crate items with no consumer go private, and Rust/JS media names agree - [CLI and gst flags](/quest/m1/api-audit/cli-flags.md) - one flag name per meaning across CLI subcommands and gst elements, and `linger` means one thing - [qmux shape](/quest/m1/api-audit/qmux-shape.md) - qmux binds framing at connect, shares flow-control names with moq-tokio, and JS drops the legacy wire - [Transport config](/quest/m1/api-audit/transport-config.md) - moq-tokio and moq-quic configs have one meaning for unset, enums for modes, no order-dependent setters, and no leftover features diff --git a/quest/m1/api-audit/media-trim.md b/quest/m1/api-audit/media-trim.md deleted file mode 100644 index 4e85c7fc329..00000000000 --- a/quest/m1/api-audit/media-trim.md +++ /dev/null @@ -1,49 +0,0 @@ -# [S] Media surface trim - -## Goal - -Media crate and package items added since release have a consumer or are -private, and Rust and JS media APIs name the same thing the same way. - -## Plan - -Decided 2026-10-22 (API audit). Make `pub(crate)` or delete: - -- `fmp4::Import::dropped_metadata` and the FLV one. -- moq-audio `Applied` and `preset()`; settings mix `with_preset` with `pub` - fields, and moq-ffi's audio config accepts preset plus duration only to - refuse it at runtime. moq-video `Applied`, `Encoder::applied`, - `Sink::applied`. -- moq-video capture `Control`/`Driver` (only `publish_capture` is used), - `DmaBuf::new`, `DmaBufLayout` (until - [OBS Linux GPU](/quest/m3/obs-linux-gpu.md)). -- moq-hls `pub mod master`, range playlists, and `history`: the CLI never - sets history, so `?start=` always answers 416 while `doc/bin/hls.md` - documents it. Remove the docs with it; see - [history floor](/quest/m1/archive/history-floor.md). -- moq-archive `segment::*`, `Key`, and most of `Store`. -- moq-json `window::Checkpoint`, `Encoder::resume`, `Producer::{resume, cut}` - and `moq_mux::timeline::Producer::resume`, reached only by tests, unless - the [archive consumer](/quest/m1/archive/consumer.md) needs them. -- hang `Catalog::from_value`, `Section`/`deserialize_section`. -- The `@moq/loc` `Producer` (test-only, no Rust twin) and the JS - `Container.Consumer` `maxAge` guard. - -Make public for a consumer: moq-hls `Route`/`Resource` out of the `server` -feature, with `segment`/`init` taking the parsed id, so moq.pro's edge stops -copying `Route::parse` without its encoded-`/` refusal. - -Naming: - -- moq-audio `encode::Settings` becomes `Config` like video and decode; the - two `encode::Options` that mean different things get distinct names; audio - `Kind::Named` (a codec) goes, since video's names a backend. -- `hang::catalog::Range` (colour) and `moq_video::color::Range` duplicate - each other; keep one or add `From`. -- moq-json `pop` and `mutate` take arguments in the same order in Rust and - JS. -- JS publishers stamp the catalog `version` by hand; export one encode. -- `playback::Observer::finish(&self)` plus `finished()` forces polling; - return a future. - -Public API: removals and renames across media crates. Wire: none. diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 1be0c7cb79a..5ce5a013707 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -87,7 +87,6 @@ from a host. Each child fixes the stale docs it touches. - [Archive consumer](/quest/m1/archive/consumer.md) - `archive::Consumer` serves a stored prefix as a broadcast from its key listing, and `moq export/import archive` use the new pair - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration - [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - one `EXT-X-TARGETDURATION` for the run, from the declared `hls` duration; an overrun is listed with a warning -- [History from the retained floor](/quest/m1/archive/history-floor.md) - HLS history over an archive consumer broadcast whose start was pruned lists from its oldest stored segment ## Related diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index ee3f0764c54..53d8abae291 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -58,8 +58,7 @@ Guidance: - An absent group below the newest stored one is a gap and reports as dropped. A group below the oldest stored key was pruned or never stored and reports - the same way; history-mode callers rely on the start answering with what - remains ([History from the retained floor](/quest/m1/archive/history-floor.md)). + the same way. - Keep a byte-bounded object LRU so neighbouring groups come from one GET. - When [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) lands, the `track::Dynamic` handler resolves a `Time(pts)` end by picking the object diff --git a/quest/m1/archive/history-floor.md b/quest/m1/archive/history-floor.md deleted file mode 100644 index 50d6f0f2a17..00000000000 --- a/quest/m1/archive/history-floor.md +++ /dev/null @@ -1,41 +0,0 @@ -# [S] History from the retained floor - -## Goal - -An HLS export in history mode lists an archive consumer broadcast whose -oldest objects were deleted, starting at its oldest stored segment. Today -`moq_hls::export::Config::history` refuses a pruned start with -`Error::HistoryPruned` before listing anything, because the exporter cannot -tell a pruned start from missing history. A gap after the floor still fails, -never listing as a complete recording. - -## Plan - -Decided 2026-10-11 (#5236): refuse pruned recordings now, and quest the floor. - -Decided 2026-10-17: kept as its own `moq-hls` quest rather than folded into -the [Archive consumer](/quest/m1/archive/consumer.md). The consumer only -answers a group below its oldest stored key as dropped; this quest makes -history mode start there. The old Reader's timeline-map pruning and any -retention field in the catalog are gone with the reader and the root -`archive` section. The archive producer's DVR mode is what prunes the start. - -History mode reads the archived `hls` track's groups from group 0. Start -at the first `hls` group the broadcast delivers, using its checkpoint's -retained window, and skip records whose first media group is below the floor. -Once [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) lands, an -`Absolute(0)..` range answers from the oldest group that exists and names the -rest with SUBSCRIBE_DROP; before that, the consumer's drop serves the same -purpose. - -Test an archive whose oldest objects are deleted: history lists from the first -stored segment, and a hole after it still fails. - -## Required - -- [Archive consumer](/quest/m1/archive/consumer.md) - serves the pruned archive and drops groups below its floor -- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the `hls` records history mode reads - -## Related - -- [DVR rewind](/quest/m2/archive-dvr.md) - seeks through the same bounded archive From 772937be017caa3068bb50a19a505019f53de93b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 10:35:44 -0700 Subject: [PATCH 10/15] fix: update merged tests for encode::Config and the removed audio preset Co-Authored-By: Claude Opus 5.5 --- rs/moq-audio/src/encode/producer.rs | 2 +- rs/moq-ffi/src/audio.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/rs/moq-audio/src/encode/producer.rs b/rs/moq-audio/src/encode/producer.rs index 0319b52576e..b06b942f0ed 100644 --- a/rs/moq-audio/src/encode/producer.rs +++ b/rs/moq-audio/src/encode/producer.rs @@ -663,7 +663,7 @@ mod tests { }; let options = Options { track: Some(format!("audio-{index}")), - settings: Settings::from_input(crate::encode::Codec::Aac, &Input::new(48_000, Layout::Mono)), + config: Config::from_input(crate::encode::Codec::Aac, &Input::new(48_000, Layout::Mono)), ..Options::default() }; let mut producer = Producer::new(&mut broadcast, catalog.clone(), input, &options).unwrap(); diff --git a/rs/moq-ffi/src/audio.rs b/rs/moq-ffi/src/audio.rs index e4760c7dda4..52f106cf8bf 100644 --- a/rs/moq-ffi/src/audio.rs +++ b/rs/moq-ffi/src/audio.rs @@ -689,7 +689,6 @@ mod tests { channels: None, bitrate: None, frame_duration_us: 0, - preset: None, }, None, ) From 8a2ab8fa7aa9a553c021faf82e3c471f10aaac7b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 12:48:31 -0700 Subject: [PATCH 11/15] fix(video): give the test-only dead_code allows a reason Co-Authored-By: Claude Opus 5.5 --- rs/moq-video/src/encode/producer.rs | 6 ++++-- rs/moq-video/src/frame.rs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 54bd7ded14d..8f9a3062fc0 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -370,8 +370,10 @@ impl Control { /// reopens updates it, since a reopen can land on a different backend. Before the probe /// finishes nothing is known, and the request is accepted because it is served anyway: no /// frame has been published yet, and the first one out of any encoder is a keyframe. - // Only tests ask for a keyframe until a publisher needs to. - #[cfg_attr(not(all(test, feature = "openh264")), allow(dead_code))] + #[cfg_attr( + not(all(test, feature = "openh264")), + allow(dead_code, reason = "only tests ask for a keyframe until a publisher needs to") + )] pub(crate) fn cut(&self) -> Result<(), Error> { // Only a `close` fails the write, and nothing closes it; the last handle dropping does. let Ok(mut requests) = self.requests.write() else { diff --git a/rs/moq-video/src/frame.rs b/rs/moq-video/src/frame.rs index 9c17fc40cb7..47c7e54d21a 100644 --- a/rs/moq-video/src/frame.rs +++ b/rs/moq-video/src/frame.rs @@ -284,8 +284,10 @@ impl DmaBuf { /// /// Clones share the descriptor and guard; exports duplicate the FD as needed. /// External buffers refuse CPU download, including linear allocations. - // Only tests build one until an external producer needs it. - #[cfg_attr(not(test), allow(dead_code))] + #[cfg_attr( + not(test), + allow(dead_code, reason = "only tests build one until an external producer needs it") + )] pub(crate) fn new(fd: OwnedFd, layout: DmaBufLayout, owner: T) -> Result { Self::adopt(layout, Arc::new(ExternalDmaBuf { fd, _owner: owner })) } From dc31bcfcd6efd400c4b4a507e578d1d2cd488b4c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 14:21:41 -0700 Subject: [PATCH 12/15] fix(audio): release a cancelled finish's waker through kio Observer::finish parked a cloned Waker in a slot nothing cleared when the future was dropped, such as on moq-cli's metrics timeout. Register through a kio::WaiterList instead, whose entries die with the waiting future. Co-Authored-By: Claude Opus 5.5 --- rs/moq-audio/Cargo.toml | 2 +- rs/moq-audio/src/playback/observe.rs | 55 ++++++++++++++++++---------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 0793a8298ed..183de7b7d02 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -54,7 +54,7 @@ capture = [ # Device playback. Mixes any number of `playback::Sink`s into one cpal output # stream (CoreAudio / WASAPI / ALSA). Same libasound build dependency on Linux # as `capture`. -playback = ["dep:cpal", "dep:fixed-resample", "dep:tokio", "dep:tracing"] +playback = ["dep:cpal", "dep:fixed-resample", "dep:kio", "dep:tokio", "dep:tracing"] # Reach devices through the Linux sound server rather than its ALSA plugin, for # the server's own device names and routing. These customize cpal only when # `capture` or `playback` activates it, so a host flag alone pulls no audio I/O. diff --git a/rs/moq-audio/src/playback/observe.rs b/rs/moq-audio/src/playback/observe.rs index 5a3c8ea599d..79bcd807e68 100644 --- a/rs/moq-audio/src/playback/observe.rs +++ b/rs/moq-audio/src/playback/observe.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, sync_channel}; use std::sync::{Arc, Mutex, OnceLock}; -use std::task::{Poll, Waker}; +use std::task::Poll; use std::time::Duration; use super::driver::Commands; @@ -105,8 +105,9 @@ pub(super) struct Shared { /// The generation whose end a callback has closed out, or 0. acknowledged: AtomicU64, /// The [`Observer::finish`] waiting on [`acknowledged`](Self::acknowledged). - /// Woken off the audio thread, which only posts a driver sync. - waker: Mutex>, + /// Woken off the audio thread, which only posts a driver sync. Its entry dies + /// with the future, so a cancelled finish holds nothing here. + waiters: Mutex, /// Posts that sync. Unset in tests that run a ledger without a driver. driver: OnceLock, events: SyncSender<(u64, Event)>, @@ -127,7 +128,7 @@ impl Default for Shared { overflowed: AtomicU64::new(0), finished: AtomicU64::new(0), acknowledged: AtomicU64::new(0), - waker: Mutex::new(None), + waiters: Mutex::new(kio::WaiterList::new()), driver: OnceLock::new(), events, received: Mutex::new(received), @@ -144,9 +145,7 @@ impl Shared { /// Wake a [`Observer::finish`] whose measurement a callback closed out. Locks, /// so the driver thread calls it, never the audio thread. pub(super) fn wake(&self) { - if let Some(waker) = self.waker.lock().unwrap().take() { - waker.wake(); - } + self.waiters.lock().unwrap().wake(); } } @@ -215,18 +214,20 @@ impl Observer { /// Dropping the future still ends the measurement; it only stops waiting. pub fn finish(&mut self) -> impl Future + '_ { self.shared.finished.store(self.generation, Ordering::Release); - std::future::poll_fn(|cx| { - let shared = &self.shared; - if shared.acknowledged.load(Ordering::Acquire) == self.generation { - return Poll::Ready(()); - } - *shared.waker.lock().unwrap() = Some(cx.waker().clone()); - // Checked again after registering, so an acknowledgement that raced it is not missed. - match shared.acknowledged.load(Ordering::Acquire) == self.generation { - true => Poll::Ready(()), - false => Poll::Pending, - } - }) + kio::wait(|waiter| self.poll_finished(waiter)) + } + + fn poll_finished(&self, waiter: &kio::Waiter) -> Poll<()> { + let shared = &self.shared; + if shared.acknowledged.load(Ordering::Acquire) == self.generation { + return Poll::Ready(()); + } + waiter.register(&mut shared.waiters.lock().unwrap()); + // Checked again after registering, so an acknowledgement that raced it is not missed. + match shared.acknowledged.load(Ordering::Acquire) == self.generation { + true => Poll::Ready(()), + false => Poll::Pending, + } } } @@ -470,6 +471,8 @@ impl Drop for Ledger { #[cfg(test)] mod tests { + use std::task::Waker; + use super::*; const RATE: u32 = 48_000; @@ -646,6 +649,20 @@ mod tests { assert!(finish.as_mut().poll(&mut cx).is_ready()); } + /// A finish dropped while it waits, such as on a timeout, leaves no waker behind. + #[test] + fn a_cancelled_finish_releases_its_waker() { + let (_ledger, mut observer) = observed(); + let shared = observer.shared.clone(); + { + let mut cx = std::task::Context::from_waker(Waker::noop()); + let mut finish = std::pin::pin!(observer.finish()); + assert!(finish.as_mut().poll(&mut cx).is_pending()); + assert!(!shared.waiters.lock().unwrap().is_empty()); + } + assert!(shared.waiters.lock().unwrap().is_empty()); + } + #[test] fn a_gap_open_across_observers_stays_with_the_first() { let shared = Arc::new(Shared::default()); From c328074ea07ad4349ded9b2934fa5d412e44ef3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 19:26:07 -0700 Subject: [PATCH 13/15] fix(audio): resolve an observer's finish once no stream is left A finish called after the engine's last stream went away waited forever: only a live ledger acknowledges one. Count live ledgers in the shared state and treat none as already final. Co-Authored-By: Claude Opus 5.5 --- rs/moq-audio/src/playback/observe.rs | 91 +++++++++++++++++++++------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/rs/moq-audio/src/playback/observe.rs b/rs/moq-audio/src/playback/observe.rs index 79bcd807e68..80a553443df 100644 --- a/rs/moq-audio/src/playback/observe.rs +++ b/rs/moq-audio/src/playback/observe.rs @@ -8,7 +8,7 @@ //! falls so far behind that the queue fills loses events, and the observer //! reports that as an error rather than an undercount. -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, sync_channel}; use std::sync::{Arc, Mutex, OnceLock}; use std::task::Poll; @@ -104,9 +104,13 @@ pub(super) struct Shared { finished: AtomicU64, /// The generation whose end a callback has closed out, or 0. acknowledged: AtomicU64, - /// The [`Observer::finish`] waiting on [`acknowledged`](Self::acknowledged). - /// Woken off the audio thread, which only posts a driver sync. Its entry dies - /// with the future, so a cancelled finish holds nothing here. + /// Ledgers still counting, one per live stream. With none, nothing is left + /// to close out a measurement, so it is already final. + ledgers: AtomicUsize, + /// The [`Observer::finish`] waiting on [`acknowledged`](Self::acknowledged) or + /// [`ledgers`](Self::ledgers). Woken off the audio thread, which only posts a + /// driver sync. Its entry dies with the future, so a cancelled finish holds + /// nothing here. waiters: Mutex, /// Posts that sync. Unset in tests that run a ledger without a driver. driver: OnceLock, @@ -128,6 +132,7 @@ impl Default for Shared { overflowed: AtomicU64::new(0), finished: AtomicU64::new(0), acknowledged: AtomicU64::new(0), + ledgers: AtomicUsize::new(0), waiters: Mutex::new(kio::WaiterList::new()), driver: OnceLock::new(), events, @@ -142,8 +147,9 @@ impl Shared { let _ = self.driver.set(driver); } - /// Wake a [`Observer::finish`] whose measurement a callback closed out. Locks, - /// so the driver thread calls it, never the audio thread. + /// Wake a [`Observer::finish`] whose measurement a callback or the last stream + /// going away closed out. Locks, so the driver thread calls it, never the + /// audio thread. pub(super) fn wake(&self) { self.waiters.lock().unwrap().wake(); } @@ -208,27 +214,36 @@ impl Observer { } /// End the measurement now, resolving once the next callback, or the stream - /// going away, has closed a gap still open and stopped counting. The read - /// after it resolves is final; only the clock moves on. + /// going away, has closed a gap still open and stopped counting. With no + /// stream it resolves at once. The read after it resolves is final; only the + /// clock moves on. /// /// Dropping the future still ends the measurement; it only stops waiting. pub fn finish(&mut self) -> impl Future + '_ { - self.shared.finished.store(self.generation, Ordering::Release); + // SeqCst pairs with a ledger starting: either it sees this store and counts + // nothing, or `poll_finished` sees it counting and waits for it. + self.shared.finished.store(self.generation, Ordering::SeqCst); kio::wait(|waiter| self.poll_finished(waiter)) } fn poll_finished(&self, waiter: &kio::Waiter) -> Poll<()> { - let shared = &self.shared; - if shared.acknowledged.load(Ordering::Acquire) == self.generation { + if self.settled() { return Poll::Ready(()); } - waiter.register(&mut shared.waiters.lock().unwrap()); - // Checked again after registering, so an acknowledgement that raced it is not missed. - match shared.acknowledged.load(Ordering::Acquire) == self.generation { + waiter.register(&mut self.shared.waiters.lock().unwrap()); + // Checked again after registering, so an acknowledgement or a last stream + // going away that raced it is not missed. + match self.settled() { true => Poll::Ready(()), false => Poll::Pending, } } + + /// Whether a callback closed out this measurement, or no stream is left to. + fn settled(&self) -> bool { + let shared = &self.shared; + shared.acknowledged.load(Ordering::Acquire) == self.generation || shared.ledgers.load(Ordering::SeqCst) == 0 + } } impl Drop for Observer { @@ -286,6 +301,7 @@ impl Ledger { // A rebuilt stream continues the engine's counts: once anything has played, // a quiet quantum while the new stream recovers is silence, not the first fill. let started = shared.quanta.load(Ordering::Relaxed) > 0; + shared.ledgers.fetch_add(1, Ordering::SeqCst); Self { shared, rate: rate as f64, @@ -429,7 +445,7 @@ impl Ledger { /// The reading observer's generation, if it has ended its measurement. fn finished(&self) -> Option { let observer = self.shared.observer.load(Ordering::Acquire); - (observer != 0 && self.shared.finished.load(Ordering::Acquire) == observer).then_some(observer) + (observer != 0 && self.shared.finished.load(Ordering::SeqCst) == observer).then_some(observer) } fn nanos(&self, frames: u64) -> u64 { @@ -457,15 +473,18 @@ impl Drop for Ledger { fn drop(&mut self) { if let Some(observer) = self.finished() { self.acknowledge(observer); - self.shared.wake(); - return; - } - self.close(); - if self.playing { - self.shared.stalled.store(true, Ordering::Relaxed); - let at = self.clock(self.frames); - self.send(Event::Stall(Stall { at, stalled: true })); + } else { + self.close(); + if self.playing { + self.shared.stalled.store(true, Ordering::Relaxed); + let at = self.clock(self.frames); + self.send(Event::Stall(Stall { at, stalled: true })); + } } + // Woken even without a finish seen above: one that raced this drop waits + // on the last ledger going away. + self.shared.ledgers.fetch_sub(1, Ordering::SeqCst); + self.shared.wake(); } } @@ -649,6 +668,32 @@ mod tests { assert!(finish.as_mut().poll(&mut cx).is_ready()); } + /// With no stream left to close it out, such as after the engine shut down, + /// a finish resolves at once, and a stream opened after it counts nothing. + #[test] + fn finish_without_a_stream_resolves_at_once() { + let shared = Arc::new(Shared::default()); + let mut observer = Observer::new(shared.clone()).unwrap(); + let mut ledger = Ledger::new(shared.clone(), RATE); + add(&mut ledger, 128, 128); + add(&mut ledger, 128, 0); + drop(ledger); + { + let mut cx = std::task::Context::from_waker(Waker::noop()); + let mut finish = std::pin::pin!(observer.finish()); + assert!(finish.as_mut().poll(&mut cx).is_ready()); + } + let mut ledger = Ledger::new(shared, RATE); + add(&mut ledger, 128, 128); + let observation = observer.read().unwrap(); + assert_eq!( + observation.gaps.len(), + 1, + "the dropped stream closed its gap: {observation:?}" + ); + assert_eq!(observation.quanta, 2, "nothing after the finish counts"); + } + /// A finish dropped while it waits, such as on a timeout, leaves no waker behind. #[test] fn a_cancelled_finish_releases_its_waker() { From c03ca5a383a9836cc6b87939d285ad6fceda8bac Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 00:37:11 -0700 Subject: [PATCH 14/15] fix(json): keep window Producer::cut public for the ffi Window moq-dev/moq#6597 exposed cut through moq-ffi's window producer, so it now has a consumer. Co-Authored-By: Claude Opus 5.5 --- rs/moq-json/src/window/producer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rs/moq-json/src/window/producer.rs b/rs/moq-json/src/window/producer.rs index 15891eccd51..8a086a60382 100644 --- a/rs/moq-json/src/window/producer.rs +++ b/rs/moq-json/src/window/producer.rs @@ -83,8 +83,7 @@ impl Producer { /// /// Idempotent: cutting when no group is open does nothing. A caller that stores complete groups /// cuts after its edits, so every edit so far sits in a group no later frame can extend. - #[cfg(test)] - pub(crate) fn cut(&mut self) -> Result<()> { + pub fn cut(&mut self) -> Result<()> { self.inner.lock().unwrap().cut() } @@ -176,7 +175,6 @@ impl Inner { std::task::Poll::Ready(Ok(())) } - #[cfg(test)] fn cut(&mut self) -> Result<()> { let Some(mut group) = self.track.group.take() else { return Ok(()); From b46d1004830f5e07e82588ba2e477f32258473a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 11:08:41 -0700 Subject: [PATCH 15/15] fix(video): import DmaBufLayout from frame in the vaapi test fixture Co-Authored-By: Claude Opus 5.5 --- rs/moq-video/src/render/dmabuf.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-video/src/render/dmabuf.rs b/rs/moq-video/src/render/dmabuf.rs index 2f8bb51e552..9268b91de9f 100644 --- a/rs/moq-video/src/render/dmabuf.rs +++ b/rs/moq-video/src/render/dmabuf.rs @@ -302,7 +302,7 @@ pub(super) mod fixture { use std::sync::Arc; use super::*; - use crate::DmaBufLayout; + use crate::frame::DmaBufLayout; /// The VA-API fourcc naming the same layout as a DRM format. ///