Skip to main content

media_pp/elements/source/test/
video.rs

1use std::{
2    sync::Arc,
3    thread,
4    time::{Duration, Instant},
5};
6
7use crate::pp_log::{PpLog, pp_info};
8use ffmpeg_next as ffmpeg;
9use thiserror::Error as ThisError;
10
11use crate::{
12    buffer::MediaBuffer,
13    bus::{Bus, BusEvent},
14    control::{ControlReceiver, drain_control},
15    element::{Element, ElementType, Source, SourceElement, element_pp_log},
16    pad::SrcPad,
17    pool::UnboundObjectPool,
18    schedule::PeriodicSchedule,
19};
20
21/// Errors specific to `TestVideoSource`. Converts into the crate-wide
22/// `Error` via `?` (see [`crate::error::Error`]).
23#[derive(Debug, ThisError)]
24pub enum TestVideoSourceError {
25    /// Seeking was requested on an unbounded generated stream.
26    #[error("TestVideoSource doesn't support seeking a generated stream")]
27    SeekUnsupported,
28}
29
30/// Construction-time options for [`TestVideoSource::new`].
31#[derive(Debug, Clone, Copy)]
32pub struct TestVideoOptions {
33    /// Width of each generated frame in pixels.
34    pub width: u32,
35    /// Height of each generated frame in pixels.
36    pub height: u32,
37    /// How fast `pts` advances per generated frame, and — since
38    /// [`TestVideoSource`] self-paces to this same rate on a drift-free
39    /// absolute schedule (see its own docs) — how fast frames actually
40    /// get generated/pushed in real time, precisely enough that a
41    /// downstream [`crate::elements::Pacer`] against
42    /// [`TestVideoSource::time_base`] turns out not to be needed purely
43    /// for smooth `D3d12Renderer` output (confirmed in
44    /// `examples/render/test_video`).
45    pub framerate: ffmpeg::Rational,
46}
47
48impl Default for TestVideoOptions {
49    fn default() -> Self {
50        Self {
51            width: 640,
52            height: 480,
53            framerate: ffmpeg::Rational::new(30, 1),
54        }
55    }
56}
57
58/// Generates a synthetic, moving-diagonal-gradient video stream —
59/// GStreamer's `videotestsrc` equivalent. No real decode/demux involved:
60/// `run()` fabricates one `Pixel::YUV420P` frame per tick, stamps it with
61/// an increasing `pts` (one tick per frame, in [`TestVideoSource::time_base`]'s
62/// units), and pushes it straight downstream — useful for exercising
63/// `SwScaler`/`Pacer`/`D3d12Renderer`/etc. without a real file or camera.
64/// `D3d12Renderer` in particular already handles `Pixel::YUV420P` on its
65/// CPU-upload path, so this can feed a renderer directly, no decoder
66/// needed.
67///
68/// Self-paces to `options.framerate` on a drift-free absolute schedule
69/// (`next_due += frame_interval` each tick in `run`, not "sleep
70/// `frame_interval` since the last push" — the latter accumulates drift,
71/// since generation itself always takes some nonzero time) — unlike
72/// `FileDemuxer`/`RtspSource` (which push as fast as they can and leave
73/// real-time pacing entirely to a downstream `Pacer`), this element's
74/// "real time" isn't defined by anything external; it's whatever
75/// `framerate` says it should be, so there's no reason not to generate at
76/// exactly that rate itself.
77///
78/// Confirmed (`examples/render/test_video`, with and without a
79/// downstream `Pacer`) that this is actually enough on its own for
80/// smooth `D3d12Renderer` output, vsync-locked presentation included — an
81/// earlier version of this doc claimed self-pacing alone was *not*
82/// enough and a `Pacer` was still required, reasoning that only the
83/// *average* rate was being kept correct, not *when* each frame lines up
84/// against the vsync grid. That reasoning wasn't wrong about the
85/// mechanism, but the fix turned out to already be in place here: a
86/// relative "since last push" schedule genuinely can drift out of phase
87/// over time, but this element was rewritten to the absolute schedule
88/// described above specifically to close that gap, and testing without a
89/// `Pacer` afterward showed no judder. See
90/// `crate::elements::DxgiCaptureSource`'s own docs for the same
91/// conclusion reached the same way, including a case (`SwScaler` sitting
92/// between source and renderer) this element doesn't have.
93///
94/// Runs until `Stop` — never reaches `Eos` on its own (no frame-count
95/// limit is exposed, deliberately, mirroring a live camera source more
96/// than a file).
97pub struct TestVideoSource {
98    pp_log: PpLog,
99    name: Arc<str>,
100    options: TestVideoOptions,
101    pad: SrcPad,
102    frame_index: i64,
103    /// `1 / options.framerate`, precomputed once — how long to wait
104    /// between generated frames. `Duration::ZERO` (never sleeps, same as
105    /// this element's old unpaced behavior) if `framerate`'s numerator is
106    /// `0`, which would otherwise make this an infinite/undefined
107    /// duration.
108    frame_interval: Duration,
109    /// Reused across every generated frame — see [`UnboundObjectPool`]'s
110    /// docs. `init` builds a fresh `Pixel::YUV420P` frame at this
111    /// element's fixed size; the next `generate_frame` call overwrites
112    /// every pixel anyway, so `release` has nothing to reset.
113    pool: UnboundObjectPool<ffmpeg::frame::Video>,
114}
115
116impl TestVideoSource {
117    /// Creates an unbounded synthetic video source with the requested output definition.
118    pub fn new(name: impl Into<String>, options: TestVideoOptions) -> Self {
119        let name: Arc<str> = name.into().into();
120        let pp_log = element_pp_log(ElementType::TestVideoSource, &name, None);
121        let pad = SrcPad::new(format!("{name}_src"));
122        pp_info!(
123            pp_log: &pp_log,
124            "created: {}x{}, framerate={}",
125            options.width,
126            options.height,
127            options.framerate
128        );
129        let (width, height) = (options.width, options.height);
130        let pool = UnboundObjectPool::new(
131            0,
132            move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height),
133            |_| {},
134        );
135        // See `frame_interval`'s own docs on the `numerator() > 0` guard.
136        let frame_interval = if options.framerate.numerator() > 0 {
137            Duration::from_secs_f64(
138                options.framerate.denominator() as f64 / options.framerate.numerator() as f64,
139            )
140        } else {
141            Duration::ZERO
142        };
143        Self {
144            name,
145            pp_log,
146            options,
147            pad,
148            frame_index: 0,
149            frame_interval,
150            pool,
151        }
152    }
153
154    /// The unit each generated frame's `pts` is expressed in — what you
155    /// need to construct a matching [`crate::elements::Pacer`].
156    pub fn time_base(&self) -> ffmpeg::Rational {
157        ffmpeg::Rational::new(
158            self.options.framerate.denominator(),
159            self.options.framerate.numerator(),
160        )
161    }
162
163    /// Fabricates the next frame: a diagonal gradient on the Y plane that
164    /// shifts by one step per frame (so it visibly moves once played
165    /// back), flat neutral chroma (grayscale — color isn't the point,
166    /// motion/format correctness is).
167    fn generate_frame(&mut self) -> crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video> {
168        let mut frame = self.pool.get();
169
170        let offset = self.frame_index;
171        let width = self.options.width as usize;
172        let y_stride = frame.stride(0);
173        let y_height = frame.plane_height(0) as usize;
174        {
175            let y_plane = frame.data_mut(0);
176            for row in 0..y_height {
177                for col in 0..width {
178                    y_plane[row * y_stride + col] =
179                        ((col as i64 + row as i64 + offset) % 256) as u8;
180                }
181            }
182        }
183        for plane in [1usize, 2usize] {
184            frame.data_mut(plane).fill(128);
185        }
186
187        frame.set_pts(Some(self.frame_index));
188        self.frame_index += 1;
189        frame
190    }
191}
192
193impl Element for TestVideoSource {
194    fn name(&self) -> Arc<str> {
195        self.name.clone()
196    }
197
198    fn element_type(&self) -> ElementType {
199        ElementType::TestVideoSource
200    }
201
202    fn pp_log(&self) -> &crate::pp_log::PpLog {
203        &self.pp_log
204    }
205
206    fn pp_log_mut(&mut self) -> &mut crate::pp_log::PpLog {
207        &mut self.pp_log
208    }
209}
210
211impl Source for TestVideoSource {
212    fn src_pads(&mut self) -> &mut [SrcPad] {
213        std::slice::from_mut(&mut self.pad)
214    }
215}
216
217impl SourceElement for TestVideoSource {
218    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> crate::error::Result<()> {
219        pp_info!(self, "started");
220        let mut schedule = PeriodicSchedule::new(self.frame_interval, Instant::now());
221        loop {
222            let outcome = drain_control(control, self, bus)?;
223            if outcome.stopped {
224                pp_info!(self, "stopped");
225                return Ok(());
226            }
227            if outcome.paused_for > Duration::ZERO {
228                schedule.resume_after_pause(outcome.paused_for, Instant::now());
229            }
230            thread::sleep(schedule.remaining(Instant::now()));
231
232            let frame = self.generate_frame();
233            // A downstream failure drops just this one frame — same
234            // "report, don't die" contract `Queue`'s worker gives a
235            // failing `Sink` — rather than ending this whole source
236            // thread over it.
237            if let Err(error) = self.pad.push(MediaBuffer::Video(Arc::new(frame))) {
238                bus.post(
239                    &self.pp_log,
240                    BusEvent::Error {
241                        element_type: ElementType::TestVideoSource,
242                        name: self.name.clone(),
243                        error,
244                    },
245                );
246            }
247            // Advance only now that this tick's own work (generate + push,
248            // which a slow downstream can stretch arbitrarily) is done —
249            // `advance_after_tick`'s resync check needs `now` to reflect
250            // that, or one abnormally slow tick's own catch-up frame slips
251            // through uncapped before the next iteration ever notices.
252            schedule.advance_after_tick(Instant::now());
253        }
254    }
255
256    fn seek(&mut self, _target: std::time::Duration) -> crate::error::Result<std::time::Duration> {
257        Err(TestVideoSourceError::SeekUnsupported.into())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use std::{sync::Mutex, thread, time::Duration};
264
265    use crate::pp_log::PpLog;
266
267    use super::*;
268    use crate::{control::ControlMsg, element::Sink, pipeline::Pipeline};
269
270    type VideoObservation = (ffmpeg::format::Pixel, u32, u32, Option<i64>);
271    type RecordedFrames = Arc<Mutex<Vec<VideoObservation>>>;
272
273    /// Captures every frame's `(format, width, height, pts)` it sees, in
274    /// order — enough to check both pixel format/size and that `pts`
275    /// actually advances frame over frame.
276    struct RecordingSink {
277        pp_log: PpLog,
278        seen: RecordedFrames,
279    }
280
281    impl Element for RecordingSink {
282        fn name(&self) -> Arc<str> {
283            "recorder".into()
284        }
285        fn element_type(&self) -> ElementType {
286            ElementType::Other
287        }
288        fn pp_log(&self) -> &PpLog {
289            &self.pp_log
290        }
291        fn pp_log_mut(&mut self) -> &mut PpLog {
292            &mut self.pp_log
293        }
294    }
295
296    impl Sink for RecordingSink {
297        fn consume(&mut self, buf: MediaBuffer) -> crate::error::Result<()> {
298            if let MediaBuffer::Video(frame) = buf {
299                self.seen.lock().unwrap().push((
300                    frame.format(),
301                    frame.width(),
302                    frame.height(),
303                    frame.pts(),
304                ));
305            }
306            Ok(())
307        }
308        fn control(&mut self, _msg: ControlMsg) -> crate::error::Result<()> {
309            Ok(())
310        }
311    }
312
313    #[test]
314    fn generates_correctly_sized_yuv420p_frames_with_increasing_pts() {
315        let seen = Arc::new(Mutex::new(Vec::new()));
316        let sink = RecordingSink {
317            seen: seen.clone(),
318            pp_log: element_pp_log(ElementType::Other, "recorder", None),
319        };
320        let source = TestVideoSource::new(
321            "test-video",
322            TestVideoOptions {
323                width: 16,
324                height: 16,
325                framerate: ffmpeg::Rational::new(30, 1),
326            },
327        );
328
329        let pipeline = Pipeline::new("test", source, |source, ctx| {
330            let branch = ctx.branch().to(Box::new(sink))?;
331            ctx.attach(source, 0, branch)?;
332            Ok(())
333        })
334        .expect("test pipeline wiring must succeed");
335
336        pipeline.run().unwrap();
337        // Long enough to observe several ticks at the 30fps `framerate`
338        // above (self-paced since `TestVideoSource` now generates at that
339        // rate itself — see its own docs), not just one or two.
340        thread::sleep(Duration::from_millis(200));
341        pipeline.stop();
342
343        // Blocks until every `Bus` handle has dropped — i.e. the source
344        // thread has actually exited, not just acked `Stop`.
345        pipeline.bus().log_events();
346
347        let frames = seen.lock().unwrap();
348        assert!(!frames.is_empty(), "expected at least one generated frame");
349        for window in frames.windows(2) {
350            let (format, width, height, pts) = window[0];
351            assert_eq!(format, ffmpeg::format::Pixel::YUV420P);
352            assert_eq!((width, height), (16, 16));
353            assert!(
354                window[1].3 > pts,
355                "expected pts to strictly increase frame over frame, got {:?} then {:?}",
356                pts,
357                window[1].3
358            );
359        }
360    }
361
362    #[test]
363    fn seek_is_explicitly_unsupported() {
364        let mut source = TestVideoSource::new("test-video", TestVideoOptions::default());
365        assert!(source.seek(Duration::from_secs(1)).is_err());
366    }
367
368    /// Records the wall-clock `Instant` each frame arrives at, rather than
369    /// its content — what
370    /// [`resuming_after_a_pause_does_not_dump_a_burst_of_catch_up_frames`]
371    /// needs to tell a steady post-resume framerate apart from a burst.
372    struct TimestampSink {
373        pp_log: PpLog,
374        seen: Arc<Mutex<Vec<Instant>>>,
375    }
376
377    impl Element for TimestampSink {
378        fn name(&self) -> Arc<str> {
379            "timestamp-recorder".into()
380        }
381        fn element_type(&self) -> ElementType {
382            ElementType::Other
383        }
384        fn pp_log(&self) -> &PpLog {
385            &self.pp_log
386        }
387        fn pp_log_mut(&mut self) -> &mut PpLog {
388            &mut self.pp_log
389        }
390    }
391
392    impl Sink for TimestampSink {
393        fn consume(&mut self, buf: MediaBuffer) -> crate::error::Result<()> {
394            if matches!(buf, MediaBuffer::Video(_)) {
395                self.seen.lock().unwrap().push(Instant::now());
396            }
397            Ok(())
398        }
399        fn control(&mut self, _msg: ControlMsg) -> crate::error::Result<()> {
400            Ok(())
401        }
402    }
403
404    /// Regression test for the pause/resume scheduling bug: `next_due` is
405    /// an absolute `Instant` deadline, and real time keeps moving while
406    /// [`Pipeline::pause`] blocks this source's own loop inside
407    /// `drain_control`. Without shifting `next_due` forward by however
408    /// long the pause actually lasted (`ControlOutcome::paused_for`),
409    /// `Resume` would find a deadline that's been sitting in the past the
410    /// whole time it was frozen and dump every "missed" frame back to
411    /// back instead of picking the steady framerate back up.
412    #[test]
413    fn resuming_after_a_pause_does_not_dump_a_burst_of_catch_up_frames() {
414        let seen = Arc::new(Mutex::new(Vec::new()));
415        let sink = TimestampSink {
416            seen: seen.clone(),
417            pp_log: element_pp_log(ElementType::Other, "timestamp-recorder", None),
418        };
419        let source = TestVideoSource::new(
420            "test-video",
421            TestVideoOptions {
422                width: 16,
423                height: 16,
424                framerate: ffmpeg::Rational::new(50, 1), // 20ms/frame
425            },
426        );
427
428        let pipeline = Pipeline::new("pause-resume-test", source, |source, ctx| {
429            let branch = ctx.branch().to(Box::new(sink))?;
430            ctx.attach(source, 0, branch)?;
431            Ok(())
432        })
433        .expect("test pipeline wiring must succeed");
434
435        pipeline.run().unwrap();
436        thread::sleep(Duration::from_millis(60));
437        pipeline.pause();
438        thread::sleep(Duration::from_millis(400));
439
440        let resumed_at = Instant::now();
441        pipeline.resume();
442        thread::sleep(Duration::from_millis(120));
443        pipeline.stop();
444        pipeline.bus().log_events();
445
446        let after_resume = seen
447            .lock()
448            .unwrap()
449            .iter()
450            .filter(|&&t| t >= resumed_at)
451            .count();
452        // At 50fps, ~120ms of real time after resume owes ~6 frames.
453        // Well under this bound if paced steadily; a 400ms pause treated
454        // as owed catch-up work would dump ~20 frames virtually at once,
455        // comfortably clearing it.
456        assert!(
457            after_resume <= 12,
458            "expected a steady framerate after resume, not a burst of catch-up frames: \
459             {after_resume} frames arrived within 120ms of resuming"
460        );
461    }
462
463    struct SlowFirstFrameSink {
464        pp_log: PpLog,
465        tx: crossbeam_channel::Sender<Instant>,
466        slow_duration: Duration,
467        delayed: bool,
468    }
469
470    impl Element for SlowFirstFrameSink {
471        fn name(&self) -> Arc<str> {
472            "slow-sink".into()
473        }
474        fn element_type(&self) -> ElementType {
475            ElementType::Other
476        }
477        fn pp_log(&self) -> &PpLog {
478            &self.pp_log
479        }
480        fn pp_log_mut(&mut self) -> &mut PpLog {
481            &mut self.pp_log
482        }
483    }
484
485    impl Sink for SlowFirstFrameSink {
486        fn consume(&mut self, buf: MediaBuffer) -> crate::error::Result<()> {
487            if matches!(buf, MediaBuffer::Video(_)) {
488                if !self.delayed {
489                    self.delayed = true;
490                    thread::sleep(self.slow_duration);
491                }
492                // Timestamped after any delay, not before — this marks
493                // when the downstream actually became free again, the
494                // reference point the next frame's arrival gets measured
495                // against.
496                let _ = self.tx.send(Instant::now());
497            }
498            Ok(())
499        }
500        fn control(&mut self, _msg: ControlMsg) -> crate::error::Result<()> {
501            Ok(())
502        }
503    }
504
505    /// Regression test for the missing processing-delay clamp:
506    /// `next_due += self.frame_interval` alone (no follow-up "did that
507    /// still land in the past?" check) let one abnormally slow downstream
508    /// `consume()` call leave `next_due` many intervals behind `now`, and
509    /// every one of those intervals would fire back-to-back with no sleep
510    /// between them as soon as the loop got a chance to run again — a
511    /// burst of catch-up frames. `SwVideoCompositor::run` already guarded
512    /// its own composition step this way; `TestVideoSource::run` now
513    /// applies the same clamp right after advancing `next_due` — and only
514    /// *after* generate+push (this test's other regression: advancing
515    /// before push meant the clamp couldn't see the slow tick's own delay
516    /// until the following iteration, letting exactly one immediate
517    /// catch-up frame slip through right after the slow one finished).
518    #[test]
519    fn a_slow_sink_does_not_cause_a_burst_of_catch_up_frames() {
520        let (tx, rx) = crossbeam_channel::unbounded();
521        let sink = SlowFirstFrameSink {
522            tx,
523            slow_duration: Duration::from_millis(300),
524            delayed: false,
525            pp_log: element_pp_log(ElementType::Other, "slow-sink", None),
526        };
527        let source = TestVideoSource::new(
528            "test-video",
529            TestVideoOptions {
530                width: 16,
531                height: 16,
532                framerate: ffmpeg::Rational::new(20, 1), // 50ms/frame
533            },
534        );
535
536        let pipeline = Pipeline::new("slow-sink-test", source, |source, ctx| {
537            let branch = ctx.branch().to(Box::new(sink))?;
538            ctx.attach(source, 0, branch)?;
539            Ok(())
540        })
541        .expect("test pipeline wiring must succeed");
542
543        pipeline.run().unwrap();
544        let slow_done = rx
545            .recv_timeout(Duration::from_secs(1))
546            .expect("expected the first (slow) frame to finish");
547        let after_slow = rx
548            .recv_timeout(Duration::from_millis(500))
549            .expect("expected the frame right after the slow one");
550        let steady = rx
551            .recv_timeout(Duration::from_millis(500))
552            .expect("expected a third frame at steady cadence");
553        pipeline.stop();
554        pipeline.bus().log_events();
555
556        let immediate_gap = after_slow.saturating_duration_since(slow_done);
557        assert!(
558            immediate_gap >= Duration::from_millis(25),
559            "expected the frame right after the slow one to wait a steady \
560             ~50ms interval, not follow immediately just because the slow \
561             sink had finally caught up: got {immediate_gap:?}"
562        );
563
564        let gap = steady.saturating_duration_since(after_slow);
565        assert!(
566            gap >= Duration::from_millis(25),
567            "expected steady ~50ms cadence once the slow sink caught up, not a \
568             burst of catch-up frames immediately following it: got {gap:?}"
569        );
570    }
571}