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];