diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index e60965a359..99bc063da5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -21,7 +21,7 @@ nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -e \ # Publish a test pattern gst-launch-1.0 -e videotestsrc is-live=true ! x264enc tune=zerolatency ! h264parse \ ! video/x-h264,stream-format=byte-stream,alignment=au ! mux.sink_0 \ - moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang + moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang sink_0::encoder=true ``` Install via `apt install gstreamer1.0-moq` or `dnf install gstreamer1-moq` @@ -48,7 +48,8 @@ directly. A cue with no duration is dropped rather than left on screen. Each `sink_%u` request pad is one track. Pad properties: `track` names it (default: after the codec), `container=loc` publishes it as -[LOC](/concept/standard#loc) instead of the legacy hang container, and +[LOC](/concept/standard#loc) instead of the legacy hang container, +`encoder=true` marks it as fed by a local encoder, and `track-status`/`track-error` report its lifecycle. Element properties: `url`, `broadcast`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keep-alive`, and read-only `status`, `connected`, `moq-version`, and @@ -73,6 +74,14 @@ while connected and 0 otherwise, reconnects are `started - 1` once `started` is at least 1, and a rate is the delta over any window you sample. Unlike `status`, a connection that drops before you poll still moves both counters. +Set `encoder=true` on audio and video pads a local encoder feeds +(`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches +the sink behind its running time and raises the catalog `jitter` by the spread, +so players buffer for an encoder that delivers irregularly. Leave it off, the +default, for file, demuxed, and network media: their arrival reflects the disk +or the network, not the original encoder, and a GStreamer segment cannot tell +the two apart. Text and opaque pads refuse it. + ## moqsrc Pads are named by kind and appear as the catalog announces renditions: diff --git a/quest/m1/README.md b/quest/m1/README.md index f2c3be7a91..a60a2eaa04 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -40,7 +40,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes -- [GStreamer encoder jitter](/quest/m1/gst-encoder-jitter-provenance.md) - only opted-in local encoder pads feed the flush clock - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers diff --git a/quest/m1/gst-encoder-jitter-provenance.md b/quest/m1/gst-encoder-jitter-provenance.md deleted file mode 100644 index ca6a4c387f..0000000000 --- a/quest/m1/gst-encoder-jitter-provenance.md +++ /dev/null @@ -1,23 +0,0 @@ -# [M] moq-gst: observe flush jitter only for local encoders - -## Goal - -A `moqsink` pad fed by a local encoder measures catalog jitter from the frame's -transport handoff, while file, pipe, demuxed, and network imports remain -clock-free. A GStreamer TIME segment and PTS alone cannot identify provenance: -`multifilesrc ! parsebin ! moqsink` supplies both. - -## Plan - -- Choose an explicit opt-in on the request pad (name and lifecycle to settle - with the maintainer). Default to imports, which retain batch/reorder estimates. -- For an opted-in pad, call the explicit codec importer flush observation after - a successful media write, with the mapped broadcast PTS and `Instant::now()`. - Refuse an invalid opt-in/timestamp combination instead of silently skipping. -- Exercise a local encoder pipeline and the existing looped MP4 import recipe; - prove only the opted-in path raises jitter. Document the opt-in in - `doc/bin/gstreamer.md` and update demo pipelines where they encode locally. - -## Related - -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the flush measurement this pad feeds diff --git a/quest/m1/jitter-flush-clock.md b/quest/m1/jitter-flush-clock.md index e22454e1c3..ad27a5edf6 100644 --- a/quest/m1/jitter-flush-clock.md +++ b/quest/m1/jitter-flush-clock.md @@ -33,11 +33,14 @@ media span of each emitted batch and advertise no `delay`. libmoq (so OBS), moq-ffi and its wrappers, and the `js/publish` encoders call it. The provisional PTS-gap floor is gone, and `moq_mux::Error::JitterDecreased` plus zero-as-absent text jitter enforce never-lower in Rust and JS. - `moq-gst` is split into [GStreamer encoder jitter](/quest/m1/gst-encoder-jitter-provenance.md). + `moq-gst` pads opt in with `encoder=true`; imports stay clock-free. What remains below is `delay` and the player. libmoq and moq-ffi expose `flush` but no discontinuity, so a binding publisher that pauses and resumes on a re-anchored PTS within the window would count the pause; add one when - such a caller appears. + such a caller appears. A `moq-gst` encoder pad has the same gap across a + `PLAYING -> PAUSED -> PLAYING` cycle (running time stops, the wall clock + does not) and a flushing seek, since `import::Track` forwards no + discontinuity. - **Measurement.** Lateness is `now - timestamp`, observed by the existing `flush` calls, so no call site changes. Each rendition keeps its own baseline, the minimum lateness over a sliding window (about 10 s), so a media diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index 9135696fae..e07f6c0178 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -10,7 +10,7 @@ //! pad lifecycle, then an object lock. No path takes the element control while holding a pad lifecycle. use std::sync::{LazyLock, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use bytes::Bytes; @@ -685,7 +685,9 @@ impl MoqSink { if lifecycle.media.is_failed() { return Ok(gst::FlowSuccess::Ok); } - let outcome = lifecycle.media.push_buffer(data, pts, duration, current_running_time); + let outcome = lifecycle + .media + .push_buffer(data, pts, duration, current_running_time, Instant::now()); let changes = match &outcome { Ok(PushOutcome::Failed(reason)) => Some(lifecycle.fail(reason.clone())), _ => None, @@ -772,7 +774,9 @@ impl MoqSink { return false; } let requested = lifecycle.requested().map(str::to_owned); - let mut options = ProducerOptions::new(&caps).with_container(lifecycle.container().into()); + let mut options = ProducerOptions::new(&caps) + .with_container(lifecycle.container().into()) + .with_encoder(lifecycle.encoder()); if let Some(track) = requested.as_deref() { options = options.with_track(track); } diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index cf73a04d36..23ecdb1a33 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -4,6 +4,8 @@ //! that pad's own streaming thread, so this type is touched from one thread and needs no generation //! tagging or cross-thread failure map. +use std::time::Instant; + use anyhow::{Context, Result, ensure}; use bytes::Bytes; @@ -30,23 +32,50 @@ enum PadState { /// Both payloads are large (a codec importer, a container producer), so each is boxed to keep the /// enum small. enum Sink { - /// `audio` decides grouping. `import::Track` draws no audio boundaries of its own (every - /// packet is independently decodable, so there is no keyframe to group on), leaving them to - /// whoever knows the latency target. A live sink wants each packet forwarded without waiting, - /// so it cuts per frame. Video groups at its own keyframes and needs nothing. - Media { - track: Box, - audio: bool, - }, + Media(Box), Text(Box), Opaque(moq_net::track::Producer), } +/// An audio or video pad, published through a codec importer. +struct Media { + track: import::Track, + /// Decides grouping. `import::Track` draws no audio boundaries of its own (every packet is + /// independently decodable, so there is no keyframe to group on), leaving them to whoever knows + /// the latency target. A live sink wants each packet forwarded without waiting, so it cuts per + /// frame. Video groups at its own keyframes and needs nothing. + audio: bool, + /// Records each frame's handoff against the wall clock, raising the catalog jitter by how + /// irregularly a local encoder delivers. Imports leave it off: their arrival times describe the + /// file or network, not the encoder. + encoder: bool, +} + +impl Media { + /// Publish one frame at `micros` on the media clock, handed over at `now`. + fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result<()> { + let ts = hang::container::Timestamp::from_micros(micros).ok(); + self.track.decode(data, ts)?; + // One group (one QUIC stream) per audio packet, so the relay forwards it without waiting for + // the next. + if self.audio { + self.track.cut(None)?; + } + if self.encoder { + // Skipping the observation would publish a frame the jitter never saw. + let ts = ts.context("encoder frame timestamp out of range")?; + self.track.flush(ts, now)?; + } + Ok(()) + } +} + /// Inputs used to build a producer after a pad observes caps. pub(super) struct ProducerOptions<'a> { container: hang::catalog::Container, caps: &'a gst::Caps, requested: Option<&'a str>, + encoder: bool, } impl<'a> ProducerOptions<'a> { @@ -55,6 +84,7 @@ impl<'a> ProducerOptions<'a> { container: hang::catalog::Container::default(), caps, requested: None, + encoder: false, } } @@ -67,6 +97,11 @@ impl<'a> ProducerOptions<'a> { self.requested = Some(track); self } + + pub(super) fn with_encoder(mut self, encoder: bool) -> Self { + self.encoder = encoder; + self + } } /// A subtitle pad. GStreamer hands us one decoded cue per buffer (`text/x-raw`, UTF-8) with the @@ -245,8 +280,15 @@ impl Pad { container, caps, requested, + encoder, } = options; let structure = caps.structure(0).context("empty caps")?; + // Only a codec rendition carries the jitter a local encoder's clock would raise. + ensure!( + !encoder || !matches!(structure.name().as_str(), "application/octet-stream" | "text/x-raw"), + "encoder is only supported on audio and video pads, not {}", + structure.name() + ); // Renegotiation: finalize the previous producer before replacing it (closed once, not abandoned). self.finalize()?; // Opaque data has no codec importer and no catalog entry, so it never reaches the codec match. @@ -422,10 +464,7 @@ impl Pad { } other => anyhow::bail!("unsupported caps: {other}"), }; - self.track = Some(Sink::Media { - track: Box::new(track), - audio, - }); + self.track = Some(Sink::Media(Box::new(Media { track, audio, encoder }))); self.caps = Some(caps.clone()); Ok(name) } @@ -597,8 +636,9 @@ impl Pad { /// Import one buffer into the producer. A failed or producer-less pad drops the buffer; a timeline /// drop is logged. Unstamped opaque data on an active timeline uses the element's current running - /// time. A bad bitstream (or an oversized frame, rejected by moq-net) invalidates only this pad and - /// says so in the returned outcome. Returns an error when an unstamped opaque buffer has no current + /// time. An encoder pad records `now` as the frame's handoff once it is published. A bad bitstream + /// (or an oversized frame, rejected by moq-net) invalidates only this pad and says so in the + /// returned outcome. Returns an error when an unstamped opaque buffer has no current /// running time, so the caller fails the flow instead of silently dropping data. pub fn push_buffer( &mut self, @@ -606,6 +646,7 @@ impl Pad { pts: Option, duration: Option, current_running_time: Option, + now: Instant, ) -> std::result::Result { if self.failed { return Ok(PushOutcome::Dropped); @@ -625,15 +666,7 @@ impl Pad { match timestamp { Ok(micros) => { let result: Result<()> = match self.track.as_mut().expect("track present") { - Sink::Media { track, audio } => { - let ts = hang::container::Timestamp::from_micros(micros).ok(); - track - .decode(&data, ts) - // One group (one QUIC stream) per audio packet, so the relay forwards - // it without waiting for the next. See `Sink::Media`. - .and_then(|()| if *audio { track.cut(None) } else { Ok(()) }) - .map_err(Into::into) - } + Sink::Media(media) => media.write(&data, micros, now), Sink::Text(text) => match std::str::from_utf8(&data) { // A cue with no duration would never be dismissed, so drop it rather than pin it // on screen; the demuxer supplies one for every real subtitle sample. @@ -687,7 +720,7 @@ impl Pad { return Ok(false); }; let closed = match track { - Sink::Media { mut track, .. } => track.finish().map_err(anyhow::Error::from), + Sink::Media(mut media) => media.track.finish().map_err(anyhow::Error::from), Sink::Text(mut text) => text.producer.finish().map_err(anyhow::Error::from), Sink::Opaque(producer) => producer.finish().map_err(anyhow::Error::from), }; @@ -831,8 +864,14 @@ mod tests { "the reserved name is the requested one" ); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let snapshot = catalog.snapshot(); let renditions: Vec = snapshot.video.renditions.keys().map(|name| name.to_string()).collect(); @@ -933,14 +972,26 @@ mod tests { pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), None)); // No observe_segment: the pad stays in NoSegment. assert_eq!( - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(), + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now() + ) + .unwrap(), PushOutcome::NoSegment, "first no-segment buffer is reported" ); assert_eq!( - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(), + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now() + ) + .unwrap(), PushOutcome::Dropped, "subsequent no-segment buffers are not re-reported" ); @@ -995,7 +1046,13 @@ mod tests { assert!(!data.is_failed()); video.observe_segment(time_segment()); video - .push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) + .push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) .unwrap(); let snapshot = catalog.snapshot(); @@ -1028,6 +1085,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(40)), None, None, + Instant::now(), ) .unwrap(); pad.push_buffer( @@ -1035,6 +1093,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(80)), None, None, + Instant::now(), ) .unwrap(); @@ -1086,8 +1145,14 @@ mod tests { CapsOutcome::Active("camera".to_string()) ); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let config = catalog.snapshot().video.renditions.get("camera").cloned().unwrap(); assert_eq!(config.container, hang::catalog::Container::Loc); @@ -1155,6 +1220,7 @@ mod tests { None, None, Some(gst::ClockTime::from_mseconds(25)), + Instant::now(), ) .unwrap(); assert!(!pad.is_failed(), "a missing PTS uses the supplied running time"); @@ -1186,7 +1252,7 @@ mod tests { pad.observe_segment(time_segment()); assert!( - pad.push_buffer(Bytes::from_static(b"no timestamp"), None, None, None) + pad.push_buffer(Bytes::from_static(b"no timestamp"), None, None, None, Instant::now()) .is_err(), "the caller gets a hard error instead of a silent drop" ); @@ -1205,8 +1271,14 @@ mod tests { ); assert!(pad.is_failed()); pad.observe_segment(time_segment()); - pad.push_buffer(Bytes::from_static(b"x"), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + Bytes::from_static(b"x"), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); } // A real IDR AU emits a frame to the published track (not just a rendition off the SPS). @@ -1217,8 +1289,14 @@ mod tests { let mut pad = Pad::new(); pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), None)); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let snapshot = catalog.snapshot(); let track = snapshot.video.renditions.keys().next().expect("a video rendition"); @@ -1419,6 +1497,7 @@ mod tests { Some(gst::ClockTime::ZERO), Some(gst::ClockTime::from_seconds(1)), None, + Instant::now(), ) .unwrap(); assert!(pad.is_failed()); @@ -1445,6 +1524,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(start_ms)), Some(gst::ClockTime::from_mseconds(dur_ms)), None, + Instant::now(), ) .unwrap(); } @@ -1463,8 +1543,14 @@ mod tests { let mut pad = Pad::new(); pad.observe_caps(&broadcast, &catalog, producer_options(&text_caps(), None)); pad.observe_segment(time_segment()); - pad.push_buffer(Bytes::from_static(b"hello"), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + Bytes::from_static(b"hello"), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); assert!(!pad.is_failed(), "a durationless cue drops the buffer, not the pad"); } @@ -1503,4 +1589,64 @@ mod tests { .count(); assert_eq!(emitted, 5, "all five decode-order frames must emit (got {emitted})"); } + + // `multifilesrc ! parsebin` and a live encoder deliver identical segments and PTS, so the opt-in is + // the only thing that tells them apart. The same late frame raises only the encoder pad's jitter. + #[test] + fn only_an_encoder_pad_measures_its_handoff() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + let mut encoder = Pad::new(); + let mut import = Pad::new(); + encoder.observe_caps( + &broadcast, + &catalog, + producer_options(&h264_caps(), Some("encoder")).with_encoder(true), + ); + import.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), Some("import"))); + + // The third frame reaches the sink 100ms later than its running time says it should. + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (33, 33), (66, 166)] { + for pad in [&mut encoder, &mut import] { + pad.observe_segment(time_segment()); + let outcome = pad + .push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival), + ) + .unwrap(); + assert_eq!(outcome, PushOutcome::Published); + } + } + + let snapshot = catalog.snapshot(); + let jitter = |name: &str| snapshot.video.renditions[name].jitter; + assert_eq!(jitter("encoder"), Some(std::time::Duration::from_millis(100))); + assert_eq!(jitter("import"), None, "an import's arrival never reaches the catalog"); + } + + // Text and opaque tracks carry no codec jitter, so asking them to measure one is a mistake to report + // rather than a setting to ignore. + #[test] + fn an_encoder_pad_must_carry_audio_or_video() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + for caps in [text_caps(), opaque_caps()] { + let mut pad = Pad::new(); + let outcome = pad.observe_caps( + &broadcast, + &catalog, + producer_options(&caps, Some("data")).with_encoder(true), + ); + assert!( + matches!(outcome, CapsOutcome::Failed(ref reason) if reason.starts_with("encoder is only supported")), + "{outcome:?}" + ); + assert!(pad.is_failed()); + } + } } diff --git a/rs/moq-gst/src/sink/request_pad.rs b/rs/moq-gst/src/sink/request_pad.rs index d8f8f540c4..9bc31f19ab 100644 --- a/rs/moq-gst/src/sink/request_pad.rs +++ b/rs/moq-gst/src/sink/request_pad.rs @@ -41,6 +41,8 @@ struct Settings { effective: Option, /// The wire container selected for this pad's media producer. container: MediaContainer, + /// Whether a local encoder feeds this pad, so its handoff clock may raise the catalog jitter. + encoder: bool, /// What the track is doing, read back through `status`. status: Status, /// The reason the pad was invalidated, read back through `track-error`. @@ -104,6 +106,11 @@ impl PadLifecycle { self.settings.container } + /// Whether a local encoder feeds this pad. + pub(super) fn encoder(&self) -> bool { + self.settings.encoder + } + /// Record the name reserved by a successful CAPS event. pub(super) fn commit(&mut self, track: String) -> Notifications { let before = self @@ -186,6 +193,19 @@ impl ObjectImpl for MoqSinkPadImp { .default_value(MediaContainer::Legacy) .mutable_playing() .build(), + // Provenance is not in the caps: `multifilesrc ! parsebin` hands over the same TIME + // segment and PTS a live encoder does, so only the application can say which it is. + glib::ParamSpecBoolean::builder("encoder") + .nick("Local encoder") + .blurb( + "A local encoder feeds this pad, so the catalog jitter includes how late each \ + frame reaches the sink behind its running time. Leave it off for file, demuxed, \ + and network media, whose arrival says nothing about the original encoder. Audio \ + and video only. Writable in any state until the CAPS event reserves the track", + ) + .default_value(false) + .mutable_playing() + .build(), glib::ParamSpecEnum::builder::("track-status") .nick("Status") .blurb( @@ -219,7 +239,7 @@ impl ObjectImpl for MoqSinkPadImp { ); return; } - // A producer keeps its reserved name and wire container for its whole life, so a later write + // A producer keeps its reserved name, wire container, and provenance for its whole life, so a later write // would read back without ever reaching the broadcast or the catalog. if lifecycle.settings.effective.is_some() { gst::warning!( @@ -236,6 +256,7 @@ impl ObjectImpl for MoqSinkPadImp { lifecycle.settings.requested = value.get::>().unwrap().filter(|name| !name.is_empty()) } "container" => lifecycle.settings.container = value.get().unwrap(), + "encoder" => lifecycle.settings.encoder = value.get().unwrap(), _ => unreachable!(), } } @@ -250,6 +271,7 @@ impl ObjectImpl for MoqSinkPadImp { .or_else(|| lifecycle.settings.requested.clone()) .to_value(), "container" => lifecycle.settings.container.to_value(), + "encoder" => lifecycle.settings.encoder.to_value(), "track-status" => lifecycle.settings.status.to_value(), "track-error" => lifecycle.settings.error.clone().to_value(), _ => unreachable!(), diff --git a/rs/moq-gst/tests/element.rs b/rs/moq-gst/tests/element.rs index 114b6166ad..833590c89a 100644 --- a/rs/moq-gst/tests/element.rs +++ b/rs/moq-gst/tests/element.rs @@ -572,6 +572,20 @@ fn a_pipeline_description_selects_loc() { ); } +#[test] +fn a_pipeline_description_marks_an_encoder_pad() { + init(); + let sink = gst::parse::launch("moqsink name=publisher url=https://127.0.0.1:1 broadcast=test sink_0::encoder=true") + .expect("parse the description"); + let _pad = sink.request_pad_simple("sink_0").expect("request sink_0"); + assert!(child_of(&sink, "sink_0").property::("encoder")); + let _other = sink.request_pad_simple("sink_1").expect("request sink_1"); + assert!( + !child_of(&sink, "sink_1").property::("encoder"), + "a pad is an import unless it says otherwise" + ); +} + // The acceptance criterion: once CAPS reserves the track, its name and container are fixed; stopping // the element makes both configurable again. #[test]