Skip to main content

oxideav_scene/
adapt.rs

1//! Automatic pixel-format adaptation for scene I/O.
2//!
3//! A scene's [`Canvas`] declares the composition's pixel format. Two
4//! places need conversion:
5//!
6//! 1. **Inbound** — when a video / image / live source feeds into a
7//!    scene object. Source frames can be any pixel format (YUV420P
8//!    from an H.264 decoder, BGRA from a capture card, RGB24 from a
9//!    PNG, …); the renderer converts them to the canvas format
10//!    before compositing. Use [`adapt_frame_to_canvas`] for that.
11//!
12//! 2. **Outbound** — when a [`SceneSink`] expects a different format
13//!    than the scene produces. Wrap the source in
14//!    [`AdaptedSource`] — it intercepts each `pull()`, converts the
15//!    rendered frame to the sink's target format, and updates the
16//!    reported [`SourceFormat`] so `init()` tells the sink the
17//!    right thing.
18//!
19//! Both paths delegate to [`oxideav_pixfmt::convert`]. Canvases that
20//! don't declare a raster pixel format (e.g. [`Canvas::Vector`] for
21//! PDF pages) pass frames through unchanged — vector exports don't
22//! go through a raster conversion step.
23
24use oxideav_core::{PixelFormat, Result, VideoFrame};
25use oxideav_pixfmt::{ConvertOptions, FrameInfo};
26
27use crate::object::Canvas;
28use crate::render::RenderedFrame;
29use crate::source::{SceneSource, SourceFormat};
30
31/// Convert `frame` to `target`. No-op when formats already match.
32///
33/// The slim [`VideoFrame`] no longer carries pixel format / dimensions, so
34/// the caller must pass a [`FrameInfo`] describing the source frame.
35pub fn adapt_frame_to(
36    frame: VideoFrame,
37    src_info: FrameInfo,
38    target: PixelFormat,
39) -> Result<VideoFrame> {
40    if src_info.format == target {
41        return Ok(frame);
42    }
43    oxideav_pixfmt::convert(&frame, src_info, target, &ConvertOptions::default())
44}
45
46/// Convert `frame` so it matches the canvas pixel format. For
47/// vector canvases (which don't rasterise) the frame passes through.
48pub fn adapt_frame_to_canvas(
49    frame: VideoFrame,
50    src_info: FrameInfo,
51    canvas: &Canvas,
52) -> Result<VideoFrame> {
53    match canvas {
54        Canvas::Raster { pixel_format, .. } => adapt_frame_to(frame, src_info, *pixel_format),
55        Canvas::Vector { .. } => Ok(frame),
56    }
57}
58
59/// Source wrapper that converts every emitted frame to a target
60/// pixel format.
61///
62/// Overrides the reported [`SourceFormat`] so the downstream sink's
63/// `init()` sees the adapted canvas, not the scene's native one.
64/// Cheap when the formats already match (the adapter short-circuits
65/// in [`adapt_frame_to`]).
66pub struct AdaptedSource<S: SceneSource> {
67    inner: S,
68    target: PixelFormat,
69}
70
71impl<S: SceneSource> AdaptedSource<S> {
72    /// Wrap `inner`, converting every pulled frame to `target`. Use
73    /// this when a sink accepts a specific pixel format that differs
74    /// from the scene's canvas (e.g. RGB24 for a JPEG writer while
75    /// the scene composes in YUV420P).
76    pub fn new(inner: S, target: PixelFormat) -> Self {
77        AdaptedSource { inner, target }
78    }
79
80    /// Access the wrapped source.
81    pub fn inner(&self) -> &S {
82        &self.inner
83    }
84
85    /// Mutable access to the wrapped source — useful for the
86    /// streaming-compositor pattern where the caller mutates scene
87    /// state between pulls.
88    pub fn inner_mut(&mut self) -> &mut S {
89        &mut self.inner
90    }
91}
92
93impl<S: SceneSource> SceneSource for AdaptedSource<S> {
94    fn format(&self) -> SourceFormat {
95        let mut f = self.inner.format();
96        // Swap the pixel format inside a Raster canvas. Vector
97        // canvases pass through — they don't declare one.
98        if let Canvas::Raster {
99            ref mut pixel_format,
100            ..
101        } = f.canvas
102        {
103            *pixel_format = self.target;
104        }
105        f
106    }
107
108    fn pull(&mut self) -> Result<Option<RenderedFrame>> {
109        let inner_canvas = self.inner.format().canvas;
110        let Some(mut frame) = self.inner.pull()? else {
111            return Ok(None);
112        };
113        if let Some(video) = frame.video.take() {
114            // Read the source FrameInfo from the wrapped source's canvas.
115            // Vector canvases don't rasterise — pass through unchanged.
116            if let Canvas::Raster {
117                width,
118                height,
119                pixel_format,
120            } = inner_canvas
121            {
122                let info = FrameInfo::new(pixel_format, width, height);
123                frame.video = Some(adapt_frame_to(video, info, self.target)?);
124            } else {
125                frame.video = Some(video);
126            }
127        }
128        Ok(Some(frame))
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::scene::Scene;
136    use crate::source::SceneSource;
137    use oxideav_core::{Rational, VideoFrame, VideoPlane};
138
139    fn yuv420p_frame(width: u32, height: u32) -> VideoFrame {
140        let y_size = (width * height) as usize;
141        let c_size = ((width / 2) * (height / 2)) as usize;
142        VideoFrame {
143            pts: None,
144            planes: vec![
145                VideoPlane {
146                    stride: width as usize,
147                    data: vec![128; y_size],
148                },
149                VideoPlane {
150                    stride: (width / 2) as usize,
151                    data: vec![128; c_size],
152                },
153                VideoPlane {
154                    stride: (width / 2) as usize,
155                    data: vec![128; c_size],
156                },
157            ],
158        }
159    }
160
161    #[test]
162    fn adapt_to_same_format_is_identity() {
163        let f = yuv420p_frame(8, 8);
164        let info = FrameInfo::new(PixelFormat::Yuv420P, 8, 8);
165        let out = adapt_frame_to(f.clone(), info, PixelFormat::Yuv420P).unwrap();
166        assert_eq!(out.planes[0].data, f.planes[0].data);
167    }
168
169    #[test]
170    fn adapt_to_canvas_vector_passes_through() {
171        let f = yuv420p_frame(8, 8);
172        let info = FrameInfo::new(PixelFormat::Yuv420P, 8, 8);
173        let canvas = Canvas::Vector {
174            width: 595.0,
175            height: 842.0,
176            unit: crate::object::LengthUnit::Point,
177        };
178        let out = adapt_frame_to_canvas(f.clone(), info, &canvas).unwrap();
179        assert_eq!(out.planes[0].data, f.planes[0].data);
180    }
181
182    struct StaticSource {
183        fmt: SourceFormat,
184        frames_left: u32,
185    }
186
187    impl SceneSource for StaticSource {
188        fn format(&self) -> SourceFormat {
189            self.fmt.clone()
190        }
191        fn pull(&mut self) -> Result<Option<RenderedFrame>> {
192            if self.frames_left == 0 {
193                return Ok(None);
194            }
195            self.frames_left -= 1;
196            Ok(Some(RenderedFrame {
197                video: Some(yuv420p_frame(8, 8)),
198                audio: Vec::new(),
199                operations: Vec::new(),
200            }))
201        }
202    }
203
204    #[test]
205    fn adapted_source_reports_target_format() {
206        let scene = Scene {
207            framerate: Rational::new(30, 1),
208            ..Scene::default()
209        };
210        let inner = StaticSource {
211            fmt: SourceFormat::from_scene(&scene),
212            frames_left: 1,
213        };
214        let adapted = AdaptedSource::new(inner, PixelFormat::Rgba);
215        match adapted.format().canvas {
216            Canvas::Raster { pixel_format, .. } => assert_eq!(pixel_format, PixelFormat::Rgba),
217            _ => panic!("expected Raster"),
218        }
219    }
220
221    #[test]
222    fn adapted_source_converts_on_pull() {
223        // Yuv420P → Rgba is a supported pair in oxideav-pixfmt; the
224        // adapter reads the source canvas's pixel format / dimensions
225        // off `inner.format()` (no longer carried per-frame).
226        let scene = Scene::default();
227        let mut inner_fmt = SourceFormat::from_scene(&scene);
228        if let Canvas::Raster {
229            ref mut width,
230            ref mut height,
231            ref mut pixel_format,
232        } = inner_fmt.canvas
233        {
234            *width = 8;
235            *height = 8;
236            *pixel_format = PixelFormat::Yuv420P;
237        }
238        let inner = StaticSource {
239            fmt: inner_fmt,
240            frames_left: 1,
241        };
242        let mut adapted = AdaptedSource::new(inner, PixelFormat::Rgba);
243        let out = adapted.pull().unwrap().expect("frame");
244        let video = out.video.unwrap();
245        // RGBA stride is width*4 = 32 for an 8-wide frame.
246        assert_eq!(video.planes[0].stride, 8 * 4);
247        assert_eq!(video.planes[0].data.len(), 8 * 8 * 4);
248    }
249}