Skip to main content

media_pp\elements\filter/
scaler.rs

1use std::sync::Arc;
2
3use crate::pp_log::{PpLog, pp_error, pp_info};
4use ffmpeg_next as ffmpeg;
5use thiserror::Error as ThisError;
6
7use crate::{
8    buffer::MediaBuffer,
9    control::ControlMsg,
10    element::{Element, ElementType, Sink, Source, element_pp_log},
11    error::Result,
12    pad::SrcPad,
13    pool::UnboundObjectPool,
14};
15
16/// How many output frames [`Scaler`] pre-allocates up front. Unlike
17/// [`crate::elements::SwDecoder`]/[`crate::elements::D3d12vaDecoder`],
18/// this doesn't have to start empty and grow — `dst_format`/`dst_width`/
19/// `dst_height` are known at construction time, so the pool can be
20/// correctly sized from the very first frame instead of paying for a
21/// handful of allocations up front, amortized. Not exposed as a
22/// constructor parameter (yet): this is a reasonable default for "a
23/// `Queue` or two downstream," not a hard limit — the pool still grows
24/// past this if more frames end up in flight at once.
25const POOL_SIZE: usize = 4;
26
27/// Errors specific to `Scaler`. Converts into the crate-wide `Error` via
28/// `?` (see [`crate::error::Error`]).
29#[derive(Debug, ThisError)]
30pub enum ScalerError {
31    #[error("ffmpeg error: {0}")]
32    Ffmpeg(#[from] ffmpeg::Error),
33
34    #[error(
35        "Scaler only converts/resizes decoded Video frames, got a {0}; \
36         link it straight after a decoder, not a demuxer"
37    )]
38    UnsupportedBuffer(&'static str),
39}
40
41/// Converts/resizes decoded video frames — pixel format (e.g. the YUV a
42/// decoder produces -> the RGB most inference models expect) and
43/// resolution (source resolution -> a model's fixed input size) in one
44/// pass via `libswscale`. A `Filter`: receives via `Sink`, pushes the
45/// converted frame on through its own (single) src pad.
46///
47/// Typical placement: right before something with a fixed input
48/// contract, e.g. an ONNX object-detection model — not a general-purpose
49/// pipeline stage, so most chains won't need one at all.
50pub struct Scaler {
51    pp_log: PpLog,
52    name: Arc<str>,
53    dst_format: ffmpeg::format::Pixel,
54    dst_width: u32,
55    dst_height: u32,
56    flags: ffmpeg::software::scaling::Flags,
57    /// Built lazily from the *first* frame's own format/dimensions
58    /// (rather than requiring the caller to pass them up front) and
59    /// rebuilt in place — via `Context::cached`, cheaper than tearing
60    /// down and reallocating from scratch — if a later frame's
61    /// format/dimensions ever differ (e.g. mid-stream resolution
62    /// change). `None` until the first frame arrives.
63    context: Option<ffmpeg::software::scaling::Context>,
64    /// Reused across every scaled frame instead of allocating a fresh one
65    /// each time — see [`UnboundObjectPool`]'s docs. Pre-filled to
66    /// `dst_format`/`dst_width`/`dst_height` in `new` (unlike a decoder's
67    /// pool, the output shape here is known up front, not learned from
68    /// the first frame).
69    pool: UnboundObjectPool<ffmpeg::frame::Video>,
70    pad: SrcPad,
71}
72
73// SAFETY: `ffmpeg::software::scaling::Context` wraps a heap-allocated
74// `SwsContext` with no thread affinity of its own — ffmpeg-next marks
75// the analogous audio `resampling::Context` (`SwrContext`) and every
76// codec type `Send` for the same reason, this one's just missing it.
77// `&mut self` on every method that touches it (see `D3d12vaDecoder`'s
78// `hw_device_ctx` for the same reasoning) already rules out concurrent
79// access from multiple threads.
80unsafe impl Send for Scaler {}
81
82impl Scaler {
83    /// `dst_format`/`dst_width`/`dst_height` describe what every output
84    /// frame will be; the source side is learned automatically from
85    /// whatever frames actually arrive (see `context`'s docs), so this
86    /// doesn't need decoder parameters up front the way
87    /// [`crate::elements::SwDecoder::new`] does.
88    pub fn new(
89        name: impl Into<String>,
90        dst_format: ffmpeg::format::Pixel,
91        dst_width: u32,
92        dst_height: u32,
93        flags: ffmpeg::software::scaling::Flags,
94    ) -> Self {
95        let name: Arc<str> = name.into().into();
96        let pp_log = element_pp_log(ElementType::Scaler, &name, None);
97        pp_info!(
98            pp_log: &pp_log,
99            "created: dst_format={dst_format:?}, dst={dst_width}x{dst_height}"
100        );
101        let pad = SrcPad::new(format!("{name}_src"));
102        let pool = UnboundObjectPool::new(
103            POOL_SIZE,
104            move || ffmpeg::frame::Video::new(dst_format, dst_width, dst_height),
105            |_| {},
106        );
107        Self {
108            name,
109            pp_log,
110            dst_format,
111            dst_width,
112            dst_height,
113            flags,
114            context: None,
115            pool,
116            pad,
117        }
118    }
119
120    /// Whether `self.context` (if any) is already configured for `frame`'s
121    /// own format/dimensions — if not, `consume` has to (re)build it
122    /// before scaling can proceed.
123    fn context_matches(&self, frame: &ffmpeg::frame::Video) -> bool {
124        match &self.context {
125            Some(context) => {
126                let input = context.input();
127                input.format == frame.format()
128                    && input.width == frame.width()
129                    && input.height == frame.height()
130            }
131            None => false,
132        }
133    }
134}
135
136impl Element for Scaler {
137    fn name(&self) -> Arc<str> {
138        self.name.clone()
139    }
140
141    fn element_type(&self) -> ElementType {
142        ElementType::Scaler
143    }
144
145    fn pp_log(&self) -> &PpLog {
146        &self.pp_log
147    }
148
149    fn pp_log_mut(&mut self) -> &mut PpLog {
150        &mut self.pp_log
151    }
152}
153
154impl Source for Scaler {
155    fn src_pads(&mut self) -> &mut [SrcPad] {
156        std::slice::from_mut(&mut self.pad)
157    }
158}
159
160impl Sink for Scaler {
161    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
162        match buf {
163            MediaBuffer::Video(frame) => {
164                if !self.context_matches(&frame) {
165                    match &mut self.context {
166                        Some(context) => context.cached(
167                            frame.format(),
168                            frame.width(),
169                            frame.height(),
170                            self.dst_format,
171                            self.dst_width,
172                            self.dst_height,
173                            self.flags,
174                        ),
175                        None => {
176                            self.context = Some(
177                                ffmpeg::software::scaling::Context::get(
178                                    frame.format(),
179                                    frame.width(),
180                                    frame.height(),
181                                    self.dst_format,
182                                    self.dst_width,
183                                    self.dst_height,
184                                    self.flags,
185                                )
186                                .inspect_err(|error| {
187                                    pp_error!(self, "failed to build scaling context: {error}")
188                                })
189                                .map_err(ScalerError::from)?,
190                            );
191                        }
192                    }
193                }
194
195                // Already allocated to `dst_format`/`dst_width`/
196                // `dst_height` (see `pool`'s docs), so `run` skips its own
197                // allocation and scales straight into this buffer.
198                let mut output = self.pool.get();
199                self.context
200                    .as_mut()
201                    .expect("built or confirmed matching above")
202                    .run(&frame, &mut output)
203                    .inspect_err(|error| pp_error!(self, "scale failed: {error}"))
204                    .map_err(ScalerError::from)?;
205                // `run` only copies pixel data, not metadata — carry the
206                // pts through by hand so downstream pacing/muxing still
207                // sees the original timestamp.
208                output.set_pts(frame.pts());
209
210                self.pad.push(MediaBuffer::Video(Arc::new(output)))
211            }
212            MediaBuffer::Eos => self.pad.push(MediaBuffer::Eos),
213            MediaBuffer::Packet(_) => {
214                pp_error!(self, "unsupported buffer: Packet");
215                Err(ScalerError::UnsupportedBuffer("Packet").into())
216            }
217            MediaBuffer::Audio(_) => {
218                pp_error!(self, "unsupported buffer: Audio");
219                Err(ScalerError::UnsupportedBuffer("Audio").into())
220            }
221        }
222    }
223
224    fn control(&mut self, msg: ControlMsg) -> Result<()> {
225        // Nothing local to react to for any `ControlMsg`: unlike a
226        // decoder, this has no reference-frame/reordering state to
227        // flush on `Seek`, and nothing buffered to drop on `Stop` — a
228        // pure per-frame spatial transform, so just forward.
229        self.pad.control(msg)
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::sync::Mutex;
236
237    use super::*;
238
239    struct CapturingSink {
240        pp_log: PpLog,
241        received: Arc<Mutex<Vec<MediaBuffer>>>,
242    }
243
244    impl Element for CapturingSink {
245        fn name(&self) -> Arc<str> {
246            "capture".into()
247        }
248
249        fn element_type(&self) -> ElementType {
250            ElementType::Other
251        }
252
253        fn pp_log(&self) -> &PpLog {
254            &self.pp_log
255        }
256
257        fn pp_log_mut(&mut self) -> &mut PpLog {
258            &mut self.pp_log
259        }
260    }
261
262    impl Sink for CapturingSink {
263        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
264            self.received.lock().unwrap().push(buf);
265            Ok(())
266        }
267
268        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
269            Ok(())
270        }
271    }
272
273    fn video_frame(
274        format: ffmpeg::format::Pixel,
275        width: u32,
276        height: u32,
277        pts: i64,
278    ) -> MediaBuffer {
279        let pool = UnboundObjectPool::new(
280            0,
281            move || ffmpeg::frame::Video::new(format, width, height),
282            |_| {},
283        );
284        let mut frame = pool.get();
285        frame.set_pts(Some(pts));
286        MediaBuffer::Video(Arc::new(frame))
287    }
288
289    fn new_scaler(
290        dst_format: ffmpeg::format::Pixel,
291        dst_width: u32,
292        dst_height: u32,
293    ) -> (Scaler, Arc<Mutex<Vec<MediaBuffer>>>) {
294        let mut scaler = Scaler::new(
295            "scaler",
296            dst_format,
297            dst_width,
298            dst_height,
299            ffmpeg::software::scaling::Flags::BILINEAR,
300        );
301        let received = Arc::new(Mutex::new(Vec::new()));
302        scaler.src_pads()[0].link(Box::new(CapturingSink {
303            received: received.clone(),
304            pp_log: element_pp_log(ElementType::Other, "capture", None),
305        }));
306        (scaler, received)
307    }
308
309    #[test]
310    fn converts_pixel_format_and_size_while_preserving_pts() {
311        let (mut scaler, received) = new_scaler(ffmpeg::format::Pixel::RGB24, 80, 60);
312        scaler
313            .consume(video_frame(ffmpeg::format::Pixel::YUV420P, 160, 120, 4242))
314            .expect("scale must succeed");
315
316        let received = received.lock().unwrap();
317        assert_eq!(received.len(), 1);
318        let MediaBuffer::Video(frame) = &received[0] else {
319            panic!("expected a Video buffer");
320        };
321        assert_eq!(frame.format(), ffmpeg::format::Pixel::RGB24);
322        assert_eq!(frame.width(), 80);
323        assert_eq!(frame.height(), 60);
324        assert_eq!(frame.pts(), Some(4242));
325    }
326
327    /// `context_matches` has to catch a mid-stream resolution change and
328    /// rebuild, not silently keep scaling from a stale `sws_scale` context
329    /// built for the previous frame's dimensions.
330    #[test]
331    fn rebuilds_its_scaling_context_when_input_dimensions_change_mid_stream() {
332        let (mut scaler, received) = new_scaler(ffmpeg::format::Pixel::RGB24, 80, 60);
333        scaler
334            .consume(video_frame(ffmpeg::format::Pixel::YUV420P, 160, 120, 0))
335            .expect("first frame must scale");
336        scaler
337            .consume(video_frame(ffmpeg::format::Pixel::YUV420P, 320, 240, 1))
338            .expect("a differently-sized second frame must still scale, not reuse a stale context");
339
340        let received = received.lock().unwrap();
341        assert_eq!(received.len(), 2);
342        for buf in received.iter() {
343            let MediaBuffer::Video(frame) = buf else {
344                panic!("expected a Video buffer");
345            };
346            assert_eq!(frame.width(), 80);
347            assert_eq!(frame.height(), 60);
348        }
349    }
350
351    #[test]
352    fn eos_forwards_downstream() {
353        let (mut scaler, received) = new_scaler(ffmpeg::format::Pixel::RGB24, 80, 60);
354        scaler
355            .consume(MediaBuffer::Eos)
356            .expect("eos must forward cleanly");
357        assert!(matches!(
358            received.lock().unwrap().as_slice(),
359            [MediaBuffer::Eos]
360        ));
361    }
362
363    #[test]
364    fn rejects_packet_and_audio_buffers_with_a_clean_error_instead_of_scaling_garbage() {
365        let (mut scaler, _received) = new_scaler(ffmpeg::format::Pixel::RGB24, 80, 60);
366
367        let packet = MediaBuffer::Packet(Arc::new(ffmpeg::Packet::empty()));
368        assert!(
369            scaler.consume(packet).is_err(),
370            "Packet must be rejected, not silently accepted"
371        );
372
373        let audio = MediaBuffer::Audio(Arc::new(ffmpeg::frame::Audio::empty()));
374        assert!(
375            scaler.consume(audio).is_err(),
376            "Audio must be rejected, not silently accepted"
377        );
378    }
379}