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