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;
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.
32pub fn adapt_frame_to(frame: VideoFrame, target: PixelFormat) -> Result<VideoFrame> {
33    if frame.format == target {
34        return Ok(frame);
35    }
36    oxideav_pixfmt::convert(&frame, target, &ConvertOptions::default())
37}
38
39/// Convert `frame` so it matches the canvas pixel format. For
40/// vector canvases (which don't rasterise) the frame passes through.
41pub fn adapt_frame_to_canvas(frame: VideoFrame, canvas: &Canvas) -> Result<VideoFrame> {
42    match canvas {
43        Canvas::Raster { pixel_format, .. } => adapt_frame_to(frame, *pixel_format),
44        Canvas::Vector { .. } => Ok(frame),
45    }
46}
47
48/// Source wrapper that converts every emitted frame to a target
49/// pixel format.
50///
51/// Overrides the reported [`SourceFormat`] so the downstream sink's
52/// `init()` sees the adapted canvas, not the scene's native one.
53/// Cheap when the formats already match (the adapter short-circuits
54/// in [`adapt_frame_to`]).
55pub struct AdaptedSource<S: SceneSource> {
56    inner: S,
57    target: PixelFormat,
58}
59
60impl<S: SceneSource> AdaptedSource<S> {
61    /// Wrap `inner`, converting every pulled frame to `target`. Use
62    /// this when a sink accepts a specific pixel format that differs
63    /// from the scene's canvas (e.g. RGB24 for a JPEG writer while
64    /// the scene composes in YUV420P).
65    pub fn new(inner: S, target: PixelFormat) -> Self {
66        AdaptedSource { inner, target }
67    }
68
69    /// Access the wrapped source.
70    pub fn inner(&self) -> &S {
71        &self.inner
72    }
73
74    /// Mutable access to the wrapped source — useful for the
75    /// streaming-compositor pattern where the caller mutates scene
76    /// state between pulls.
77    pub fn inner_mut(&mut self) -> &mut S {
78        &mut self.inner
79    }
80}
81
82impl<S: SceneSource> SceneSource for AdaptedSource<S> {
83    fn format(&self) -> SourceFormat {
84        let mut f = self.inner.format();
85        // Swap the pixel format inside a Raster canvas. Vector
86        // canvases pass through — they don't declare one.
87        if let Canvas::Raster {
88            ref mut pixel_format,
89            ..
90        } = f.canvas
91        {
92            *pixel_format = self.target;
93        }
94        f
95    }
96
97    fn pull(&mut self) -> Result<Option<RenderedFrame>> {
98        let Some(mut frame) = self.inner.pull()? else {
99            return Ok(None);
100        };
101        if let Some(video) = frame.video.take() {
102            frame.video = Some(adapt_frame_to(video, self.target)?);
103        }
104        Ok(Some(frame))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::scene::Scene;
112    use crate::source::SceneSource;
113    use oxideav_core::{Rational, TimeBase, VideoFrame, VideoPlane};
114
115    fn yuv420p_frame(width: u32, height: u32) -> VideoFrame {
116        let y_size = (width * height) as usize;
117        let c_size = ((width / 2) * (height / 2)) as usize;
118        VideoFrame {
119            format: PixelFormat::Yuv420P,
120            width,
121            height,
122            pts: None,
123            time_base: TimeBase::new(1, 30),
124            planes: vec![
125                VideoPlane {
126                    stride: width as usize,
127                    data: vec![128; y_size],
128                },
129                VideoPlane {
130                    stride: (width / 2) as usize,
131                    data: vec![128; c_size],
132                },
133                VideoPlane {
134                    stride: (width / 2) as usize,
135                    data: vec![128; c_size],
136                },
137            ],
138        }
139    }
140
141    #[test]
142    fn adapt_to_same_format_is_identity() {
143        let f = yuv420p_frame(8, 8);
144        let out = adapt_frame_to(f.clone(), PixelFormat::Yuv420P).unwrap();
145        assert_eq!(out.format, PixelFormat::Yuv420P);
146        assert_eq!(out.planes[0].data, f.planes[0].data);
147    }
148
149    #[test]
150    fn adapt_to_canvas_vector_passes_through() {
151        let f = yuv420p_frame(8, 8);
152        let canvas = Canvas::Vector {
153            width: 595.0,
154            height: 842.0,
155            unit: crate::object::LengthUnit::Point,
156        };
157        let out = adapt_frame_to_canvas(f, &canvas).unwrap();
158        assert_eq!(out.format, PixelFormat::Yuv420P);
159    }
160
161    struct StaticSource {
162        fmt: SourceFormat,
163        frames_left: u32,
164    }
165
166    impl SceneSource for StaticSource {
167        fn format(&self) -> SourceFormat {
168            self.fmt.clone()
169        }
170        fn pull(&mut self) -> Result<Option<RenderedFrame>> {
171            if self.frames_left == 0 {
172                return Ok(None);
173            }
174            self.frames_left -= 1;
175            Ok(Some(RenderedFrame {
176                video: Some(yuv420p_frame(8, 8)),
177                audio: Vec::new(),
178                operations: Vec::new(),
179            }))
180        }
181    }
182
183    #[test]
184    fn adapted_source_reports_target_format() {
185        let scene = Scene {
186            framerate: Rational::new(30, 1),
187            ..Scene::default()
188        };
189        let inner = StaticSource {
190            fmt: SourceFormat::from_scene(&scene),
191            frames_left: 1,
192        };
193        let adapted = AdaptedSource::new(inner, PixelFormat::Rgba);
194        match adapted.format().canvas {
195            Canvas::Raster { pixel_format, .. } => assert_eq!(pixel_format, PixelFormat::Rgba),
196            _ => panic!("expected Raster"),
197        }
198    }
199
200    #[test]
201    fn adapted_source_converts_on_pull() {
202        // Yuv420P → Rgba is a supported pair in oxideav-pixfmt; the
203        // conversion just needs to produce a frame whose `format`
204        // field is now Rgba.
205        let scene = Scene::default();
206        let inner = StaticSource {
207            fmt: SourceFormat::from_scene(&scene),
208            frames_left: 1,
209        };
210        let mut adapted = AdaptedSource::new(inner, PixelFormat::Rgba);
211        let out = adapted.pull().unwrap().expect("frame");
212        let video = out.video.unwrap();
213        assert_eq!(video.format, PixelFormat::Rgba);
214        assert_eq!(video.width, 8);
215        assert_eq!(video.height, 8);
216    }
217}