Skip to main content

g2g_core/runtime/
launch.rs

1//! `gst-launch`-style text pipeline parser (M106, M117, M118): turn
2//! `"videotestsrc num-buffers=3 ! videoflip method=rotate-180 ! fakesink"` into a
3//! runnable [`Graph`], the front door that makes g2g usable without hand-writing
4//! Rust for every pipeline.
5//!
6//! Built on the M104 property system and the M105 by-name registry: each `!`
7//! separated node is `element-name key=value ...`; the parser constructs the
8//! element by name from the [`Registry`], looks up each property's
9//! [`PropKind`](crate::PropKind) to parse its textual value, and applies it.
10//! Roles follow connectivity: an element with no incoming link is a source, one
11//! with no outgoing link a sink, the rest transforms (so a linear chain is still
12//! source -> transforms -> sink).
13//!
14//! Branching (M118): `tee name=t` fans one output to many. A `tee` is the
15//! structural fan-out node (no element), its output width derived from how many
16//! branches reference it; a branch is a `t.` pad reference that starts a chain (a
17//! head ref, linking *from* the named element) or, right after a `!`, ends one (a
18//! tail ref, linking *into* it). So
19//! `videotestsrc ! tee name=t ! fakesink   t. ! fakesink` broadcasts each frame
20//! to two sinks. The caps shorthand (`! video/x-raw,format=nv12,... !`, M117) is
21//! a bare media-type node rewritten to a `capsfilter`.
22//!
23//! Fan-in (M122): an element with several inbound links is a muxer, built from
24//! the registry's [`MuxerFactory`](crate::runtime::MuxerFactory) with that input
25//! count (so its `input_count` matches the node's pads). Each feeding chain ends
26//! with a `m.` tail ref, so
27//! `src1 ! m.   src2 ! m.   funnel name=m ! fakesink` joins two streams. Feeding
28//! chains come first (a new chain can only begin after a `!` / ref / caps
29//! boundary, so a chain starting with a bare element name would be read as the
30//! previous element's property); the muxer chain is last. A muxer has one output
31//! pad, so it must feed a downstream consumer.
32//!
33//! Value grammar (M840): a value may carry spaces by quoting (`"..."` or
34//! `'...'`, either anywhere in the fragment: `location=/tmp/"my dir"/a.ts`) or by
35//! escaping (`\ `). A `\` escapes a quote, another `\`, a `!`, a `#`, or
36//! whitespace; before anything else it stays literal, so a Windows path
37//! (`C:\videos\a.ts`) survives unescaped. An enum property's value is checked
38//! against its [`PropertySpec::enum_values`](crate::PropertySpec) before the
39//! element sees it, and a [`Flags`](crate::PropKind::Flags) property takes a
40//! `+`-joined set (`protocols=udp+tcp`), so a bad name names the valid ones.
41//! A pad-name suffix on a reference (`t.src_0`) is accepted but ignored (pads are
42//! positional).
43//!
44//! Encoding profiles (M1089): `encodebin profile="<container>:<stream>[:<stream>]"`
45//! is a macro too, expanding into the encoder each stream names plus the muxer
46//! for the container (`encodebin2` and `transcodebin` are gst's other names for
47//! it, the latter being `decodebin ! encodebin`). The container picks the muxer
48//! and a stream picks its encoder; anything the profile does not name
49//! negotiates. A stream part may also pin what it does care about (M1097): a
50//! `width` / `height`, a `framerate`, a sample `rate` or a `channels` each
51//! splice the converter that applies it, and a `bitrate` is set on the encoder.
52//! Any other field is refused. An uncompressed stream
53//! (`audio/x-raw`) inserts no encoder, and a profile with no container at all is
54//! just the encoder. A converter goes in ahead of an encoder that does not take
55//! what the branch produces (`videoconvert`, or `audioconvert` +
56//! `audioresample`), so a profile works off whatever raw form the source has. With several streams the bin's `name=` moves to the muxer,
57//! so a second branch reaches it as `e.` the way any fan-in does, and that
58//! branch gets the encoder for its own kind of input.
59//!
60//! Two `key=value` pairs are launch keywords rather than properties: `name=` is
61//! the instance name (and the handle pad references resolve against), and
62//! `log-category=` (M847) replaces that instance's `G2G_DEBUG` category, leaving
63//! the auto `<type>N` naming alone.
64
65use alloc::boxed::Box;
66use alloc::string::{String, ToString};
67use alloc::vec::Vec;
68
69use crate::caps::{Caps, CapsSet};
70use crate::element::DynAsyncElement;
71use crate::graph::{Graph, GraphError, NodeId, PadId};
72use crate::link::LinkPolicy;
73use crate::memory::MemoryDomainKind;
74use crate::property::{PropError, PropValue, PropertySpec, ValueError};
75use crate::runtime::autoplug::{
76    is_raw_audio, is_raw_video, PadKind, PadRequest, Registry, UriError,
77};
78use crate::runtime::{DynSourceLoop, GraphNode, GraphNodeRef};
79
80/// Why [`parse_launch`] could not build a graph.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum ParseError {
83    /// The pipeline string was empty or all whitespace.
84    Empty,
85    /// A node between `!` separators had no element name.
86    EmptyStage,
87    /// Fewer than two elements: a runnable pipeline needs at least a source and a
88    /// sink.
89    TooFewStages,
90    /// A source-position element names no registered source.
91    UnknownSource(String),
92    /// A transform / sink-position element names no registered element.
93    UnknownElement(String),
94    /// A property token had no `=` (expected `key=value`).
95    MalformedProperty { element: String, token: String },
96    /// The element has no property of that name.
97    UnknownProperty { element: String, key: String },
98    /// The value did not parse for the property's kind, or was rejected.
99    BadValue {
100        element: String,
101        key: String,
102        value: String,
103    },
104    /// The value names no declared choice of an enum / flag property. `value` is
105    /// the offending nick (one entry of a `+`-joined set), or the whole value when
106    /// the set was malformed; `values` is the property's declared list.
107    BadEnumValue {
108        element: String,
109        key: String,
110        value: String,
111        values: &'static str,
112    },
113    /// A `name.` reference names no element declared with that `name=`.
114    UnknownReference(String),
115    /// Two elements share the same `name=` handle.
116    DuplicateName(String),
117    /// More than one link feeds an element's input, but it names no registered
118    /// muxer: fan-in needs a [`MuxerFactory`](crate::runtime::MuxerFactory).
119    NotAMuxer(String),
120    /// A muxer (an element with several inputs) has no outgoing link; its single
121    /// output pad must feed a downstream consumer.
122    MuxerWithoutOutput(String),
123    /// A named input-pad reference (`mux.foo_0`) names a request pad this muxer
124    /// does not define (M481): the element's `input_pad_index` scheme declined it.
125    UnknownInputPad(String),
126    /// Two input-pad references resolve to the same muxer input index (M481), e.g.
127    /// `mux.video_0` named twice, or a named pad colliding with a positional one.
128    DuplicateInputPad(String),
129    /// A `queue` / `queue2` sits anywhere but a 1-in/1-out position. It is not an
130    /// element in g2g (it collapses into the edge's backpressure policy), so it
131    /// cannot be a source, a sink, or a fan-out / fan-in node.
132    QueueRole(String),
133    /// A `decodebin` has no upstream element to take its input caps from (it was
134    /// the first element, or followed a bare `name.` reference). decodebin
135    /// auto-plugs from its predecessor's declared caps, so it needs one.
136    DecodebinNoUpstream,
137    /// `decodebin` found no chain of registered decoders / parsers from its input
138    /// caps to raw video or audio (the input caps are quoted). Either no decoder
139    /// feature is compiled in, or the input is a container that needs a demuxer
140    /// (auto-plugging through fan-out demuxers is not yet supported).
141    NoDecodeChain(String),
142    /// An `encodebin` had no `profile=`, or one that is not
143    /// `container-caps:stream-caps[:stream-caps...]` (the message says which).
144    EncodingProfile(String),
145    /// An `encodebin` profile named a container no registered muxer writes, or a
146    /// stream codec no compiled-in encoder produces (the message quotes it).
147    NoEncoder(String),
148    /// An `encodebin` chain's input is not raw video or audio, or its profile has
149    /// no stream of that kind, so nothing in the profile can encode it.
150    EncodebinInput(String),
151    /// A `uridecodebin` / `playbin` was not at the head of its chain. It provides
152    /// the source, so it must start the pipeline.
153    UriSourceNotAtHead(String),
154    /// A `uridecodebin` / `playbin` had no `uri=` property.
155    MissingUri(String),
156    /// The `uri=` could not be turned into a source (bad URI, or no handler
157    /// registered for its scheme). The message quotes the URI and reason.
158    Uri(String),
159    /// Linking two nodes into the graph failed.
160    Graph(GraphError),
161}
162
163impl From<GraphError> for ParseError {
164    fn from(e: GraphError) -> Self {
165        ParseError::Graph(e)
166    }
167}
168
169impl core::fmt::Display for ParseError {
170    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
171        match self {
172            ParseError::Empty => f.write_str("empty pipeline"),
173            ParseError::EmptyStage => f.write_str("empty node between '!' separators"),
174            ParseError::TooFewStages => f.write_str("pipeline needs at least a source and a sink"),
175            ParseError::UnknownSource(n) => write!(f, "unknown source element: {n}"),
176            ParseError::UnknownElement(n) => write!(f, "unknown element: {n}"),
177            ParseError::MalformedProperty { element, token } => {
178                write!(
179                    f,
180                    "{element}: malformed property '{token}' (expected key=value)"
181                )
182            }
183            ParseError::UnknownProperty { element, key } => {
184                write!(f, "{element}: no property named '{key}'")
185            }
186            ParseError::BadValue {
187                element,
188                key,
189                value,
190            } => {
191                write!(f, "{element}: invalid value '{value}' for property '{key}'")?;
192                // Inline caps become a `capsfilter` (key `caps`); a gst dev often
193                // reaches for range / list / feature syntax g2g's launch parser
194                // does not accept, and the bare "invalid value" hides why.
195                if element == "capsfilter" && key == "caps" && value.contains(['[', '{', '(']) {
196                    write!(
197                        f,
198                        " (a launch caps filter takes fixed fields, ranges [min,max], \
199                         and lists {{a,b}} with numeric / known values; caps features \
200                         like (memory:...) are not supported)"
201                    )?;
202                }
203                Ok(())
204            }
205            ParseError::BadEnumValue {
206                element,
207                key,
208                value,
209                values,
210            } => {
211                write!(f, "{element}: invalid value '{value}' for property '{key}'")?;
212                if !values.is_empty() {
213                    write!(f, " (valid: {values})")?;
214                }
215                if value.contains('+') {
216                    write!(f, " (a flag set joins nicks with '+', e.g. video+audio)")?;
217                }
218                Ok(())
219            }
220            ParseError::UnknownReference(n) => {
221                write!(f, "reference to undeclared element name: {n}")
222            }
223            ParseError::DuplicateName(n) => write!(f, "duplicate element name: {n}"),
224            ParseError::NotAMuxer(n) => {
225                write!(
226                    f,
227                    "{n}: more than one input links here, but it is not a registered muxer"
228                )
229            }
230            ParseError::MuxerWithoutOutput(n) => {
231                write!(
232                    f,
233                    "{n}: muxer has no outgoing link; its output must feed a consumer"
234                )
235            }
236            ParseError::UnknownInputPad(n) => {
237                write!(
238                    f,
239                    "{n}: no such input request pad (this element defines no pad by that name)"
240                )
241            }
242            ParseError::DuplicateInputPad(n) => {
243                write!(f, "{n}: two inputs resolve to the same request pad index")
244            }
245            ParseError::QueueRole(n) => {
246                write!(f, "{n}: a queue must sit between two elements (1-in/1-out); it maps to an edge policy, not a source/sink/branch")
247            }
248            ParseError::DecodebinNoUpstream => {
249                write!(f, "decodebin has no upstream element to decode; it must follow a source or element with declared caps")
250            }
251            ParseError::NoDecodeChain(caps) => {
252                write!(f, "decodebin: no decoder chain from {caps} to raw (no decoder feature compiled in, or a container that needs a demuxer)")
253            }
254            ParseError::EncodingProfile(msg) => write!(
255                f,
256                "encodebin profile: {msg} (expected profile=\"container-caps:stream-caps[:stream-caps]\", e.g. profile=\"video/x-matroska:video/x-vp8:audio/x-opus\")"
257            ),
258            ParseError::NoEncoder(msg) => write!(f, "encodebin: {msg}"),
259            ParseError::EncodebinInput(msg) => write!(f, "encodebin: {msg}"),
260            ParseError::UriSourceNotAtHead(n) => {
261                write!(f, "{n}: provides the source, so it must start the pipeline (be the first element)")
262            }
263            ParseError::MissingUri(n) => write!(f, "{n}: missing required 'uri=' property"),
264            ParseError::Uri(msg) => write!(f, "uri error: {msg}"),
265            ParseError::Graph(e) => write!(f, "graph link error: {e:?}"),
266        }
267    }
268}
269
270/// One parsed element: factory name plus its `key=value` properties (all owned so
271/// errors can name them), and the optional `name=` handle that pad references
272/// resolve against. `name` and `log-category` are special-cased here (launch
273/// keywords, not properties), never applied as properties.
274struct ElementSpec {
275    name: String,
276    props: Vec<(String, String)>,
277    instance: Option<String>,
278    log_category: Option<String>,
279}
280
281/// An item in a chain: an element to build, a `t.` reference to a named element
282/// declared elsewhere (the branching / link-by-name syntax), or a node already
283/// constructed by a macro expansion (`uridecodebin` / `playbin`, M196), spliced
284/// in directly rather than built by name.
285enum Item {
286    Element(ElementSpec),
287    /// A `t.` / `d.video_0` reference to a named element. `pad` is the suffix after
288    /// the dot (`""` for a bare `t.`, `"video_0"` for `d.video_0`), used by the
289    /// explicit-demux fan-out (M476) to select which stream a branch reads; ignored
290    /// for a tee (positional).
291    Ref {
292        name: String,
293        pad: String,
294    },
295    Prebuilt(PrebuiltNode),
296}
297
298/// A node a macro expansion built ahead of the structural pass: a source
299/// constructed from a `uri=` scheme handler, or a decoder the auto-plug search
300/// instantiated. Spliced into the graph as-is (it has no name to build by).
301enum PrebuiltNode {
302    Source(Box<dyn DynSourceLoop>),
303    Element(Box<dyn DynAsyncElement>),
304}
305
306/// A run of items linked left-to-right by `!`. Branches are separate chains
307/// joined through named references.
308type Chain = Vec<Item>;
309
310/// A caps description node (`video/x-raw,format=nv12,...`): a media type whose
311/// `/` precedes any `=` field. A property value's `/` (a path or a fraction)
312/// comes after its `=`, so it is not mistaken for caps.
313fn is_caps_token(tok: &str) -> bool {
314    match (tok.find('/'), tok.find('=')) {
315        (Some(slash), Some(eq)) => slash < eq,
316        (Some(_), None) => true,
317        _ => false,
318    }
319}
320
321/// A pad reference (`t.`, `t.src_0`, `d.video_0`): a name, a `.`, and no `=` / `/`.
322/// Returns the referenced element name and the pad suffix after the first dot
323/// (`""` for a bare `t.`). The suffix drives the explicit-demux fan-out (M476);
324/// a tee ignores it (positional).
325fn split_pad_ref(tok: &str) -> Option<(&str, &str)> {
326    if tok.contains('=') || tok.contains('/') || !tok.contains('.') {
327        return None;
328    }
329    let (name, pad) = tok.split_once('.').unwrap_or((tok, ""));
330    (!name.is_empty()).then_some((name, pad))
331}
332
333/// The referenced element name of a pad reference (the suffix dropped); the
334/// token-boundary test in [`consume_element`].
335fn as_ref_name(tok: &str) -> Option<&str> {
336    split_pad_ref(tok).map(|(name, _)| name)
337}
338
339/// Parse a demux output-pad suffix into a [`PadRequest`] (M476): `"video_0"` ->
340/// `{ Video, 0 }`, `"audio_1"` -> `{ Audio, 1 }`, `"text_0"` / `"subtitle_0"` ->
341/// `{ Text, 0 }`, `"caption"` -> `{ Text, 0 }`, `"src_2"` -> `{ Any, 2 }`. A bare `d.` (empty suffix) or an
342/// unrecognized prefix is `{ Any, ordinal }`, i.e. positional by reference order.
343fn parse_pad_request(pad: &str, ordinal: usize) -> PadRequest {
344    let (prefix, index) = match pad.rsplit_once('_') {
345        Some((p, n)) => (p, n.parse::<usize>().ok()),
346        None => (pad, None),
347    };
348    let kind = match prefix {
349        "video" => PadKind::Video,
350        "audio" => PadKind::Audio,
351        // `caption` is the name a closed-caption fan-in gives its second pad; it
352        // rides the text kind, which is the one every sidecar-cue pad uses.
353        "text" | "subtitle" | "caption" => PadKind::Text,
354        _ => PadKind::Any,
355    };
356    // `src_N` (output) / `sink_N` (input) and unrecognized prefixes select the Nth
357    // stream / pad; a bare `d.` has no index, so it takes the positional ordinal.
358    let index = match kind {
359        PadKind::Any if prefix == "src" || prefix == "sink" || prefix.is_empty() => {
360            index.unwrap_or(ordinal)
361        }
362        PadKind::Any => ordinal,
363        _ => index.unwrap_or(0),
364    };
365    PadRequest { kind, index }
366}
367
368/// Consume an element's `key=value` properties from the token stream, stopping at
369/// a `!`, a caps node, or a pad reference (the next node begins). A bare token
370/// with no `=` is a malformed property (the gst typo case), reported by name.
371fn consume_element<'a, I: Iterator<Item = &'a str>>(
372    name: &str,
373    tokens: &mut core::iter::Peekable<I>,
374    knows_element: &dyn Fn(&str) -> bool,
375) -> Result<ElementSpec, ParseError> {
376    let mut spec = ElementSpec {
377        name: name.to_string(),
378        props: Vec::new(),
379        instance: None,
380        log_category: None,
381    };
382    while let Some(&tok) = tokens.peek() {
383        if tok == "!" || is_caps_token(tok) || as_ref_name(tok).is_some() {
384            break;
385        }
386        // A bare token naming a registered element opens a new top-level chain
387        // rather than being this element's property, the way gst-launch parses
388        // `videotestsrc ! xvimagesink audiotestsrc ! pulsesink`. One that names
389        // nothing registered is the typo it looks like.
390        if !tok.contains('=') && knows_element(tok) {
391            break;
392        }
393        let (key, value) = tok
394            .split_once('=')
395            .ok_or_else(|| ParseError::MalformedProperty {
396                element: name.to_string(),
397                token: tok.to_string(),
398            })?;
399        tokens.next();
400        let value = unquote_value(value);
401        if key == "name" {
402            spec.instance = Some(value);
403        } else if key == "log-category" {
404            // M847: a launch keyword like `name=`, not a property: it renames this
405            // instance's `G2G_DEBUG` filter key rather than configuring it.
406            spec.log_category = Some(value);
407        } else {
408            spec.props.push((key.to_string(), value));
409        }
410    }
411    Ok(spec)
412}
413
414/// The characters a `\` may escape in a launch line: the quote characters, a `\`
415/// itself, the `!` separator, the `#` comment marker, and whitespace. A `\`
416/// before anything else is literal, so an unescaped Windows path
417/// (`location=C:\videos\a.ts`) survives.
418fn is_escapable(c: char) -> bool {
419    matches!(c, '"' | '\'' | '\\' | '!' | '#') || c.is_whitespace()
420}
421
422/// Resolve a token's quoting into the literal property value: drop the quote
423/// characters that open / close a quoted region and resolve `\x` escapes. A value
424/// may open and close a quoted region more than once
425/// (`location=/tmp/"my dir"/a.ts`), so this walks the whole token rather than
426/// stripping one surrounding pair. Both quote characters work; gst-launch quotes
427/// only with `"` (a `'` is literal there), so this accepts a superset.
428fn unquote_value(v: &str) -> String {
429    let mut out = String::with_capacity(v.len());
430    let mut quote: Option<char> = None;
431    let mut chars = v.chars();
432    while let Some(c) = chars.next() {
433        match c {
434            '\\' => match chars.next() {
435                Some(n) if is_escapable(n) => out.push(n),
436                Some(n) => {
437                    out.push('\\');
438                    out.push(n);
439                }
440                None => out.push('\\'),
441            },
442            '"' | '\'' if quote.is_none() => quote = Some(c),
443            _ if Some(c) == quote => quote = None,
444            _ => out.push(c),
445        }
446    }
447    out
448}
449
450/// Split a pipeline string into tokens, honoring quoted property values and
451/// `#` comments. Outside quotes, whitespace separates tokens and `!` is a
452/// standalone token; inside a `"..."` or `'...'` region both are literal, so a
453/// value may contain spaces (and even `!`), e.g. `element="x264enc bitrate=4000"`
454/// or `location='/my file.ts'`. A `\` escapes the next character when it is one
455/// of the launch-special ones ([`is_escapable`]), so `location=/my\ file.ts` and
456/// a `\"` inside a quoted region are literal. A `#` outside quotes starts a
457/// comment that runs to end of line (a pasted multi-line pipeline may carry
458/// them). Quotes and escapes are kept on the token; [`unquote_value`] resolves
459/// them once the `key=` split is known. An unterminated quote runs to end of
460/// input (best-effort; the property parse then reports any resulting malformed
461/// token).
462fn tokenize(s: &str) -> Vec<String> {
463    let mut tokens = Vec::new();
464    let mut cur = String::new();
465    // The open quote char (`"` or `'`), or `None` outside a quoted region.
466    let mut quote: Option<char> = None;
467    let mut in_comment = false;
468    let mut chars = s.chars().peekable();
469    while let Some(c) = chars.next() {
470        if in_comment {
471            // A comment runs to end of line; the newline (whitespace) ends it.
472            if c == '\n' {
473                in_comment = false;
474            }
475            continue;
476        }
477        // An escape pair stays whole (and inert) through tokenization: neither
478        // char can close a quote, split a token, or start a comment.
479        if c == '\\' {
480            if let Some(&next) = chars.peek() {
481                if is_escapable(next) {
482                    chars.next();
483                    cur.push('\\');
484                    cur.push(next);
485                    continue;
486                }
487            }
488            cur.push('\\');
489            continue;
490        }
491        match c {
492            '"' | '\'' if quote.is_none() => {
493                quote = Some(c);
494                cur.push(c);
495            }
496            _ if Some(c) == quote => {
497                quote = None;
498                cur.push(c);
499            }
500            // A `#` only starts a comment at a token boundary (nothing buffered);
501            // mid-token it is literal, so a URI fragment (`uri=...#closed-captions=cc1`,
502            // `#t=10`) is preserved.
503            '#' if quote.is_none() && cur.is_empty() => {
504                in_comment = true;
505            }
506            '!' if quote.is_none() => {
507                if !cur.is_empty() {
508                    tokens.push(core::mem::take(&mut cur));
509                }
510                tokens.push("!".to_string());
511            }
512            c if c.is_whitespace() && quote.is_none() => {
513                if !cur.is_empty() {
514                    tokens.push(core::mem::take(&mut cur));
515                }
516            }
517            _ => cur.push(c),
518        }
519    }
520    if !cur.is_empty() {
521        tokens.push(cur);
522    }
523    tokens
524}
525
526/// Split a `gst-launch` pipeline string into chains: runs of nodes linked by `!`,
527/// with branches expressed as separate chains joined through `name=` / `t.`.
528#[cfg(test)]
529fn parse_chains(pipeline: &str) -> Result<Vec<Chain>, ParseError> {
530    // The unit tests exercise the tokenizer alone, with nothing registered: every
531    // bare token is then a property, which is the pre-registry behaviour.
532    parse_chains_with(pipeline, &|_| false)
533}
534
535/// Split a `gst-launch` pipeline string into chains, asking `knows_element`
536/// whether a bare token opens a new chain (see [`consume_element`]).
537fn parse_chains_with(
538    pipeline: &str,
539    knows_element: &dyn Fn(&str) -> bool,
540) -> Result<Vec<Chain>, ParseError> {
541    let trimmed = pipeline.trim();
542    if trimmed.is_empty() {
543        return Err(ParseError::Empty);
544    }
545    // Tokenize quote-aware so a `!` (a standalone token) and whitespace inside a
546    // quoted value are literal, letting a property value carry spaces.
547    let toks = tokenize(trimmed);
548    let mut tokens = toks.iter().map(String::as_str).peekable();
549
550    #[derive(Clone, Copy)]
551    enum St {
552        Start,
553        AfterBang,
554        AfterNode,
555    }
556
557    let mut chains: Vec<Chain> = Vec::new();
558    let mut cur: Chain = Vec::new();
559    let mut st = St::Start;
560
561    loop {
562        match st {
563            St::Start | St::AfterBang => {
564                let after_bang = matches!(st, St::AfterBang);
565                let Some(tok) = tokens.next() else {
566                    if after_bang {
567                        return Err(ParseError::EmptyStage); // trailing `!`
568                    }
569                    break;
570                };
571                if tok == "!" {
572                    return Err(ParseError::EmptyStage); // leading or doubled `!`
573                }
574                if is_caps_token(tok) {
575                    cur.push(Item::Element(ElementSpec {
576                        name: "capsfilter".to_string(),
577                        props: alloc::vec![("caps".to_string(), tok.to_string())],
578                        instance: None,
579                        log_category: None,
580                    }));
581                    st = St::AfterNode;
582                } else if let Some((name, pad)) = split_pad_ref(tok) {
583                    cur.push(Item::Ref {
584                        name: name.to_string(),
585                        pad: pad.to_string(),
586                    });
587                    if after_bang {
588                        // Tail ref (`! t.`): links the upstream node into the
589                        // named element and ends the chain.
590                        chains.push(core::mem::take(&mut cur));
591                        st = St::Start;
592                    } else {
593                        // Head ref (`t. ! ...`): feeds the chain from it.
594                        st = St::AfterNode;
595                    }
596                } else {
597                    cur.push(Item::Element(consume_element(
598                        tok,
599                        &mut tokens,
600                        knows_element,
601                    )?));
602                    st = St::AfterNode;
603                }
604            }
605            St::AfterNode => match tokens.peek() {
606                Some(&"!") => {
607                    tokens.next();
608                    st = St::AfterBang;
609                }
610                Some(_) => {
611                    // A node not joined by `!`: the current chain ends here and a
612                    // new one starts at this token (reprocessed as a head).
613                    chains.push(core::mem::take(&mut cur));
614                    st = St::Start;
615                }
616                None => break,
617            },
618        }
619    }
620
621    if !cur.is_empty() {
622        chains.push(cur);
623    }
624    Ok(chains)
625}
626
627/// The property surface a launch node exposes, so one applier serves every node
628/// shape (source, transform / sink, muxer, demuxer, fan-out source) instead of a
629/// copy of the parse-and-set loop per shape.
630trait PropTarget {
631    fn specs(&self) -> &'static [PropertySpec];
632    fn set(&mut self, name: &str, value: PropValue) -> Result<(), PropError>;
633}
634
635macro_rules! impl_prop_target {
636    ($($t:ty),* $(,)?) => {
637        $(impl PropTarget for Box<$t> {
638            fn specs(&self) -> &'static [PropertySpec] {
639                (**self).properties()
640            }
641            fn set(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
642                (**self).set_property(name, value)
643            }
644        })*
645    };
646}
647
648impl_prop_target!(
649    dyn DynSourceLoop,
650    dyn DynAsyncElement,
651    dyn crate::runtime::DynMultiInputElement,
652    dyn crate::runtime::DynMultiOutputElement,
653    dyn crate::fanout::DynMultiOutputSource,
654);
655
656/// Apply parsed `key=value` props to a node: look each name up in the element's
657/// declared [`PropertySpec`]s, parse the text for that spec (which validates an
658/// enum nick / flag set against its declared values), then set it.
659fn apply_props<T: PropTarget>(
660    target: &mut T,
661    name: &str,
662    props: &[(String, String)],
663) -> Result<(), ParseError> {
664    for (key, raw) in props {
665        let bad_value = || ParseError::BadValue {
666            element: name.into(),
667            key: key.clone(),
668            value: raw.clone(),
669        };
670        let spec = target.specs().iter().find(|s| s.name == key).copied();
671        let value = match spec {
672            Some(spec) => spec.parse_value(raw).map_err(|e| match e {
673                ValueError::Kind(_) => bad_value(),
674                ValueError::Nick(nick) => ParseError::BadEnumValue {
675                    element: name.into(),
676                    key: key.clone(),
677                    value: nick,
678                    values: spec.enum_values.unwrap_or(""),
679                },
680            })?,
681            // The element takes names it cannot declare, so there is no kind to
682            // parse for: the text goes on as written for whatever does know it.
683            None if crate::property::takes_undeclared_properties(target.specs()) => {
684                PropValue::Str(raw.clone())
685            }
686            None => {
687                return Err(ParseError::UnknownProperty {
688                    element: name.into(),
689                    key: key.clone(),
690                })
691            }
692        };
693        target.set(key, value).map_err(|_| bad_value())?;
694    }
695    Ok(())
696}
697
698/// Build the runnable [`Graph`] from parsed chains: flatten elements, resolve
699/// `t.` references into directed links, derive each element's role (and any tee's
700/// fan-out width) from its link degree, then construct and wire the nodes.
701/// `decodebin`: not an element, but a macro that expands, at parse time, into the
702/// chain of decoders / parsers the auto-plug search finds from its upstream caps
703/// down to raw video or audio (M193).
704fn is_decodebin(name: &str) -> bool {
705    matches!(name, "decodebin")
706}
707
708/// Depth bound for the decodebin auto-plug search: a parse + decode (+ a spare
709/// hop) is 2-3, so this leaves headroom without letting an unsatisfiable target
710/// wander.
711const DECODEBIN_MAX_DEPTH: usize = 6;
712
713/// The decode chain to raw for `input`, decoding into `preferred` (the memory
714/// the element right after the macro takes).
715///
716/// The chain is returned built, not named: the caps the search chose a decoder
717/// to produce reach the element only through the factory that takes them, and a
718/// parameterless launch factory would build the decoder's default format again.
719/// That is how `decodebin ! wgpusink` reached a strict-NV12 sink with a decoder
720/// left on I420; the solver re-fixates among the decoder's advertised formats
721/// once the chosen one arrives.
722fn autoplug_for_consumer(
723    registry: &Registry,
724    input: &Caps,
725    preferred: MemoryDomainKind,
726    avoided: &[&str],
727) -> Option<Vec<Box<dyn DynAsyncElement>>> {
728    let raw = |c: &Caps| is_raw_video(c) || is_raw_audio(c);
729    let mut chain =
730        registry.autoplug_avoiding(input, &raw, DECODEBIN_MAX_DEPTH, preferred, avoided)?;
731    // M421/M676: prepend the re-framing parser ahead of a real decode of an
732    // elementary stream, like the boxed `decodebin` splice (the caps-identity
733    // parser is invisible to the shortest-chain search, so it never appears in
734    // the chain).
735    if !chain.is_empty() {
736        if let Some(parser) = registry
737            .parser_name(input)
738            .and_then(|p| registry.make_element(p))
739        {
740            chain.insert(0, parser);
741        }
742    }
743    Some(chain)
744}
745
746/// Expand every `decodebin` node into the decoder chain the registry auto-plugs
747/// from its predecessor's declared caps down to raw (video or audio). An empty
748/// chain (the input is already raw) drops the node entirely, so its predecessor
749/// links straight to its consumer. The predecessor is the element immediately
750/// before the `decodebin` in the same chain; a `decodebin` with no upstream
751/// element (chain head, or after a bare `name.` reference) is a loud error,
752/// since it has nothing to take its input caps from.
753fn expand_decodebin(
754    registry: &Registry,
755    chains: Vec<Chain>,
756    avoided: &[&str],
757) -> Result<Vec<Chain>, ParseError> {
758    // Names referenced as `name.` somewhere: a `decodebin name=d` with such refs is
759    // a FAN-OUT node (M482), not the inline linear case, so it is left unexpanded
760    // here and handled by the decodebin-select path in `build_graph` (which probes
761    // the file, demuxes, and decodes each requested port). Only the unreferenced
762    // inline `... ! decodebin ! ...` expands to a linear decode chain below.
763    let mut referenced: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
764    for chain in &chains {
765        for item in chain {
766            if let Item::Ref { name, .. } = item {
767                referenced.insert(name.clone());
768            }
769        }
770    }
771    let is_fanout_decodebin = |spec: &ElementSpec| {
772        is_decodebin(&spec.name)
773            && spec
774                .instance
775                .as_deref()
776                .map(|n| referenced.contains(n))
777                .unwrap_or(false)
778    };
779
780    let mut out = Vec::with_capacity(chains.len());
781    for chain in chains {
782        let mut new_chain: Chain = Vec::with_capacity(chain.len());
783        // The element (name + props) whose output caps feed the next decodebin:
784        // the most recent real element. A `Ref` clears it (its caps live in
785        // another chain). Props matter because they can re-type the output (a
786        // `filesrc`'s `bytestream-format` selects the container).
787        let mut upstream: Option<(String, Vec<(String, String)>)> = None;
788        let mut items = chain.into_iter().peekable();
789        while let Some(item) = items.next() {
790            match item {
791                // A fan-out `decodebin name=d` is left for `build_graph`'s
792                // decodebin-select path; it is not linearly expandable.
793                Item::Element(spec) if is_fanout_decodebin(&spec) => {
794                    upstream = Some((spec.name.clone(), spec.props.clone()));
795                    new_chain.push(Item::Element(spec));
796                }
797                Item::Element(spec) if is_decodebin(&spec.name) => {
798                    let (pred, props) = upstream.as_ref().ok_or(ParseError::DecodebinNoUpstream)?;
799                    let caps = resolve_upstream_caps(registry, pred, props)?;
800                    // M746: a single-stream demux fixes its output pad before parsing
801                    // any byte, so it defaults to a video port; on an audio-only
802                    // container the default auto-plug would pick a video decoder and
803                    // fail "no caps overlap". If a primary-stream hook sniffs the file
804                    // and names the real (audio) stream, plug that demux with its
805                    // stream selection and auto-plug the decoder from the elementary
806                    // caps instead. A hook declines a container with a video track (the
807                    // default video path is right) or one it does not parse.
808                    let location = props
809                        .iter()
810                        .find(|(k, _)| k == "location")
811                        .map(|(_, v)| v.as_str());
812                    let chain_input =
813                        match location.and_then(|loc| registry.primary_stream(loc, &caps)) {
814                            Some(primary) => {
815                                new_chain.push(Item::Element(ElementSpec {
816                                    name: primary.demux.to_string(),
817                                    props: primary.props.clone(),
818                                    instance: None,
819                                    log_category: None,
820                                }));
821                                primary.caps
822                            }
823                            None => caps,
824                        };
825                    // M1018: the element right after the `decodebin` decides what
826                    // memory the decoder should decode into, so a GPU-resident
827                    // consumer gets a decoder that decodes into its domain rather
828                    // than one whose frames have to be downloaded. Only the
829                    // immediate consumer counts, the rule the graph-side
830                    // derivation follows.
831                    let preferred = match items.peek() {
832                        Some(Item::Element(consumer)) => {
833                            registry.declared_memory_preference(&consumer.name)
834                        }
835                        _ => MemoryDomainKind::System,
836                    };
837                    let decoders =
838                        autoplug_for_consumer(registry, &chain_input, preferred, avoided)
839                            .ok_or_else(|| {
840                                ParseError::NoDecodeChain(alloc::format!("{chain_input:?}"))
841                            })?;
842                    // Built here rather than named, so each element is
843                    // constructed for the caps the search chose it to produce (a
844                    // multi-format decoder emits the format the consumer takes,
845                    // not the first one it lists). A pre-built node carries no
846                    // name, so `upstream` clears as it does after a
847                    // `uridecodebin`.
848                    for element in decoders {
849                        new_chain.push(Item::Prebuilt(PrebuiltNode::Element(element)));
850                    }
851                    upstream = None;
852                }
853                Item::Element(spec) => {
854                    upstream = Some((spec.name.clone(), spec.props.clone()));
855                    new_chain.push(Item::Element(spec));
856                }
857                Item::Ref { name, pad } => {
858                    upstream = None;
859                    new_chain.push(Item::Ref { name, pad });
860                }
861                // A pre-built node (from `uridecodebin` / `playbin`) carries no
862                // name to take declared caps from, so a `decodebin` cannot follow
863                // it. Clear the upstream; if a decodebin does follow, it reports
864                // the missing-upstream error.
865                prebuilt @ Item::Prebuilt(_) => {
866                    upstream = None;
867                    new_chain.push(prebuilt);
868                }
869            }
870        }
871        out.push(new_chain);
872    }
873    Ok(out)
874}
875
876/// `encodebin`: not an element either, but a macro that expands, at parse time,
877/// into one encoder per profile stream plus the muxer that writes the profile's
878/// container (M1089). `encodebin2` is gst's name for the same thing.
879fn is_encodebin(name: &str) -> bool {
880    matches!(name, "encodebin" | "encodebin2")
881}
882
883/// The separator between an encoding profile's container and its streams, the
884/// same string form gst's `GstEncodingProfile` serializes to.
885const PROFILE_SEPARATOR: char = ':';
886
887/// A parsed `profile=`: the container to mux into (`None` for a container-less
888/// profile, which is one coded stream and no muxer), and each stream it carries.
889struct EncodingProfile {
890    container: Option<Caps>,
891    streams: Vec<ProfileStream>,
892}
893
894/// One stream of a profile: the coded form it names, plus whatever its part
895/// pins (M1097). A pinned raw field is honoured by the converter spliced ahead
896/// of the encoder; a pinned bitrate is a property on the encoder itself.
897struct ProfileStream {
898    caps: Caps,
899    width: Option<u32>,
900    height: Option<u32>,
901    /// Frames per second as the `numerator/denominator` `videorate` takes.
902    framerate: Option<(u32, u32)>,
903    sample_rate: Option<u32>,
904    channels: Option<u8>,
905    /// Bits per second, gst's `bitrate` unit.
906    bitrate: Option<u64>,
907}
908
909/// The encoder property a profile's pinned bitrate is applied through.
910const BITRATE_PROPERTY: &str = "bitrate";
911
912/// Parse `container-caps:stream-caps[:stream-caps...]`.
913///
914/// The media type of each part chooses the element: the container names the
915/// muxer, a stream names the codec its encoder must produce. A stream part may
916/// also pin a width / height / framerate (or a sample `rate` / `channels`),
917/// which splices the converter that reaches it, and a `bitrate`, which is set on
918/// the encoder. Any other field is refused rather than dropped.
919fn parse_encoding_profile(text: &str) -> Result<EncodingProfile, ParseError> {
920    let bad = |msg: &str| ParseError::EncodingProfile(msg.to_string());
921    let mut parts = text.split(PROFILE_SEPARATOR).map(str::trim).peekable();
922    let first = *parts
923        .peek()
924        .filter(|p| !p.is_empty())
925        .ok_or_else(|| bad("empty"))?;
926    let head = single_caps(first).ok_or_else(|| {
927        ParseError::EncodingProfile(alloc::format!("unknown media type `{first}`"))
928    })?;
929    // A leading container is the muxing form; a leading stream is gst's
930    // container-less profile, which encodes and stops.
931    let container = match head {
932        Caps::ByteStream { .. } => {
933            parts.next();
934            Some(head)
935        }
936        _ => None,
937    };
938    let mut streams = Vec::new();
939    for part in parts {
940        if part.is_empty() {
941            return Err(bad("empty stream"));
942        }
943        streams.push(parse_profile_stream(part)?);
944    }
945    if streams.is_empty() {
946        return Err(bad("no stream"));
947    }
948    Ok(EncodingProfile { container, streams })
949}
950
951/// Parse one stream part: its media type plus the fields it pins.
952///
953/// The caps parser ignores a field it has no home for, so the pinned settings
954/// are read from the text here instead: an audio caps carries a channel count
955/// and a sample rate whether or not the part named them, and `bitrate` is not a
956/// caps field at all.
957fn parse_profile_stream(part: &str) -> Result<ProfileStream, ParseError> {
958    let caps = single_caps(part)
959        .ok_or_else(|| ParseError::EncodingProfile(alloc::format!("unknown stream `{part}`")))?;
960    let video = matches!(caps, Caps::CompressedVideo { .. } | Caps::RawVideo { .. });
961    let audio = matches!(caps, Caps::Audio { .. });
962    let mut stream = ProfileStream {
963        caps,
964        width: None,
965        height: None,
966        framerate: None,
967        sample_rate: None,
968        channels: None,
969        bitrate: None,
970    };
971    for field in crate::caps_parse::split_top_commas(part)
972        .into_iter()
973        .skip(1)
974    {
975        let (key, value) = field.split_once('=').ok_or_else(|| {
976            ParseError::EncodingProfile(alloc::format!(
977                "`{part}` has a field `{}` that is not key=value",
978                field.trim()
979            ))
980        })?;
981        let (key, value) = (key.trim(), value.trim());
982        match key {
983            "width" if video => {
984                stream.width = Some(profile_number(part, key, value, MAX_PROFILE_VALUE)? as u32)
985            }
986            "height" if video => {
987                stream.height = Some(profile_number(part, key, value, MAX_PROFILE_VALUE)? as u32)
988            }
989            "framerate" if video => stream.framerate = Some(profile_framerate(part, value)?),
990            "rate" if audio => {
991                stream.sample_rate = Some(profile_number(part, key, value, MAX_PROFILE_VALUE)? as u32)
992            }
993            "channels" if audio => {
994                stream.channels = Some(profile_number(part, key, value, MAX_CHANNELS)? as u8)
995            }
996            BITRATE_PROPERTY => {
997                stream.bitrate = Some(profile_number(part, key, value, MAX_PROFILE_VALUE)?)
998            }
999            _ => {
1000                return Err(ParseError::EncodingProfile(alloc::format!(
1001                    "`{part}` pins `{key}`, which a profile cannot apply: set it on an element ahead of the encodebin instead"
1002                )))
1003            }
1004        }
1005    }
1006    // The scaler takes a whole output geometry, so half of one has nothing to
1007    // scale to.
1008    if stream.width.is_some() != stream.height.is_some() {
1009        return Err(ParseError::EncodingProfile(alloc::format!(
1010            "`{part}` pins one of width / height: a scale needs both, so pin both or neither"
1011        )));
1012    }
1013    Ok(stream)
1014}
1015
1016/// Upper bounds on a pinned profile field, so a bogus value fails the parse
1017/// rather than reaching an element as a truncated cast. Every field but the
1018/// channel count is a `u32` where it lands.
1019const MAX_PROFILE_VALUE: u64 = u32::MAX as u64;
1020const MAX_CHANNELS: u64 = u8::MAX as u64;
1021
1022/// A pinned profile field's number. Zero is rejected everywhere it appears
1023/// (a zero geometry, rate, channel count or bitrate pins nothing).
1024fn profile_number(part: &str, key: &str, value: &str, max: u64) -> Result<u64, ParseError> {
1025    value
1026        .parse::<u64>()
1027        .ok()
1028        .filter(|n| (1..=max).contains(n))
1029        .ok_or_else(|| {
1030            ParseError::EncodingProfile(alloc::format!(
1031                "`{part}` has a bad `{key}={value}`: expected a whole number 1..={max}"
1032            ))
1033        })
1034}
1035
1036/// A pinned `framerate=N/D` (or a bare `framerate=N`, gst's shorthand), in the
1037/// fraction form `videorate`'s property takes.
1038fn profile_framerate(part: &str, value: &str) -> Result<(u32, u32), ParseError> {
1039    let (numerator, denominator) = value.split_once('/').unwrap_or((value, "1"));
1040    Ok((
1041        profile_number(part, "framerate", numerator.trim(), MAX_PROFILE_VALUE)? as u32,
1042        profile_number(part, "framerate", denominator.trim(), MAX_PROFILE_VALUE)? as u32,
1043    ))
1044}
1045
1046/// One caps from a media-type description. A format-less raw description
1047/// (`video/x-raw`, `audio/x-raw`) expands to every format it could be, and an
1048/// uncompressed profile stream means "store what arrives" rather than any one of
1049/// them, so its first alternative stands for the whole set: the expansion reads
1050/// only which kind of stream it is. `None` when the text names no media type
1051/// this build knows.
1052fn single_caps(desc: &str) -> Option<Caps> {
1053    let set = CapsSet::from_gst_string(desc)?;
1054    match set.alternatives() {
1055        [only] => Some(only.clone()),
1056        [first, ..] if is_raw_video(first) || is_raw_audio(first) => Some(first.clone()),
1057        _ => None,
1058    }
1059}
1060
1061/// The profile stream that encodes `input`: a raw video input takes the first
1062/// coded video stream, raw audio the first coded audio one. `None` when the
1063/// profile carries nothing of that kind.
1064fn stream_for_input<'a>(profile: &'a EncodingProfile, input: &Caps) -> Option<&'a ProfileStream> {
1065    let want_video = is_raw_video(input);
1066    let want_audio = is_raw_audio(input);
1067    profile.streams.iter().find(|stream| match stream.caps {
1068        Caps::CompressedVideo { .. } => want_video,
1069        Caps::Audio { .. } => want_audio,
1070        _ => false,
1071    })
1072}
1073
1074/// The converters an encoder needs ahead of it, when the raw stream reaching it
1075/// is not in a form it takes: a pixel-format convert for video, a sample-format
1076/// convert plus a resampler for audio (either the format or the rate can be the
1077/// mismatch). Asked of the encoder itself rather than guessed from a template,
1078/// so an encoder that already accepts what arrives gets nothing spliced.
1079///
1080/// With no upstream caps to check (a decode chain the parser expanded has no
1081/// name to ask), the converters go in anyway: they pass a matching format
1082/// through, where a missing one fails the negotiation.
1083fn conversion_specs(
1084    registry: &Registry,
1085    encoder: &ElementSpec,
1086    input: Option<&Caps>,
1087    stream: &Caps,
1088) -> Vec<ElementSpec> {
1089    let plain = |name: &str| ElementSpec {
1090        name: name.to_string(),
1091        props: Vec::new(),
1092        instance: None,
1093        log_category: None,
1094    };
1095    if let Some(input) = input {
1096        let accepted = registry
1097            .make_element(&encoder.name)
1098            .is_some_and(|element| element.intercept_caps(input).is_ok());
1099        if accepted {
1100            return Vec::new();
1101        }
1102    }
1103    // Which converters belong to this stream is the stream's own kind: the
1104    // encoder for a video stream takes raw video, whatever reaches it.
1105    match stream {
1106        Caps::CompressedVideo { .. } => Vec::from([plain("videoconvert")]),
1107        Caps::Audio { .. } => Vec::from([plain("audioconvert"), plain("audioresample")]),
1108        _ => Vec::new(),
1109    }
1110}
1111
1112/// The encoder element for one profile stream, as a spec the builder constructs
1113/// like any named element. The properties come from the registry's choice (a
1114/// multi-codec encoder is pinned to this codec with `codec=`), plus the
1115/// profile's own `bitrate` where it pinned one.
1116fn encoder_spec(
1117    registry: &Registry,
1118    stream: &ProfileStream,
1119) -> Result<Option<ElementSpec>, ParseError> {
1120    // An uncompressed stream profile (`audio/x-raw` in a WAV, `video/x-raw` in a
1121    // y4m) stores what arrives, so there is nothing to encode.
1122    if is_raw_video(&stream.caps) || is_raw_audio(&stream.caps) {
1123        if let Some(bitrate) = stream.bitrate {
1124            return Err(ParseError::EncodingProfile(alloc::format!(
1125                "pins `bitrate={bitrate}` on an uncompressed stream, which has no encoder to set it on"
1126            )));
1127        }
1128        return Ok(None);
1129    }
1130    let choice = registry.encoder_choice(&stream.caps).ok_or_else(|| {
1131        ParseError::NoEncoder(alloc::format!(
1132            "no encoder for {} is compiled in (build the feature for it, or name an encoder explicitly)",
1133            stream.caps.to_gst_string()
1134        ))
1135    })?;
1136    let mut props: Vec<(String, String)> = choice
1137        .props
1138        .iter()
1139        .map(|(k, v)| (k.to_string(), v.to_string()))
1140        .collect();
1141    if let Some(bitrate) = stream.bitrate {
1142        if !declares_bitrate(registry, choice.element) {
1143            return Err(ParseError::EncodingProfile(alloc::format!(
1144                "pins `bitrate={bitrate}`, which `{}` has no property for: drop it, or name the encoder yourself with the knob it does have",
1145                choice.element
1146            )));
1147        }
1148        props.push((BITRATE_PROPERTY.to_string(), alloc::format!("{bitrate}")));
1149    }
1150    Ok(Some(ElementSpec {
1151        name: choice.element.to_string(),
1152        props,
1153        instance: None,
1154        log_category: None,
1155    }))
1156}
1157
1158/// Whether an encoder takes a target bitrate at all, asked of the element rather
1159/// than assumed, so a profile that pins one on a fixed-rate encoder fails the
1160/// parse instead of encoding at some other rate.
1161fn declares_bitrate(registry: &Registry, element: &str) -> bool {
1162    registry.make_element(element).is_some_and(|element| {
1163        element
1164            .properties()
1165            .iter()
1166            .any(|spec| spec.name == BITRATE_PROPERTY)
1167    })
1168}
1169
1170/// The elements one branch of a profile expands to: the converters its encoder
1171/// needs, the converters its pinned settings need, then the encoder itself. A
1172/// pinned setting on an uncompressed stream still gets its converter, there is
1173/// just nothing after it but the muxer.
1174fn stream_specs(
1175    registry: &Registry,
1176    stream: &ProfileStream,
1177    upstream: Option<&(String, Vec<(String, String)>)>,
1178) -> Result<Vec<ElementSpec>, ParseError> {
1179    let encoder = encoder_spec(registry, stream)?;
1180    let mut specs = match &encoder {
1181        Some(encoder) => {
1182            let input = upstream
1183                .and_then(|(name, props)| resolve_upstream_caps(registry, name, props).ok());
1184            conversion_specs(registry, encoder, input.as_ref(), &stream.caps)
1185        }
1186        None => Vec::new(),
1187    };
1188    splice_pinned_settings(&mut specs, stream);
1189    specs.extend(encoder);
1190    Ok(specs)
1191}
1192
1193/// Apply a stream's pinned settings to the converters ahead of its encoder: a
1194/// geometry sets `videoscale`, a framerate `videorate`, a channel count
1195/// `audioconvert` and a sample rate `audioresample`, adding that converter when
1196/// the encoder did not already need one.
1197fn splice_pinned_settings(specs: &mut Vec<ElementSpec>, stream: &ProfileStream) {
1198    if let (Some(width), Some(height)) = (stream.width, stream.height) {
1199        pin_on_converter(specs, "videoscale", "width", alloc::format!("{width}"));
1200        pin_on_converter(specs, "videoscale", "height", alloc::format!("{height}"));
1201    }
1202    if let Some((numerator, denominator)) = stream.framerate {
1203        pin_on_converter(
1204            specs,
1205            "videorate",
1206            "framerate",
1207            alloc::format!("{numerator}/{denominator}"),
1208        );
1209    }
1210    if let Some(channels) = stream.channels {
1211        pin_on_converter(
1212            specs,
1213            "audioconvert",
1214            "channels",
1215            alloc::format!("{channels}"),
1216        );
1217    }
1218    if let Some(sample_rate) = stream.sample_rate {
1219        pin_on_converter(
1220            specs,
1221            "audioresample",
1222            "samplerate",
1223            alloc::format!("{sample_rate}"),
1224        );
1225    }
1226}
1227
1228/// Set a property on the named converter, appending the converter when the list
1229/// does not already carry it.
1230fn pin_on_converter(specs: &mut Vec<ElementSpec>, element: &str, key: &str, value: String) {
1231    match specs.iter_mut().find(|spec| spec.name == element) {
1232        Some(spec) => spec.props.push((key.to_string(), value)),
1233        None => specs.push(ElementSpec {
1234            name: element.to_string(),
1235            props: Vec::from([(key.to_string(), value)]),
1236            instance: None,
1237            log_category: None,
1238        }),
1239    }
1240}
1241
1242/// `transcodebin`: decode whatever arrives and re-encode it to a profile, which
1243/// is exactly `decodebin ! encodebin profile=...`. Rewritten into those two
1244/// macros before either expands, so it inherits both behaviours (and both error
1245/// messages) rather than repeating them.
1246fn expand_transcodebin(chains: Vec<Chain>) -> Vec<Chain> {
1247    let mut out = Vec::with_capacity(chains.len());
1248    for chain in chains {
1249        let mut new_chain: Chain = Vec::with_capacity(chain.len() + 1);
1250        for item in chain {
1251            match item {
1252                Item::Element(spec) if spec.name == "transcodebin" => {
1253                    new_chain.push(Item::Element(ElementSpec {
1254                        name: "decodebin".to_string(),
1255                        props: Vec::new(),
1256                        instance: None,
1257                        log_category: spec.log_category.clone(),
1258                    }));
1259                    new_chain.push(Item::Element(ElementSpec {
1260                        name: "encodebin".to_string(),
1261                        ..spec
1262                    }));
1263                }
1264                other => new_chain.push(other),
1265            }
1266        }
1267        out.push(new_chain);
1268    }
1269    out
1270}
1271
1272/// Expand every `encodebin` into `<encoder> ! <muxer>`: the encoder for the
1273/// stream matching that chain's input, and the muxer for the profile's container.
1274/// The muxer inherits the bin's `name=`, so a second branch reaching it as `e.`
1275/// links into the muxer the way any fan-in does; each such branch gets the
1276/// encoder for its own kind of input inserted ahead of the reference.
1277fn expand_encodebin(registry: &Registry, chains: Vec<Chain>) -> Result<Vec<Chain>, ParseError> {
1278    // Nothing to do for the common line with no encodebin in it.
1279    if !chains
1280        .iter()
1281        .flatten()
1282        .any(|item| matches!(item, Item::Element(spec) if is_encodebin(&spec.name)))
1283    {
1284        return Ok(chains);
1285    }
1286
1287    // Every encodebin's profile and instance name, so a branch referencing the
1288    // name knows which profile to encode for.
1289    let mut profiles: Vec<(Option<String>, EncodingProfile)> = Vec::new();
1290    for chain in &chains {
1291        for item in chain {
1292            if let Item::Element(spec) = item {
1293                if is_encodebin(&spec.name) {
1294                    let text = prop(spec, "profile")
1295                        .ok_or_else(|| ParseError::EncodingProfile("missing".to_string()))?;
1296                    for (key, _) in &spec.props {
1297                        if !matches!(key.as_str(), "profile") {
1298                            return Err(ParseError::EncodingProfile(alloc::format!(
1299                                "unknown property `{key}`"
1300                            )));
1301                        }
1302                    }
1303                    profiles.push((spec.instance.clone(), parse_encoding_profile(text)?));
1304                }
1305            }
1306        }
1307    }
1308
1309    let mut out = Vec::with_capacity(chains.len());
1310    for chain in chains {
1311        let mut new_chain: Chain = Vec::with_capacity(chain.len() + 1);
1312        let mut upstream: Option<(String, Vec<(String, String)>)> = None;
1313        for item in chain {
1314            match item {
1315                Item::Element(spec) if is_encodebin(&spec.name) => {
1316                    let profile = profiles
1317                        .iter()
1318                        .find(|(instance, _)| *instance == spec.instance)
1319                        .map(|(_, profile)| profile)
1320                        .expect("every encodebin's profile was parsed above");
1321                    // The container is the profile's head, so a missing muxer is
1322                    // reported before a missing encoder.
1323                    let muxer = muxer_spec(registry, profile, &spec)?;
1324                    let stream = profile_stream(registry, profile, upstream.as_ref())?;
1325                    for element in stream_specs(registry, stream, upstream.as_ref())? {
1326                        new_chain.push(Item::Element(element));
1327                    }
1328                    if let Some(muxer) = muxer {
1329                        new_chain.push(Item::Element(muxer));
1330                    }
1331                    upstream = None;
1332                }
1333                // A branch feeding an encodebin by name encodes its own input
1334                // first: the reference links into the muxer, which takes coded
1335                // streams.
1336                Item::Ref { name, pad } => {
1337                    if let Some((_, profile)) = profiles
1338                        .iter()
1339                        .find(|(instance, _)| instance.as_deref() == Some(name.as_str()))
1340                    {
1341                        let stream = profile_stream(registry, profile, upstream.as_ref())?;
1342                        for element in stream_specs(registry, stream, upstream.as_ref())? {
1343                            new_chain.push(Item::Element(element));
1344                        }
1345                    }
1346                    upstream = None;
1347                    new_chain.push(Item::Ref { name, pad });
1348                }
1349                Item::Element(spec) => {
1350                    upstream = Some((spec.name.clone(), spec.props.clone()));
1351                    new_chain.push(Item::Element(spec));
1352                }
1353                prebuilt @ Item::Prebuilt(_) => {
1354                    upstream = None;
1355                    new_chain.push(prebuilt);
1356                }
1357            }
1358        }
1359        out.push(new_chain);
1360    }
1361    Ok(out)
1362}
1363
1364/// The profile stream a branch encodes into. A single-stream profile leaves no
1365/// choice, so it needs no input caps at all (which is what lets `decodebin !
1366/// encodebin` work: an expanded decode chain has no name to ask for caps). With
1367/// several streams the branch's own input decides which one, so the element
1368/// ahead of it has to declare its output.
1369fn profile_stream<'a>(
1370    registry: &Registry,
1371    profile: &'a EncodingProfile,
1372    upstream: Option<&(String, Vec<(String, String)>)>,
1373) -> Result<&'a ProfileStream, ParseError> {
1374    if let [only] = &profile.streams[..] {
1375        return Ok(only);
1376    }
1377    let input = encodebin_input_caps(registry, upstream)?;
1378    stream_for_input(profile, &input).ok_or_else(|| {
1379        ParseError::EncodebinInput(alloc::format!(
1380            "the profile carries no stream that encodes {}",
1381            input.to_gst_string()
1382        ))
1383    })
1384}
1385
1386/// The raw caps reaching an `encodebin` (or a branch that references one): the
1387/// declared output of the element ahead of it.
1388fn encodebin_input_caps(
1389    registry: &Registry,
1390    upstream: Option<&(String, Vec<(String, String)>)>,
1391) -> Result<Caps, ParseError> {
1392    let (name, props) = upstream.ok_or_else(|| {
1393        ParseError::EncodebinInput(
1394            "no upstream element to encode; it must follow a source or element with declared caps"
1395                .to_string(),
1396        )
1397    })?;
1398    resolve_upstream_caps(registry, name, props).map_err(|_| {
1399        ParseError::EncodebinInput(alloc::format!("`{name}` declares no output caps to encode"))
1400    })
1401}
1402
1403/// The muxer element for a profile's container, carrying the bin's instance name
1404/// so a `name.` reference resolves to it.
1405fn muxer_spec(
1406    registry: &Registry,
1407    profile: &EncodingProfile,
1408    bin: &ElementSpec,
1409) -> Result<Option<ElementSpec>, ParseError> {
1410    let Some(container) = &profile.container else {
1411        return Ok(None);
1412    };
1413    let name = registry.muxer_name(container).ok_or_else(|| {
1414        ParseError::NoEncoder(alloc::format!(
1415            "no muxer for {} is compiled in",
1416            container.to_gst_string()
1417        ))
1418    })?;
1419    Ok(Some(ElementSpec {
1420        name: name.to_string(),
1421        props: Vec::new(),
1422        instance: bin.instance.clone(),
1423        log_category: bin.log_category.clone(),
1424    }))
1425}
1426
1427/// The caps a `decodebin` predecessor produces, used as the auto-plug input
1428/// (M195). For a registered source, build it and apply its properties so a
1429/// property that re-types the output (a `filesrc`'s `bytestream-format`) is
1430/// reflected via [`SourceLoop::probe_output_caps`]; fall back to the registry's
1431/// declared caps (a fixed source, or a transform's source-pad template).
1432/// `probe_output_caps` also sniffs a `bytestream-format=auto` source's header at
1433/// parse time (M480), so `decodebin` picks the demuxer from the real content even
1434/// when the file extension is wrong; only an unreadable / unrecognized file falls
1435/// back to the declared default.
1436fn resolve_upstream_caps(
1437    registry: &Registry,
1438    name: &str,
1439    props: &[(String, String)],
1440) -> Result<Caps, ParseError> {
1441    if let Some(mut src) = registry.make_source(name) {
1442        apply_props(&mut src, name, props)?;
1443        // `probe_output_caps` may sniff the header (a `bytestream-format=auto`
1444        // source), so `decodebin` picks the demuxer from the real content, not a
1445        // mislabeled extension; it falls back to the no-I/O caps otherwise.
1446        if let Some(caps) = src.probe_output_caps() {
1447            return Ok(caps);
1448        }
1449    }
1450    registry
1451        .declared_output_caps(name)
1452        .ok_or(ParseError::DecodebinNoUpstream)
1453}
1454
1455/// `uridecodebin` / `playbin`: a source-providing macro. `uridecodebin uri=X`
1456/// builds the source from the URI scheme handler and auto-plugs the decode chain
1457/// to raw; `playbin uri=X` is that plus an auto sink (`autovideosink`, or the
1458/// `video-sink=` override), i.e. a complete pipeline.
1459fn is_uri_source(name: &str) -> bool {
1460    matches!(name, "uridecodebin" | "playbin")
1461}
1462
1463/// The value of a spec property by key, if present.
1464fn prop<'a>(spec: &'a ElementSpec, key: &str) -> Option<&'a str> {
1465    spec.props
1466        .iter()
1467        .find(|(k, _)| k == key)
1468        .map(|(_, v)| v.as_str())
1469}
1470
1471/// Expand every `uridecodebin` / `playbin` (a source position element) into the
1472/// `uri=` scheme handler's source plus the auto-plugged decode chain, as
1473/// pre-built nodes spliced straight into the chain. `playbin` additionally
1474/// appends an auto sink so the line is a complete pipeline. The element must head
1475/// its chain (it provides the source).
1476fn expand_uri_sources(
1477    registry: &Registry,
1478    chains: Vec<Chain>,
1479    avoided: &[&str],
1480) -> Result<Vec<Chain>, ParseError> {
1481    let mut out = Vec::with_capacity(chains.len());
1482    for chain in chains {
1483        let mut new_chain: Chain = Vec::with_capacity(chain.len());
1484        let mut items = chain.into_iter().enumerate().peekable();
1485        while let Some((i, item)) = items.next() {
1486            let spec = match item {
1487                Item::Element(spec) if is_uri_source(&spec.name) => spec,
1488                other => {
1489                    new_chain.push(other);
1490                    continue;
1491                }
1492            };
1493            if i != 0 {
1494                return Err(ParseError::UriSourceNotAtHead(spec.name));
1495            }
1496            let is_playbin = spec.name.starts_with("playbin");
1497            let uri =
1498                prop(&spec, "uri").ok_or_else(|| ParseError::MissingUri(spec.name.clone()))?;
1499            let (source, caps) = registry
1500                .build_uri_source(uri)
1501                .map_err(|e: UriError| ParseError::Uri(alloc::format!("{uri}: {e:?}")))?;
1502            // `playbin` names its own sink, a bare `uridecodebin` is followed by
1503            // one: either way the consumer's declared input memory picks the
1504            // decoder (M1018), as it does after a `decodebin`.
1505            let sink = is_playbin.then(|| {
1506                prop(&spec, "video-sink")
1507                    .unwrap_or("autovideosink")
1508                    .to_string()
1509            });
1510            let consumer = match (&sink, items.peek()) {
1511                (Some(sink), _) => Some(sink.as_str()),
1512                (None, Some((_, Item::Element(consumer)))) => Some(consumer.name.as_str()),
1513                _ => None,
1514            };
1515            let preferred = consumer.map_or(MemoryDomainKind::System, |name| {
1516                registry.declared_memory_preference(name)
1517            });
1518            let target = |c: &Caps| is_raw_video(c) || is_raw_audio(c);
1519            let decoders = registry
1520                .autoplug_avoiding(&caps, &target, DECODEBIN_MAX_DEPTH, preferred, avoided)
1521                .ok_or_else(|| ParseError::NoDecodeChain(alloc::format!("{caps:?}")))?;
1522            new_chain.push(Item::Prebuilt(PrebuiltNode::Source(source)));
1523            for dec in decoders {
1524                new_chain.push(Item::Prebuilt(PrebuiltNode::Element(dec)));
1525            }
1526            if let Some(sink) = sink {
1527                new_chain.push(Item::Element(ElementSpec {
1528                    name: sink,
1529                    props: Vec::new(),
1530                    instance: None,
1531                    log_category: None,
1532                }));
1533            }
1534        }
1535        out.push(new_chain);
1536    }
1537    Ok(out)
1538}
1539
1540fn build_graph(
1541    registry: &Registry,
1542    chains: Vec<Chain>,
1543    avoided: &[&str],
1544) -> Result<Graph<GraphNode>, ParseError> {
1545    // Expand the source-providing (uridecodebin / playbin) and mid-chain
1546    // (decodebin) macros into concrete nodes before the structural build, so the
1547    // rest of the builder sees only real elements and pre-built nodes.
1548    let chains = expand_transcodebin(chains);
1549    let chains = expand_uri_sources(registry, chains, avoided)?;
1550    let chains = expand_decodebin(registry, chains, avoided)?;
1551    let chains = expand_encodebin(registry, chains)?;
1552
1553    // A chain endpoint after flattening: a concrete element index, or a still
1554    // unresolved reference by name.
1555    enum Endpoint {
1556        Element(usize),
1557        Ref { name: String, pad: String },
1558    }
1559
1560    let mut specs: Vec<ElementSpec> = Vec::new();
1561    // Parallel to `specs`: the pre-built node for that index (a `uridecodebin` /
1562    // `playbin` source or decoder), or `None` for a normal name-built element.
1563    // The placeholder spec for a pre-built node carries a benign name so the
1564    // structural closures (`is_queue` / `is_tee`) never match it, and node
1565    // construction uses the pre-built node instead of looking the name up.
1566    let mut prebuilt: Vec<Option<PrebuiltNode>> = Vec::new();
1567    let mut names: Vec<(String, usize)> = Vec::new();
1568    let mut chain_eps: Vec<Vec<Endpoint>> = Vec::with_capacity(chains.len());
1569
1570    for chain in chains {
1571        let mut eps = Vec::with_capacity(chain.len());
1572        for item in chain {
1573            match item {
1574                Item::Element(spec) => {
1575                    let ei = specs.len();
1576                    if let Some(inst) = &spec.instance {
1577                        if names.iter().any(|(n, _)| n == inst) {
1578                            return Err(ParseError::DuplicateName(inst.clone()));
1579                        }
1580                        names.push((inst.clone(), ei));
1581                    }
1582                    specs.push(spec);
1583                    prebuilt.push(None);
1584                    eps.push(Endpoint::Element(ei));
1585                }
1586                Item::Prebuilt(node) => {
1587                    let ei = specs.len();
1588                    let name = match node {
1589                        PrebuiltNode::Source(_) => "uridecodebin",
1590                        PrebuiltNode::Element(_) => "(decoder)",
1591                    };
1592                    specs.push(ElementSpec {
1593                        name: name.to_string(),
1594                        props: Vec::new(),
1595                        instance: None,
1596                        log_category: None,
1597                    });
1598                    prebuilt.push(Some(node));
1599                    eps.push(Endpoint::Element(ei));
1600                }
1601                Item::Ref { name, pad } => eps.push(Endpoint::Ref { name, pad }),
1602            }
1603        }
1604        chain_eps.push(eps);
1605    }
1606
1607    if specs.len() < 2 {
1608        return Err(ParseError::TooFewStages);
1609    }
1610
1611    // Resolve references, collect the directed links (by element index), and
1612    // record each demux output-pad request (M476): a head-ref `d.video_0` that
1613    // sources a link contributes a `PadRequest` to `demux_pads[d]`, in link (port)
1614    // order, so a demux-select hook can map port i to the requested stream.
1615    let mut raw_links: Vec<(usize, usize)> = Vec::new();
1616    let mut demux_pads: Vec<Vec<PadRequest>> = alloc::vec![Vec::new(); specs.len()];
1617    // The DESTINATION pad request per raw link (M481): a named input-pad ref
1618    // (`... ! mux.audio_0`) carries a request; a bare `mux.` or an inline consumer
1619    // carries `None` (positional). The transpose of `demux_pads` (output side).
1620    let mut raw_dest_req: Vec<Option<PadRequest>> = Vec::new();
1621    for eps in &chain_eps {
1622        let mut idxs: Vec<usize> = Vec::with_capacity(eps.len());
1623        // The pad suffix of each endpoint that is a reference (`None` for a
1624        // concrete element), parallel to `idxs`.
1625        let mut pads: Vec<Option<&str>> = Vec::with_capacity(eps.len());
1626        for ep in eps {
1627            match ep {
1628                Endpoint::Element(ei) => {
1629                    idxs.push(*ei);
1630                    pads.push(None);
1631                }
1632                Endpoint::Ref { name, pad } => {
1633                    let i = names
1634                        .iter()
1635                        .find(|(n, _)| n == name)
1636                        .map(|(_, i)| *i)
1637                        .ok_or_else(|| ParseError::UnknownReference(name.clone()))?;
1638                    idxs.push(i);
1639                    pads.push(Some(pad.as_str()));
1640                }
1641            }
1642        }
1643        for w in 0..idxs.len().saturating_sub(1) {
1644            let (s, d) = (idxs[w], idxs[w + 1]);
1645            raw_links.push((s, d));
1646            // Record the source's output-pad request in port order: a pad-ref
1647            // source (`d.video_0`) carries a named request; an inline output
1648            // (`d ! x`) takes the positional Nth-forwardable-stream request. Only
1649            // consulted for an explicit-demux fan-out node (M476); harmless noise
1650            // for a normal element or a tee.
1651            let ordinal = demux_pads[s].len();
1652            let req = match pads[w] {
1653                Some(pad) => parse_pad_request(pad, ordinal),
1654                None => PadRequest {
1655                    kind: PadKind::Any,
1656                    index: ordinal,
1657                },
1658            };
1659            demux_pads[s].push(req);
1660            // The destination's input-pad request: a named ref (`mux.audio_0`)
1661            // parses; a bare `mux.` (empty suffix) or an inline consumer is `None`
1662            // (positional, resolved by the sequential input counter below).
1663            raw_dest_req.push(match pads[w + 1] {
1664                Some(pad) if !pad.is_empty() => Some(parse_pad_request(pad, 0)),
1665                _ => None,
1666            });
1667        }
1668    }
1669
1670    // M190: `queue` / `queue2` is not an element in g2g. Per the design,
1671    // per-edge `LinkPolicy` (Block / DropOldest / DropNewest) is the leaky-queue
1672    // analog, so a queue node collapses into the backpressure policy of the edge
1673    // it sits on rather than becoming a buffering element + extra hop. Validate
1674    // each queue is 1-in/1-out, read its `leaky=`, then contract it out of the
1675    // link list, walking chains of queues to the first real consumer and keeping
1676    // the downstream-most leaky policy.
1677    let is_queue = |ei: usize| matches!(specs[ei].name.as_str(), "queue" | "queue2");
1678    let mut raw_in = alloc::vec![0usize; specs.len()];
1679    let mut raw_out = alloc::vec![0usize; specs.len()];
1680    for &(s, d) in &raw_links {
1681        raw_out[s] += 1;
1682        raw_in[d] += 1;
1683    }
1684    let mut queue_succ: Vec<Option<usize>> = alloc::vec![None; specs.len()];
1685    let mut queue_policy = alloc::vec![LinkPolicy::Block; specs.len()];
1686    let mut queue_capacity: Vec<Option<usize>> = alloc::vec![None; specs.len()];
1687    for ei in 0..specs.len() {
1688        if is_queue(ei) {
1689            if raw_in[ei] != 1 || raw_out[ei] != 1 {
1690                return Err(ParseError::QueueRole(specs[ei].name.clone()));
1691            }
1692            queue_policy[ei] = queue_leaky_policy(&specs[ei]);
1693            queue_capacity[ei] = queue_capacity_of(&specs[ei]);
1694            queue_succ[ei] = raw_links.iter().find(|(s, _)| *s == ei).map(|(_, d)| *d);
1695        }
1696    }
1697    // Each edge whose source is a real element walks through any run of queues to
1698    // its terminal consumer, carrying the accumulated policy; edges out of a queue
1699    // are consumed by that walk (skipped here).
1700    let mut links: Vec<(usize, usize, LinkPolicy, Option<usize>)> = Vec::new();
1701    // The destination input-pad request per contracted link (M481), aligned with
1702    // `links`; taken from the raw link that lands on the terminal consumer (so a
1703    // `... ! queue ! mux.audio_0` keeps its named pad through the queue contraction).
1704    let mut link_dest_req: Vec<Option<PadRequest>> = Vec::new();
1705    for (li, &(s, d)) in raw_links.iter().enumerate() {
1706        if is_queue(s) {
1707            continue;
1708        }
1709        let (mut cur, mut src_li) = (d, li);
1710        let mut policy = LinkPolicy::Block;
1711        let mut capacity: Option<usize> = None;
1712        while is_queue(cur) {
1713            if queue_policy[cur] != LinkPolicy::Block {
1714                policy = queue_policy[cur];
1715            }
1716            // A run of queues carries the last explicit depth (rare to chain them).
1717            if let Some(c) = queue_capacity[cur] {
1718                capacity = Some(c);
1719            }
1720            let next = queue_succ[cur].expect("queue validated 1-out above");
1721            // The named-pad suffix lives on the raw link that enters the terminal
1722            // consumer, i.e. `(cur -> next)`; find it for the request.
1723            src_li = raw_links
1724                .iter()
1725                .position(|&(a, b)| a == cur && b == next)
1726                .unwrap_or(src_li);
1727            cur = next;
1728        }
1729        links.push((s, cur, policy, capacity));
1730        link_dest_req.push(raw_dest_req[src_li].clone());
1731    }
1732
1733    // Link degree per element fixes its role and any tee's output width. Computed
1734    // over the contracted links, so queue indices drop to degree 0 and are skipped
1735    // as nodes below.
1736    let mut in_deg = alloc::vec![0usize; specs.len()];
1737    let mut out_deg = alloc::vec![0usize; specs.len()];
1738    for &(s, d, _, _) in &links {
1739        out_deg[s] += 1;
1740        in_deg[d] += 1;
1741    }
1742
1743    let is_tee = |ei: usize| specs[ei].name == "tee";
1744    // A non-tee node with several inbound links is a muxer (built from the
1745    // registry with that input count); a tee has a single input pad.
1746    let is_muxer = |ei: usize| !is_tee(ei) && in_deg[ei] > 1;
1747    // A node registered as a demuxer with several outbound links is a fan-out
1748    // demux (M210): the transpose of a muxer. A registered name with one output
1749    // falls back to its single-output launch element (e.g. `tsdemux`), the way a
1750    // one-input muxer name falls back to its single-input element.
1751    let is_demux = |ei: usize| !is_tee(ei) && out_deg[ei] > 1 && registry.is_demux(&specs[ei].name);
1752    // Explicit-demux fan-out (M476): a non-tee, non-registered-demux element that
1753    // fans out to several pads and is fed by a file source is built by a registered
1754    // demux-select hook, which probes the file (`location=`) and returns a
1755    // multi-output demuxer with one port per pad request (in reference order). This
1756    // is how `matroskademux` / `tsdemux` / `qtdemux` in a launch line split a file
1757    // into its elementary streams, honoring `d.video_0` / `d.audio_0` selection.
1758    let mut demux_select_node: Vec<Option<Box<dyn crate::runtime::DynMultiOutputElement>>> =
1759        (0..specs.len()).map(|_| None).collect();
1760    if !registry.demux_select_hooks().is_empty() {
1761        for ei in 0..specs.len() {
1762            if is_tee(ei) || is_demux(ei) || out_deg[ei] <= 1 || prebuilt[ei].is_some() {
1763                continue;
1764            }
1765            // The upstream file location (the source linking into this demux).
1766            let Some(location) = links
1767                .iter()
1768                .find(|(_, d, _, _)| *d == ei)
1769                .and_then(|(s, _, _, _)| prop(&specs[*s], "location"))
1770            else {
1771                continue;
1772            };
1773            for hook in registry.demux_select_hooks() {
1774                if let Some(demux) = hook(&specs[ei].name, location, &demux_pads[ei]) {
1775                    demux_select_node[ei] = Some(demux);
1776                    break;
1777                }
1778            }
1779        }
1780    }
1781    // `decodebin name=d` fan-out (M482): a `decodebin` node left unexpanded (it has
1782    // named refs) with a file source upstream probes the file, builds the
1783    // multi-output demuxer (stored like a demux-select node so it fans out on its
1784    // own pads), and records each port's elementary caps so the wiring below splices
1785    // a decoder onto every port (the decode-per-port that makes it `decodebin`, not a
1786    // bare demuxer). Declining hooks leave it unbuilt (a loud error at node build).
1787    let mut decode_fanout_caps: Vec<Option<Vec<Caps>>> = (0..specs.len()).map(|_| None).collect();
1788    if !registry.decodebin_select_hooks().is_empty() {
1789        for ei in 0..specs.len() {
1790            if !is_decodebin(&specs[ei].name) || out_deg[ei] == 0 || prebuilt[ei].is_some() {
1791                continue;
1792            }
1793            let Some(location) = links
1794                .iter()
1795                .find(|(_, d, _, _)| *d == ei)
1796                .and_then(|(s, _, _, _)| prop(&specs[*s], "location"))
1797            else {
1798                continue;
1799            };
1800            for hook in registry.decodebin_select_hooks() {
1801                if let Some((demux, caps)) = hook(location, &demux_pads[ei]) {
1802                    demux_select_node[ei] = Some(demux);
1803                    decode_fanout_caps[ei] = Some(caps);
1804                    break;
1805                }
1806            }
1807        }
1808    }
1809    let is_select: Vec<bool> = demux_select_node.iter().map(|d| d.is_some()).collect();
1810    // Auto-tee (M473): a non-tee, non-demux node whose single output fans out to
1811    // several consumers gets an implicit `tee` spliced in below, so a gst-launch
1812    // line that omits the explicit tee still builds. `tee`, registered demuxers,
1813    // and explicit-demux fan-out nodes fan out on their own pads and are left alone.
1814    let needs_tee = |ei: usize| {
1815        !is_tee(ei)
1816            && !is_demux(ei)
1817            && !is_select[ei]
1818            && !registry.is_fanout_src(&specs[ei].name)
1819            && out_deg[ei] > 1
1820    };
1821    // A fan-in element with no output is checked at construction below: a
1822    // terminal session (`is_terminal`) legally ends the graph (M713), a
1823    // merging muxer without a downstream stays `MuxerWithoutOutput`.
1824    for (ei, spec) in specs.iter().enumerate() {
1825        if is_tee(ei) && !spec.props.is_empty() {
1826            // The structural tee carries no element, so it has no properties.
1827            return Err(ParseError::UnknownProperty {
1828                element: "tee".to_string(),
1829                key: spec.props[0].0.clone(),
1830            });
1831        }
1832    }
1833
1834    // Construct nodes in element-index order so `node_of[ei]` lines up. Queue
1835    // indices were contracted into edge policies above, so they get no node
1836    // (`None`); they never appear as an endpoint in the contracted links.
1837    let mut graph: Graph<GraphNode> = Graph::new();
1838    let mut node_of: Vec<Option<NodeId>> = Vec::with_capacity(specs.len());
1839    // The resolved muxer input-pad index per contracted link (M481), aligned with
1840    // `links`; filled at muxer construction from the element's `input_pad_index`
1841    // scheme. `None` for a non-muxer link or an unnamed ref (sequential fallback).
1842    let mut mux_pad_of_link: Vec<Option<u8>> = alloc::vec![None; links.len()];
1843    for ei in 0..specs.len() {
1844        if is_queue(ei) {
1845            node_of.push(None);
1846            continue;
1847        }
1848        // A pre-built node (uridecodebin / playbin source or decoder) is spliced
1849        // in directly; its role still follows link degree (a source has no input,
1850        // a terminal decoder no output).
1851        if let Some(node) = prebuilt[ei].take() {
1852            let nid = match node {
1853                PrebuiltNode::Source(src) => graph.add_source(GraphNodeRef::Source(src)),
1854                PrebuiltNode::Element(el) if out_deg[ei] == 0 => {
1855                    graph.add_sink(GraphNodeRef::Element(el))
1856                }
1857                PrebuiltNode::Element(el) => graph.add_transform(GraphNodeRef::Element(el)),
1858            };
1859            node_of.push(Some(nid));
1860            continue;
1861        }
1862        let spec = &specs[ei];
1863        let node = if is_tee(ei) {
1864            graph.add_tee(out_deg[ei] as u8).node()
1865        } else if in_deg[ei] == 0 && registry.is_fanout_src(&spec.name) {
1866            // Terminal fan-out source (M727): 0 inputs, one output per named
1867            // pad reference (`s. ! ...`). The element's intrinsic port count
1868            // must match the linked outputs.
1869            let mut src = registry
1870                .make_fanout_src(&spec.name, out_deg[ei])
1871                .ok_or_else(|| ParseError::UnknownElement(spec.name.clone()))?;
1872            if src.output_count() != out_deg[ei] {
1873                return Err(ParseError::UnknownInputPad(spec.name.clone()));
1874            }
1875            apply_props(&mut src, &spec.name, &spec.props)?;
1876            graph
1877                .add_fanout_src(GraphNodeRef::FanoutSource(src), out_deg[ei] as u8)
1878                .node()
1879        } else if in_deg[ei] == 0 {
1880            let mut src = registry
1881                .make_source(&spec.name)
1882                .ok_or_else(|| ParseError::UnknownSource(spec.name.clone()))?;
1883            apply_props(&mut src, &spec.name, &spec.props)?;
1884            graph.add_source(GraphNodeRef::Source(src))
1885        } else if is_muxer(ei) {
1886            let mut mux = registry
1887                .make_muxer(&spec.name, in_deg[ei])
1888                .ok_or_else(|| ParseError::NotAMuxer(spec.name.clone()))?;
1889            apply_props(&mut mux, &spec.name, &spec.props)?;
1890            // Resolve named input-pad refs (M481) to concrete indices via the
1891            // muxer's own scheme, so `... ! mux.audio_0  ... ! mux.video_0` routes
1892            // by name regardless of order. Named refs claim their index first;
1893            // bare refs fill the remaining slots in link order (the historical
1894            // positional behavior).
1895            let n = in_deg[ei];
1896            let incoming: Vec<usize> = links
1897                .iter()
1898                .enumerate()
1899                .filter(|(_, (_, d, _, _))| *d == ei)
1900                .map(|(k, _)| k)
1901                .collect();
1902            let mut used = alloc::vec![false; n];
1903            for (ord, &k) in incoming.iter().enumerate() {
1904                let Some(req) = &link_dest_req[k] else {
1905                    continue;
1906                };
1907                let idx = mux
1908                    .input_pad_index(req, ord)
1909                    .filter(|&i| i < n)
1910                    .ok_or_else(|| ParseError::UnknownInputPad(spec.name.clone()))?;
1911                if core::mem::replace(&mut used[idx], true) {
1912                    return Err(ParseError::DuplicateInputPad(spec.name.clone()));
1913                }
1914                mux_pad_of_link[k] = Some(idx as u8);
1915            }
1916            for &k in &incoming {
1917                if link_dest_req[k].is_none() {
1918                    let idx = used
1919                        .iter()
1920                        .position(|u| !u)
1921                        .expect("in_deg matches link count");
1922                    used[idx] = true;
1923                    mux_pad_of_link[k] = Some(idx as u8);
1924                }
1925            }
1926            if out_deg[ei] == 0 {
1927                // Nothing downstream: legal only for a terminal fan-in session
1928                // (M713), whose element consumes its inputs with no merged
1929                // output. A merging muxer here would silently drop its output.
1930                if !mux.is_terminal() {
1931                    return Err(ParseError::MuxerWithoutOutput(spec.name.clone()));
1932                }
1933                graph
1934                    .add_fanin_sink(GraphNodeRef::Muxer(mux), in_deg[ei] as u8)
1935                    .node()
1936            } else {
1937                graph
1938                    .add_muxer(GraphNodeRef::Muxer(mux), in_deg[ei] as u8)
1939                    .node()
1940            }
1941        } else if is_select[ei] {
1942            // M476: a demux-select hook already built the multi-output demuxer
1943            // (probing the upstream file); splice it in with one port per pad.
1944            let demux = demux_select_node[ei]
1945                .take()
1946                .expect("select demux built above");
1947            graph
1948                .add_demux(GraphNodeRef::Demux(demux), out_deg[ei] as u8)
1949                .node()
1950        } else if is_demux(ei) {
1951            let mut demux = registry
1952                .make_demux(&spec.name, out_deg[ei])
1953                .ok_or_else(|| ParseError::UnknownElement(spec.name.clone()))?;
1954            apply_props(&mut demux, &spec.name, &spec.props)?;
1955            graph
1956                .add_demux(GraphNodeRef::Demux(demux), out_deg[ei] as u8)
1957                .node()
1958        } else if out_deg[ei] == 0 {
1959            let mut el = registry
1960                .make_element(&spec.name)
1961                .ok_or_else(|| ParseError::UnknownElement(spec.name.clone()))?;
1962            apply_props(&mut el, &spec.name, &spec.props)?;
1963            graph.add_sink(GraphNodeRef::Element(el))
1964        } else {
1965            let mut el = registry
1966                .make_element(&spec.name)
1967                .ok_or_else(|| ParseError::UnknownElement(spec.name.clone()))?;
1968            apply_props(&mut el, &spec.name, &spec.props)?;
1969            graph.add_transform(GraphNodeRef::Element(el))
1970        };
1971        // M842: a `name=` is the element's instance name at run time, not just
1972        // the handle pad references resolve against.
1973        if let Some(inst) = &spec.instance {
1974            graph.set_node_name(node, inst.clone());
1975        }
1976        // M847: `log-category=` renames this instance's log category, which the
1977        // runner hands to the element before naming it.
1978        if let Some(cat) = &spec.log_category {
1979            graph.set_node_log_category(node, cat.clone());
1980        }
1981        node_of.push(Some(node));
1982    }
1983
1984    // Auto-tee (M473): for each fan-out node that is not itself a tee/demux, splice
1985    // an implicit `tee` onto its output. The node's single output pad feeds the
1986    // tee (a plain blocking link); the tee's `out_deg` pads feed the consumers,
1987    // which the edge loop below sources from the tee instead of the node.
1988    let mut implicit_tee: Vec<Option<NodeId>> = alloc::vec![None; specs.len()];
1989    for ei in 0..specs.len() {
1990        if needs_tee(ei) {
1991            if let Some(src_node) = node_of[ei] {
1992                let tee = graph.add_tee(out_deg[ei] as u8).node();
1993                graph.link_with(PadId::from(src_node), PadId::from(tee), LinkPolicy::Block)?;
1994                implicit_tee[ei] = Some(tee);
1995            }
1996        }
1997    }
1998
1999    // Wire edges. Each tee or demux branch takes a distinct output pad (0..n) and
2000    // each muxer input a distinct input pad (0..n); every other output and input
2001    // is pad 0. A queue's `leaky=` rides along as the edge's `LinkPolicy`.
2002    let mut tee_next = alloc::vec![0u8; specs.len()];
2003    for (k, &(s, d, policy, capacity)) in links.iter().enumerate() {
2004        let node_s = node_of[s].expect("contracted link source is a real node");
2005        let node_d = node_of[d].expect("contracted link destination is a real node");
2006        let src = if let Some(tee) = implicit_tee[s] {
2007            // The fan-out node's consumers source from its spliced-in tee's pads.
2008            let index = tee_next[s];
2009            tee_next[s] += 1;
2010            PadId { node: tee, index }
2011        } else if is_tee(s) || is_demux(s) || is_select[s] || registry.is_fanout_src(&specs[s].name)
2012        {
2013            let index = tee_next[s];
2014            tee_next[s] += 1;
2015            PadId {
2016                node: node_s,
2017                index,
2018            }
2019        } else {
2020            PadId::from(node_s)
2021        };
2022        let dst = if is_muxer(d) {
2023            // The input index was resolved at construction (named pads via the
2024            // muxer's scheme, bare refs sequentially); every muxer link has one.
2025            let index = mux_pad_of_link[k].expect("muxer link assigned an input pad");
2026            PadId {
2027                node: node_d,
2028                index,
2029            }
2030        } else {
2031            PadId::from(node_d)
2032        };
2033        // `decodebin` fan-out (M482): splice a decoder between the demux port and the
2034        // branch consumer instead of a bare link, so each `d.video_0` / `d.audio_0`
2035        // branch receives DECODED (raw) frames. A text port carries `Text{Utf8}`
2036        // already, so it links straight through (no codec).
2037        if let Some(caps) = &decode_fanout_caps[s] {
2038            let port = src.index as usize;
2039            let kind = demux_pads[s]
2040                .get(port)
2041                .map(|r| r.kind)
2042                .unwrap_or(PadKind::Any);
2043            if !matches!(kind, PadKind::Text) {
2044                let target: &dyn Fn(&Caps) -> bool = match kind {
2045                    PadKind::Audio => &is_raw_audio,
2046                    _ => &is_raw_video,
2047                };
2048                registry
2049                    .decodebin(
2050                        &mut graph,
2051                        src,
2052                        dst,
2053                        &caps[port],
2054                        target,
2055                        DECODEBIN_MAX_DEPTH,
2056                    )
2057                    .map_err(|_| ParseError::NoDecodeChain(alloc::format!("{:?}", caps[port])))?;
2058                continue;
2059            }
2060        }
2061        graph.link_full(src, dst, policy, capacity)?;
2062    }
2063
2064    Ok(graph)
2065}
2066
2067/// Map a `queue` / `queue2` node's `leaky=` property to the edge backpressure
2068/// policy it stands in for (M190). gst accepts the enum by value or nick:
2069/// `0`/`no` (lossless, the default), `1`/`upstream` (drop the newest incoming
2070/// buffer), `2`/`downstream` (drop the oldest queued buffer). The other buffering
2071/// bounds (`max-size-bytes` / `max-size-time`, `min-threshold-*`, `silent`) are
2072/// accepted but not modeled (g2g's link depth is a buffer count), so they are
2073/// ignored for paste compatibility rather than rejected; `max-size-buffers` maps
2074/// to the edge depth, see [`queue_capacity_of`].
2075fn queue_leaky_policy(spec: &ElementSpec) -> LinkPolicy {
2076    for (k, v) in &spec.props {
2077        if k == "leaky" {
2078            return match v.as_str() {
2079                "1" | "upstream" => LinkPolicy::DropNewest,
2080                "2" | "downstream" => LinkPolicy::DropOldest,
2081                // "0" / "no" and anything unrecognized: lossless block.
2082                _ => LinkPolicy::Block,
2083            };
2084        }
2085    }
2086    LinkPolicy::Block
2087}
2088
2089/// A `queue max-size-buffers=N` sets the depth of the edge it contracts to (the
2090/// gst per-queue buffer bound), overriding the runner's graph-wide `link_capacity`
2091/// for just that link. `0` in gst means "unbounded"; g2g has no unbounded channel,
2092/// so a `0` is ignored (falls back to the default depth). An unparseable value is
2093/// ignored too (paste compatibility).
2094fn queue_capacity_of(spec: &ElementSpec) -> Option<usize> {
2095    spec.props
2096        .iter()
2097        .find(|(k, _)| k == "max-size-buffers")
2098        .and_then(|(_, v)| v.parse::<usize>().ok())
2099        .filter(|&n| n > 0)
2100}
2101
2102/// Parse a `gst-launch`-style pipeline string into a runnable [`Graph`], building
2103/// each element by name from `registry`, applying its `key=value` properties, and
2104/// linking the chains (including `tee` branches) into the DAG. Roles follow
2105/// connectivity. The result drops straight onto
2106/// [`run_graph`](crate::runtime::run_graph).
2107///
2108/// ```text
2109/// videotestsrc num-buffers=3 ! tee name=t ! fakesink   t. ! videoflip ! fakesink
2110/// ```
2111pub fn parse_launch(registry: &Registry, pipeline: &str) -> Result<Graph<GraphNode>, ParseError> {
2112    parse_launch_avoiding(registry, pipeline, &[])
2113}
2114
2115/// [`parse_launch`], with the auto-plug search forbidden from picking any
2116/// factory named in `avoided` (M1023). The line is otherwise parsed identically,
2117/// so an element the text names explicitly is still built: this bounds only what
2118/// a `decodebin` / `uridecodebin` / `playbin` may choose. An application retries
2119/// through here after a chosen decoder turned out not to decode the stream.
2120pub fn parse_launch_avoiding(
2121    registry: &Registry,
2122    pipeline: &str,
2123    avoided: &[&str],
2124) -> Result<Graph<GraphNode>, ParseError> {
2125    // The parser's own built-ins are not registry factories, but they are
2126    // element names all the same, so a chain may start on one.
2127    let knows = |name: &str| {
2128        registry.knows_element(name)
2129            || matches!(
2130                name,
2131                "queue"
2132                    | "queue2"
2133                    | "decodebin"
2134                    | "uridecodebin"
2135                    | "playbin"
2136                    | "encodebin"
2137                    | "encodebin2"
2138                    | "transcodebin"
2139            )
2140    };
2141    let chains = parse_chains_with(pipeline, &knows)?;
2142    // playbin uri=X auto-fan-out (M382): a lone `playbin uri=` probes the
2143    // container via the registered hook and auto-builds source -> demux ->
2144    // per-stream decode -> auto sinks (multi-stream). Without a hook (or if it
2145    // declines, e.g. a non-Matroska file), fall through to build_graph, which
2146    // expands `playbin` to the single-stream pipeline (M196).
2147    if let Some(uri) = lone_playbin_uri(&chains) {
2148        // Try each registered hook (one per container type) until one handles the
2149        // URI; a hook returns Ok(None) to decline a container it does not parse.
2150        for hook in registry.playbin_hooks() {
2151            if let Some(graph) = hook(registry, uri)? {
2152                return Ok(splice_domain_converters(registry, graph));
2153            }
2154        }
2155    }
2156    Ok(splice_domain_converters(
2157        registry,
2158        build_graph(registry, chains, avoided)?,
2159    ))
2160}
2161
2162/// The factory to bar from the auto-plug search after a run ended in
2163/// `failed_element`, or `None` when re-plugging cannot help (M1023). Pass the
2164/// result to [`parse_launch_avoiding`] (accumulating it into `avoided`) and run
2165/// the line again; `None` means report the failure instead.
2166///
2167/// The caller owns the rest of the policy, because only it knows whether a
2168/// restart is safe: retry only while nothing has been presented (a run that
2169/// output nothing restarts invisibly, one that did not would repeat itself) and
2170/// not for a live source ([`has_live_source`], where a restart reopens the
2171/// connection and drops what was in flight).
2172///
2173/// `None` when the failing element is unknown to the registry, when the line
2174/// names it explicitly (the user asked for that element by name, so substituting
2175/// another would defy them), or when it has already been barred once.
2176pub fn fallback_factory(
2177    registry: &Registry,
2178    pipeline: &str,
2179    failed_element: &str,
2180    avoided: &[&str],
2181) -> Option<&'static str> {
2182    let factory = registry.factory_of_instance(failed_element)?;
2183    if avoided.contains(&factory) {
2184        return None;
2185    }
2186    let named_in_line = tokenize(pipeline).iter().any(|t| t == factory);
2187    (!named_in_line).then_some(factory)
2188}
2189
2190/// Whether any element in `graph` offers a live-source clock: a capture or
2191/// network source, which a restart would reopen at the cost of whatever was in
2192/// flight. The signal a caller checks before re-running a pipeline it has already
2193/// started (see [`fallback_factory`]).
2194pub fn has_live_source(graph: &Graph<GraphNode>) -> bool {
2195    (0..graph.node_count())
2196        .filter_map(|i| graph.element(NodeId(i as u32)))
2197        .filter_map(|node| match node {
2198            GraphNodeRef::Source(source) => source.provide_clock(),
2199            GraphNodeRef::Element(element) => element.provide_clock(),
2200            _ => None,
2201        })
2202        .any(|candidate| candidate.priority == crate::clock::ClockPriority::LiveSource)
2203}
2204
2205/// Splice memory-domain converters into a just-parsed graph (M1017), where the
2206/// registry carries a factory: a text pipeline that links a GPU decoder to a sink
2207/// in another domain gets the bridge (or the download) it needs without naming
2208/// it. A no-op when the domains already agree, or when no factory is registered.
2209fn splice_domain_converters(registry: &Registry, graph: Graph<GraphNode>) -> Graph<GraphNode> {
2210    match registry.domain_converter() {
2211        Some(factory) => crate::runtime::auto_plug_domain_converters(graph, &factory),
2212        None => graph,
2213    }
2214}
2215
2216/// The `uri=` of a pipeline that is a single bare `playbin uri=X` element (and
2217/// nothing else), the M382 multi-stream auto-fan-out trigger. `None` for any
2218/// other shape: a `playbin` mid-pipeline, alongside other elements, or without a
2219/// `uri=` is left to the normal builder (the M196 single-stream expansion).
2220fn lone_playbin_uri(chains: &[Chain]) -> Option<&str> {
2221    let [chain] = chains else { return None };
2222    let [Item::Element(spec)] = chain.as_slice() else {
2223        return None;
2224    };
2225    if spec.name != "playbin" {
2226        return None;
2227    }
2228    prop(spec, "uri")
2229}
2230
2231#[cfg(test)]
2232mod tests {
2233    use super::*;
2234
2235    fn item_names(chain: &Chain) -> Vec<&str> {
2236        chain
2237            .iter()
2238            .map(|i| match i {
2239                Item::Element(s) => s.name.as_str(),
2240                Item::Ref { name, .. } => name.as_str(),
2241                Item::Prebuilt(_) => "(prebuilt)",
2242            })
2243            .collect()
2244    }
2245
2246    #[test]
2247    fn parse_chains_splits_names_and_props() {
2248        let chains = parse_chains(
2249            "videotestsrc num-buffers=3 pattern=snow ! videoflip method=rotate-180 ! fakesink",
2250        )
2251        .unwrap();
2252        assert_eq!(chains.len(), 1);
2253        assert_eq!(
2254            item_names(&chains[0]),
2255            ["videotestsrc", "videoflip", "fakesink"]
2256        );
2257        let Item::Element(src) = &chains[0][0] else {
2258            panic!("first is an element")
2259        };
2260        assert_eq!(
2261            src.props,
2262            [
2263                ("num-buffers".to_string(), "3".to_string()),
2264                ("pattern".into(), "snow".into())
2265            ]
2266        );
2267        let Item::Element(sink) = &chains[0][2] else {
2268            panic!("last is an element")
2269        };
2270        assert!(sink.props.is_empty());
2271    }
2272
2273    #[test]
2274    fn parse_chains_strips_quoted_values() {
2275        // A double-quoted value has its quotes stripped.
2276        let chains = parse_chains("filesrc location=\"file.mp4\" ! fakesink").unwrap();
2277        let Item::Element(src) = &chains[0][0] else {
2278            panic!("element")
2279        };
2280        assert_eq!(
2281            src.props[0],
2282            ("location".to_string(), "file.mp4".to_string())
2283        );
2284    }
2285
2286    #[test]
2287    fn parse_chains_keeps_spaces_in_quoted_values() {
2288        // The quote-aware tokenizer keeps spaces inside a value, so a nested
2289        // element description (the `gstwrap` case) survives as one property.
2290        let chains = parse_chains("gstwrap element=\"x264enc bitrate=4000\" ! fakesink").unwrap();
2291        assert_eq!(item_names(&chains[0]), ["gstwrap", "fakesink"]);
2292        let Item::Element(w) = &chains[0][0] else {
2293            panic!("element")
2294        };
2295        assert_eq!(
2296            w.props[0],
2297            ("element".to_string(), "x264enc bitrate=4000".to_string())
2298        );
2299    }
2300
2301    #[test]
2302    fn parse_chains_keeps_bang_inside_quotes() {
2303        // A `!` inside a quoted value is literal, not a stage separator: one
2304        // element with one property, not two chained nodes.
2305        let chains = parse_chains("gstwrap element=\"a ! b\" ! fakesink").unwrap();
2306        assert_eq!(item_names(&chains[0]), ["gstwrap", "fakesink"]);
2307        let Item::Element(w) = &chains[0][0] else {
2308            panic!("element")
2309        };
2310        assert_eq!(w.props[0], ("element".to_string(), "a ! b".to_string()));
2311    }
2312
2313    #[test]
2314    fn tokenize_treats_quoted_region_as_one_token() {
2315        assert_eq!(
2316            tokenize("gstwrap element=\"x y\" ! sink"),
2317            ["gstwrap", "element=\"x y\"", "!", "sink"]
2318        );
2319    }
2320
2321    #[test]
2322    fn tokenize_treats_single_quoted_region_as_one_token() {
2323        assert_eq!(
2324            tokenize("filesink location='/my file.ts' ! sink"),
2325            ["filesink", "location='/my file.ts'", "!", "sink"]
2326        );
2327    }
2328
2329    #[test]
2330    fn single_quoted_value_is_unquoted() {
2331        let chains = parse_chains("videotestsrc ! identity note='a b c' ! fakesink").unwrap();
2332        let Item::Element(id) = &chains[0][1] else {
2333            panic!("element")
2334        };
2335        assert_eq!(id.props, [("note".to_string(), "a b c".to_string())]);
2336    }
2337
2338    #[test]
2339    fn escaped_space_keeps_a_value_whole() {
2340        // gst-launch 1.26 does the same: `\ ` is a literal space, not a token
2341        // break (verified against `filesink location=`).
2342        assert_eq!(
2343            tokenize(r"filesink location=/my\ file.ts ! sink"),
2344            ["filesink", r"location=/my\ file.ts", "!", "sink"]
2345        );
2346        let chains = parse_chains(r"videotestsrc ! filesink location=/my\ file.ts").unwrap();
2347        let Item::Element(fs) = &chains[0][1] else {
2348            panic!("element")
2349        };
2350        assert_eq!(fs.props, [("location".to_string(), "/my file.ts".into())]);
2351    }
2352
2353    #[test]
2354    fn escaped_quote_does_not_close_the_region() {
2355        // `"a\"b"` is one token holding `a"b`, as in gst-launch.
2356        assert_eq!(
2357            tokenize(r#"identity note="a\" b" ! sink"#),
2358            ["identity", r#"note="a\" b""#, "!", "sink"]
2359        );
2360        let chains = parse_chains(r#"videotestsrc ! identity note="a\" b""#).unwrap();
2361        let Item::Element(id) = &chains[0][1] else {
2362            panic!("element")
2363        };
2364        assert_eq!(id.props, [("note".to_string(), "a\" b".into())]);
2365    }
2366
2367    #[test]
2368    fn quoted_region_may_sit_mid_value() {
2369        // gst-launch rejects this ("no element ..."): its lexer only quotes a
2370        // whole value. g2g accepts the superset, so a path with one spaced
2371        // component needs no quoting of the rest.
2372        let chains =
2373            parse_chains(r#"videotestsrc ! filesink location=/tmp/"my dir"/a.ts"#).unwrap();
2374        let Item::Element(fs) = &chains[0][1] else {
2375            panic!("element")
2376        };
2377        assert_eq!(
2378            fs.props,
2379            [("location".to_string(), "/tmp/my dir/a.ts".into())]
2380        );
2381    }
2382
2383    #[test]
2384    fn unescaped_backslash_stays_literal() {
2385        // A Windows path survives unescaped. gst-launch would drop these
2386        // backslashes (`videos\a.ts` opens a file called `videosa.ts`), so this
2387        // is a deliberate deviation: `\` escapes only the launch-special
2388        // characters.
2389        assert_eq!(unquote_value(r"C:\videos\a.ts"), r"C:\videos\a.ts");
2390        assert_eq!(unquote_value(r"a\ b"), "a b");
2391        assert_eq!(unquote_value(r"a\\b"), r"a\b");
2392    }
2393
2394    #[test]
2395    fn hash_starts_a_comment_to_end_of_line() {
2396        // Trailing comment on one line, and a comment mid-pipeline across lines.
2397        let chains = parse_chains("videotestsrc ! fakesink # trailing note").unwrap();
2398        assert_eq!(item_names(&chains[0]), ["videotestsrc", "fakesink"]);
2399        let chains = parse_chains("videotestsrc  # the source\n  ! fakesink").unwrap();
2400        assert_eq!(item_names(&chains[0]), ["videotestsrc", "fakesink"]);
2401    }
2402
2403    #[test]
2404    fn hash_inside_a_value_is_literal_not_a_comment() {
2405        // A URI fragment (`#closed-captions=cc1`, `#t=10`) must survive; `#` is a
2406        // comment only at a token boundary.
2407        assert_eq!(
2408            tokenize("uridecodebin uri=file:///v.mp4#closed-captions=cc1 ! sink"),
2409            [
2410                "uridecodebin",
2411                "uri=file:///v.mp4#closed-captions=cc1",
2412                "!",
2413                "sink"
2414            ]
2415        );
2416    }
2417
2418    #[test]
2419    fn caps_range_value_error_carries_a_syntax_hint() {
2420        let e = ParseError::BadValue {
2421            element: "capsfilter".to_string(),
2422            key: "caps".to_string(),
2423            value: "video/x-raw,width=[1,1920]".to_string(),
2424        };
2425        let msg = alloc::format!("{e}");
2426        assert!(msg.contains("ranges"), "caps range hint present: {msg}");
2427        // A plain element property error keeps the bare message (no caps hint).
2428        let plain = ParseError::BadValue {
2429            element: "videobox".to_string(),
2430            key: "top".to_string(),
2431            value: "abc".to_string(),
2432        };
2433        assert!(!alloc::format!("{plain}").contains("ranges"));
2434    }
2435
2436    #[test]
2437    fn enum_value_error_lists_the_valid_nicks() {
2438        let e = ParseError::BadEnumValue {
2439            element: "videoflip".to_string(),
2440            key: "method".to_string(),
2441            value: "sideways".to_string(),
2442            values: "none | clockwise",
2443        };
2444        let msg = alloc::format!("{e}");
2445        assert!(msg.contains("videoflip"), "{msg}");
2446        assert!(msg.contains("'method'"), "{msg}");
2447        assert!(msg.contains("valid: none | clockwise"), "{msg}");
2448        assert!(!msg.contains("flag set"), "no flag hint for a plain enum");
2449        // A malformed flag set (the whole value is reported) also hints at the
2450        // `+` syntax.
2451        let flags = ParseError::BadEnumValue {
2452            element: "rtspsrc".to_string(),
2453            key: "protocols".to_string(),
2454            value: "udp+".to_string(),
2455            values: "udp | tcp",
2456        };
2457        assert!(alloc::format!("{flags}").contains("flag set"));
2458    }
2459
2460    #[test]
2461    fn caps_description_becomes_capsfilter() {
2462        // A bare `media/type,...` node is the inline caps-filter shorthand.
2463        let chains =
2464            parse_chains("videotestsrc ! video/x-raw,format=nv12,width=320 ! fakesink").unwrap();
2465        assert_eq!(
2466            item_names(&chains[0]),
2467            ["videotestsrc", "capsfilter", "fakesink"]
2468        );
2469        let Item::Element(caps) = &chains[0][1] else {
2470            panic!("element")
2471        };
2472        assert_eq!(
2473            caps.props,
2474            [(
2475                "caps".to_string(),
2476                "video/x-raw,format=nv12,width=320".to_string()
2477            )]
2478        );
2479    }
2480
2481    #[test]
2482    fn tee_branch_parses_into_two_chains() {
2483        // `name=` is the instance handle (not a property); `t.` opens the branch.
2484        let chains =
2485            parse_chains("videotestsrc ! tee name=t ! fakesink t. ! videoflip ! fakesink").unwrap();
2486        assert_eq!(chains.len(), 2);
2487        assert_eq!(item_names(&chains[0]), ["videotestsrc", "tee", "fakesink"]);
2488        assert_eq!(item_names(&chains[1]), ["t", "videoflip", "fakesink"]);
2489        let Item::Element(tee) = &chains[0][1] else {
2490            panic!("element")
2491        };
2492        assert_eq!(tee.instance.as_deref(), Some("t"));
2493        assert!(tee.props.is_empty(), "name= is the handle, not a property");
2494        assert!(matches!(&chains[1][0], Item::Ref { name, .. } if name == "t"));
2495    }
2496
2497    #[test]
2498    fn empty_and_too_few_stages_error() {
2499        let reg = Registry::new();
2500        assert!(matches!(parse_launch(&reg, "   "), Err(ParseError::Empty)));
2501        assert!(matches!(
2502            parse_launch(&reg, "videotestsrc"),
2503            Err(ParseError::TooFewStages)
2504        ));
2505    }
2506
2507    #[test]
2508    fn malformed_property_is_reported() {
2509        assert!(matches!(
2510            parse_chains("videotestsrc bogus ! fakesink"),
2511            Err(ParseError::MalformedProperty { .. })
2512        ));
2513    }
2514
2515    /// gst-launch runs several top-level chains in one pipeline
2516    /// (`videotestsrc ! xvimagesink audiotestsrc ! pulsesink`); a bare element
2517    /// name after a completed chain opens the next one. Before, the sink ate it
2518    /// as a property and reported it malformed.
2519    #[test]
2520    fn a_second_chain_may_follow_a_sink() {
2521        let known = |n: &str| matches!(n, "videotestsrc" | "fakesink" | "filesink");
2522        let chains = parse_chains_with("videotestsrc ! fakesink videotestsrc ! fakesink", &known)
2523            .expect("two chains parse");
2524        let shapes: Vec<Vec<&str>> = chains.iter().map(item_names).collect();
2525        assert_eq!(
2526            shapes,
2527            [["videotestsrc", "fakesink"], ["videotestsrc", "fakesink"]],
2528            "one chain per source"
2529        );
2530    }
2531
2532    /// The property case must not regress: a bare token naming nothing
2533    /// registered is still the typo it looks like, not a new chain.
2534    #[test]
2535    fn a_bare_token_that_names_nothing_is_still_a_malformed_property() {
2536        let known = |n: &str| matches!(n, "videotestsrc" | "fakesink" | "filesink");
2537        assert!(matches!(
2538            parse_chains_with("videotestsrc bogus ! fakesink", &known),
2539            Err(ParseError::MalformedProperty { .. })
2540        ));
2541        // And after a sink, where the new-chain rule applies.
2542        assert!(matches!(
2543            parse_chains_with("videotestsrc ! fakesink bogus", &known),
2544            Err(ParseError::MalformedProperty { .. })
2545        ));
2546    }
2547
2548    /// A property whose value happens to name an element is still a property:
2549    /// the `=` decides.
2550    #[test]
2551    fn a_key_value_token_is_never_a_chain_head() {
2552        let known = |n: &str| matches!(n, "videotestsrc" | "fakesink" | "filesink");
2553        let chains =
2554            parse_chains_with("videotestsrc ! filesink location=fakesink", &known).expect("parses");
2555        let shapes: Vec<Vec<&str>> = chains.iter().map(item_names).collect();
2556        assert_eq!(shapes, [["videotestsrc", "filesink"]], "one chain");
2557    }
2558
2559    #[test]
2560    fn unknown_reference_is_reported() {
2561        // The degree / reference checks precede registry construction, so an
2562        // empty registry still surfaces them.
2563        let reg = Registry::new();
2564        let err = parse_launch(
2565            &reg,
2566            "videotestsrc ! tee name=t ! fakesink nope. ! fakesink",
2567        )
2568        .unwrap_err();
2569        assert_eq!(err, ParseError::UnknownReference("nope".to_string()));
2570    }
2571
2572    #[test]
2573    fn duplicate_name_is_reported() {
2574        let reg = Registry::new();
2575        let err =
2576            parse_launch(&reg, "videotestsrc name=x ! videoflip name=x ! fakesink").unwrap_err();
2577        assert_eq!(err, ParseError::DuplicateName("x".to_string()));
2578    }
2579
2580    // Auto-tee (M473): fan-out without an explicit `tee` no longer errors; the
2581    // parser splices one in. Covered end to end (build + run + topology) in
2582    // g2g-plugins/tests/m118_launch_branching.rs, where real elements exist.
2583}