Skip to main content

media_pp/elements/source/compositor/
sw_video_compositor.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::{
4        Arc, Mutex, Weak,
5        atomic::{AtomicU64, Ordering},
6    },
7    thread,
8    time::{Duration, Instant},
9};
10
11use crate::pp_log::{PpLog, pp_info};
12use crate::rate::FrameRate;
13use arc_swap::ArcSwapOption;
14use ffmpeg_next::{self as ffmpeg, ffi};
15use thiserror::Error as ThisError;
16
17use super::video_layer::{
18    self, LayerGeometry, MAX_DIMENSION, VideoFit, VideoInputId, VideoLayer, VideoLayerError,
19    VideoRect, VideoSourceRect,
20};
21use crate::{
22    buffer::{MediaBuffer, picture_id, picture_is_referenced, release_picture},
23    bus::{Bus, BusEvent},
24    color::Color,
25    contract::{InputContract, MediaKind, MemoryDomain, OutputContract, PortContract},
26    control::{ControlMsg, ControlReceiver, drain_control},
27    element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
28    error::Result,
29    pad::SrcPad,
30    pool::{UnboundObjectPool, UnboundObjectPoolRef},
31    schedule::PeriodicSchedule,
32};
33
34const OUTPUT_POOL_SIZE: usize = 4;
35const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(5);
36
37/// The compositor's fixed output definition. Every emitted frame is an
38/// opaque [`ffmpeg::format::Pixel::BGRA`] frame at `width` x `height`.
39#[derive(Debug, Clone, Copy)]
40pub struct VideoCompositorOptions {
41    /// Width of the composed output frame in pixels.
42    pub width: u32,
43    /// Height of the composed output frame in pixels.
44    pub height: u32,
45    /// Fixed output frame rate; both rational components must be positive.
46    pub frame_rate: ffmpeg::Rational,
47    /// Color used for output pixels not covered by an opaque layer.
48    pub background: Color,
49}
50
51impl Default for VideoCompositorOptions {
52    fn default() -> Self {
53        Self {
54            width: 1920,
55            height: 1080,
56            frame_rate: ffmpeg::Rational::new(30, 1),
57            background: Color::BLACK,
58        }
59    }
60}
61
62/// Errors specific to [`SwVideoCompositor`].
63#[derive(Debug, ThisError)]
64pub enum SwVideoCompositorError {
65    /// FFmpeg rejected frame allocation or software scaling.
66    #[error("ffmpeg error: {0}")]
67    Ffmpeg(#[from] ffmpeg::Error),
68
69    /// FFmpeg could not take a second reference to the frame last composed,
70    /// which is how an unchanged picture is offered again.
71    #[error("failed to reference the previous composite (code {0})")]
72    FrameRef(i32),
73
74    /// The output canvas dimensions are zero or exceed the safety limit.
75    #[error(
76        "invalid output dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
77    )]
78    InvalidOutputDimensions {
79        /// Invalid output width in pixels.
80        width: u32,
81        /// Invalid output height in pixels.
82        height: u32,
83    },
84
85    /// The output frame-rate numerator or denominator is non-positive.
86    #[error("invalid frame rate {0}; numerator and denominator must both be positive")]
87    InvalidFrameRate(ffmpeg::Rational),
88
89    /// A layer destination rectangle is zero-sized or exceeds the safety limit.
90    #[error(
91        "invalid layer dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
92    )]
93    InvalidLayerDimensions {
94        /// Invalid layer width in output pixels.
95        width: u32,
96        /// Invalid layer height in output pixels.
97        height: u32,
98    },
99
100    /// A layer opacity is non-finite or outside `0.0..=1.0`.
101    #[error("layer opacity must be finite and between 0.0 and 1.0, got {0}")]
102    InvalidOpacity(f32),
103
104    /// A layer's source region is empty. Hiding a layer is
105    /// [`VideoLayer::visible`]; asking it to draw nothing is a mistake.
106    #[error("layer source region has invalid dimensions {width}x{height}")]
107    InvalidSourceRegion {
108        /// Region width as given.
109        width: u32,
110        /// Region height as given.
111        height: u32,
112    },
113
114    /// An input frame reports a zero width or height.
115    #[error("input frame has invalid dimensions {width}x{height}")]
116    InvalidInputDimensions {
117        /// Invalid input width in pixels.
118        width: u32,
119        /// Invalid input height in pixels.
120        height: u32,
121    },
122
123    /// Aspect-ratio fitting would create an intermediate image above the safety limit.
124    #[error("scaled layer would exceed {MAX_DIMENSION}px: {width}x{height}")]
125    ScaledLayerTooLarge {
126        /// Computed scaled width in pixels.
127        width: u32,
128        /// Computed scaled height in pixels.
129        height: u32,
130    },
131
132    /// A runtime layer handle refers to an input that has been removed or replaced.
133    #[error("the compositor input has been removed")]
134    SourceRemoved,
135
136    /// An input sink received a buffer other than decoded video.
137    #[error(
138        "SwVideoCompositorInputSink only accepts decoded Video frames, got a {0}; link it after a decoder or video source"
139    )]
140    UnsupportedBuffer(&'static str),
141
142    /// Seeking was requested on a live compositor with no stored timeline.
143    #[error("SwVideoCompositor doesn't support seeking a live composition")]
144    SeekUnsupported,
145}
146
147struct VideoInput {
148    id: VideoInputId,
149    /// The hot producer/consumer path is an atomic latest-value slot:
150    /// input pipelines replace the pointer without taking the layer lock,
151    /// and the compositor acquires a stable Arc snapshot independently.
152    latest_frame: ArcSwapOption<UnboundObjectPoolRef<ffmpeg::frame::Video>>,
153    /// Layer changes are infrequent and update several related fields as
154    /// one coherent value, so a small dedicated lock remains appropriate.
155    layer: Mutex<VideoLayer>,
156}
157
158struct CompositorShared {
159    inputs: Mutex<HashMap<Arc<str>, Arc<VideoInput>>>,
160    next_input_id: AtomicU64,
161    /// The output rate, which the tick loop reads each pass and
162    /// [`SwVideoCompositorHandle::set_frame_rate`] writes.
163    frame_rate: Arc<FrameRate>,
164}
165
166/// A cheaply cloneable handle for adding and removing compositor inputs.
167/// It mirrors [`crate::elements::MixerHandle`], but each registration also
168/// returns a [`SwVideoLayerHandle`] for changing that input's placement.
169#[derive(Clone)]
170pub struct SwVideoCompositorHandle {
171    shared: Weak<CompositorShared>,
172}
173
174/// The two endpoints created for one compositor input registration.
175/// Move `sink` into the upstream pipeline and retain `layer` in application
176/// code for runtime placement changes.
177pub struct SwVideoCompositorInput {
178    /// Terminal sink to attach to the input pipeline branch.
179    pub sink: Box<dyn Sink>,
180    /// Runtime control for this input's placement and visibility.
181    pub layer: SwVideoLayerHandle,
182}
183
184impl SwVideoCompositorHandle {
185    /// Registers an input and returns its terminal Sink plus independent
186    /// runtime layer control. Reusing `name` replaces the old registration;
187    /// old sinks and layer handles become harmlessly stale.
188    pub fn add_source(
189        &self,
190        name: impl Into<String>,
191        layer: VideoLayer,
192    ) -> std::result::Result<Option<SwVideoCompositorInput>, SwVideoCompositorError> {
193        validate_layer(layer)?;
194        let Some(shared) = self.shared.upgrade() else {
195            return Ok(None);
196        };
197        let name: Arc<str> = name.into().into();
198        let id = VideoInputId(shared.next_input_id.fetch_add(1, Ordering::Relaxed));
199        let input = Arc::new(VideoInput {
200            id,
201            latest_frame: ArcSwapOption::empty(),
202            layer: Mutex::new(layer),
203        });
204        shared
205            .inputs
206            .lock()
207            .unwrap()
208            .insert(name.clone(), input.clone());
209
210        Ok(Some(SwVideoCompositorInput {
211            sink: Box::new(SwVideoCompositorInputSink {
212                name: name.clone(),
213                pp_log: element_pp_log(ElementType::SwVideoCompositor, &name, None),
214                shared: self.shared.clone(),
215                input: Arc::downgrade(&input),
216            }),
217            layer: SwVideoLayerHandle {
218                id,
219                name,
220                input: Arc::downgrade(&input),
221            },
222        }))
223    }
224
225    /// Removes `name` immediately. Existing input sinks and layer handles
226    /// become disconnected and cannot affect a later same-name source.
227    pub fn remove_source(&self, name: &str) {
228        if let Some(shared) = self.shared.upgrade() {
229            shared.inputs.lock().unwrap().remove(name);
230        }
231    }
232
233    /// Returns the number of compositor inputs currently registered.
234    ///
235    /// Returns zero after the compositor has been dropped.
236    /// Changes the rate this compositor emits at, from the next tick.
237    ///
238    /// Returns `false` for a rate that is not positive, and for a compositor
239    /// that has already been dropped. The same contract as the GPU
240    /// compositors' setters, and with the same caveat: [`
241    /// SwVideoCompositor::time_base`] is the reciprocal of this and the output
242    /// `pts` is a tick counter in those units, so a change re-means every
243    /// timestamp after it while the ones already downstream were stamped under
244    /// the old rate — see [`crate::rate`].
245    pub fn set_frame_rate(&self, frame_rate: ffmpeg::Rational) -> bool {
246        self.shared
247            .upgrade()
248            .is_some_and(|shared| shared.frame_rate.set(frame_rate))
249    }
250
251    /// The rate this compositor is emitting at, or `None` once it is gone.
252    ///
253    /// Read back rather than remembered by the caller: a rate refused by
254    /// [`Self::set_frame_rate`] leaves the old one in place.
255    pub fn frame_rate(&self) -> Option<ffmpeg::Rational> {
256        Some(self.shared.upgrade()?.frame_rate.get())
257    }
258
259    pub fn source_count(&self) -> usize {
260        self.shared
261            .upgrade()
262            .map(|shared| shared.inputs.lock().unwrap().len())
263            .unwrap_or(0)
264    }
265}
266
267/// Thread-safe runtime placement control for one compositor input.
268/// Retaining it does not keep the input or compositor alive.
269#[derive(Clone)]
270pub struct SwVideoLayerHandle {
271    id: VideoInputId,
272    name: Arc<str>,
273    input: Weak<VideoInput>,
274}
275
276impl SwVideoLayerHandle {
277    /// Returns the stable identity of this particular input registration.
278    pub fn id(&self) -> VideoInputId {
279        self.id
280    }
281
282    /// Returns the registration name, which may be reused by a newer input.
283    pub fn name(&self) -> Arc<str> {
284        self.name.clone()
285    }
286
287    /// Returns the current layer settings, or `None` after this registration is removed.
288    pub fn layer(&self) -> Option<VideoLayer> {
289        self.input
290            .upgrade()
291            .map(|input| *input.layer.lock().unwrap())
292    }
293
294    /// Atomically replaces every layer setting.
295    ///
296    /// Returns [`SwVideoCompositorError::SourceRemoved`] if this handle is stale.
297    pub fn set_layer(&self, layer: VideoLayer) -> std::result::Result<(), SwVideoCompositorError> {
298        validate_layer(layer)?;
299        self.update(|current| *current = layer)
300    }
301
302    /// Replaces the destination rectangle while retaining the other layer settings.
303    pub fn set_rect(&self, rect: VideoRect) -> std::result::Result<(), SwVideoCompositorError> {
304        validate_rect(rect)?;
305        self.update(|layer| layer.rect = rect)
306    }
307
308    /// Replaces the layer opacity after validating the `0.0..=1.0` range.
309    pub fn set_opacity(&self, opacity: f32) -> std::result::Result<(), SwVideoCompositorError> {
310        validate_opacity(opacity)?;
311        self.update(|layer| layer.opacity = opacity)
312    }
313
314    /// Changes the stacking order; larger values are drawn later.
315    pub fn set_z_index(&self, z_index: i32) -> std::result::Result<(), SwVideoCompositorError> {
316        self.update(|layer| layer.z_index = z_index)
317    }
318
319    /// Shows or hides the input without removing its registration.
320    pub fn set_visible(&self, visible: bool) -> std::result::Result<(), SwVideoCompositorError> {
321        self.update(|layer| layer.visible = visible)
322    }
323
324    /// Changes how the input aspect ratio maps into its rectangle.
325    pub fn set_fit(&self, fit: VideoFit) -> std::result::Result<(), SwVideoCompositorError> {
326        self.update(|layer| layer.fit = fit)
327    }
328
329    /// Draws only part of the input, or all of it again with `None`.
330    ///
331    /// Not checked against the frame, which may not have arrived yet and may
332    /// change size later — see [`VideoSourceRect`]. Only an empty region is
333    /// refused here.
334    pub fn set_source(
335        &self,
336        source: Option<VideoSourceRect>,
337    ) -> std::result::Result<(), SwVideoCompositorError> {
338        video_layer::validate_source(source).map_err(map_layer_error)?;
339        self.update(|layer| layer.source = source)
340    }
341
342    fn update(
343        &self,
344        update: impl FnOnce(&mut VideoLayer),
345    ) -> std::result::Result<(), SwVideoCompositorError> {
346        let input = self
347            .input
348            .upgrade()
349            .ok_or(SwVideoCompositorError::SourceRemoved)?;
350        update(&mut input.layer.lock().unwrap());
351        Ok(())
352    }
353}
354
355/// One terminal video input returned by
356/// [`SwVideoCompositorHandle::add_source`]. It stores only the latest frame,
357/// so a fast producer cannot build an unbounded queue behind a slower
358/// compositor output rate.
359pub struct SwVideoCompositorInputSink {
360    pp_log: PpLog,
361    name: Arc<str>,
362    shared: Weak<CompositorShared>,
363    input: Weak<VideoInput>,
364}
365
366impl SwVideoCompositorInputSink {
367    fn detach(&self) {
368        let (Some(shared), Some(input)) = (self.shared.upgrade(), self.input.upgrade()) else {
369            return;
370        };
371        let mut inputs = shared.inputs.lock().unwrap();
372        let is_current = inputs
373            .get(&self.name)
374            .is_some_and(|current| Arc::ptr_eq(current, &input));
375        if is_current {
376            inputs.remove(&self.name);
377        }
378    }
379}
380
381impl Element for SwVideoCompositorInputSink {
382    fn name(&self) -> Arc<str> {
383        self.name.clone()
384    }
385
386    fn element_type(&self) -> ElementType {
387        ElementType::SwVideoCompositor
388    }
389
390    fn pp_log(&self) -> &PpLog {
391        &self.pp_log
392    }
393
394    fn pp_log_mut(&mut self) -> &mut PpLog {
395        &mut self.pp_log
396    }
397}
398
399impl Sink for SwVideoCompositorInputSink {
400    /// The CPU counterpart of D3d11VideoCompositor: layers are blended
401    /// plane by plane, so every input arrives in system memory.
402    fn input_contract(&self) -> InputContract {
403        InputContract::Fixed(PortContract::frame(
404            MediaKind::VideoFrame,
405            MemoryDomain::System,
406        ))
407    }
408
409    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
410        let Some(input) = self.input.upgrade() else {
411            return Ok(());
412        };
413        match buf {
414            MediaBuffer::Video(frame) => {
415                input.latest_frame.store(Some(frame));
416                Ok(())
417            }
418            MediaBuffer::Eos => {
419                self.detach();
420                Ok(())
421            }
422            MediaBuffer::Packet(_) => {
423                Err(SwVideoCompositorError::UnsupportedBuffer("Packet").into())
424            }
425            MediaBuffer::Audio(_) => Err(SwVideoCompositorError::UnsupportedBuffer("Audio").into()),
426        }
427    }
428
429    fn control(&mut self, msg: ControlMsg) -> Result<()> {
430        match msg {
431            ControlMsg::Stop => self.detach(),
432            ControlMsg::Flush => {
433                if let Some(input) = self.input.upgrade() {
434                    input.latest_frame.store(None);
435                }
436            }
437            ControlMsg::Pause
438            | ControlMsg::Resume
439            | ControlMsg::Seek(_)
440            | ControlMsg::CheckSeek(_)
441            | ControlMsg::Preroll(_) => {}
442        }
443        Ok(())
444    }
445}
446
447#[derive(Clone)]
448struct InputSnapshot {
449    id: VideoInputId,
450    layer: VideoLayer,
451    frame: Option<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>>,
452}
453
454impl InputSnapshot {
455    /// Same input, in the same place, showing the same pixels.
456    ///
457    /// The pixels are compared by which buffer they live in rather than by
458    /// the frame around them: an input with nothing new to show still hands
459    /// over a fresh frame on every tick. See [`picture_id`] for why that
460    /// identity is sound here — this snapshot holds the frame, so its buffer
461    /// cannot be released and reallocated underneath it.
462    fn same_as(&self, other: &Self) -> bool {
463        self.id == other.id
464            && self.layer == other.layer
465            && match (&self.frame, &other.frame) {
466                (Some(drawn), Some(now)) => picture_id(drawn) == picture_id(now),
467                (None, None) => true,
468                _ => false,
469            }
470    }
471}
472
473/// What the last composite was made from, and what it produced.
474///
475/// A tick that finds every input frame and every layer exactly as the last
476/// one left them has nothing to draw: the picture it would compose is the
477/// picture already composed. It hands out that frame again instead, under a
478/// new timestamp, never a copy.
479///
480/// # Why this holds the pooled reference rather than a frame reference
481///
482/// `CudaVideoCompositor` does the same thing by holding an `av_frame_ref` of
483/// the surface it composed, which is safe there because its output pool
484/// acquires a *fresh* surface per composite and will not hand back one still
485/// referenced. This pool is the other kind: it holds a fixed set of frames
486/// and composites into their existing buffers. A plain frame reference would
487/// not stop the pool handing that frame out again, and the next background
488/// fill would then write over the pixels this is still offering. Holding the
489/// pooled reference is what keeps it out of the pool at all.
490struct Composed {
491    inputs: Vec<InputSnapshot>,
492    frame: Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>,
493}
494
495impl Composed {
496    fn matches(&self, inputs: &[InputSnapshot]) -> bool {
497        self.inputs.len() == inputs.len()
498            && std::iter::zip(&self.inputs, inputs).all(|(drawn, now)| drawn.same_as(now))
499    }
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503struct ScaleDefinition {
504    source_format: ffmpeg::format::Pixel,
505    source_width: u32,
506    source_height: u32,
507    target_width: u32,
508    target_height: u32,
509}
510
511struct InputScaler {
512    definition: Option<ScaleDefinition>,
513    context: Option<ffmpeg::software::scaling::Context>,
514    output: ffmpeg::frame::Video,
515}
516
517impl InputScaler {
518    fn new() -> Self {
519        Self {
520            definition: None,
521            context: None,
522            output: ffmpeg::frame::Video::empty(),
523        }
524    }
525
526    fn scale(
527        &mut self,
528        frame: &ffmpeg::frame::Video,
529        region: VideoSourceRect,
530        width: u32,
531        height: u32,
532    ) -> std::result::Result<&ffmpeg::frame::Video, ffmpeg::Error> {
533        let source = cropped(frame, region)?;
534        let source = source.as_ref().unwrap_or(frame);
535        let definition = ScaleDefinition {
536            source_format: source.format(),
537            source_width: source.width(),
538            source_height: source.height(),
539            target_width: width,
540            target_height: height,
541        };
542        if self.definition != Some(definition) {
543            self.context = Some(ffmpeg::software::scaling::Context::get(
544                definition.source_format,
545                definition.source_width,
546                definition.source_height,
547                ffmpeg::format::Pixel::BGRA,
548                width,
549                height,
550                ffmpeg::software::scaling::Flags::BILINEAR,
551            )?);
552            self.output = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height);
553            self.definition = Some(definition);
554        }
555        self.context
556            .as_mut()
557            .expect("created with a changed definition or retained from a matching one")
558            .run(source, &mut self.output)?;
559        Ok(&self.output)
560    }
561}
562
563/// Composites the latest frames from any number of independent input
564/// pipelines into one fixed-rate opaque BGRA video stream.
565///
566/// Like [`crate::elements::AudioMixer`], this is a [`SourceElement`], not
567/// a conventional one-input filter: upstream pipelines terminate at the
568/// sinks returned by [`SwVideoCompositorHandle::add_source`], while this
569/// element's own pipeline drives output on its independent clock. Input
570/// frame PTS values therefore do not become output PTS; output advances by
571/// one tick in [`SwVideoCompositor::time_base`] for every composed frame.
572pub struct SwVideoCompositor {
573    pp_log: PpLog,
574    name: Arc<str>,
575    shared: Arc<CompositorShared>,
576    options: VideoCompositorOptions,
577    frame_index: i64,
578    scalers: HashMap<VideoInputId, InputScaler>,
579    output_pool: UnboundObjectPool<ffmpeg::frame::Video>,
580    /// Wrappers for re-emitting an unchanged composite. Empty frames, never
581    /// composited into: each one is pointed at the picture in [`Composed`]
582    /// and carries only this tick's timestamp.
583    repeat_pool: UnboundObjectPool<ffmpeg::frame::Video>,
584    /// The last composite and what it was made from — see [`Composed`].
585    composed: Option<Composed>,
586    /// Pictures a repeat published earlier may still be pointing at.
587    ///
588    /// A repeat shares the picture's *buffer*, not its pool slot: once
589    /// [`Composed`] stops holding that slot, the pool is free to hand the
590    /// frame back and the next background fill writes over pixels a repeat
591    /// still queued downstream is showing. So a replaced picture moves here
592    /// instead, and is released only once nothing but the frame itself
593    /// references its pixels — see [`picture_is_referenced`], which is
594    /// checked on every composite, so this holds only what is genuinely
595    /// still in flight.
596    retired: Vec<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>>,
597    pad: SrcPad,
598}
599
600// SAFETY: `SwsContext` has no thread affinity and every scaling context is
601// exclusively accessed through `&mut self` on the compositor's one source
602// thread. ffmpeg-next simply omits Send for this wrapper, as with SwScaler.
603unsafe impl Send for SwVideoCompositor {}
604
605impl SwVideoCompositor {
606    /// Creates a compositor and a weak runtime handle for registering inputs.
607    pub fn new(
608        name: impl Into<String>,
609        options: VideoCompositorOptions,
610    ) -> std::result::Result<(Self, SwVideoCompositorHandle), SwVideoCompositorError> {
611        validate_output_options(options)?;
612        let name: Arc<str> = name.into().into();
613        let pp_log = element_pp_log(ElementType::SwVideoCompositor, &name, None);
614        let shared = Arc::new(CompositorShared {
615            inputs: Mutex::new(HashMap::new()),
616            next_input_id: AtomicU64::new(1),
617            frame_rate: FrameRate::new(options.frame_rate),
618        });
619        let (width, height) = (options.width, options.height);
620        let output_pool = UnboundObjectPool::new(
621            OUTPUT_POOL_SIZE,
622            move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
623            |_| {},
624        );
625        pp_info!(
626            pp_log: &pp_log,
627            "created: {}x{}, frame_rate={}, format=BGRA",
628            width,
629            height,
630            options.frame_rate
631        );
632        Ok((
633            Self {
634                name: name.clone(),
635                pp_log,
636                shared: shared.clone(),
637                options,
638                frame_index: 0,
639                scalers: HashMap::new(),
640                output_pool,
641                repeat_pool: UnboundObjectPool::new(
642                    0,
643                    ffmpeg::frame::Video::empty,
644                    release_picture,
645                ),
646                composed: None,
647                retired: Vec::new(),
648                pad: SrcPad::with_contract(
649                    format!("{name}_src"),
650                    OutputContract::Fixed(PortContract::frame(
651                        MediaKind::VideoFrame,
652                        MemoryDomain::System,
653                    )),
654                ),
655            },
656            SwVideoCompositorHandle {
657                shared: Arc::downgrade(&shared),
658            },
659        ))
660    }
661
662    /// Returns the fixed output pixel format, [`ffmpeg::format::Pixel::BGRA`].
663    pub fn format(&self) -> ffmpeg::format::Pixel {
664        ffmpeg::format::Pixel::BGRA
665    }
666
667    /// Returns the fixed output width in pixels.
668    pub fn width(&self) -> u32 {
669        self.options.width
670    }
671
672    /// Returns the fixed output height in pixels.
673    pub fn height(&self) -> u32 {
674        self.options.height
675    }
676
677    /// The output frame rate, which is what construction was given unless
678    /// [`SwVideoCompositorHandle::set_frame_rate`] has changed it since.
679    pub fn frame_rate(&self) -> ffmpeg::Rational {
680        self.shared.frame_rate.get()
681    }
682
683    /// The reciprocal of [`Self::frame_rate`], used as output PTS units — and
684    /// so a value that moves with it.
685    pub fn time_base(&self) -> ffmpeg::Rational {
686        self.frame_rate().invert()
687    }
688
689    fn snapshots(&self) -> Vec<InputSnapshot> {
690        let inputs: Vec<_> = self
691            .shared
692            .inputs
693            .lock()
694            .unwrap()
695            .values()
696            .cloned()
697            .collect();
698        inputs
699            .into_iter()
700            .map(|input| InputSnapshot {
701                id: input.id,
702                layer: *input.layer.lock().unwrap(),
703                frame: input.latest_frame.load_full(),
704            })
705            .collect()
706    }
707
708    /// The picture for this tick, composed or — where nothing changed — the
709    /// one already composed.
710    ///
711    /// Returns what will be pushed rather than the pooled reference itself:
712    /// the shared reference is the thing [`Composed`] has to hold on to.
713    fn compose_frame(
714        &mut self,
715    ) -> std::result::Result<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>, SwVideoCompositorError>
716    {
717        let mut snapshots = self.snapshots();
718        let active: HashSet<_> = snapshots.iter().map(|snapshot| snapshot.id).collect();
719        self.scalers.retain(|id, _| active.contains(id));
720        snapshots.sort_by(|left, right| {
721            left.layer
722                .z_index
723                .cmp(&right.layer.z_index)
724                .then_with(|| left.id.cmp(&right.id))
725        });
726
727        // Nothing moved and no input produced a frame, so this tick's picture
728        // is the one already composed — see [`Composed`].
729        if self
730            .composed
731            .as_ref()
732            .is_some_and(|composed| composed.matches(&snapshots))
733        {
734            return self.repeat_frame();
735        }
736        let kept = snapshots.clone();
737
738        // This tick draws, so the picture behind the last one stops being the
739        // one to offer again — but a repeat already pushed downstream may
740        // still be showing it, so it goes to `retired` rather than straight
741        // back to the pool.
742        if let Some(previous) = self.composed.take() {
743            self.retired.push(previous.frame);
744        }
745        self.retired
746            .retain(|picture| picture_is_referenced(picture));
747
748        let mut output = self.output_pool.get();
749        fill_background(&mut output, self.options.background);
750        for snapshot in snapshots {
751            if !snapshot.layer.visible || snapshot.layer.opacity == 0.0 {
752                continue;
753            }
754            let Some(frame) = snapshot.frame else {
755                continue;
756            };
757            let Some(source) =
758                video_layer::source_region(snapshot.layer.source, frame.width(), frame.height())
759            else {
760                // A crop the frame is too small for: nothing of this layer is
761                // in the picture, which is not an error — see `source_region`.
762                continue;
763            };
764            // The region's own size, not the frame's: a crop decides what the
765            // fit is fitting.
766            let geometry = layer_geometry(
767                source.width,
768                source.height,
769                snapshot.layer.rect,
770                snapshot.layer.fit,
771            )?;
772            let scaled = self
773                .scalers
774                .entry(snapshot.id)
775                .or_insert_with(InputScaler::new)
776                .scale(&frame, source, geometry.image_width, geometry.image_height)
777                .map_err(SwVideoCompositorError::from)?;
778            blend_bgra(&mut output, scaled, geometry, snapshot.layer.opacity);
779        }
780        output.set_pts(Some(self.frame_index));
781        self.frame_index += 1;
782        let output = Arc::new(output);
783        // Held so the next tick can tell whether it has anything to draw, and
784        // so the frame it may offer again stays out of the output pool.
785        self.composed = Some(Composed {
786            inputs: kept,
787            frame: Arc::clone(&output),
788        });
789        Ok(output)
790    }
791
792    /// Offers the picture already composed, under this tick's timestamp.
793    ///
794    /// `av_frame_ref` points an empty wrapper at the same buffer rather than
795    /// copying it, so an unchanged scene costs a refcount instead of a
796    /// background fill and a blend per layer.
797    fn repeat_frame(
798        &mut self,
799    ) -> std::result::Result<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>, SwVideoCompositorError>
800    {
801        let mut output = self.repeat_pool.get();
802        let composed = self
803            .composed
804            .as_ref()
805            .expect("only reached with a previous composite");
806        // SAFETY: `ptr` is the pooled wrapper's own `AVFrame`, unreferenced
807        // before it is given a new one, and the source is the composite this
808        // element still holds a pooled reference to — both live, and distinct
809        // from each other.
810        unsafe {
811            let ptr = output.as_mut_ptr();
812            ffi::av_frame_unref(ptr);
813            let code = ffi::av_frame_ref(ptr, composed.frame.as_ptr());
814            if code < 0 {
815                return Err(SwVideoCompositorError::FrameRef(code));
816            }
817        }
818        output.set_pts(Some(self.frame_index));
819        self.frame_index += 1;
820        Ok(Arc::new(output))
821    }
822
823    fn push_frame(&mut self, bus: &Bus) -> std::result::Result<(), SwVideoCompositorError> {
824        let output = self.compose_frame()?;
825        if let Err(error) = self.pad.push(MediaBuffer::Video(output)) {
826            bus.post(
827                &self.pp_log,
828                BusEvent::Error {
829                    element_type: ElementType::SwVideoCompositor,
830                    name: self.name.clone(),
831                    error,
832                },
833            );
834        }
835        Ok(())
836    }
837}
838
839impl Element for SwVideoCompositor {
840    fn name(&self) -> Arc<str> {
841        self.name.clone()
842    }
843
844    fn element_type(&self) -> ElementType {
845        ElementType::SwVideoCompositor
846    }
847
848    fn pp_log(&self) -> &PpLog {
849        &self.pp_log
850    }
851
852    fn pp_log_mut(&mut self) -> &mut PpLog {
853        &mut self.pp_log
854    }
855}
856
857impl Source for SwVideoCompositor {
858    fn src_pads(&mut self) -> &mut [SrcPad] {
859        std::slice::from_mut(&mut self.pad)
860    }
861}
862
863impl SourceElement for SwVideoCompositor {
864    fn is_live(&self) -> bool {
865        true
866    }
867
868    fn is_seekable(&self) -> bool {
869        false
870    }
871
872    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
873        pp_info!(self, "started");
874        let mut schedule = PeriodicSchedule::new(self.shared.frame_rate.interval(), Instant::now());
875        loop {
876            let outcome = drain_control(control, self, bus)?;
877            if outcome.stopped {
878                pp_info!(self, "stopped");
879                return Ok(());
880            }
881            if outcome.paused_for > Duration::ZERO {
882                schedule.resume_after_pause(outcome.paused_for, Instant::now());
883            }
884
885            // Followed here rather than at construction, so a rate set while
886            // this is running is kept from the next tick on.
887            let interval = self.shared.frame_rate.interval();
888            let now = Instant::now();
889            if schedule.interval() != interval {
890                pp_info!(self, "frame rate is now {}", self.frame_rate());
891                schedule.set_interval(interval, now);
892            }
893            if !schedule.is_due(now) {
894                thread::sleep(schedule.remaining(now).min(CONTROL_POLL_INTERVAL));
895                continue;
896            }
897
898            self.push_frame(bus)?;
899            schedule.advance_after_tick(Instant::now());
900        }
901    }
902
903    fn seek(&mut self, _target: Duration) -> Result<Duration> {
904        Err(SwVideoCompositorError::SeekUnsupported.into())
905    }
906}
907
908fn validate_output_options(
909    options: VideoCompositorOptions,
910) -> std::result::Result<(), SwVideoCompositorError> {
911    if options.width == 0
912        || options.height == 0
913        || options.width > MAX_DIMENSION
914        || options.height > MAX_DIMENSION
915    {
916        return Err(SwVideoCompositorError::InvalidOutputDimensions {
917            width: options.width,
918            height: options.height,
919        });
920    }
921    if options.frame_rate.numerator() <= 0 || options.frame_rate.denominator() <= 0 {
922        return Err(SwVideoCompositorError::InvalidFrameRate(options.frame_rate));
923    }
924    Ok(())
925}
926
927/// A view of `frame` holding only `region`, or `None` when that is the whole
928/// frame already.
929///
930/// `av_frame_apply_cropping` rather than pointer arithmetic of this file's
931/// own: which byte a pixel starts at depends on the format — how many planes
932/// there are, how far the chroma ones are subsampled, how wide a pixel is —
933/// and libavutil already has every format's descriptor to answer that. The
934/// view shares the frame's buffers, so this costs a reference and some
935/// arithmetic rather than a copy.
936///
937/// `UNALIGNED` because a crop is where the user put it: refusing an odd
938/// offset would silently move the region, and the scaler that reads this does
939/// not need the alignment SIMD paths want.
940fn cropped(
941    frame: &ffmpeg::frame::Video,
942    region: VideoSourceRect,
943) -> std::result::Result<Option<ffmpeg::frame::Video>, ffmpeg::Error> {
944    if region.x == 0
945        && region.y == 0
946        && region.width == frame.width()
947        && region.height == frame.height()
948    {
949        return Ok(None);
950    }
951    let mut view = ffmpeg::frame::Video::empty();
952    // SAFETY: both frames are live and distinct — `view` was just created
953    // empty — and `av_frame_ref` only adds a reference to `frame`'s buffers.
954    let code = unsafe { ffi::av_frame_ref(view.as_mut_ptr(), frame.as_ptr()) };
955    if code < 0 {
956        return Err(ffmpeg::Error::from(code));
957    }
958    // SAFETY: `view` owns this `AVFrame`, and these are the plain integer
959    // fields `av_frame_apply_cropping` is documented to read.
960    let code = unsafe {
961        let raw = view.as_mut_ptr();
962        (*raw).crop_left = usize::try_from(region.x).unwrap_or(0);
963        (*raw).crop_top = usize::try_from(region.y).unwrap_or(0);
964        (*raw).crop_right = usize::try_from(frame.width() - region.x - region.width).unwrap_or(0);
965        (*raw).crop_bottom =
966            usize::try_from(frame.height() - region.y - region.height).unwrap_or(0);
967        ffi::av_frame_apply_cropping(raw, ffi::AV_FRAME_CROP_UNALIGNED as i32)
968    };
969    if code < 0 {
970        return Err(ffmpeg::Error::from(code));
971    }
972    Ok(Some(view))
973}
974
975/// Thin adapters over the shared, backend-agnostic logic in
976/// [`super::video_layer`] — translate its [`VideoLayerError`] into this
977/// backend's own [`SwVideoCompositorError`] variants so every existing call
978/// site/error consumer here keeps seeing the same error shape it always
979/// has.
980fn map_layer_error(error: VideoLayerError) -> SwVideoCompositorError {
981    match error {
982        VideoLayerError::InvalidDimensions { width, height } => {
983            SwVideoCompositorError::InvalidLayerDimensions { width, height }
984        }
985        VideoLayerError::InvalidOpacity(opacity) => SwVideoCompositorError::InvalidOpacity(opacity),
986        VideoLayerError::InvalidInputDimensions { width, height } => {
987            SwVideoCompositorError::InvalidInputDimensions { width, height }
988        }
989        VideoLayerError::ScaledLayerTooLarge { width, height } => {
990            SwVideoCompositorError::ScaledLayerTooLarge { width, height }
991        }
992        VideoLayerError::InvalidSourceRegion { width, height } => {
993            SwVideoCompositorError::InvalidSourceRegion { width, height }
994        }
995    }
996}
997
998fn validate_layer(layer: VideoLayer) -> std::result::Result<(), SwVideoCompositorError> {
999    video_layer::validate_layer(layer).map_err(map_layer_error)
1000}
1001
1002fn validate_rect(rect: VideoRect) -> std::result::Result<(), SwVideoCompositorError> {
1003    video_layer::validate_rect(rect).map_err(map_layer_error)
1004}
1005
1006fn validate_opacity(opacity: f32) -> std::result::Result<(), SwVideoCompositorError> {
1007    video_layer::validate_opacity(opacity).map_err(map_layer_error)
1008}
1009
1010fn layer_geometry(
1011    source_width: u32,
1012    source_height: u32,
1013    rect: VideoRect,
1014    fit: VideoFit,
1015) -> std::result::Result<LayerGeometry, SwVideoCompositorError> {
1016    video_layer::layer_geometry(source_width, source_height, rect, fit).map_err(map_layer_error)
1017}
1018
1019fn fill_background(frame: &mut ffmpeg::frame::Video, color: Color) {
1020    let width = frame.width() as usize;
1021    let height = frame.height() as usize;
1022    let stride = frame.stride(0);
1023    let data = frame.data_mut(0);
1024    for row in 0..height {
1025        for pixel in data[row * stride..row * stride + width * 4]
1026            .as_chunks_mut::<4>()
1027            .0
1028        {
1029            *pixel = [color.blue, color.green, color.red, 255];
1030        }
1031    }
1032}
1033
1034fn blend_bgra(
1035    destination: &mut ffmpeg::frame::Video,
1036    source: &ffmpeg::frame::Video,
1037    geometry: LayerGeometry,
1038    opacity: f32,
1039) {
1040    let output_width = i64::from(destination.width());
1041    let output_height = i64::from(destination.height());
1042    let clip_left = i64::from(geometry.clip.x).max(0);
1043    let clip_top = i64::from(geometry.clip.y).max(0);
1044    let clip_right =
1045        (i64::from(geometry.clip.x) + i64::from(geometry.clip.width)).min(output_width);
1046    let clip_bottom =
1047        (i64::from(geometry.clip.y) + i64::from(geometry.clip.height)).min(output_height);
1048    let left = geometry.image_x.max(clip_left);
1049    let top = geometry.image_y.max(clip_top);
1050    let right = (geometry.image_x + i64::from(geometry.image_width)).min(clip_right);
1051    let bottom = (geometry.image_y + i64::from(geometry.image_height)).min(clip_bottom);
1052    if left >= right || top >= bottom {
1053        return;
1054    }
1055
1056    let source_stride = source.stride(0);
1057    let destination_stride = destination.stride(0);
1058    let source_data = source.data(0);
1059    let destination_data = destination.data_mut(0);
1060    for output_y in top..bottom {
1061        let source_y = (output_y - geometry.image_y) as usize;
1062        let destination_y = output_y as usize;
1063        for output_x in left..right {
1064            let source_x = (output_x - geometry.image_x) as usize;
1065            let destination_x = output_x as usize;
1066            let source_offset = source_y * source_stride + source_x * 4;
1067            let destination_offset = destination_y * destination_stride + destination_x * 4;
1068            let source_pixel = &source_data[source_offset..source_offset + 4];
1069            let destination_pixel =
1070                &mut destination_data[destination_offset..destination_offset + 4];
1071            let alpha = (f32::from(source_pixel[3]) / 255.0) * opacity;
1072            let inverse = 1.0 - alpha;
1073            for channel in 0..3 {
1074                destination_pixel[channel] = (f32::from(source_pixel[channel]) * alpha
1075                    + f32::from(destination_pixel[channel]) * inverse)
1076                    .round()
1077                    .clamp(0.0, 255.0) as u8;
1078            }
1079            destination_pixel[3] = 255;
1080        }
1081    }
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use std::{
1087        sync::{
1088            Mutex as StdMutex,
1089            atomic::{AtomicBool, Ordering as AtomicOrdering},
1090        },
1091        thread,
1092    };
1093
1094    use super::*;
1095
1096    struct CapturingSink {
1097        pp_log: PpLog,
1098        received: Arc<StdMutex<Vec<MediaBuffer>>>,
1099    }
1100
1101    impl Element for CapturingSink {
1102        fn name(&self) -> Arc<str> {
1103            "capture".into()
1104        }
1105
1106        fn element_type(&self) -> ElementType {
1107            ElementType::Other
1108        }
1109
1110        fn pp_log(&self) -> &PpLog {
1111            &self.pp_log
1112        }
1113
1114        fn pp_log_mut(&mut self) -> &mut PpLog {
1115            &mut self.pp_log
1116        }
1117    }
1118
1119    impl Sink for CapturingSink {
1120        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1121            self.received.lock().unwrap().push(buf);
1122            Ok(())
1123        }
1124
1125        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1126            Ok(())
1127        }
1128    }
1129
1130    fn options(width: u32, height: u32) -> VideoCompositorOptions {
1131        VideoCompositorOptions {
1132            width,
1133            height,
1134            frame_rate: ffmpeg::Rational::new(30, 1),
1135            background: Color::BLACK,
1136        }
1137    }
1138
1139    fn solid_frame(
1140        width: u32,
1141        height: u32,
1142        color: Color,
1143    ) -> Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>> {
1144        let pool = UnboundObjectPool::new(
1145            0,
1146            move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
1147            |_| {},
1148        );
1149        let mut frame = pool.get();
1150        fill_background(&mut frame, color);
1151        Arc::new(frame)
1152    }
1153
1154    fn pixel(frame: &ffmpeg::frame::Video, x: usize, y: usize) -> [u8; 4] {
1155        let offset = y * frame.stride(0) + x * 4;
1156        frame.data(0)[offset..offset + 4].try_into().unwrap()
1157    }
1158
1159    fn input(
1160        handle: &SwVideoCompositorHandle,
1161        name: &str,
1162        layer: VideoLayer,
1163    ) -> (Box<dyn Sink>, SwVideoLayerHandle) {
1164        let input = handle.add_source(name, layer).unwrap().unwrap();
1165        (input.sink, input.layer)
1166    }
1167
1168    /// Four quadrants in one frame, so which part of it was drawn is
1169    /// readable from the output's colour alone.
1170    fn quadrant_frame(width: u32, height: u32) -> Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>> {
1171        let pool = UnboundObjectPool::new(
1172            0,
1173            move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
1174            |_| {},
1175        );
1176        let mut frame = pool.get();
1177        let stride = frame.stride(0);
1178        for y in 0..height as usize {
1179            for x in 0..width as usize {
1180                let bgra = match (x >= width as usize / 2, y >= height as usize / 2) {
1181                    (false, false) => [0, 0, 255, 255],
1182                    (true, false) => [0, 255, 0, 255],
1183                    (false, true) => [255, 0, 0, 255],
1184                    (true, true) => [0, 255, 255, 255],
1185                };
1186                let offset = y * stride + x * 4;
1187                frame.data_mut(0)[offset..offset + 4].copy_from_slice(&bgra);
1188            }
1189        }
1190        Arc::new(frame)
1191    }
1192
1193    /// The whole of cropping, in one composite: what is drawn is the region,
1194    /// and the fit then works on the region rather than on the frame.
1195    #[test]
1196    fn a_layer_draws_only_its_source_region() {
1197        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
1198        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1199        layer.fit = VideoFit::Stretch;
1200        // The bottom-right quadrant, which is the one nothing else is.
1201        layer.source = Some(VideoSourceRect::new(4, 4, 4, 4));
1202        let (mut sink, _) = input(&handle, "input", layer);
1203        sink.consume(MediaBuffer::Video(quadrant_frame(8, 8)))
1204            .unwrap();
1205
1206        let frame = compositor.compose_frame().unwrap();
1207
1208        for (x, y) in [(0, 0), (3, 0), (0, 3), (3, 3)] {
1209            assert_eq!(
1210                pixel(&frame, x, y),
1211                [0, 255, 255, 255],
1212                "every output pixel must come from the cropped quadrant, at {x},{y}"
1213            );
1214        }
1215    }
1216
1217    /// A crop the frame turned out to be too small for is a frame arriving
1218    /// late to a decision, not a fault: the layer is left out and the rest of
1219    /// the scene is composed.
1220    #[test]
1221    fn a_source_region_outside_the_frame_leaves_the_layer_out() {
1222        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
1223        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1224        layer.fit = VideoFit::Stretch;
1225        layer.source = Some(VideoSourceRect::new(64, 64, 4, 4));
1226        let (mut sink, _) = input(&handle, "input", layer);
1227        sink.consume(MediaBuffer::Video(solid_frame(8, 8, Color::new(255, 0, 0))))
1228            .unwrap();
1229
1230        let frame = compositor.compose_frame().unwrap();
1231
1232        assert_eq!(
1233            pixel(&frame, 0, 0),
1234            [0, 0, 0, 255],
1235            "the background is what a layer with nothing to draw leaves"
1236        );
1237    }
1238
1239    /// Hiding a layer is `visible`. An empty region is a mistake, and one
1240    /// that would otherwise reach a scaler as a zero-sized picture.
1241    #[test]
1242    fn an_empty_source_region_is_refused() {
1243        let (_compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
1244        let (_sink, layer) = input(
1245            &handle,
1246            "input",
1247            VideoLayer::new(VideoRect::new(0, 0, 4, 4)),
1248        );
1249
1250        let refused = layer.set_source(Some(VideoSourceRect::new(0, 0, 4, 0)));
1251
1252        assert!(matches!(
1253            refused,
1254            Err(SwVideoCompositorError::InvalidSourceRegion { .. })
1255        ));
1256    }
1257
1258    #[test]
1259    fn composes_inputs_in_z_order_and_preserves_output_contract() {
1260        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
1261        let mut background = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1262        background.fit = VideoFit::Stretch;
1263        let (mut red_sink, _) = input(&handle, "red", background);
1264        let mut overlay = VideoLayer::new(VideoRect::new(1, 1, 2, 2));
1265        overlay.z_index = 1;
1266        overlay.fit = VideoFit::Stretch;
1267        let (mut blue_sink, _) = input(&handle, "blue", overlay);
1268        red_sink
1269            .consume(MediaBuffer::Video(solid_frame(4, 4, Color::new(255, 0, 0))))
1270            .unwrap();
1271        blue_sink
1272            .consume(MediaBuffer::Video(solid_frame(2, 2, Color::new(0, 0, 255))))
1273            .unwrap();
1274
1275        let frame = compositor.compose_frame().unwrap();
1276        assert_eq!(frame.format(), ffmpeg::format::Pixel::BGRA);
1277        assert_eq!((frame.width(), frame.height()), (4, 4));
1278        assert_eq!(frame.pts(), Some(0));
1279        assert_eq!(pixel(&frame, 0, 0), [0, 0, 255, 255]);
1280        assert_eq!(pixel(&frame, 1, 1), [255, 0, 0, 255]);
1281    }
1282
1283    /// A repeat holds the picture's buffer but not its pool slot, so the
1284    /// slot must stay out of the pool while that repeat is still in flight —
1285    /// otherwise a later composite fills a background over the pixels it is
1286    /// showing.
1287    #[test]
1288    fn a_repeat_still_in_flight_is_never_composed_over() {
1289        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
1290        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1291        layer.fit = VideoFit::Stretch;
1292        let (mut sink, _layer_handle) = input(&handle, "only", layer);
1293        sink.consume(MediaBuffer::Video(solid_frame(4, 4, Color::new(255, 0, 0))))
1294            .unwrap();
1295
1296        // Composed, pushed, and consumed downstream: only the repeat below
1297        // still refers to this picture.
1298        drop(compositor.compose_frame().unwrap());
1299        let in_flight = compositor.compose_frame().unwrap();
1300        let showing = picture_id(&in_flight);
1301
1302        // Every one of these gives the input a new frame, so every one really
1303        // composes, and each result is dropped immediately — the pool recycles
1304        // as fast as it can, which is exactly the case that would reuse the
1305        // picture the repeat above is still showing.
1306        for _ in 0..(OUTPUT_POOL_SIZE * 2 + 2) {
1307            sink.consume(MediaBuffer::Video(solid_frame(4, 4, Color::new(0, 255, 0))))
1308                .unwrap();
1309            let composed = compositor.compose_frame().unwrap();
1310            assert_ne!(
1311                picture_id(&composed),
1312                showing,
1313                "composed into the buffer a repeat still in flight is showing"
1314            );
1315        }
1316
1317        assert_eq!(
1318            pixel(&in_flight, 0, 0),
1319            [0, 0, 255, 255],
1320            "the repeat still shows the picture it was published with"
1321        );
1322    }
1323
1324    /// A tick that finds nothing changed offers the picture it composed
1325    /// last rather than composing the same one again — and a layer that
1326    /// moves puts it straight back to work.
1327    #[test]
1328    fn an_unchanged_scene_is_composed_once() {
1329        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
1330        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1331        layer.fit = VideoFit::Stretch;
1332        let (mut sink, layer_handle) = input(&handle, "only", layer);
1333        sink.consume(MediaBuffer::Video(solid_frame(4, 4, Color::new(255, 0, 0))))
1334            .unwrap();
1335
1336        let composed = compositor.compose_frame().unwrap();
1337        let picture = picture_id(&composed);
1338        assert_eq!(composed.pts(), Some(0));
1339
1340        let repeated = compositor.compose_frame().unwrap();
1341        assert_eq!(
1342            picture_id(&repeated),
1343            picture,
1344            "nothing changed, so this is the picture already composed"
1345        );
1346        assert_eq!(
1347            repeated.pts(),
1348            Some(1),
1349            "a repeat carries this tick's timestamp, not the one it points at"
1350        );
1351        assert_eq!(
1352            pixel(&repeated, 0, 0),
1353            [0, 0, 255, 255],
1354            "and it still shows what was composed"
1355        );
1356
1357        layer_handle.set_rect(VideoRect::new(1, 1, 2, 2)).unwrap();
1358        let moved = compositor.compose_frame().unwrap();
1359        assert_ne!(
1360            picture_id(&moved),
1361            picture,
1362            "a moved layer is a different picture and must be composed"
1363        );
1364        assert_eq!(
1365            pixel(&composed, 0, 0),
1366            [0, 0, 255, 255],
1367            "composing again must not draw over the picture still held"
1368        );
1369    }
1370
1371    #[test]
1372    fn layer_handle_moves_blends_and_hides_a_live_source() {
1373        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(3, 1)).unwrap();
1374        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1375        let (mut sink, layer_handle) = input(&handle, "white", layer);
1376        sink.consume(MediaBuffer::Video(solid_frame(1, 1, Color::WHITE)))
1377            .unwrap();
1378
1379        layer_handle.set_rect(VideoRect::new(1, 0, 1, 1)).unwrap();
1380        layer_handle.set_opacity(0.5).unwrap();
1381        let blended = compositor.compose_frame().unwrap();
1382        assert_eq!(pixel(&blended, 0, 0), [0, 0, 0, 255]);
1383        assert_eq!(pixel(&blended, 1, 0), [128, 128, 128, 255]);
1384
1385        layer_handle.set_visible(false).unwrap();
1386        let hidden = compositor.compose_frame().unwrap();
1387        assert_eq!(pixel(&hidden, 1, 0), [0, 0, 0, 255]);
1388        assert_eq!(hidden.pts(), Some(1));
1389    }
1390
1391    #[test]
1392    fn input_keeps_only_the_latest_frame() {
1393        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1394        let (mut sink, _) = input(
1395            &handle,
1396            "latest",
1397            VideoLayer::new(VideoRect::new(0, 0, 1, 1)),
1398        );
1399        sink.consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(255, 0, 0))))
1400            .unwrap();
1401        sink.consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(0, 255, 0))))
1402            .unwrap();
1403
1404        let frame = compositor.compose_frame().unwrap();
1405        assert_eq!(pixel(&frame, 0, 0), [0, 255, 0, 255]);
1406    }
1407
1408    #[test]
1409    fn frame_replacement_and_composition_run_concurrently() {
1410        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1411        let (mut sink, _) = input(&handle, "live", VideoLayer::new(VideoRect::new(0, 0, 1, 1)));
1412        let red = solid_frame(1, 1, Color::new(255, 0, 0));
1413        let green = solid_frame(1, 1, Color::new(0, 255, 0));
1414        let done = Arc::new(AtomicBool::new(false));
1415        let producer_done = done.clone();
1416        let producer = thread::spawn(move || {
1417            for index in 0..2_000 {
1418                let frame = if index % 2 == 0 {
1419                    red.clone()
1420                } else {
1421                    green.clone()
1422                };
1423                sink.consume(MediaBuffer::Video(frame)).unwrap();
1424            }
1425            // Make the final observable value deterministic after the
1426            // concurrent replacement phase ends.
1427            sink.consume(MediaBuffer::Video(green)).unwrap();
1428            producer_done.store(true, AtomicOrdering::Release);
1429        });
1430
1431        while !done.load(AtomicOrdering::Acquire) {
1432            let frame = compositor.compose_frame().unwrap();
1433            assert!(matches!(
1434                pixel(&frame, 0, 0),
1435                [0, 0, 0, 255] | [0, 0, 255, 255] | [0, 255, 0, 255]
1436            ));
1437        }
1438        producer.join().unwrap();
1439        let final_frame = compositor.compose_frame().unwrap();
1440        assert_eq!(pixel(&final_frame, 0, 0), [0, 255, 0, 255]);
1441    }
1442
1443    #[test]
1444    fn replacing_a_name_invalidates_old_sink_and_layer_handle() {
1445        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1446        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1447        let (mut old_sink, old_layer) = input(&handle, "camera", layer);
1448        let (mut new_sink, _) = input(&handle, "camera", layer);
1449        assert!(matches!(
1450            old_layer.set_visible(false),
1451            Err(SwVideoCompositorError::SourceRemoved)
1452        ));
1453        old_sink
1454            .consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(255, 0, 0))))
1455            .unwrap();
1456        new_sink
1457            .consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(0, 0, 255))))
1458            .unwrap();
1459
1460        let frame = compositor.compose_frame().unwrap();
1461        assert_eq!(pixel(&frame, 0, 0), [255, 0, 0, 255]);
1462        assert_eq!(handle.source_count(), 1);
1463    }
1464
1465    #[test]
1466    fn stop_removes_only_the_current_registration() {
1467        let (_compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1468        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1469        let (mut old_sink, _) = input(&handle, "camera", layer);
1470        let (_new_sink, _) = input(&handle, "camera", layer);
1471        old_sink.control(ControlMsg::Stop).unwrap();
1472        assert_eq!(handle.source_count(), 1);
1473
1474        handle.remove_source("camera");
1475        assert_eq!(handle.source_count(), 0);
1476    }
1477
1478    #[test]
1479    fn contain_and_cover_preserve_aspect_ratio() {
1480        let rect = VideoRect::new(10, 20, 100, 100);
1481        let contain = layer_geometry(160, 90, rect, VideoFit::Contain).unwrap();
1482        assert_eq!((contain.image_width, contain.image_height), (100, 56));
1483        assert_eq!((contain.image_x, contain.image_y), (10, 42));
1484
1485        let cover = layer_geometry(160, 90, rect, VideoFit::Cover).unwrap();
1486        assert_eq!((cover.image_width, cover.image_height), (178, 100));
1487        assert_eq!((cover.image_x, cover.image_y), (-29, 20));
1488    }
1489
1490    #[test]
1491    fn rejects_invalid_layers_and_non_video_buffers() {
1492        let (_compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1493        let invalid = VideoLayer {
1494            opacity: 1.5,
1495            ..VideoLayer::new(VideoRect::new(0, 0, 1, 1))
1496        };
1497        assert!(matches!(
1498            handle.add_source("invalid", invalid),
1499            Err(SwVideoCompositorError::InvalidOpacity(1.5))
1500        ));
1501
1502        let (mut sink, _) = input(
1503            &handle,
1504            "valid",
1505            VideoLayer::new(VideoRect::new(0, 0, 1, 1)),
1506        );
1507        let error = sink
1508            .consume(MediaBuffer::Packet(Arc::new(ffmpeg::Packet::empty())))
1509            .unwrap_err();
1510        assert!(matches!(
1511            error,
1512            crate::Error::SwVideoCompositorError(SwVideoCompositorError::UnsupportedBuffer(
1513                "Packet"
1514            ))
1515        ));
1516    }
1517
1518    #[test]
1519    fn pushes_fixed_format_frames_with_contiguous_pts() {
1520        let (mut compositor, _) = SwVideoCompositor::new("compositor", options(2, 2)).unwrap();
1521        let received = Arc::new(StdMutex::new(Vec::new()));
1522        compositor.src_pads()[0].link(Box::new(CapturingSink {
1523            received: received.clone(),
1524            pp_log: element_pp_log(ElementType::Other, "capture", None),
1525        }));
1526        let (bus, _) = Bus::new();
1527        compositor.push_frame(&bus).unwrap();
1528        compositor.push_frame(&bus).unwrap();
1529
1530        let received = received.lock().unwrap();
1531        let pts: Vec<_> = received
1532            .iter()
1533            .filter_map(|buffer| match buffer {
1534                MediaBuffer::Video(frame) => Some(frame.pts()),
1535                _ => None,
1536            })
1537            .collect();
1538        assert_eq!(pts, vec![Some(0), Some(1)]);
1539    }
1540
1541    struct TimestampSink {
1542        pp_log: PpLog,
1543        tx: crossbeam_channel::Sender<Instant>,
1544    }
1545
1546    impl Element for TimestampSink {
1547        fn name(&self) -> Arc<str> {
1548            "timestamp-recorder".into()
1549        }
1550        fn element_type(&self) -> ElementType {
1551            ElementType::Other
1552        }
1553        fn pp_log(&self) -> &PpLog {
1554            &self.pp_log
1555        }
1556        fn pp_log_mut(&mut self) -> &mut PpLog {
1557            &mut self.pp_log
1558        }
1559    }
1560
1561    impl Sink for TimestampSink {
1562        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1563            if matches!(buf, MediaBuffer::Video(_)) {
1564                let _ = self.tx.send(Instant::now());
1565            }
1566            Ok(())
1567        }
1568        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1569            Ok(())
1570        }
1571    }
1572
1573    /// Regression test: `SwVideoCompositor::run` never folded
1574    /// `ControlOutcome::paused_for` back into `next_due` — a `Pause` let
1575    /// real time blow straight past the stale deadline, so the loop
1576    /// iteration right after `Resume` always found `next_due` already in
1577    /// the past and pushed immediately, resetting the output cadence's
1578    /// phase to the resume instant instead of preserving wherever it was
1579    /// before the freeze. A slow 10fps (100ms/frame) rate keeps the
1580    /// expected gap (near-zero vs. near-one-interval) well clear of
1581    /// scheduling jitter. Pausing is triggered synchronously right after a
1582    /// frame is observed, so `next_due` is a known ~100ms away the instant
1583    /// `Pause` is drained (`run`'s own control check only happens at the
1584    /// top of the loop, after that deadline has already been advanced).
1585    #[test]
1586    fn resuming_after_a_pause_preserves_output_phase() {
1587        use crate::pipeline::Pipeline;
1588
1589        let (tx, rx) = crossbeam_channel::unbounded();
1590        let sink = TimestampSink {
1591            tx,
1592            pp_log: element_pp_log(ElementType::Other, "timestamp-recorder", None),
1593        };
1594        let (compositor, _handle) = SwVideoCompositor::new(
1595            "compositor",
1596            VideoCompositorOptions {
1597                frame_rate: ffmpeg::Rational::new(10, 1),
1598                ..options(2, 2)
1599            },
1600        )
1601        .unwrap();
1602
1603        let pipeline = Pipeline::new("phase-test", compositor, |source, ctx| {
1604            let branch = ctx.branch().to(Box::new(sink))?;
1605            ctx.attach(source, 0, branch)?;
1606            Ok(())
1607        })
1608        .expect("test pipeline wiring must succeed");
1609
1610        pipeline.run().unwrap();
1611        // Warm up, then pause the instant a frame is observed — `next_due`
1612        // is then a known one interval away.
1613        for _ in 0..2 {
1614            rx.recv_timeout(Duration::from_millis(500))
1615                .expect("expected steady frames before pausing");
1616        }
1617        pipeline.pause();
1618        thread::sleep(Duration::from_millis(500));
1619
1620        let resumed_at = Instant::now();
1621        pipeline.resume();
1622        let first_after_resume = rx
1623            .recv_timeout(Duration::from_millis(500))
1624            .expect("expected a frame after resume");
1625        pipeline.stop();
1626        pipeline.bus().log_events();
1627
1628        let gap = first_after_resume.saturating_duration_since(resumed_at);
1629        assert!(
1630            gap >= Duration::from_millis(50),
1631            "expected the post-pause frame to land close to a full 100ms \
1632             interval after resume (phase preserved from before the \
1633             pause), not almost immediately (phase reset to the resume \
1634             instant): got {gap:?}"
1635        );
1636    }
1637
1638    /// The rate a running compositor emits at can be changed, and the unit its
1639    /// output timestamps are in moves with it.
1640    #[test]
1641    fn the_frame_rate_can_be_changed_while_it_is_running() {
1642        let (compositor, handle) = SwVideoCompositor::new(
1643            "rate",
1644            VideoCompositorOptions {
1645                width: 64,
1646                height: 64,
1647                frame_rate: ffmpeg::Rational::new(60, 1),
1648                background: Color::BLACK,
1649            },
1650        )
1651        .expect("compositor");
1652
1653        assert_eq!(compositor.frame_rate(), ffmpeg::Rational::new(60, 1));
1654        assert_eq!(compositor.time_base(), ffmpeg::Rational::new(1, 60));
1655
1656        assert!(handle.set_frame_rate(ffmpeg::Rational::new(24, 1)));
1657        assert_eq!(handle.frame_rate(), Some(ffmpeg::Rational::new(24, 1)));
1658        // The element and the handle read one value, not two.
1659        assert_eq!(compositor.frame_rate(), ffmpeg::Rational::new(24, 1));
1660        assert_eq!(compositor.time_base(), ffmpeg::Rational::new(1, 24));
1661
1662        // Refused, leaving the running rate alone.
1663        assert!(!handle.set_frame_rate(ffmpeg::Rational::new(0, 1)));
1664        assert_eq!(compositor.frame_rate(), ffmpeg::Rational::new(24, 1));
1665
1666        // And answered rather than applied once the compositor is gone.
1667        drop(compositor);
1668        assert!(!handle.set_frame_rate(ffmpeg::Rational::new(30, 1)));
1669        assert_eq!(handle.frame_rate(), None);
1670    }
1671}