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