Skip to main content

g2g_python/
element.rs

1//! [`PyTransform`]: a gst-python-ml element shell hosted as a g2g transform.
2//!
3//! This is the Rust mirror of gst-python-ml's `backend/gst` `BaseTransform`:
4//! it negotiates caps, then on each frame hands the buffer to a hosted Python
5//! instance and pushes the result downstream. The negotiation half is pure
6//! Rust and always compiles; the per-frame Python call lives in [`crate::host`]
7//! behind the `python` feature.
8//!
9//! Caps model: an overlay/inference-in-place element (the `ActionTask` shape)
10//! takes a raw-video frame and returns one in the same format, so it is a
11//! non-boundary transform whose output caps equal its input. `output-caps=`
12//! makes it a boundary instead ([`AsyncElement::is_format_boundary`] plus a
13//! `DerivedOutput` constraint naming the declared caps, like `g2g-ml`'s
14//! `OrtInference`): the single-chain gst-python-ml families read one media type
15//! and write another (audio in and a transcript out, text in and speech out),
16//! and their output is not the size of their input, so the hosted element
17//! returns it through `meta.emit` (see [`crate::host`]).
18//!
19//! Memory-domain model (M985): the frame is read where it lies and forwarded
20//! untouched, so both pads carry the one domain the hosted code reads, System or
21//! (under `cuda-frames`) CUDA. See [`AsyncElement::input_domains`] below.
22
23use core::future::Future;
24use core::pin::Pin;
25
26use g2g_core::memory::{DomainSet, MemoryDomainKind};
27use g2g_core::{
28    AllocationParams, AsyncElement, Caps, CapsConstraint, CapsSet, ConfigureOutcome, Dim,
29    ElementMetadata, Frame, G2gError, OutputSink, PadTemplate, PadTemplates, PipelinePacket,
30    PropError, PropKind, PropValue, PropertySpec, Rate, RawVideoFormat,
31};
32
33use crate::format::{format_from_py, format_to_py, frame_bytes};
34use crate::props::{fixed_caps, hosted_element_props};
35
36/// A gst-python-ml element hosted as a first-class g2g transform.
37#[derive(Debug)]
38pub struct PyTransform {
39    /// Python module to import, e.g. `"action"` (a gst-python-ml element shell
40    /// running under `PYML_BACKEND=g2g`).
41    module: String,
42    /// Class within the module to instantiate, e.g. `"ActionTransform"`.
43    class: String,
44    /// Caps this element accepts on its sink pad. Default: RGBA at any
45    /// geometry / rate. A real element derives this from the Python class's
46    /// declared sink-pad template; `with_accept` overrides it meanwhile.
47    accept: Caps,
48    /// Caps produced downstream when the hosted element emits a different media
49    /// type than it reads: audio in / text out for transcription, and the rest
50    /// of the 1-in-1-out gst-python-ml families. Unset means it emits what it
51    /// read (the overlay / detector shape).
52    produce: Option<Caps>,
53    /// Overlay flag bridged to the Python task (an example backend-declared
54    /// property; `ActionTask` reads `self.draw_label`).
55    draw_label: bool,
56    /// Whether the hosted element works on GPU-resident CUDA frames, which it
57    /// reads through `g2g_process_cuda` and passes through untouched. It decides
58    /// both halves of this element's memory-domain declaration: the frame arrives
59    /// and leaves in the one domain, so the two cannot disagree.
60    cuda_frames: bool,
61    /// Element properties forwarded verbatim to the hosted Python instance at
62    /// construction (e.g. `model-name`, `engine-name`, `device`): the gst-python
63    /// GObject-property analog. The Python class declares these (via the g2g
64    /// backend's `GObject` shim); the host `setattr`s them on the instance with
65    /// `-` mapped to `_`. Kept in insertion order for deterministic application.
66    params: Vec<(String, PropValue)>,
67    configured: bool,
68    /// The negotiated, fully fixed input caps captured at configure time, so
69    /// `process` knows the concrete geometry / format to hand Python.
70    fixed: Option<Caps>,
71    emitted: u64,
72    /// The hosted Python element on its own GIL-owning worker thread, spawned
73    /// at configure time. Present only in the `python` build.
74    #[cfg(feature = "python")]
75    worker: Option<crate::host::PyWorker>,
76}
77
78impl PyTransform {
79    /// Host the `class` from Python `module`. The instance is created at
80    /// `configure_pipeline` time (under the GIL), not here, so construction
81    /// stays cheap and infallible like the other elements' `new`.
82    pub fn new(module: impl Into<String>, class: impl Into<String>) -> Self {
83        Self {
84            module: module.into(),
85            class: class.into(),
86            accept: Caps::RawVideo {
87                format: RawVideoFormat::Rgba8,
88                width: Dim::Any,
89                height: Dim::Any,
90                framerate: Rate::Any,
91                interlace: g2g_core::Interlace::Any,
92            },
93            produce: None,
94            draw_label: false,
95            cuda_frames: false,
96            params: Vec::new(),
97            configured: false,
98            fixed: None,
99            emitted: 0,
100            #[cfg(feature = "python")]
101            worker: None,
102        }
103    }
104
105    /// Override the accepted sink caps (e.g. to host an NV12 element). The
106    /// supported set may carry `Any` dims/rate: negotiation fixes them against
107    /// concrete upstream caps, so `process` always sees a fixed format.
108    pub fn with_accept(mut self, caps: Caps) -> Self {
109        self.accept = caps;
110        self
111    }
112
113    /// Emit `caps` downstream instead of the negotiated input caps, for a hosted
114    /// element that changes media type.
115    pub fn with_produce(mut self, caps: Caps) -> Self {
116        self.produce = Some(caps);
117        self
118    }
119
120    /// The caps this element puts on its source pad for `input`: the declared
121    /// output when it changes media type, else the input it passes through.
122    fn output_for(&self, input: &Caps) -> Caps {
123        self.produce.clone().unwrap_or_else(|| input.clone())
124    }
125
126    /// Set the `draw-label` overlay flag forwarded to the Python task.
127    pub fn with_draw_label(mut self, on: bool) -> Self {
128        self.draw_label = on;
129        self
130    }
131
132    /// Host an element that works on GPU-resident CUDA frames: they reach it as
133    /// `__cuda_array_interface__` planes through `g2g_process_cuda` and flow on
134    /// still device-resident. Sets this element's whole memory-domain story (see
135    /// [`AsyncElement::input_domains`]); the hosted class must define
136    /// `g2g_process_cuda` and the caps must be semi-planar (NV12 / P010).
137    pub fn with_cuda_frames(mut self, on: bool) -> Self {
138        self.cuda_frames = on;
139        self
140    }
141
142    /// The one memory domain frames arrive and leave in.
143    fn domain(&self) -> MemoryDomainKind {
144        if self.cuda_frames {
145            MemoryDomainKind::Cuda
146        } else {
147            MemoryDomainKind::System
148        }
149    }
150
151    /// Count of frames pushed downstream. Useful in tests.
152    pub fn emitted_count(&self) -> u64 {
153        self.emitted
154    }
155
156    #[cfg(feature = "python")]
157    async fn run(&self, frame: Frame) -> Result<Vec<Frame>, G2gError> {
158        let worker = self.worker.as_ref().ok_or(G2gError::NotConfigured)?;
159        let caps = self.fixed.as_ref().ok_or(G2gError::NotConfigured)?;
160        worker.run(frame, caps).await
161    }
162
163    #[cfg(not(feature = "python"))]
164    async fn run(&self, _frame: Frame) -> Result<Vec<Frame>, G2gError> {
165        // The per-frame Python call embeds CPython via pyo3 and lives behind
166        // the `python` feature. The default build negotiates caps but cannot
167        // run frames; build with `--features python`.
168        Err(G2gError::UnsupportedDomain)
169    }
170}
171
172impl AsyncElement for PyTransform {
173    type ProcessFuture<'a>
174        = Pin<Box<dyn Future<Output = Result<(), G2gError>> + 'a>>
175    where
176        Self: 'a;
177
178    /// The hosted element reads and writes the frame in place, so the output
179    /// caps equal the input (when it is in the accepted set) unless
180    /// `output-caps=` declared a different media type. Declaring this native
181    /// constraint (rather than the default legacy intercept-only path, whose
182    /// output the solver leaves unconstrained) lets the graph solver derive this
183    /// element's output edge and lets the runtime forward-caps resolve steer a
184    /// mid-stream `CapsChanged` (e.g. an upstream decoder's first-frame caps)
185    /// cleanly through it, instead of stalling on an unconstrained boundary.
186    fn caps_constraint_as_transform(&self) -> CapsConstraint<'_> {
187        // Stating both sides lets the solver narrow the input link too. Derivation
188        // only pushes forward, so wavparse's any-rate any-channels fixates alone.
189        if let Some(produce) = self.produce.clone() {
190            return CapsConstraint::Mapping(vec![(
191                CapsSet::one(self.accept.clone()),
192                CapsSet::one(produce),
193            )]);
194        }
195        let accept = self.accept.clone();
196        CapsConstraint::DerivedOutput(Box::new(move |input: &Caps| {
197            match input.intersect(&accept) {
198                Ok(_) => CapsSet::one(input.clone()),
199                Err(_) => CapsSet::from_alternatives(Vec::new()),
200            }
201        }))
202    }
203
204    /// A hosted element that declares `output-caps=` turns one media type into
205    /// another (audio into a transcript), which is what a boundary is.
206    fn is_format_boundary(&self) -> bool {
207        self.produce.is_some()
208    }
209
210    /// The legacy-bridge half of the constraint above, for the runner paths that
211    /// derive a boundary element's output side through this hook.
212    fn propose_output_caps(&self, input: &Caps) -> Caps {
213        self.output_for(input)
214    }
215
216    fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError> {
217        upstream_caps.intersect(&self.accept)
218    }
219
220    /// The hosted element reads the frame where it already is and forwards it
221    /// untouched, so the domain it emits is the domain it consumes: System bytes
222    /// over the buffer protocol, or CUDA device memory over
223    /// `__cuda_array_interface__` under `cuda-frames`. Declaring one domain on
224    /// both pads keeps that relation honest, so the domain-converter auto-plug
225    /// splices a download / upload *ahead* of this element when upstream cannot
226    /// deliver what the hosted code reads, and splices nothing after it (the
227    /// frame really does leave in the declared domain).
228    fn input_domains(&self) -> DomainSet {
229        DomainSet::only(self.domain())
230    }
231
232    fn output_memory(&self) -> MemoryDomainKind {
233        self.domain()
234    }
235
236    /// Ask upstream to allocate in the domain the hosted code can read, so a
237    /// multi-domain producer (an NVDEC that can keep frames on the device or
238    /// download them) settles on it rather than needing a converter node. Only
239    /// the domain is constrained: this element allocates nothing of its own, so
240    /// it imposes no buffer count or alignment, and the size is one frame.
241    fn propose_allocation(&self, caps: &Caps) -> Option<AllocationParams> {
242        let Caps::RawVideo {
243            format,
244            width: Dim::Fixed(width),
245            height: Dim::Fixed(height),
246            ..
247        } = caps
248        else {
249            return None;
250        };
251        let size = frame_bytes(*format, *width, *height);
252        Some(if self.cuda_frames {
253            AllocationParams::cuda(size, 1, 1)
254        } else {
255            AllocationParams::system(size, 1)
256        })
257    }
258
259    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
260        absolute_caps.intersect(&self.accept)?;
261        self.fixed = Some(absolute_caps.clone());
262        #[cfg(feature = "python")]
263        {
264            // A registry-built `pyelement` starts with empty module/class until
265            // `module=`/`class=` properties are applied; fail clearly here
266            // rather than importing the empty module.
267            if self.module.is_empty() || self.class.is_empty() {
268                return Err(G2gError::NotConfigured);
269            }
270            // Spawn the worker once: a re-configure (re-negotiation) must not tear
271            // down and re-init the hosted instance, which would discard a loaded
272            // model. Matches `PySource` / `PyAggregator`.
273            if self.worker.is_none() {
274                self.worker = Some(crate::host::PyWorker::spawn(
275                    &self.module,
276                    &self.class,
277                    self.draw_label,
278                    &self.params,
279                )?);
280            }
281        }
282        self.configured = true;
283        Ok(ConfigureOutcome::Accepted)
284    }
285
286    fn process<'a>(
287        &'a mut self,
288        packet: PipelinePacket,
289        out: &'a mut dyn OutputSink,
290    ) -> Self::ProcessFuture<'a> {
291        Box::pin(async move {
292            if !self.configured {
293                return Err(G2gError::NotConfigured);
294            }
295            match packet {
296                PipelinePacket::DataFrame(frame) => {
297                    for output in self.run(frame).await? {
298                        self.emitted += 1;
299                        out.push(PipelinePacket::DataFrame(output)).await?;
300                    }
301                }
302                // A mid-stream change to anything outside the accepted set is a
303                // hard error; otherwise announce this element's own output side
304                // so downstream stays in step.
305                PipelinePacket::CapsChanged(c) => {
306                    c.intersect(&self.accept)?;
307                    let announce = self.output_for(&c);
308                    out.push(PipelinePacket::CapsChanged(announce)).await?;
309                }
310                PipelinePacket::Flush => {
311                    out.push(PipelinePacket::Flush).await?;
312                }
313                PipelinePacket::Segment(seg) => {
314                    out.push(PipelinePacket::Segment(seg)).await?;
315                }
316                // Stateless per-frame host: nothing buffered to drain.
317                PipelinePacket::Eos => {}
318                other => {
319                    out.push(other).await?;
320                }
321            }
322            Ok(())
323        })
324    }
325
326    fn metadata(&self) -> ElementMetadata {
327        ElementMetadata::new(
328            "Python ML element host",
329            "Filter/Effect/Video",
330            "Hosts a gst-python-ml element shell as a g2g transform via embedded CPython.",
331            "g2g",
332        )
333    }
334
335    fn properties(&self) -> &'static [PropertySpec] {
336        PYTRANSFORM_PROPS
337    }
338
339    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
340        match name {
341            "module" => {
342                self.module = value.as_str().ok_or(PropError::Type)?.to_string();
343                Ok(())
344            }
345            "class" => {
346                self.class = value.as_str().ok_or(PropError::Type)?.to_string();
347                Ok(())
348            }
349            "draw-label" => {
350                self.draw_label = value.as_bool().ok_or(PropError::Type)?;
351                Ok(())
352            }
353            "cuda-frames" => {
354                self.cuda_frames = value.as_bool().ok_or(PropError::Type)?;
355                Ok(())
356            }
357            "format" => {
358                let parsed = format_from_py(value.as_str().ok_or(PropError::Type)?)
359                    .ok_or(PropError::Value)?;
360                let Caps::RawVideo { format, .. } = &mut self.accept else {
361                    return Err(PropError::Value);
362                };
363                *format = parsed;
364                Ok(())
365            }
366            "input-caps" => {
367                self.accept = fixed_caps(value.as_str().ok_or(PropError::Type)?)?;
368                Ok(())
369            }
370            "output-caps" => {
371                self.produce = Some(fixed_caps(value.as_str().ok_or(PropError::Type)?)?);
372                Ok(())
373            }
374            // Any other property goes to the hosted Python instance. Which names
375            // are real is the class's to say, so a typo is caught when it loads.
376            other => {
377                crate::props::forward(&mut self.params, other, value);
378                Ok(())
379            }
380        }
381    }
382
383    fn get_property(&self, name: &str) -> Option<PropValue> {
384        match name {
385            "module" => Some(PropValue::Str(self.module.clone())),
386            "class" => Some(PropValue::Str(self.class.clone())),
387            "draw-label" => Some(PropValue::Bool(self.draw_label)),
388            "cuda-frames" => Some(PropValue::Bool(self.cuda_frames)),
389            "format" => match &self.accept {
390                Caps::RawVideo { format, .. } => {
391                    Some(PropValue::Str(format_to_py(*format).to_string()))
392                }
393                _ => None,
394            },
395            "input-caps" => Some(PropValue::Str(self.accept.to_gst_string())),
396            "output-caps" => self
397                .produce
398                .as_ref()
399                .map(|c| PropValue::Str(c.to_gst_string())),
400            other => self
401                .params
402                .iter()
403                .find(|(k, _)| k == other)
404                .map(|(_, v)| v.clone()),
405        }
406    }
407}
408
409impl PadTemplates for PyTransform {
410    /// Advertise the default accepted format (RGBA, any geometry) on both pads
411    /// for `gst-inspect` / autoplug. A `pyelement` is a same-format transform,
412    /// so sink and source carry the same set. (`with_accept` can host another
413    /// format programmatically; the launch template reflects the default.)
414    fn pad_templates() -> Vec<PadTemplate> {
415        let rgba = Caps::RawVideo {
416            format: RawVideoFormat::Rgba8,
417            width: Dim::Any,
418            height: Dim::Any,
419            framerate: Rate::Any,
420            interlace: g2g_core::Interlace::Any,
421        };
422        let set = CapsSet::one(rgba);
423        Vec::from([PadTemplate::sink(set.clone()), PadTemplate::source(set)])
424    }
425}
426
427/// `PyTransform`'s settable properties (the runtime / `gst-launch` face).
428static PYTRANSFORM_PROPS: &[PropertySpec] = hosted_element_props![
429    PropertySpec::new(
430        "module",
431        PropKind::Str,
432        "Python module to import (the element shell)",
433    ),
434    PropertySpec::new(
435        "class",
436        PropKind::Str,
437        "class within the module to instantiate",
438    ),
439    PropertySpec::new(
440        "draw-label",
441        PropKind::Bool,
442        "overlay the inferred label on the frame",
443    )
444    .with_default("false"),
445    PropertySpec::new(
446        "format",
447        PropKind::Str,
448        "pixel format the hosted element accepts (RGBA | BGRA | NV12 | I420 | YUY2 | P010_10LE)",
449    )
450    .with_default("RGBA"),
451    PropertySpec::new(
452        "cuda-frames",
453        PropKind::Bool,
454        "host an element that reads GPU-resident CUDA frames (needs g2g_process_cuda, NV12 / P010)",
455    )
456    .with_default("false"),
457    PropertySpec::new(
458        "input-caps",
459        PropKind::Str,
460        "caps accepted on the sink pad, e.g. audio/x-raw,format=S16LE,rate=16000",
461    )
462    .with_default("video/x-raw,format=RGBA"),
463    PropertySpec::new(
464        "output-caps",
465        PropKind::Str,
466        "caps produced downstream when the hosted element changes media type, e.g. text/x-raw,format=utf8",
467    ),
468];