Skip to main content

g2g_core/
dot.rs

1//! Graphviz DOT rendering for a pipeline graph (developer tooling): the
2//! `GST_DEBUG_DUMP_DOT_DIR` analog. [`ValidatedGraph::to_dot`] turns a graph,
3//! plus the solver's per-edge negotiated [`Caps`] and (when known) each link's
4//! memory domain, into a `digraph { .. }` a developer can render with
5//! `dot -Tsvg`. Self-contained and `no_std + alloc`: it only formats `String`s,
6//! no I/O, so it builds on every target the core does.
7//!
8//! The graph carries an opaque element payload `E`, so node display names come
9//! from a caller-supplied closure (the runner has each element's instance
10//! name; a structural tee/muxer falls back to its kind). Edges are annotated
11//! from [`DotAnnotations`], indexed by edge id to match [`ValidatedGraph::edge`]
12//! and the `Vec<Caps>` that
13//! [`solve_graph`](crate::runtime::solver::solve_graph) returns.
14
15use alloc::format;
16use alloc::string::{String, ToString};
17use core::fmt::Write as _;
18
19use crate::caps::Caps;
20use crate::graph::{Edge, Graph, NodeId, NodeKind, ValidatedGraph};
21use crate::link::LinkPolicy;
22use crate::memory::MemoryDomainKind;
23
24/// Per-edge annotations layered onto a [`ValidatedGraph`] DOT dump: the
25/// solver's negotiated caps and, when the runner knows it, the link's memory
26/// domain. Both are indexed by edge id (the index into
27/// [`ValidatedGraph::edge`], which is also how
28/// [`solve_graph`](crate::runtime::solver::solve_graph) returns its solution),
29/// and either may be omitted (a pre-negotiation dump has neither). An entry is
30/// rendered only when the slice covers that edge id.
31#[derive(Debug, Default, Clone, Copy)]
32pub struct DotAnnotations<'a> {
33    /// Negotiated caps per edge, e.g. from `solve_graph`. Rendered as the
34    /// edge's primary label via [`Caps::to_gst_string`].
35    pub edge_caps: Option<&'a [Caps]>,
36    /// Memory domain per edge. Memory domains are not part of [`Caps`] (they
37    /// ride the auto-plug metadata, see DESIGN.md 4.13.9), so they are passed
38    /// alongside. A non-`System` domain marks a zero-copy GPU link and is drawn
39    /// bold.
40    pub edge_memory: Option<&'a [MemoryDomainKind]>,
41}
42
43impl<E> ValidatedGraph<E> {
44    /// Render this graph as Graphviz DOT. `label(node)` supplies a node's
45    /// display name (typically the element instance name); returning `None`
46    /// falls back to the node's structural kind, which is the right answer for
47    /// a `tee` / `mux` that carries no element. `ann` adds negotiated caps and
48    /// memory domains per edge; pass `&DotAnnotations::default()` for a bare
49    /// topology dump.
50    ///
51    /// `title` names the `digraph` (Graphviz requires a valid identifier or
52    /// quoted string; it is quoted and escaped here).
53    pub fn to_dot(
54        &self,
55        title: &str,
56        label: impl Fn(NodeId) -> Option<String>,
57        ann: &DotAnnotations<'_>,
58    ) -> String {
59        render(
60            title,
61            self.node_count(),
62            |n| self.kind(n),
63            self.edges(),
64            label,
65            ann,
66        )
67    }
68}
69
70impl<E> Graph<E> {
71    /// Render the (not-yet-validated) graph as Graphviz DOT, for a dump before
72    /// `finish()` runs (a parsed launch line, a half-built graph). Same shape as
73    /// [`ValidatedGraph::to_dot`]; only [`DotAnnotations`] caps/memory are
74    /// usually absent pre-negotiation. Node ids `0..node_count` always exist, so
75    /// the kind lookup never misses.
76    pub fn to_dot(
77        &self,
78        title: &str,
79        label: impl Fn(NodeId) -> Option<String>,
80        ann: &DotAnnotations<'_>,
81    ) -> String {
82        render(
83            title,
84            self.node_count(),
85            |n| self.node_kind(n).expect("node id in range"),
86            self.edges(),
87            label,
88            ann,
89        )
90    }
91}
92
93/// Shared DOT body for [`Graph`] and [`ValidatedGraph`]: both supply a node
94/// count, a kind lookup, and the edge slice (edge id = index, the key `ann`
95/// uses).
96fn render(
97    title: &str,
98    node_count: usize,
99    kind_of: impl Fn(NodeId) -> NodeKind,
100    edges: &[Edge],
101    label: impl Fn(NodeId) -> Option<String>,
102    ann: &DotAnnotations<'_>,
103) -> String {
104    let mut s = String::new();
105    let _ = writeln!(s, "digraph \"{}\" {{", escape(title));
106    s.push_str("  rankdir=LR;\n");
107    s.push_str("  node [fontname=\"monospace\", fontsize=10];\n");
108    s.push_str("  edge [fontname=\"monospace\", fontsize=9];\n");
109
110    // Nodes, in id order so the output is stable across runs.
111    for i in 0..node_count {
112        let node = NodeId(i as u32);
113        let kind = kind_of(node);
114        let name = label(node).unwrap_or_else(|| kind_label(kind).to_string());
115        let _ = writeln!(
116            s,
117            "  n{i} [label=\"{}\"{}];",
118            escape(&name),
119            node_style(kind)
120        );
121    }
122
123    s.push('\n');
124
125    // Edges, in edge-id order (the index `ann` is keyed by).
126    for (id, e) in edges.iter().enumerate() {
127        let (src, dst) = (e.src.node.0, e.dst.node.0);
128        let domain = ann.edge_memory.and_then(|m| m.get(id).copied());
129        let label = edge_label(ann.edge_caps.and_then(|c| c.get(id)), domain, e.policy);
130        let mut attrs = String::new();
131        if !label.is_empty() {
132            let _ = write!(attrs, "label=\"{}\"", escape(&label));
133        }
134        // A non-System domain is a GPU / zero-copy link: draw it bold so a
135        // PCIe download (a System link between two GPU stages) stands out.
136        if matches!(domain, Some(d) if d != MemoryDomainKind::System) {
137            if !attrs.is_empty() {
138                attrs.push_str(", ");
139            }
140            attrs.push_str("color=\"#b58900\", penwidth=2");
141        }
142        // Pad indices for the fan-out / fan-in nodes, so a tee's branch or a
143        // muxer's input pad is identifiable.
144        let pads = pad_labels(e.src.index, e.dst.index);
145        if !pads.is_empty() {
146            if !attrs.is_empty() {
147                attrs.push_str(", ");
148            }
149            attrs.push_str(&pads);
150        }
151        if attrs.is_empty() {
152            let _ = writeln!(s, "  n{src} -> n{dst};");
153        } else {
154            let _ = writeln!(s, "  n{src} -> n{dst} [{attrs}];");
155        }
156    }
157
158    s.push_str("}\n");
159    s
160}
161
162/// Default node label when the caller has no name: the structural kind. Public
163/// so any tooling that names nodes (the DOT dump, the JSON validate dump) falls
164/// back to the same word.
165pub fn kind_label(kind: NodeKind) -> &'static str {
166    match kind {
167        NodeKind::Source => "source",
168        NodeKind::Transform => "transform",
169        NodeKind::Sink => "sink",
170        NodeKind::Tee(_) => "tee",
171        NodeKind::Muxer(_) => "mux",
172        NodeKind::FaninSink(_) => "fanin-sink",
173        NodeKind::FanoutSrc(_) => "fanout-src",
174    }
175}
176
177/// Per-kind shape + fill, so the role reads at a glance (green sources, red
178/// sinks, blue transforms, tan fan-out/in). Returned as the trailing
179/// `, shape=.., style=..` of a node statement.
180fn node_style(kind: NodeKind) -> &'static str {
181    match kind {
182        NodeKind::Source => ", shape=box, style=\"rounded,filled\", fillcolor=\"#cde8cd\"",
183        NodeKind::Sink => ", shape=box, style=\"rounded,filled\", fillcolor=\"#f0cdcd\"",
184        NodeKind::Transform => ", shape=box, style=\"rounded,filled\", fillcolor=\"#cddcf0\"",
185        NodeKind::Tee(_) => ", shape=diamond, style=filled, fillcolor=\"#f0e8cd\"",
186        NodeKind::Muxer(_) => ", shape=trapezium, style=filled, fillcolor=\"#f0e8cd\"",
187        NodeKind::FaninSink(_) => ", shape=trapezium, style=filled, fillcolor=\"#f0cdcd\"",
188        NodeKind::FanoutSrc(_) => ", shape=invtrapezium, style=filled, fillcolor=\"#f0cdcd\"",
189    }
190}
191
192/// Build an edge's label from its (optional) caps, memory domain, and policy,
193/// one fact per line. Empty when nothing is known and the policy is the
194/// default `Block` (a bare arrow).
195fn edge_label(caps: Option<&Caps>, domain: Option<MemoryDomainKind>, policy: LinkPolicy) -> String {
196    let mut lines: alloc::vec::Vec<String> = alloc::vec::Vec::new();
197    if let Some(c) = caps {
198        lines.push(c.to_gst_string());
199    }
200    if let Some(d) = domain {
201        if d != MemoryDomainKind::System {
202            lines.push(format!("memory:{d:?}"));
203        }
204    }
205    if policy != LinkPolicy::Block {
206        lines.push(format!("[{policy:?}]"));
207    }
208    // Graphviz line break inside a quoted label is the two-char sequence \n.
209    lines.join("\\n")
210}
211
212/// `taillabel` / `headlabel` for a fan-out / fan-in edge, naming the pad index
213/// at each end when it is not the default 0. Empty for plain 1:1 links.
214fn pad_labels(src_index: u8, dst_index: u8) -> String {
215    let mut parts: alloc::vec::Vec<String> = alloc::vec::Vec::new();
216    if src_index != 0 {
217        parts.push(format!("taillabel=\"{src_index}\""));
218    }
219    if dst_index != 0 {
220        parts.push(format!("headlabel=\"{dst_index}\""));
221    }
222    parts.join(", ")
223}
224
225/// Escape a string for a quoted Graphviz attribute: backslash and double quote.
226fn escape(s: &str) -> String {
227    let mut out = String::with_capacity(s.len());
228    for c in s.chars() {
229        match c {
230            '\\' => out.push_str("\\\\"),
231            '"' => out.push_str("\\\""),
232            _ => out.push(c),
233        }
234    }
235    out
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::caps::{Caps, Dim, Rate, VideoCodec};
242    use crate::graph::Graph;
243    use crate::link::LinkPolicy;
244
245    type G = Graph<&'static str>;
246
247    fn h264(w: u32, h: u32) -> Caps {
248        Caps::CompressedVideo {
249            codec: VideoCodec::H264,
250            width: Dim::Fixed(w),
251            height: Dim::Fixed(h),
252            framerate: Rate::Fixed(30 << 16),
253        }
254    }
255
256    #[test]
257    fn linear_chain_renders_nodes_and_caps_labelled_edges() {
258        let mut g = G::new();
259        let src = g.add_source("rtspsrc");
260        let tx = g.add_transform("h264parse");
261        let sink = g.add_sink("fakesink");
262        g.link(src, tx).unwrap();
263        g.link(tx, sink).unwrap();
264        let v = g.finish().unwrap();
265
266        let caps = [h264(1920, 1080), h264(1920, 1080)];
267        let dot = v.to_dot(
268            "pipeline",
269            |n| v.element(n).map(|e| (*e).to_string()),
270            &DotAnnotations {
271                edge_caps: Some(&caps),
272                edge_memory: None,
273            },
274        );
275
276        assert!(dot.starts_with("digraph \"pipeline\" {"));
277        assert!(dot.trim_end().ends_with('}'));
278        // Each element name appears as a node label.
279        assert!(dot.contains("label=\"rtspsrc\""));
280        assert!(dot.contains("label=\"h264parse\""));
281        assert!(dot.contains("label=\"fakesink\""));
282        // Both edges exist and carry the negotiated caps.
283        assert!(dot.contains("n0 -> n1"));
284        assert!(dot.contains("n1 -> n2"));
285        assert!(
286            dot.contains("video/x-h264"),
287            "edge caps should be labelled: {dot}"
288        );
289        // Source/sink get distinct fills.
290        assert!(dot.contains("fillcolor=\"#cde8cd\"")); // source green
291        assert!(dot.contains("fillcolor=\"#f0cdcd\"")); // sink red
292    }
293
294    #[test]
295    fn structural_nodes_fall_back_to_kind_and_pads_are_labelled() {
296        let mut g = G::new();
297        let src = g.add_source("src");
298        let tee = g.add_tee(2);
299        let a = g.add_sink("a");
300        let b = g.add_sink("b");
301        g.link(src, tee.input()).unwrap();
302        g.link(tee.out(0), a).unwrap();
303        // Branch 1 is a leaky preview branch.
304        g.link_with(tee.out(1), b, LinkPolicy::DropOldest).unwrap();
305        let v = g.finish().unwrap();
306
307        // The closure has no name for the tee node, so it falls back to "tee".
308        let dot = v.to_dot(
309            "fanout",
310            |n| v.element(n).map(|e| (*e).to_string()),
311            &DotAnnotations::default(),
312        );
313        assert!(
314            dot.contains("label=\"tee\""),
315            "tee uses kind fallback: {dot}"
316        );
317        assert!(dot.contains("shape=diamond"));
318        // The second tee output pad (index 1) is named, and its leaky policy shows.
319        assert!(
320            dot.contains("taillabel=\"1\""),
321            "tee branch pad index: {dot}"
322        );
323        assert!(
324            dot.contains("[DropOldest]"),
325            "non-default policy shown: {dot}"
326        );
327    }
328
329    #[test]
330    fn gpu_memory_edge_is_marked() {
331        let mut g = G::new();
332        let src = g.add_source("nvdec");
333        let sink = g.add_sink("nvenc");
334        g.link(src, sink).unwrap();
335        let v = g.finish().unwrap();
336
337        let mem = [MemoryDomainKind::Cuda];
338        let dot = v.to_dot(
339            "gpu",
340            |n| v.element(n).map(|e| (*e).to_string()),
341            &DotAnnotations {
342                edge_caps: None,
343                edge_memory: Some(&mem),
344            },
345        );
346        assert!(dot.contains("memory:Cuda"), "CUDA domain labelled: {dot}");
347        assert!(dot.contains("penwidth=2"), "GPU link drawn bold: {dot}");
348        // A System domain would not be annotated.
349        let sys = [MemoryDomainKind::System];
350        let dot2 = v.to_dot(
351            "sys",
352            |_| None,
353            &DotAnnotations {
354                edge_caps: None,
355                edge_memory: Some(&sys),
356            },
357        );
358        assert!(
359            !dot2.contains("memory:"),
360            "System domain is not labelled: {dot2}"
361        );
362    }
363
364    #[test]
365    fn title_and_names_are_escaped() {
366        let mut g = G::new();
367        let src = g.add_source("a\"b");
368        let sink = g.add_sink("sink");
369        g.link(src, sink).unwrap();
370        let v = g.finish().unwrap();
371        let dot = v.to_dot(
372            "t\"t",
373            |n| v.element(n).map(|e| (*e).to_string()),
374            &DotAnnotations::default(),
375        );
376        assert!(
377            dot.contains("digraph \"t\\\"t\""),
378            "title quote escaped: {dot}"
379        );
380        assert!(
381            dot.contains("label=\"a\\\"b\""),
382            "name quote escaped: {dot}"
383        );
384    }
385}