Skip to main content

g2g_core/
fanout.rs

1//! Fan-out primitives for the dynamic graph layer (DESIGN.md §4.8.4).
2//!
3//! M9 (1→N slice): a multi-output sink abstraction plus the two routing
4//! primitives that cover branch enable/disable and A/B switching:
5//!
6//! - [`Gate`] — 1→1. Forwards or drops each `DataFrame` by an atomic flag.
7//!   It is a plain [`AsyncElement`], so it drops into the existing
8//!   `run_source_transform_sink` runner unchanged.
9//! - [`Router`] — 1→N. Sends each `DataFrame` to exactly one output port
10//!   chosen by an atomic discriminator, and broadcasts `CapsChanged` to
11//!   every port. It implements [`MultiOutputElement`], driven by the
12//!   `run_source_fanout` runner.
13//!
14//! Both expose a cloneable control handle ([`GateHandle`], [`RouterHandle`]),
15//! mirroring `SwapHandle` (`slot.rs`), so application code or another task
16//! flips routing mid-stream without stalling the pipeline.
17//!
18//! The Merger (fan-in) and `BranchSlot` are a later slice. EOS broadcast on
19//! the `Router` is the runner's responsibility, matching the existing
20//! "runner forwards Eos" transform contract.
21
22use alloc::boxed::Box;
23use alloc::sync::Arc;
24use alloc::vec::Vec;
25use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
26
27use crate::caps::Caps;
28use crate::element::{
29    AsyncElement, BoxFuture, ConfigureOutcome, ElementBound, OutputSink, PushOutcome, Reconfigure,
30};
31use crate::error::G2gError;
32use crate::format_element::CapsConstraint;
33use crate::frame::PipelinePacket;
34use crate::memory::DomainSet;
35use crate::property::{ElementMetadata, PropError, PropValue, PropertySpec};
36use crate::runtime::SenderSink;
37use crate::runtime::{PadKind, PadRequest};
38use portable_atomic::AtomicU64;
39
40/// Sentinel for "no bitrate pending" in a [`ReverseChannel`] (a real target is
41/// a `u32`, so `u64::MAX` can never collide).
42const NO_BITRATE: u64 = u64::MAX;
43
44/// A per-input reverse-signal handle a fan-in session shares with the runner, so
45/// a signal that originates on one track (a WebRTC PLI or BWE estimate arriving
46/// for a given m-line) reaches the *matching* upstream source rather than being
47/// broadcast or lost. It is the multi-input analog of the single-track
48/// [`AsyncElement::take_reconfigure`](crate::AsyncElement::take_reconfigure) /
49/// [`take_bitrate`](crate::AsyncElement::take_bitrate) the linear runner polls.
50///
51/// Cloneable and `Send` (`Arc`-backed atomics): the session task (which owns the
52/// `Rtc` / network handle) writes with [`request_keyframe`](Self::request_keyframe)
53/// / [`set_bitrate`](Self::set_bitrate); the runner's per-source adapter reads
54/// the highest-priority pending signal with [`take`](Self::take) after each push
55/// and surfaces it to that source as a [`PushOutcome`], exactly as a linked sink
56/// would. Reconfigure outranks bitrate, matching the linear reverse channel.
57#[derive(Debug, Clone)]
58pub struct ReverseChannel {
59    keyframe: Arc<AtomicBool>,
60    bitrate: Arc<AtomicU64>,
61}
62
63impl Default for ReverseChannel {
64    fn default() -> Self {
65        Self {
66            keyframe: Arc::new(AtomicBool::new(false)),
67            bitrate: Arc::new(AtomicU64::new(NO_BITRATE)),
68        }
69    }
70}
71
72impl ReverseChannel {
73    /// A fresh channel with nothing pending.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Session side: request an upstream keyframe (a remote PLI for this track).
79    pub fn request_keyframe(&self) {
80        self.keyframe.store(true, Ordering::Relaxed);
81    }
82
83    /// Session side: relay a target send bitrate (bits/second) for this track.
84    pub fn set_bitrate(&self, bps: u32) {
85        self.bitrate.store(bps as u64, Ordering::Relaxed);
86    }
87
88    /// Runner side: consume the highest-priority pending signal as a
89    /// [`PushOutcome`], or `None` if nothing is pending. Reconfigure (keyframe)
90    /// takes priority over a bitrate update, as on the linear reverse channel.
91    pub fn take(&self) -> Option<PushOutcome> {
92        if self.keyframe.swap(false, Ordering::Relaxed) {
93            return Some(PushOutcome::Reconfigure(Reconfigure::ForceKeyframe));
94        }
95        match self.bitrate.swap(NO_BITRATE, Ordering::Relaxed) {
96            NO_BITRATE => None,
97            bps => Some(PushOutcome::Bitrate(bps as u32)),
98        }
99    }
100}
101
102/// Downstream output addressing one of N ports. The fan-out analog of
103/// [`OutputSink`]: `push_to` selects the destination port. Dyn-safe via the
104/// poll form (no heap), mirroring [`OutputSink::poll_push`]; `push_to` wraps
105/// it in the concrete [`PushToFuture`] so `&mut dyn MultiOutputSink` callers
106/// await it unchanged.
107pub trait MultiOutputSink {
108    /// Drive one packet toward `port`. Same slot contract as
109    /// [`OutputSink::poll_push`].
110    fn poll_push_to(
111        &mut self,
112        cx: &mut core::task::Context<'_>,
113        port: usize,
114        packet: &mut Option<PipelinePacket>,
115    ) -> core::task::Poll<Result<PushOutcome, G2gError>>;
116
117    /// Discard any phase a cancelled earlier push to `port` left behind.
118    fn begin_push_to(&mut self, _port: usize) {}
119
120    fn port_count(&self) -> usize;
121
122    /// Add an output port carrying `caps` and return its index, or `None` if this
123    /// sink cannot grow (M1014). A duplex session calls it when the peer adds a
124    /// track and none of its declared pads is free; the dynamic duplex runner
125    /// answers by building that port's link and finding it a sink. Never blocks,
126    /// so it is callable from a session's own poll loop: a sink that cannot take
127    /// the port right now answers `None` and the caller keeps whatever it does for
128    /// a track it cannot place.
129    ///
130    /// Default `None`: the fixed-arity multi-sinks the static runners build refuse
131    /// to grow, so a session written against this sees exactly today's behavior.
132    fn add_port(&mut self, _caps: &Caps) -> Option<usize> {
133        None
134    }
135}
136
137/// `push_to` for concrete (sized) sinks; split out for the same
138/// dyn-vs-provided-method ambiguity [`OutputSinkExt`] resolves.
139///
140/// [`OutputSinkExt`]: crate::element::OutputSinkExt
141pub trait MultiOutputSinkExt: MultiOutputSink + Sized {
142    fn push_to(&mut self, port: usize, packet: PipelinePacket) -> PushToFuture<'_, Self> {
143        self.begin_push_to(port);
144        PushToFuture {
145            sink: self,
146            port,
147            packet: Some(packet),
148        }
149    }
150}
151
152impl<S: MultiOutputSink> MultiOutputSinkExt for S {}
153
154impl<'e> dyn MultiOutputSink + 'e {
155    /// [`MultiOutputSink::push_to`] for trait objects (the provided method
156    /// needs `Self: Sized`).
157    pub fn push_to(
158        &mut self,
159        port: usize,
160        packet: PipelinePacket,
161    ) -> PushToFuture<'_, dyn MultiOutputSink + 'e> {
162        self.begin_push_to(port);
163        PushToFuture {
164            sink: self,
165            port,
166            packet: Some(packet),
167        }
168    }
169}
170
171/// Concrete future behind [`MultiOutputSink::push_to`]: the packet slot
172/// [`MultiOutputSink::poll_push_to`] drains. No heap.
173#[allow(missing_debug_implementations)]
174pub struct PushToFuture<'a, S: MultiOutputSink + ?Sized> {
175    sink: &'a mut S,
176    port: usize,
177    packet: Option<PipelinePacket>,
178}
179
180impl<S: MultiOutputSink + ?Sized> core::future::Future for PushToFuture<'_, S> {
181    type Output = Result<PushOutcome, G2gError>;
182
183    fn poll(
184        self: core::pin::Pin<&mut Self>,
185        cx: &mut core::task::Context<'_>,
186    ) -> core::task::Poll<Self::Output> {
187        let this = self.get_mut();
188        this.sink.poll_push_to(cx, this.port, &mut this.packet)
189    }
190}
191
192/// [`MultiOutputSink`] backed by one [`SenderSink`] per output link. Built
193/// by the fan-out runner from the branch links; `push_to` forwards to the
194/// addressed branch.
195#[derive(Debug)]
196pub struct MultiSenderSink {
197    ports: Vec<SenderSink>,
198}
199
200impl MultiSenderSink {
201    pub fn new(ports: Vec<SenderSink>) -> Self {
202        Self { ports }
203    }
204
205    /// Bank every port's push-wait on the producing element's probe (M947), so a
206    /// fan-out element's `proc` percentiles exclude the backpressure of whichever
207    /// branch is slowest.
208    // Only the std fan-out / demux runners build a probed multi-sink, so without
209    // std this would be dead code (which the workspace denies).
210    #[cfg(feature = "std")]
211    pub(crate) fn set_push_wait_probe(&mut self, probe: crate::runtime::Probe) {
212        for port in self.ports.iter_mut() {
213            port.set_push_wait_probe(probe.clone());
214        }
215    }
216
217    /// Append a port, for the growable multi-sink the dynamic duplex runner wraps
218    /// this in (M1014).
219    // Only that runner grows a multi-sink, so without std this would be dead code
220    // (which the workspace denies).
221    #[cfg(feature = "std")]
222    pub(crate) fn push_port(&mut self, port: SenderSink) {
223        self.ports.push(port);
224    }
225}
226
227impl MultiOutputSink for MultiSenderSink {
228    fn begin_push_to(&mut self, port: usize) {
229        if let Some(sink) = self.ports.get_mut(port) {
230            sink.begin_push();
231        }
232    }
233
234    fn poll_push_to(
235        &mut self,
236        cx: &mut core::task::Context<'_>,
237        port: usize,
238        packet: &mut Option<PipelinePacket>,
239    ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
240        // Port range is an internal invariant: `Router` clamps its selection
241        // and broadcasts only over `0..port_count`, so an out-of-range port
242        // is a framework bug, not a runtime error.
243        let sink = self
244            .ports
245            .get_mut(port)
246            .expect("push_to: port out of range");
247        sink.poll_push(cx, packet)
248    }
249
250    fn port_count(&self) -> usize {
251        self.ports.len()
252    }
253}
254
255/// A terminal multi-output *source*: 0 inputs to N outputs, driven by
256/// [`run_fanout_session`](crate::runtime::run_fanout_session). Where
257/// [`MultiOutputElement`] demultiplexes an upstream input stream, this generates
258/// its outputs itself from an external source (e.g. a WHEP session that receives
259/// video + audio over one PeerConnection and emits each on its own pad). It is
260/// the fan-out mirror of a [`MultiInputElement`] used as a terminal session sink.
261pub trait MultiOutputSource: ElementBound {
262    type RunFuture<'a>: core::future::Future<Output = Result<u64, G2gError>> + 'a
263    where
264        Self: 'a;
265
266    /// Number of output pads (one per produced track).
267    fn output_count(&self) -> usize;
268
269    /// The caps this source produces on `output`. The runner fixates each and
270    /// configures the matching downstream sink before [`Self::run`]. Geometry the
271    /// source only learns later (e.g. H.264 dimensions from the in-band SPS) is
272    /// reported as `Any`, exactly as a single-output `SourceLoop` does.
273    fn output_caps(&self, output: usize) -> Result<Caps, G2gError>;
274
275    /// Run until EOS / disconnect, pushing frames to outputs via
276    /// `out.push_to(port, ..)`. The implementation MUST push a
277    /// [`PipelinePacket::Eos`] to every output before returning `Ok`, so no
278    /// downstream branch is stranded. Returns the count of `DataFrame`s pushed.
279    fn run<'a>(&'a mut self, out: &'a mut dyn MultiOutputSink) -> Self::RunFuture<'a>;
280
281    /// Settable properties, so a `gst-launch` line can configure the source
282    /// (M727). Default none.
283    fn properties(&self) -> &'static [PropertySpec] {
284        &[]
285    }
286
287    fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
288        Err(PropError::Unknown)
289    }
290
291    fn get_property(&self, _name: &str) -> Option<PropValue> {
292        None
293    }
294
295    /// Receive this instance's log name and a per-instance log category
296    /// override, mirroring
297    /// [`AsyncElement::set_instance_name`](crate::AsyncElement::set_instance_name)
298    /// / [`set_log_category`](crate::AsyncElement::set_log_category). Default:
299    /// ignore. A session source that logs about itself stores them in a
300    /// [`LogName`](crate::log::LogName).
301    fn set_instance_name(&mut self, _name: alloc::string::String) {}
302
303    /// See [`set_instance_name`](Self::set_instance_name).
304    fn set_log_category(&mut self, _category: alloc::string::String) {}
305}
306
307/// Dyn-safe mirror of [`MultiOutputSource`] (boxed-future `run`), so a terminal
308/// fan-out source can be a graph node payload (M727), like
309/// [`DynSourceLoop`](crate::runtime::DynSourceLoop) for single-output sources.
310pub trait DynMultiOutputSource: ElementBound {
311    fn output_count(&self) -> usize;
312    fn output_caps(&self, output: usize) -> Result<Caps, G2gError>;
313    fn run<'a>(
314        &'a mut self,
315        out: &'a mut dyn MultiOutputSink,
316    ) -> BoxFuture<'a, Result<u64, G2gError>>;
317    fn properties(&self) -> &'static [PropertySpec] {
318        &[]
319    }
320    fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
321        Err(PropError::Unknown)
322    }
323    fn get_property(&self, _name: &str) -> Option<PropValue> {
324        None
325    }
326    /// Dyn-safe mirror of [`MultiOutputSource::set_instance_name`].
327    fn set_instance_name(&mut self, _name: alloc::string::String) {}
328    /// Dyn-safe mirror of [`MultiOutputSource::set_log_category`].
329    fn set_log_category(&mut self, _category: alloc::string::String) {}
330}
331
332impl<T: MultiOutputSource> DynMultiOutputSource for T {
333    fn output_count(&self) -> usize {
334        MultiOutputSource::output_count(self)
335    }
336    fn output_caps(&self, output: usize) -> Result<Caps, G2gError> {
337        MultiOutputSource::output_caps(self, output)
338    }
339    fn run<'a>(
340        &'a mut self,
341        out: &'a mut dyn MultiOutputSink,
342    ) -> BoxFuture<'a, Result<u64, G2gError>> {
343        Box::pin(MultiOutputSource::run(self, out))
344    }
345    fn properties(&self) -> &'static [PropertySpec] {
346        MultiOutputSource::properties(self)
347    }
348    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
349        MultiOutputSource::set_property(self, name, value)
350    }
351    fn get_property(&self, name: &str) -> Option<PropValue> {
352        MultiOutputSource::get_property(self, name)
353    }
354    fn set_instance_name(&mut self, name: alloc::string::String) {
355        MultiOutputSource::set_instance_name(self, name)
356    }
357    fn set_log_category(&mut self, category: alloc::string::String) {
358        MultiOutputSource::set_log_category(self, category)
359    }
360}
361
362/// Forwarding impl so a borrowed `&mut dyn DynMultiOutputSource` can be boxed
363/// as a graph payload without taking ownership.
364impl<'b> DynMultiOutputSource for &'b mut (dyn DynMultiOutputSource + 'b) {
365    fn output_count(&self) -> usize {
366        (**self).output_count()
367    }
368    fn output_caps(&self, output: usize) -> Result<Caps, G2gError> {
369        (**self).output_caps(output)
370    }
371    fn run<'a>(
372        &'a mut self,
373        out: &'a mut dyn MultiOutputSink,
374    ) -> BoxFuture<'a, Result<u64, G2gError>> {
375        (**self).run(out)
376    }
377    fn set_instance_name(&mut self, name: alloc::string::String) {
378        (**self).set_instance_name(name)
379    }
380    fn set_log_category(&mut self, category: alloc::string::String) {
381        (**self).set_log_category(category)
382    }
383}
384
385/// Inbound side of a [`MultiDuplexSession`]: the runner hands the session a
386/// stream of `(input_index, packet)` drawn from its N send-side sources, the
387/// receive-end analog of the [`MultiOutputSink`] it pushes received tracks into.
388/// `recv` yields `None` once every send source has ended (all senders dropped),
389/// so the session can stop publishing while still draining the peer.
390pub trait DuplexInbound {
391    fn recv(&mut self) -> BoxFuture<'_, Option<(usize, PipelinePacket)>>;
392
393    /// The [`ReverseChannel`] of send input `input`, for a pad the session only
394    /// learned about mid-run (M1014). The runner asks
395    /// [`MultiDuplexSession::reverse_channel`] for the pads that exist when the
396    /// run starts, so a track attached later has no route for its PLI / BWE until
397    /// the session reads it back here, keyed by the index its packets arrive on.
398    /// Default `None`: the fixed-arity runner hands every channel over up front.
399    fn reverse_channel(&self, _input: usize) -> Option<ReverseChannel> {
400        None
401    }
402}
403
404/// A terminal **duplex** session: N send-side inputs **and** M recv-side outputs
405/// over one connection, with no external upstream or downstream beyond itself.
406/// The union of [`MultiInputElement`] used as a terminal sink
407/// ([`run_fanin_session`](crate::runtime::run_fanin_session)) and
408/// [`MultiOutputSource`] ([`run_fanout_session`](crate::runtime::run_fanout_session)):
409/// a `WebRtcBin`-style sendrecv PeerConnection both publishes local tracks and
410/// emits the peer's tracks. Driven by
411/// [`run_duplex_session`](crate::runtime::run_duplex_session).
412///
413/// One `run` loop owns the connection and is the sole holder of `&mut self`, so
414/// (unlike the egress session, which spawns a detached task to dodge aliasing)
415/// the send and recv halves share state directly: `run` selects over the inbound
416/// packets (`inbound.recv()`) and the network, feeding the former into the
417/// connection and pushing the latter to `out`.
418pub trait MultiDuplexSession: ElementBound {
419    type RunFuture<'a>: core::future::Future<Output = Result<u64, G2gError>> + 'a
420    where
421        Self: 'a;
422
423    /// Number of send-side input pads (local tracks published to the peer).
424    fn input_count(&self) -> usize;
425
426    /// Number of recv-side output pads (peer tracks emitted locally).
427    fn output_count(&self) -> usize;
428
429    /// Phase 1 for one send-side input pad: narrow that input's proposed caps.
430    fn intercept_caps(&self, input: usize, upstream_caps: &Caps) -> Result<Caps, G2gError>;
431
432    /// Phase 2 for one send-side input pad: fixate and configure it (the session
433    /// reads the track kind, e.g. H.264 video vs Opus audio, from these caps).
434    fn configure_input(
435        &mut self,
436        input: usize,
437        absolute_caps: &Caps,
438    ) -> Result<ConfigureOutcome, G2gError>;
439
440    /// The caps this session produces on one recv-side output pad. Geometry only
441    /// learned later (e.g. H.264 dimensions from the in-band SPS) is reported as a
442    /// `Range` placeholder, exactly as [`MultiOutputSource`] does.
443    fn output_caps(&self, output: usize) -> Result<Caps, G2gError>;
444
445    /// A [`ReverseChannel`] for send-side input pad `input`, if this session routes
446    /// reverse signals (WebRTC PLI / BWE arriving on that track's m-line) back to
447    /// the individual upstream source feeding it. The duplex runner clones it
448    /// before running and polls it after each push from that source, surfacing any
449    /// pending signal as a [`PushOutcome`], exactly as
450    /// [`MultiInputElement::reverse_channel`] does for a fan-in session. Default
451    /// `None`: no per-input reverse channel.
452    fn reverse_channel(&self, _input: usize) -> Option<ReverseChannel> {
453        None
454    }
455
456    /// Declare one send input pad's negotiation-time constraint, mirroring
457    /// [`MultiInputElement::caps_constraint_as_input`].
458    fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_>
459    where
460        Self: Sized,
461    {
462        CapsConstraint::LegacySink(alloc::boxed::Box::new(move |c: &Caps| {
463            <Self as MultiDuplexSession>::intercept_caps(self, input, c)
464        }))
465    }
466
467    /// Drive the session until the connection ends: drain `inbound` (the send-side
468    /// packets, tagged with their input pad) into the connection and push received
469    /// frames to `out`. Must push a [`PipelinePacket::Eos`] to every output before
470    /// returning `Ok`, so no downstream branch is stranded. Returns the count of
471    /// received `DataFrame`s pushed to outputs.
472    fn run<'a>(
473        &'a mut self,
474        inbound: &'a mut dyn DuplexInbound,
475        out: &'a mut dyn MultiOutputSink,
476    ) -> Self::RunFuture<'a>;
477}
478
479/// Multi-output element trait variant: identical negotiation to
480/// [`AsyncElement`], but `process` emits into a [`MultiOutputSink`] rather
481/// than a single downstream. [`Router`] is the first implementor; user code
482/// can write others (e.g. a content-based demux).
483pub trait MultiOutputElement: ElementBound {
484    type ProcessFuture<'a>: core::future::Future<Output = Result<(), G2gError>> + 'a
485    where
486        Self: 'a;
487
488    fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError>;
489
490    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError>;
491
492    fn process<'a>(
493        &'a mut self,
494        packet: PipelinePacket,
495        out: &'a mut dyn MultiOutputSink,
496    ) -> Self::ProcessFuture<'a>;
497
498    /// M18 step 1: declare the fan-out's input-side negotiation
499    /// constraint. Default wraps `intercept_caps(...)` as a
500    /// `LegacySink` (the fan-out narrows what it accepts from the
501    /// upstream source; downstream branches all receive the narrowed
502    /// caps via broadcast). Migrated fan-outs override to return
503    /// native variants (typically `AcceptsAny` for pass-through fan-
504    /// outs like `Router` whose output broadcasts the input verbatim,
505    /// or `Accepts(set)` for fan-outs that filter format on the input
506    /// side).
507    ///
508    /// Phase C FO-2 (per-branch downstream re-solve) will sit on top
509    /// of this: once the runner has the fan-out's negotiated input
510    /// caps, it broadcasts to each branch and runs Phase B's
511    /// `re_solve_downstream_sink` per branch sink.
512    fn caps_constraint_as_input(&self) -> CapsConstraint<'_>
513    where
514        Self: Sized,
515    {
516        CapsConstraint::LegacySink(alloc::boxed::Box::new(move |c: &Caps| {
517            <Self as MultiOutputElement>::intercept_caps(self, c)
518        }))
519    }
520
521    /// The caps output port `port` carries, if this fan-out is a **demux** whose
522    /// ports each carry a distinct elementary stream, rather than a broadcast tee
523    /// (M380). When every port returns `Some`, the graph solver negotiates each
524    /// branch against its port's caps (decoupled from the byte-stream input), so a
525    /// real decoder downstream of the port configures against its codec at startup
526    /// instead of having to retype from the input caps at runtime. Default `None`
527    /// for every port: a broadcast fan-out (e.g. [`Router`]) whose branches all
528    /// carry the input caps and negotiate as a tee. A demux (e.g. `MkvDemuxN`)
529    /// overrides it, returning `Some` for every port it exposes. The caps may be a
530    /// placeholder (geometry as a `Range`), refined at runtime via the port's
531    /// `CapsChanged`, exactly as a single-output demuxer's output caps are.
532    fn port_output_caps(&self, _port: usize) -> Option<Caps> {
533        None
534    }
535
536    /// The memory domains this fan-out accepts on its single input pad,
537    /// mirroring [`AsyncElement::input_domains`](crate::AsyncElement::input_domains).
538    /// Default [`DomainSet::ALL`] (no requirement). A demux that parses host
539    /// bytes narrows it to `System`, and the allocation cascade turns that into a
540    /// download demand on a GPU producer feeding it.
541    fn input_domains(&self) -> DomainSet {
542        DomainSet::ALL
543    }
544
545    /// Receive this instance's log name and a per-instance log category
546    /// override, mirroring
547    /// [`AsyncElement::set_instance_name`](crate::AsyncElement::set_instance_name)
548    /// / [`set_log_category`](crate::AsyncElement::set_log_category). Default:
549    /// ignore. A demux that logs about itself stores them in a
550    /// [`LogName`](crate::log::LogName).
551    fn set_instance_name(&mut self, _name: alloc::string::String) {}
552
553    /// See [`set_instance_name`](Self::set_instance_name).
554    fn set_log_category(&mut self, _category: alloc::string::String) {}
555
556    /// Runtime properties this demux exposes (M104), mirroring
557    /// [`AsyncElement::properties`](crate::AsyncElement::properties). Default:
558    /// none. A demux overrides this (with `set_property` / `get_property`) to be
559    /// settable by name from a `gst-launch` line, the same as a transform.
560    fn properties(&self) -> &'static [PropertySpec] {
561        &[]
562    }
563
564    /// Set a property by name (M104). Default: every name is unknown.
565    fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
566        Err(PropError::Unknown)
567    }
568
569    /// Read a property back by name (M104). Default: `None`.
570    fn get_property(&self, _name: &str) -> Option<PropValue> {
571        None
572    }
573}
574
575/// Multi-input element trait variant: an N-input, 1-output element (a
576/// muxer). The mirror of [`MultiOutputElement`]. Negotiation is **per
577/// input** — each input pad narrows and fixates its own caps — and the
578/// element exposes a single merged `output_caps`. The fan-in runner
579/// (`run_muxer_sink`) aggregates EOS itself, so `process` is only ever
580/// handed `DataFrame`/`CapsChanged`, tagged with the originating `input`.
581pub trait MultiInputElement: ElementBound {
582    type ProcessFuture<'a>: core::future::Future<Output = Result<(), G2gError>> + 'a
583    where
584        Self: 'a;
585
586    fn input_count(&self) -> usize;
587
588    /// Whether the runner should deliver this element's inputs in global
589    /// presentation-timestamp order. Default `false` (arrival-order round-robin,
590    /// the historical behavior). When `true`, the runner merges the per-input
591    /// streams by `DataFrame` PTS, releasing the globally-earliest only once every
592    /// still-open input has one queued, so `process(pad, DataFrame(..))` arrives in
593    /// non-decreasing PTS across all pads.
594    ///
595    /// An element wanting time-aligned input without hand-rolling an
596    /// [`InputAggregator`](crate::InputAggregator) (a muxer, a multi-camera grid, a
597    /// PTS-synchronized compositor) opts in by returning `true`. Per-input `Eos`
598    /// and `CapsChanged` are still delivered as they occur; the merge holds only
599    /// `DataFrame`s. Inputs are assumed monotonic in PTS (the merge invariant).
600    fn input_pts_ordered(&self) -> bool {
601        false
602    }
603
604    /// Deadline tick period in nanoseconds, or `None` (the default) for no ticks.
605    ///
606    /// When set, and the runner was given a clock to sleep on, the fan-in arm
607    /// delivers [`PipelinePacket::Tick`] as `process(0, Tick, ..)` every period
608    /// even while the inputs are silent, so an element whose output cadence is its
609    /// own (a compositor holding the last frame of a stalled pad, zero-order-hold)
610    /// can emit without a packet arriving. The tick may fire spuriously: it says
611    /// the period elapsed, not that output is due, so the element decides.
612    fn tick_interval_ns(&self) -> Option<u64> {
613        None
614    }
615
616    /// Phase 1 for one input pad: narrow that input's proposed caps.
617    fn intercept_caps(&self, input: usize, upstream_caps: &Caps) -> Result<Caps, G2gError>;
618
619    /// Phase 2 for one input pad: fixate and configure that input.
620    fn configure_pipeline(
621        &mut self,
622        input: usize,
623        absolute_caps: &Caps,
624    ) -> Result<ConfigureOutcome, G2gError>;
625
626    /// The merged-output caps, valid once every input has been configured.
627    fn output_caps(&self) -> Result<Caps, G2gError>;
628
629    /// A [`ReverseChannel`] for input pad `input`, if this session routes reverse
630    /// signals (WebRTC PLI / BWE) back to individual upstream sources. The runner
631    /// clones it before running and polls it after each push from that source,
632    /// surfacing any pending signal as a [`PushOutcome`]. Default `None`: no
633    /// per-input reverse channel (a plain muxer imposes none).
634    fn reverse_channel(&self, _input: usize) -> Option<ReverseChannel> {
635        None
636    }
637
638    /// Whether this element is a terminal fan-in (a session sink that consumes
639    /// its inputs and produces no merged output, e.g. a WebRTC publisher). A
640    /// terminal element may end a graph with nothing downstream
641    /// (`Graph::add_fanin_sink`); a merging muxer without a downstream stays a
642    /// parse error, since its output would be silently dropped. Default `false`.
643    fn is_terminal(&self) -> bool {
644        false
645    }
646
647    /// If `Some(pad)`, the merged output's caps are the negotiated caps of input
648    /// pad `pad` (an identity-passthrough mux: an overlay / watermark / alpha
649    /// mixer that decorates a primary stream with a sidecar one). The solver then
650    /// derives the output edge from that input edge instead of from
651    /// [`caps_constraint_for_output`](Self::caps_constraint_for_output), so the
652    /// element need not know the output caps up front. Default `None`: the output
653    /// is independent (a container interleave, a fixed-size compositor), declared
654    /// by `caps_constraint_for_output`.
655    fn output_follows_input(&self) -> Option<usize> {
656        None
657    }
658
659    /// Map a named input-pad request to this element's concrete input index
660    /// (M481), so a `gst-launch` line can reference request pads by name
661    /// (`... ! mux.audio_0`, `... ! o.text`) instead of relying on the order the
662    /// branches are written. The transpose of the demuxer's output-pad selection
663    /// (M476): `req.kind` is `Video` / `Audio` / `Text` / `Any` with an ordinal.
664    ///
665    /// The default maps a bare `mux.` (`PadKind::Any`) to `ordinal` (the
666    /// positional behavior, unchanged), and declines a typed request (`None`) so a
667    /// homogeneous muxer without a naming scheme reports "no such pad" rather than
668    /// silently mis-routing. An element with named pads overrides this: an overlay
669    /// maps `Video -> 0`, `Text -> 1`; a container mux maps `video_%u` / `audio_%u`.
670    fn input_pad_index(&self, req: &PadRequest, ordinal: usize) -> Option<usize> {
671        let _ = ordinal;
672        match req.kind {
673            // A generic request pad (`sink_%u`, or a bare `mux.` whose index the
674            // parser set to the positional ordinal) maps straight to its index.
675            PadKind::Any => Some(req.index),
676            // A typed pad (`video_%u` / `audio_%u` / `text_%u`) needs an element
677            // scheme; a muxer without one declines it (reported as "no such pad").
678            _ => None,
679        }
680    }
681
682    /// Whether this element takes another input at runtime (M975), asked before a
683    /// source added through
684    /// [`DynamicFaninHandle::add_input`](crate::runtime::DynamicFaninHandle::add_input)
685    /// is attached to `pad`, with the caps that source would arrive with. The
686    /// runner has already checked `pad` against the declared
687    /// [`input_count`](Self::input_count) and the pad's own
688    /// [`caps_constraint_as_input`](Self::caps_constraint_as_input); this is the
689    /// element's veto for what neither expresses: a session with no spare pad of
690    /// that media kind, a container that cannot carry a second video track.
691    /// Refusing fails that one add ([`G2gError::InputRefused`] reaches the
692    /// caller), and the run continues on the inputs it already has.
693    ///
694    /// Default `true`: pad capacity is the only limit, the fixed-arity behavior.
695    /// The pad is not live until [`configure_pipeline`](Self::configure_pipeline)
696    /// runs for it, so do not commit pad state here.
697    fn accepts_runtime_input(&self, _pad: usize, _caps: &Caps) -> bool {
698        true
699    }
700
701    /// Combine one packet from `input` into the merged output.
702    ///
703    /// M22: a per-input `Eos` is delivered here when that input ends, so a
704    /// stateful muxer (a batcher) can flush per-input state. Implementations
705    /// must NOT forward `Eos` downstream: the runner aggregates input ends
706    /// and emits the single merged `Eos` itself.
707    fn process<'a>(
708        &'a mut self,
709        input: usize,
710        packet: PipelinePacket,
711        out: &'a mut dyn OutputSink,
712    ) -> Self::ProcessFuture<'a>;
713
714    /// M18 step 1: declare this input pad's negotiation-time
715    /// constraint. Default wraps `intercept_caps(input, ...)` as a
716    /// `LegacySink` (per-pad legacy bridge). Migrated muxers override
717    /// to return native variants (typically `AcceptsAny` for
718    /// per-frame-tagged interleave muxers, or `Accepts(set)` for
719    /// per-input format-restricted muxers).
720    ///
721    /// The runner calls this per-input during startup negotiation
722    /// (replacing the inline `LegacySink` construction in
723    /// `run_muxer_sink`) and during per-input mid-stream re-solve
724    /// once Phase C MX-1 lands.
725    fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_>
726    where
727        Self: Sized,
728    {
729        CapsConstraint::LegacySink(alloc::boxed::Box::new(move |c: &Caps| {
730            <Self as MultiInputElement>::intercept_caps(self, input, c)
731        }))
732    }
733
734    /// M18 step 1: declare the merged output's negotiation-time
735    /// constraint, evaluated against the muxer's current configured
736    /// inputs. Default eagerly calls `output_caps()` and wraps as
737    /// `LegacySource`. Migrated muxers with static or input-derived
738    /// output may override with `Produces(set)` or `DerivedOutput(fn)`.
739    ///
740    /// The runner uses this in place of `output_caps()?.fixate()` so
741    /// the downstream sink sees a uniformly-shaped constraint and the
742    /// Phase B-style re-solve (workaround #3 §4) extends naturally to
743    /// the muxer-output boundary.
744    fn caps_constraint_for_output(&self) -> Result<CapsConstraint<'_>, G2gError>
745    where
746        Self: Sized,
747    {
748        Ok(CapsConstraint::LegacySource(self.output_caps()?))
749    }
750
751    /// The allocation this muxer wants on one input pad, given that pad's
752    /// negotiated caps. The DAG runner stores it on the input edge during the
753    /// reverse-topo allocation cascade, so the demand crosses the muxer boundary
754    /// and re-cascades up that branch independently (a CUDA-resident interleave
755    /// muxer asking each video pad for device buffers, say). Default `None`: a
756    /// plain container muxer imposes no per-pad allocation, so the branch keeps
757    /// its own. Mirrors [`AsyncElement::propose_allocation`](crate::AsyncElement::propose_allocation),
758    /// but per input pad rather than on the single input.
759    fn propose_allocation_for_input(
760        &self,
761        _input: usize,
762        _caps: &Caps,
763    ) -> Option<crate::query::AllocationParams> {
764        None
765    }
766
767    /// The allocation this muxer's merged output needs, given the output's
768    /// negotiated caps. Default `None`: a container muxer's byte output has no
769    /// memory-domain tie to its inputs, so it imposes nothing downstream. A muxer
770    /// whose output pool is derived from its inputs overrides it (a device-resident
771    /// interleave writing into a surface sized by its video pads).
772    ///
773    /// The DAG runner re-queries this whenever an allocation change re-cascades
774    /// into any input pad, so the answer must fold in the current per-pad state
775    /// *and* whatever the element last absorbed through
776    /// [`configure_allocation_for_output`](Self::configure_allocation_for_output).
777    /// The runner walks to a fixed point of that pair, so an override that never
778    /// stops changing fails the run with `AllocationConflict` rather than looping.
779    fn propose_allocation_for_output(
780        &self,
781        _caps: &Caps,
782    ) -> Option<crate::query::AllocationParams> {
783        None
784    }
785
786    /// Absorb the allocation now in force on the merged output: this muxer's own
787    /// re-derived proposal, or a downstream consumer's demand that re-cascaded
788    /// into the output boundary. Default: ignore.
789    ///
790    /// A muxer whose output pool constrains its inputs overrides it and folds the
791    /// params into its state, so the following
792    /// [`propose_allocation_for_input`](Self::propose_allocation_for_input) answers
793    /// carry the constraint and the runner re-cascades it up the pads whose demand
794    /// actually moved.
795    fn configure_allocation_for_output(&mut self, _params: &crate::query::AllocationParams) {}
796
797    /// The memory domains this fan-in accepts on **every** input pad, mirroring
798    /// [`AsyncElement::input_domains`](crate::AsyncElement::input_domains).
799    /// Default [`DomainSet::ALL`] (no requirement). A muxer that reads host
800    /// memory narrows it to `System`, and the allocation cascade turns that into
801    /// a download demand on each GPU producer feeding a pad. Per-pad domains are
802    /// not expressible: a fan-in whose pads differ declares the union it can take
803    /// on any pad and rejects the rest at
804    /// [`configure_pipeline`](Self::configure_pipeline).
805    fn input_domains(&self) -> DomainSet {
806        DomainSet::ALL
807    }
808
809    /// Receive this instance's log name and a per-instance log category
810    /// override, mirroring
811    /// [`AsyncElement::set_instance_name`](crate::AsyncElement::set_instance_name)
812    /// / [`set_log_category`](crate::AsyncElement::set_log_category). Default:
813    /// ignore. A muxer that logs about itself stores them in a
814    /// [`LogName`](crate::log::LogName).
815    fn set_instance_name(&mut self, _name: alloc::string::String) {}
816
817    /// See [`set_instance_name`](Self::set_instance_name).
818    fn set_log_category(&mut self, _category: alloc::string::String) {}
819
820    /// Runtime properties this muxer exposes (M104), mirroring
821    /// [`AsyncElement::properties`](crate::AsyncElement::properties). Default:
822    /// none. A muxer overrides this (with `set_property` / `get_property`) to be
823    /// settable by name from a `gst-launch` line, the same as a transform.
824    fn properties(&self) -> &'static [PropertySpec] {
825        &[]
826    }
827
828    /// Set a property by name (M104). Default: every name is unknown.
829    fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
830        Err(PropError::Unknown)
831    }
832
833    /// Read a property back by name (M104). Default: `None`.
834    fn get_property(&self, _name: &str) -> Option<PropValue> {
835        None
836    }
837
838    /// Static introspection metadata for this muxer (M178), the `gst-inspect`
839    /// "Factory Details" (long-name / classification / description / author),
840    /// mirroring [`AsyncElement::metadata`](crate::AsyncElement::metadata).
841    /// Default: empty. A muxer overrides it with a `const ElementMetadata`.
842    fn metadata(&self) -> ElementMetadata {
843        ElementMetadata::default()
844    }
845}
846
847/// 1→1 enable/disable element. Forwards `CapsChanged` unconditionally and
848/// `DataFrame` only while open; `Eos` is forwarded by the runner, never by
849/// the element (the transform contract). Drops dropped frames silently —
850/// observability of gate drops is a tracing concern for a later milestone.
851///
852/// # Example
853///
854/// ```no_run
855/// use g2g_core::fanout::Gate;
856///
857/// let gate = Gate::new(true);
858/// let handle = gate.handle();
859/// handle.set_open(false);
860/// ```
861#[derive(Debug)]
862pub struct Gate {
863    open: Arc<AtomicBool>,
864}
865
866impl Gate {
867    pub fn new(open: bool) -> Self {
868        Self {
869            open: Arc::new(AtomicBool::new(open)),
870        }
871    }
872
873    /// A cloneable handle that flips this gate from another task while the
874    /// runner drives it.
875    pub fn handle(&self) -> GateHandle {
876        GateHandle {
877            open: self.open.clone(),
878        }
879    }
880}
881
882/// Detached control handle for a [`Gate`].
883#[derive(Debug, Clone)]
884pub struct GateHandle {
885    open: Arc<AtomicBool>,
886}
887
888impl GateHandle {
889    pub fn set_open(&self, open: bool) {
890        self.open.store(open, Ordering::SeqCst);
891    }
892
893    pub fn is_open(&self) -> bool {
894        self.open.load(Ordering::SeqCst)
895    }
896}
897
898impl AsyncElement for Gate {
899    type ProcessFuture<'a>
900        = BoxFuture<'a, Result<(), G2gError>>
901    where
902        Self: 'a;
903
904    fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError> {
905        Ok(upstream_caps.clone())
906    }
907
908    fn configure_pipeline(&mut self, _absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
909        Ok(ConfigureOutcome::Accepted)
910    }
911
912    fn process<'a>(
913        &'a mut self,
914        packet: PipelinePacket,
915        out: &'a mut dyn OutputSink,
916    ) -> Self::ProcessFuture<'a> {
917        let open = self.open.load(Ordering::SeqCst);
918        Box::pin(async move {
919            match packet {
920                PipelinePacket::DataFrame(f) => {
921                    if open {
922                        out.push(PipelinePacket::DataFrame(f)).await?;
923                    }
924                }
925                PipelinePacket::CapsChanged(c) => {
926                    out.push(PipelinePacket::CapsChanged(c)).await?;
927                }
928                // Flush is control: forward regardless of open state.
929                PipelinePacket::Flush => {
930                    out.push(PipelinePacket::Flush).await?;
931                }
932                // Segment is control: forward regardless of open state.
933                PipelinePacket::Segment(s) => {
934                    out.push(PipelinePacket::Segment(s)).await?;
935                }
936                // Runner forwards Eos after process() returns. A fan-in arm's
937                // deadline tick never reaches a 1->1 element.
938                PipelinePacket::Eos | PipelinePacket::Tick => {}
939            }
940            Ok(())
941        })
942    }
943}
944
945/// 1→N router. Each `DataFrame` goes to the single port named by an atomic
946/// discriminator; `CapsChanged` is broadcast to every port so all branches
947/// stay configured. `Eos` is broadcast by the runner.
948///
949/// # Example
950///
951/// ```no_run
952/// use g2g_core::fanout::Router;
953///
954/// let router = Router::new(3);
955/// let handle = router.handle();
956/// handle.select(2);
957/// ```
958#[derive(Debug)]
959pub struct Router {
960    selected: Arc<AtomicUsize>,
961    ports: usize,
962}
963
964impl Router {
965    pub fn new(ports: usize) -> Self {
966        assert!(ports > 0, "Router needs at least one output port");
967        Self {
968            selected: Arc::new(AtomicUsize::new(0)),
969            ports,
970        }
971    }
972
973    /// Number of output ports. The fan-out runner allocates one branch link
974    /// per port.
975    pub fn port_count(&self) -> usize {
976        self.ports
977    }
978
979    /// A cloneable handle that re-targets this router from another task.
980    pub fn handle(&self) -> RouterHandle {
981        RouterHandle {
982            selected: self.selected.clone(),
983            ports: self.ports,
984        }
985    }
986}
987
988/// Detached control handle for a [`Router`].
989#[derive(Debug, Clone)]
990pub struct RouterHandle {
991    selected: Arc<AtomicUsize>,
992    ports: usize,
993}
994
995impl RouterHandle {
996    /// Select the output port subsequent `DataFrame`s route to. Panics if
997    /// `port >= port_count`.
998    pub fn select(&self, port: usize) {
999        assert!(port < self.ports, "select: port out of range");
1000        self.selected.store(port, Ordering::SeqCst);
1001    }
1002
1003    pub fn selected(&self) -> usize {
1004        self.selected.load(Ordering::SeqCst)
1005    }
1006}
1007
1008impl MultiOutputElement for Router {
1009    type ProcessFuture<'a>
1010        = BoxFuture<'a, Result<(), G2gError>>
1011    where
1012        Self: 'a;
1013
1014    fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError> {
1015        Ok(upstream_caps.clone())
1016    }
1017
1018    /// M18 step 1: pass-through wildcard. `Router` broadcasts the
1019    /// upstream caps verbatim to every active branch and has no
1020    /// per-branch format restriction. `AcceptsAny` is the native
1021    /// shape; skips the dynamic intercept callback on the solver
1022    /// path.
1023    fn caps_constraint_as_input(&self) -> CapsConstraint<'_> {
1024        CapsConstraint::AcceptsAny
1025    }
1026
1027    fn configure_pipeline(&mut self, _absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
1028        Ok(ConfigureOutcome::Accepted)
1029    }
1030
1031    fn process<'a>(
1032        &'a mut self,
1033        packet: PipelinePacket,
1034        out: &'a mut dyn MultiOutputSink,
1035    ) -> Self::ProcessFuture<'a> {
1036        // Clamp defensively so a stale handle write can never index past the
1037        // port list (the runner allocated exactly `ports` branches).
1038        let selected = self.selected.load(Ordering::SeqCst).min(self.ports - 1);
1039        let ports = self.ports;
1040        Box::pin(async move {
1041            match packet {
1042                PipelinePacket::DataFrame(f) => {
1043                    out.push_to(selected, PipelinePacket::DataFrame(f)).await?;
1044                }
1045                PipelinePacket::CapsChanged(c) => {
1046                    for port in 0..ports {
1047                        out.push_to(port, PipelinePacket::CapsChanged(c.clone()))
1048                            .await?;
1049                    }
1050                }
1051                // Flush is broadcast to every branch, like CapsChanged.
1052                PipelinePacket::Flush => {
1053                    for port in 0..ports {
1054                        out.push_to(port, PipelinePacket::Flush).await?;
1055                    }
1056                }
1057                // Segment is broadcast to every branch, like CapsChanged.
1058                PipelinePacket::Segment(s) => {
1059                    for port in 0..ports {
1060                        out.push_to(port, PipelinePacket::Segment(s)).await?;
1061                    }
1062                }
1063                // Runner broadcasts Eos to all ports after process() returns. A
1064                // fan-in arm's deadline tick never reaches a 1->N element.
1065                PipelinePacket::Eos | PipelinePacket::Tick => {}
1066            }
1067            Ok(())
1068        })
1069    }
1070}
1071
1072/// N→1 fan-in selector: the control-driven mirror of [`Router`]. An atomic
1073/// discriminator names the single active input; the fan-in runner forwards
1074/// that input's frames and drains/discards the rest. The merged stream ends
1075/// only once every input has reached EOS (see `run_fanin_sink`). `Merger`
1076/// holds just the selector; the forwarding lives in the runner.
1077///
1078/// # Example
1079///
1080/// ```no_run
1081/// use g2g_core::fanout::Merger;
1082///
1083/// let merger = Merger::new(2);
1084/// let handle = merger.handle();
1085/// handle.select(1);
1086/// ```
1087#[derive(Debug)]
1088pub struct Merger {
1089    selected: Arc<AtomicUsize>,
1090    inputs: usize,
1091}
1092
1093impl Merger {
1094    pub fn new(inputs: usize) -> Self {
1095        assert!(inputs > 0, "Merger needs at least one input");
1096        Self {
1097            selected: Arc::new(AtomicUsize::new(0)),
1098            inputs,
1099        }
1100    }
1101
1102    /// Number of input ports. The fan-in runner allocates one branch link
1103    /// per input.
1104    pub fn input_count(&self) -> usize {
1105        self.inputs
1106    }
1107
1108    /// A cloneable handle that re-selects the active input from another task.
1109    pub fn handle(&self) -> MergerHandle {
1110        MergerHandle {
1111            selected: self.selected.clone(),
1112            inputs: self.inputs,
1113        }
1114    }
1115}
1116
1117/// Detached control handle for a [`Merger`].
1118#[derive(Debug, Clone)]
1119pub struct MergerHandle {
1120    selected: Arc<AtomicUsize>,
1121    inputs: usize,
1122}
1123
1124impl MergerHandle {
1125    /// Select which input feeds the merged output. Panics if
1126    /// `input >= input_count`.
1127    pub fn select(&self, input: usize) {
1128        assert!(input < self.inputs, "select: input out of range");
1129        self.selected.store(input, Ordering::SeqCst);
1130    }
1131
1132    pub fn selected(&self) -> usize {
1133        self.selected.load(Ordering::SeqCst)
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use crate::caps::{Dim, Rate, RawVideoFormat};
1141    use crate::frame::{Frame, FrameTiming};
1142    use crate::memory::{MemoryDomain, SystemSlice};
1143    use core::future::Future;
1144    use core::pin::Pin;
1145
1146    fn caps() -> Caps {
1147        Caps::RawVideo {
1148            format: RawVideoFormat::Rgba8,
1149            width: Dim::Fixed(16),
1150            height: Dim::Fixed(16),
1151            framerate: Rate::Fixed(30 << 16),
1152            interlace: crate::Interlace::Any,
1153        }
1154    }
1155
1156    fn data(seq: u64) -> PipelinePacket {
1157        PipelinePacket::DataFrame(Frame {
1158            domain: MemoryDomain::System(SystemSlice::from_boxed(Box::new([0u8; 4]))),
1159            timing: FrameTiming::default(),
1160            sequence: seq,
1161            meta: Default::default(),
1162        })
1163    }
1164
1165    /// Records the kind of every packet pushed, per port, without channels.
1166    #[derive(Default)]
1167    struct RecordingMultiSink {
1168        ports: usize,
1169        data_seqs: Vec<Vec<u64>>,
1170        caps_changes: Vec<usize>,
1171    }
1172
1173    impl RecordingMultiSink {
1174        fn new(ports: usize) -> Self {
1175            Self {
1176                ports,
1177                data_seqs: alloc::vec![Vec::new(); ports],
1178                caps_changes: alloc::vec![0; ports],
1179            }
1180        }
1181    }
1182
1183    impl MultiOutputSink for RecordingMultiSink {
1184        fn poll_push_to(
1185            &mut self,
1186            _cx: &mut core::task::Context<'_>,
1187            port: usize,
1188            packet: &mut Option<PipelinePacket>,
1189        ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
1190            match packet.take().expect("poll_push_to without a packet") {
1191                PipelinePacket::DataFrame(f) => self.data_seqs[port].push(f.sequence),
1192                PipelinePacket::CapsChanged(_) => self.caps_changes[port] += 1,
1193                PipelinePacket::Eos
1194                | PipelinePacket::Flush
1195                | PipelinePacket::Segment(_)
1196                | PipelinePacket::Tick => {}
1197            }
1198            core::task::Poll::Ready(Ok(PushOutcome::Accepted))
1199        }
1200
1201        fn port_count(&self) -> usize {
1202            self.ports
1203        }
1204    }
1205
1206    /// Records every packet a single-output element forwards.
1207    #[derive(Default)]
1208    struct RecordingSink {
1209        data_seqs: Vec<u64>,
1210        caps_changes: usize,
1211    }
1212
1213    impl OutputSink for RecordingSink {
1214        fn poll_push(
1215            &mut self,
1216            _cx: &mut core::task::Context<'_>,
1217            packet: &mut Option<PipelinePacket>,
1218        ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
1219            match packet.take().expect("poll_push without a packet") {
1220                PipelinePacket::DataFrame(f) => self.data_seqs.push(f.sequence),
1221                PipelinePacket::CapsChanged(_) => self.caps_changes += 1,
1222                PipelinePacket::Eos
1223                | PipelinePacket::Flush
1224                | PipelinePacket::Segment(_)
1225                | PipelinePacket::Tick => {}
1226            }
1227            core::task::Poll::Ready(Ok(PushOutcome::Accepted))
1228        }
1229    }
1230
1231    /// Single-poll block_on; all futures here resolve immediately.
1232    fn block_on<F: Future>(mut fut: F) -> F::Output {
1233        use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
1234        static VT: RawWakerVTable = RawWakerVTable::new(
1235            |_| RawWaker::new(core::ptr::null(), &VT),
1236            |_| {},
1237            |_| {},
1238            |_| {},
1239        );
1240        // SAFETY: VT's hooks never dereference the data pointer.
1241        let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VT)) };
1242        let mut cx = Context::from_waker(&waker);
1243        // SAFETY: `fut` is pinned to the stack for the duration of this call.
1244        let mut pinned = unsafe { Pin::new_unchecked(&mut fut) };
1245        match pinned.as_mut().poll(&mut cx) {
1246            Poll::Ready(v) => v,
1247            Poll::Pending => panic!("fanout::tests::block_on saw Pending"),
1248        }
1249    }
1250
1251    #[test]
1252    fn router_input_constraint_is_wildcard() {
1253        // M18 step 1: Router broadcasts upstream caps verbatim, no
1254        // per-branch format restriction. AcceptsAny is the native
1255        // shape; skips the dynamic intercept callback on the solver
1256        // path.
1257        let r = Router::new(3);
1258        let c = r.caps_constraint_as_input();
1259        assert!(
1260            matches!(c, CapsConstraint::AcceptsAny),
1261            "Router input should be AcceptsAny, got {c:?}"
1262        );
1263    }
1264
1265    #[test]
1266    fn router_sends_each_frame_to_selected_port() {
1267        let mut router = Router::new(2);
1268        let handle = router.handle();
1269        let mut out = RecordingMultiSink::new(2);
1270
1271        block_on(router.process(data(0), &mut out)).unwrap(); // port 0
1272        handle.select(1);
1273        block_on(router.process(data(1), &mut out)).unwrap(); // port 1
1274        block_on(router.process(data(2), &mut out)).unwrap(); // port 1 (sticky)
1275        handle.select(0);
1276        block_on(router.process(data(3), &mut out)).unwrap(); // port 0
1277
1278        assert_eq!(out.data_seqs[0], alloc::vec![0, 3]);
1279        assert_eq!(out.data_seqs[1], alloc::vec![1, 2]);
1280    }
1281
1282    #[test]
1283    fn router_broadcasts_caps_changed_to_all_ports() {
1284        let mut router = Router::new(3);
1285        let mut out = RecordingMultiSink::new(3);
1286
1287        block_on(router.process(PipelinePacket::CapsChanged(caps()), &mut out)).unwrap();
1288
1289        assert_eq!(out.caps_changes, alloc::vec![1, 1, 1]);
1290    }
1291
1292    #[test]
1293    fn gate_open_forwards_data_closed_drops_it() {
1294        let gate = Gate::new(true);
1295        let handle = gate.handle();
1296        let mut gate = gate;
1297        let mut out = RecordingSink::default();
1298
1299        block_on(gate.process(data(0), &mut out)).unwrap(); // open -> pass
1300        handle.set_open(false);
1301        block_on(gate.process(data(1), &mut out)).unwrap(); // closed -> drop
1302        handle.set_open(true);
1303        block_on(gate.process(data(2), &mut out)).unwrap(); // open -> pass
1304
1305        assert_eq!(
1306            out.data_seqs,
1307            alloc::vec![0, 2],
1308            "frame 1 dropped while closed"
1309        );
1310    }
1311
1312    #[test]
1313    fn gate_forwards_caps_changed_regardless_of_open_state() {
1314        let mut gate = Gate::new(false);
1315        let mut out = RecordingSink::default();
1316
1317        block_on(gate.process(PipelinePacket::CapsChanged(caps()), &mut out)).unwrap();
1318
1319        assert_eq!(
1320            out.caps_changes, 1,
1321            "CapsChanged forwarded even while closed"
1322        );
1323    }
1324
1325    #[test]
1326    fn merger_handle_selects_active_input() {
1327        let merger = Merger::new(3);
1328        let handle = merger.handle();
1329        assert_eq!(handle.selected(), 0, "defaults to input 0");
1330        handle.select(2);
1331        assert_eq!(handle.selected(), 2);
1332        assert_eq!(merger.input_count(), 3);
1333    }
1334
1335    #[test]
1336    #[should_panic(expected = "input out of range")]
1337    fn merger_handle_rejects_out_of_range_input() {
1338        Merger::new(2).handle().select(2);
1339    }
1340}