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 arc_swap::ArcSwapOption;
13use ffmpeg_next as ffmpeg;
14use thiserror::Error as ThisError;
15
16use super::video_layer::{
17    self, LayerGeometry, MAX_DIMENSION, VideoFit, VideoInputId, VideoLayer, VideoLayerError,
18    VideoRect,
19};
20use crate::{
21    buffer::MediaBuffer,
22    bus::{Bus, BusEvent},
23    color::Color,
24    control::{ControlMsg, ControlReceiver, drain_control},
25    element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
26    error::Result,
27    pad::SrcPad,
28    pool::{UnboundObjectPool, UnboundObjectPoolRef},
29    schedule::PeriodicSchedule,
30};
31
32const OUTPUT_POOL_SIZE: usize = 4;
33const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(5);
34
35/// The compositor's fixed output definition. Every emitted frame is an
36/// opaque [`ffmpeg::format::Pixel::BGRA`] frame at `width` x `height`.
37#[derive(Debug, Clone, Copy)]
38pub struct VideoCompositorOptions {
39    /// Width of the composed output frame in pixels.
40    pub width: u32,
41    /// Height of the composed output frame in pixels.
42    pub height: u32,
43    /// Fixed output frame rate; both rational components must be positive.
44    pub frame_rate: ffmpeg::Rational,
45    /// Color used for output pixels not covered by an opaque layer.
46    pub background: Color,
47}
48
49impl Default for VideoCompositorOptions {
50    fn default() -> Self {
51        Self {
52            width: 1920,
53            height: 1080,
54            frame_rate: ffmpeg::Rational::new(30, 1),
55            background: Color::BLACK,
56        }
57    }
58}
59
60/// Errors specific to [`SwVideoCompositor`].
61#[derive(Debug, ThisError)]
62pub enum SwVideoCompositorError {
63    /// FFmpeg rejected frame allocation or software scaling.
64    #[error("ffmpeg error: {0}")]
65    Ffmpeg(#[from] ffmpeg::Error),
66
67    /// The output canvas dimensions are zero or exceed the safety limit.
68    #[error(
69        "invalid output dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
70    )]
71    InvalidOutputDimensions {
72        /// Invalid output width in pixels.
73        width: u32,
74        /// Invalid output height in pixels.
75        height: u32,
76    },
77
78    /// The output frame-rate numerator or denominator is non-positive.
79    #[error("invalid frame rate {0}; numerator and denominator must both be positive")]
80    InvalidFrameRate(ffmpeg::Rational),
81
82    /// A layer destination rectangle is zero-sized or exceeds the safety limit.
83    #[error(
84        "invalid layer dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
85    )]
86    InvalidLayerDimensions {
87        /// Invalid layer width in output pixels.
88        width: u32,
89        /// Invalid layer height in output pixels.
90        height: u32,
91    },
92
93    /// A layer opacity is non-finite or outside `0.0..=1.0`.
94    #[error("layer opacity must be finite and between 0.0 and 1.0, got {0}")]
95    InvalidOpacity(f32),
96
97    /// An input frame reports a zero width or height.
98    #[error("input frame has invalid dimensions {width}x{height}")]
99    InvalidInputDimensions {
100        /// Invalid input width in pixels.
101        width: u32,
102        /// Invalid input height in pixels.
103        height: u32,
104    },
105
106    /// Aspect-ratio fitting would create an intermediate image above the safety limit.
107    #[error("scaled layer would exceed {MAX_DIMENSION}px: {width}x{height}")]
108    ScaledLayerTooLarge {
109        /// Computed scaled width in pixels.
110        width: u32,
111        /// Computed scaled height in pixels.
112        height: u32,
113    },
114
115    /// A runtime layer handle refers to an input that has been removed or replaced.
116    #[error("the compositor input has been removed")]
117    SourceRemoved,
118
119    /// An input sink received a buffer other than decoded video.
120    #[error(
121        "SwVideoCompositorInputSink only accepts decoded Video frames, got a {0}; link it after a decoder or video source"
122    )]
123    UnsupportedBuffer(&'static str),
124
125    /// Seeking was requested on a live compositor with no stored timeline.
126    #[error("SwVideoCompositor doesn't support seeking a live composition")]
127    SeekUnsupported,
128}
129
130struct VideoInput {
131    id: VideoInputId,
132    /// The hot producer/consumer path is an atomic latest-value slot:
133    /// input pipelines replace the pointer without taking the layer lock,
134    /// and the compositor acquires a stable Arc snapshot independently.
135    latest_frame: ArcSwapOption<UnboundObjectPoolRef<ffmpeg::frame::Video>>,
136    /// Layer changes are infrequent and update several related fields as
137    /// one coherent value, so a small dedicated lock remains appropriate.
138    layer: Mutex<VideoLayer>,
139}
140
141struct CompositorShared {
142    inputs: Mutex<HashMap<Arc<str>, Arc<VideoInput>>>,
143    next_input_id: AtomicU64,
144}
145
146/// A cheaply cloneable handle for adding and removing compositor inputs.
147/// It mirrors [`crate::elements::MixerHandle`], but each registration also
148/// returns a [`SwVideoLayerHandle`] for changing that input's placement.
149#[derive(Clone)]
150pub struct SwVideoCompositorHandle {
151    shared: Weak<CompositorShared>,
152}
153
154/// The two endpoints created for one compositor input registration.
155/// Move `sink` into the upstream pipeline and retain `layer` in application
156/// code for runtime placement changes.
157pub struct SwVideoCompositorInput {
158    /// Terminal sink to attach to the input pipeline branch.
159    pub sink: Box<dyn Sink>,
160    /// Runtime control for this input's placement and visibility.
161    pub layer: SwVideoLayerHandle,
162}
163
164impl SwVideoCompositorHandle {
165    /// Registers an input and returns its terminal Sink plus independent
166    /// runtime layer control. Reusing `name` replaces the old registration;
167    /// old sinks and layer handles become harmlessly stale.
168    pub fn add_source(
169        &self,
170        name: impl Into<String>,
171        layer: VideoLayer,
172    ) -> std::result::Result<Option<SwVideoCompositorInput>, SwVideoCompositorError> {
173        validate_layer(layer)?;
174        let Some(shared) = self.shared.upgrade() else {
175            return Ok(None);
176        };
177        let name: Arc<str> = name.into().into();
178        let id = VideoInputId(shared.next_input_id.fetch_add(1, Ordering::Relaxed));
179        let input = Arc::new(VideoInput {
180            id,
181            latest_frame: ArcSwapOption::empty(),
182            layer: Mutex::new(layer),
183        });
184        shared
185            .inputs
186            .lock()
187            .unwrap()
188            .insert(name.clone(), input.clone());
189
190        Ok(Some(SwVideoCompositorInput {
191            sink: Box::new(SwVideoCompositorInputSink {
192                name: name.clone(),
193                pp_log: element_pp_log(ElementType::SwVideoCompositor, &name, None),
194                shared: self.shared.clone(),
195                input: Arc::downgrade(&input),
196            }),
197            layer: SwVideoLayerHandle {
198                id,
199                name,
200                input: Arc::downgrade(&input),
201            },
202        }))
203    }
204
205    /// Removes `name` immediately. Existing input sinks and layer handles
206    /// become disconnected and cannot affect a later same-name source.
207    pub fn remove_source(&self, name: &str) {
208        if let Some(shared) = self.shared.upgrade() {
209            shared.inputs.lock().unwrap().remove(name);
210        }
211    }
212
213    /// Returns the number of compositor inputs currently registered.
214    ///
215    /// Returns zero after the compositor has been dropped.
216    pub fn source_count(&self) -> usize {
217        self.shared
218            .upgrade()
219            .map(|shared| shared.inputs.lock().unwrap().len())
220            .unwrap_or(0)
221    }
222}
223
224/// Thread-safe runtime placement control for one compositor input.
225/// Retaining it does not keep the input or compositor alive.
226#[derive(Clone)]
227pub struct SwVideoLayerHandle {
228    id: VideoInputId,
229    name: Arc<str>,
230    input: Weak<VideoInput>,
231}
232
233impl SwVideoLayerHandle {
234    /// Returns the stable identity of this particular input registration.
235    pub fn id(&self) -> VideoInputId {
236        self.id
237    }
238
239    /// Returns the registration name, which may be reused by a newer input.
240    pub fn name(&self) -> Arc<str> {
241        self.name.clone()
242    }
243
244    /// Returns the current layer settings, or `None` after this registration is removed.
245    pub fn layer(&self) -> Option<VideoLayer> {
246        self.input
247            .upgrade()
248            .map(|input| *input.layer.lock().unwrap())
249    }
250
251    /// Atomically replaces every layer setting.
252    ///
253    /// Returns [`SwVideoCompositorError::SourceRemoved`] if this handle is stale.
254    pub fn set_layer(&self, layer: VideoLayer) -> std::result::Result<(), SwVideoCompositorError> {
255        validate_layer(layer)?;
256        self.update(|current| *current = layer)
257    }
258
259    /// Replaces the destination rectangle while retaining the other layer settings.
260    pub fn set_rect(&self, rect: VideoRect) -> std::result::Result<(), SwVideoCompositorError> {
261        validate_rect(rect)?;
262        self.update(|layer| layer.rect = rect)
263    }
264
265    /// Replaces the layer opacity after validating the `0.0..=1.0` range.
266    pub fn set_opacity(&self, opacity: f32) -> std::result::Result<(), SwVideoCompositorError> {
267        validate_opacity(opacity)?;
268        self.update(|layer| layer.opacity = opacity)
269    }
270
271    /// Changes the stacking order; larger values are drawn later.
272    pub fn set_z_index(&self, z_index: i32) -> std::result::Result<(), SwVideoCompositorError> {
273        self.update(|layer| layer.z_index = z_index)
274    }
275
276    /// Shows or hides the input without removing its registration.
277    pub fn set_visible(&self, visible: bool) -> std::result::Result<(), SwVideoCompositorError> {
278        self.update(|layer| layer.visible = visible)
279    }
280
281    /// Changes how the input aspect ratio maps into its rectangle.
282    pub fn set_fit(&self, fit: VideoFit) -> std::result::Result<(), SwVideoCompositorError> {
283        self.update(|layer| layer.fit = fit)
284    }
285
286    fn update(
287        &self,
288        update: impl FnOnce(&mut VideoLayer),
289    ) -> std::result::Result<(), SwVideoCompositorError> {
290        let input = self
291            .input
292            .upgrade()
293            .ok_or(SwVideoCompositorError::SourceRemoved)?;
294        update(&mut input.layer.lock().unwrap());
295        Ok(())
296    }
297}
298
299/// One terminal video input returned by
300/// [`SwVideoCompositorHandle::add_source`]. It stores only the latest frame,
301/// so a fast producer cannot build an unbounded queue behind a slower
302/// compositor output rate.
303pub struct SwVideoCompositorInputSink {
304    pp_log: PpLog,
305    name: Arc<str>,
306    shared: Weak<CompositorShared>,
307    input: Weak<VideoInput>,
308}
309
310impl SwVideoCompositorInputSink {
311    fn detach(&self) {
312        let (Some(shared), Some(input)) = (self.shared.upgrade(), self.input.upgrade()) else {
313            return;
314        };
315        let mut inputs = shared.inputs.lock().unwrap();
316        let is_current = inputs
317            .get(&self.name)
318            .is_some_and(|current| Arc::ptr_eq(current, &input));
319        if is_current {
320            inputs.remove(&self.name);
321        }
322    }
323}
324
325impl Element for SwVideoCompositorInputSink {
326    fn name(&self) -> Arc<str> {
327        self.name.clone()
328    }
329
330    fn element_type(&self) -> ElementType {
331        ElementType::SwVideoCompositor
332    }
333
334    fn pp_log(&self) -> &PpLog {
335        &self.pp_log
336    }
337
338    fn pp_log_mut(&mut self) -> &mut PpLog {
339        &mut self.pp_log
340    }
341}
342
343impl Sink for SwVideoCompositorInputSink {
344    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
345        let Some(input) = self.input.upgrade() else {
346            return Ok(());
347        };
348        match buf {
349            MediaBuffer::Video(frame) => {
350                input.latest_frame.store(Some(frame));
351                Ok(())
352            }
353            MediaBuffer::Eos => {
354                self.detach();
355                Ok(())
356            }
357            MediaBuffer::Packet(_) => {
358                Err(SwVideoCompositorError::UnsupportedBuffer("Packet").into())
359            }
360            MediaBuffer::Audio(_) => Err(SwVideoCompositorError::UnsupportedBuffer("Audio").into()),
361        }
362    }
363
364    fn control(&mut self, msg: ControlMsg) -> Result<()> {
365        match msg {
366            ControlMsg::Stop => self.detach(),
367            ControlMsg::Seek(_) => {
368                if let Some(input) = self.input.upgrade() {
369                    input.latest_frame.store(None);
370                }
371            }
372            ControlMsg::Pause | ControlMsg::Resume => {}
373        }
374        Ok(())
375    }
376}
377
378#[derive(Clone)]
379struct InputSnapshot {
380    id: VideoInputId,
381    layer: VideoLayer,
382    frame: Option<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>>,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386struct ScaleDefinition {
387    source_format: ffmpeg::format::Pixel,
388    source_width: u32,
389    source_height: u32,
390    target_width: u32,
391    target_height: u32,
392}
393
394struct InputScaler {
395    definition: Option<ScaleDefinition>,
396    context: Option<ffmpeg::software::scaling::Context>,
397    output: ffmpeg::frame::Video,
398}
399
400impl InputScaler {
401    fn new() -> Self {
402        Self {
403            definition: None,
404            context: None,
405            output: ffmpeg::frame::Video::empty(),
406        }
407    }
408
409    fn scale(
410        &mut self,
411        source: &ffmpeg::frame::Video,
412        width: u32,
413        height: u32,
414    ) -> std::result::Result<&ffmpeg::frame::Video, ffmpeg::Error> {
415        let definition = ScaleDefinition {
416            source_format: source.format(),
417            source_width: source.width(),
418            source_height: source.height(),
419            target_width: width,
420            target_height: height,
421        };
422        if self.definition != Some(definition) {
423            self.context = Some(ffmpeg::software::scaling::Context::get(
424                definition.source_format,
425                definition.source_width,
426                definition.source_height,
427                ffmpeg::format::Pixel::BGRA,
428                width,
429                height,
430                ffmpeg::software::scaling::Flags::BILINEAR,
431            )?);
432            self.output = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height);
433            self.definition = Some(definition);
434        }
435        self.context
436            .as_mut()
437            .expect("created with a changed definition or retained from a matching one")
438            .run(source, &mut self.output)?;
439        Ok(&self.output)
440    }
441}
442
443/// Composites the latest frames from any number of independent input
444/// pipelines into one fixed-rate opaque BGRA video stream.
445///
446/// Like [`crate::elements::AudioMixer`], this is a [`SourceElement`], not
447/// a conventional one-input filter: upstream pipelines terminate at the
448/// sinks returned by [`SwVideoCompositorHandle::add_source`], while this
449/// element's own pipeline drives output on its independent clock. Input
450/// frame PTS values therefore do not become output PTS; output advances by
451/// one tick in [`SwVideoCompositor::time_base`] for every composed frame.
452pub struct SwVideoCompositor {
453    pp_log: PpLog,
454    name: Arc<str>,
455    shared: Arc<CompositorShared>,
456    options: VideoCompositorOptions,
457    frame_interval: Duration,
458    frame_index: i64,
459    scalers: HashMap<VideoInputId, InputScaler>,
460    output_pool: UnboundObjectPool<ffmpeg::frame::Video>,
461    pad: SrcPad,
462}
463
464// SAFETY: `SwsContext` has no thread affinity and every scaling context is
465// exclusively accessed through `&mut self` on the compositor's one source
466// thread. ffmpeg-next simply omits Send for this wrapper, as with SwScaler.
467unsafe impl Send for SwVideoCompositor {}
468
469impl SwVideoCompositor {
470    /// Creates a compositor and a weak runtime handle for registering inputs.
471    pub fn new(
472        name: impl Into<String>,
473        options: VideoCompositorOptions,
474    ) -> std::result::Result<(Self, SwVideoCompositorHandle), SwVideoCompositorError> {
475        validate_output_options(options)?;
476        let name: Arc<str> = name.into().into();
477        let pp_log = element_pp_log(ElementType::SwVideoCompositor, &name, None);
478        let shared = Arc::new(CompositorShared {
479            inputs: Mutex::new(HashMap::new()),
480            next_input_id: AtomicU64::new(1),
481        });
482        let frame_interval = Duration::from_secs_f64(
483            options.frame_rate.denominator() as f64 / options.frame_rate.numerator() as f64,
484        );
485        let (width, height) = (options.width, options.height);
486        let output_pool = UnboundObjectPool::new(
487            OUTPUT_POOL_SIZE,
488            move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
489            |_| {},
490        );
491        pp_info!(
492            pp_log: &pp_log,
493            "created: {}x{}, frame_rate={}, format=BGRA",
494            width,
495            height,
496            options.frame_rate
497        );
498        Ok((
499            Self {
500                name: name.clone(),
501                pp_log,
502                shared: shared.clone(),
503                options,
504                frame_interval,
505                frame_index: 0,
506                scalers: HashMap::new(),
507                output_pool,
508                pad: SrcPad::new(format!("{name}_src")),
509            },
510            SwVideoCompositorHandle {
511                shared: Arc::downgrade(&shared),
512            },
513        ))
514    }
515
516    /// Returns the fixed output pixel format, [`ffmpeg::format::Pixel::BGRA`].
517    pub fn format(&self) -> ffmpeg::format::Pixel {
518        ffmpeg::format::Pixel::BGRA
519    }
520
521    /// Returns the fixed output width in pixels.
522    pub fn width(&self) -> u32 {
523        self.options.width
524    }
525
526    /// Returns the fixed output height in pixels.
527    pub fn height(&self) -> u32 {
528        self.options.height
529    }
530
531    /// Returns the configured output frame rate.
532    pub fn frame_rate(&self) -> ffmpeg::Rational {
533        self.options.frame_rate
534    }
535
536    /// Returns the reciprocal of [`Self::frame_rate`], used as output PTS units.
537    pub fn time_base(&self) -> ffmpeg::Rational {
538        ffmpeg::Rational::new(
539            self.options.frame_rate.denominator(),
540            self.options.frame_rate.numerator(),
541        )
542    }
543
544    fn snapshots(&self) -> Vec<InputSnapshot> {
545        let inputs: Vec<_> = self
546            .shared
547            .inputs
548            .lock()
549            .unwrap()
550            .values()
551            .cloned()
552            .collect();
553        inputs
554            .into_iter()
555            .map(|input| InputSnapshot {
556                id: input.id,
557                layer: *input.layer.lock().unwrap(),
558                frame: input.latest_frame.load_full(),
559            })
560            .collect()
561    }
562
563    fn compose_frame(
564        &mut self,
565    ) -> std::result::Result<UnboundObjectPoolRef<ffmpeg::frame::Video>, SwVideoCompositorError>
566    {
567        let mut snapshots = self.snapshots();
568        let active: HashSet<_> = snapshots.iter().map(|snapshot| snapshot.id).collect();
569        self.scalers.retain(|id, _| active.contains(id));
570        snapshots.sort_by(|left, right| {
571            left.layer
572                .z_index
573                .cmp(&right.layer.z_index)
574                .then_with(|| left.id.cmp(&right.id))
575        });
576
577        let mut output = self.output_pool.get();
578        fill_background(&mut output, self.options.background);
579        for snapshot in snapshots {
580            if !snapshot.layer.visible || snapshot.layer.opacity == 0.0 {
581                continue;
582            }
583            let Some(frame) = snapshot.frame else {
584                continue;
585            };
586            let geometry = layer_geometry(
587                frame.width(),
588                frame.height(),
589                snapshot.layer.rect,
590                snapshot.layer.fit,
591            )?;
592            let scaled = self
593                .scalers
594                .entry(snapshot.id)
595                .or_insert_with(InputScaler::new)
596                .scale(&frame, geometry.image_width, geometry.image_height)
597                .map_err(SwVideoCompositorError::from)?;
598            blend_bgra(&mut output, scaled, geometry, snapshot.layer.opacity);
599        }
600        output.set_pts(Some(self.frame_index));
601        self.frame_index += 1;
602        Ok(output)
603    }
604
605    fn push_frame(&mut self, bus: &Bus) -> std::result::Result<(), SwVideoCompositorError> {
606        let output = self.compose_frame()?;
607        if let Err(error) = self.pad.push(MediaBuffer::Video(Arc::new(output))) {
608            bus.post(
609                &self.pp_log,
610                BusEvent::Error {
611                    element_type: ElementType::SwVideoCompositor,
612                    name: self.name.clone(),
613                    error,
614                },
615            );
616        }
617        Ok(())
618    }
619}
620
621impl Element for SwVideoCompositor {
622    fn name(&self) -> Arc<str> {
623        self.name.clone()
624    }
625
626    fn element_type(&self) -> ElementType {
627        ElementType::SwVideoCompositor
628    }
629
630    fn pp_log(&self) -> &PpLog {
631        &self.pp_log
632    }
633
634    fn pp_log_mut(&mut self) -> &mut PpLog {
635        &mut self.pp_log
636    }
637}
638
639impl Source for SwVideoCompositor {
640    fn src_pads(&mut self) -> &mut [SrcPad] {
641        std::slice::from_mut(&mut self.pad)
642    }
643}
644
645impl SourceElement for SwVideoCompositor {
646    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
647        pp_info!(self, "started");
648        let mut schedule = PeriodicSchedule::new(self.frame_interval, Instant::now());
649        loop {
650            let outcome = drain_control(control, self, bus)?;
651            if outcome.stopped {
652                pp_info!(self, "stopped");
653                return Ok(());
654            }
655            if outcome.paused_for > Duration::ZERO {
656                schedule.resume_after_pause(outcome.paused_for, Instant::now());
657            }
658
659            let now = Instant::now();
660            if !schedule.is_due(now) {
661                thread::sleep(schedule.remaining(now).min(CONTROL_POLL_INTERVAL));
662                continue;
663            }
664
665            self.push_frame(bus)?;
666            schedule.advance_after_tick(Instant::now());
667        }
668    }
669
670    fn seek(&mut self, _target: Duration) -> Result<Duration> {
671        Err(SwVideoCompositorError::SeekUnsupported.into())
672    }
673}
674
675fn validate_output_options(
676    options: VideoCompositorOptions,
677) -> std::result::Result<(), SwVideoCompositorError> {
678    if options.width == 0
679        || options.height == 0
680        || options.width > MAX_DIMENSION
681        || options.height > MAX_DIMENSION
682    {
683        return Err(SwVideoCompositorError::InvalidOutputDimensions {
684            width: options.width,
685            height: options.height,
686        });
687    }
688    if options.frame_rate.numerator() <= 0 || options.frame_rate.denominator() <= 0 {
689        return Err(SwVideoCompositorError::InvalidFrameRate(options.frame_rate));
690    }
691    Ok(())
692}
693
694/// Thin adapters over the shared, backend-agnostic logic in
695/// [`super::video_layer`] — translate its [`VideoLayerError`] into this
696/// backend's own [`SwVideoCompositorError`] variants so every existing call
697/// site/error consumer here keeps seeing the same error shape it always
698/// has.
699fn map_layer_error(error: VideoLayerError) -> SwVideoCompositorError {
700    match error {
701        VideoLayerError::InvalidDimensions { width, height } => {
702            SwVideoCompositorError::InvalidLayerDimensions { width, height }
703        }
704        VideoLayerError::InvalidOpacity(opacity) => SwVideoCompositorError::InvalidOpacity(opacity),
705        VideoLayerError::InvalidInputDimensions { width, height } => {
706            SwVideoCompositorError::InvalidInputDimensions { width, height }
707        }
708        VideoLayerError::ScaledLayerTooLarge { width, height } => {
709            SwVideoCompositorError::ScaledLayerTooLarge { width, height }
710        }
711    }
712}
713
714fn validate_layer(layer: VideoLayer) -> std::result::Result<(), SwVideoCompositorError> {
715    video_layer::validate_layer(layer).map_err(map_layer_error)
716}
717
718fn validate_rect(rect: VideoRect) -> std::result::Result<(), SwVideoCompositorError> {
719    video_layer::validate_rect(rect).map_err(map_layer_error)
720}
721
722fn validate_opacity(opacity: f32) -> std::result::Result<(), SwVideoCompositorError> {
723    video_layer::validate_opacity(opacity).map_err(map_layer_error)
724}
725
726fn layer_geometry(
727    source_width: u32,
728    source_height: u32,
729    rect: VideoRect,
730    fit: VideoFit,
731) -> std::result::Result<LayerGeometry, SwVideoCompositorError> {
732    video_layer::layer_geometry(source_width, source_height, rect, fit).map_err(map_layer_error)
733}
734
735fn fill_background(frame: &mut ffmpeg::frame::Video, color: Color) {
736    let width = frame.width() as usize;
737    let height = frame.height() as usize;
738    let stride = frame.stride(0);
739    let data = frame.data_mut(0);
740    for row in 0..height {
741        for pixel in data[row * stride..row * stride + width * 4]
742            .as_chunks_mut::<4>()
743            .0
744        {
745            *pixel = [color.blue, color.green, color.red, 255];
746        }
747    }
748}
749
750fn blend_bgra(
751    destination: &mut ffmpeg::frame::Video,
752    source: &ffmpeg::frame::Video,
753    geometry: LayerGeometry,
754    opacity: f32,
755) {
756    let output_width = i64::from(destination.width());
757    let output_height = i64::from(destination.height());
758    let clip_left = i64::from(geometry.clip.x).max(0);
759    let clip_top = i64::from(geometry.clip.y).max(0);
760    let clip_right =
761        (i64::from(geometry.clip.x) + i64::from(geometry.clip.width)).min(output_width);
762    let clip_bottom =
763        (i64::from(geometry.clip.y) + i64::from(geometry.clip.height)).min(output_height);
764    let left = geometry.image_x.max(clip_left);
765    let top = geometry.image_y.max(clip_top);
766    let right = (geometry.image_x + i64::from(geometry.image_width)).min(clip_right);
767    let bottom = (geometry.image_y + i64::from(geometry.image_height)).min(clip_bottom);
768    if left >= right || top >= bottom {
769        return;
770    }
771
772    let source_stride = source.stride(0);
773    let destination_stride = destination.stride(0);
774    let source_data = source.data(0);
775    let destination_data = destination.data_mut(0);
776    for output_y in top..bottom {
777        let source_y = (output_y - geometry.image_y) as usize;
778        let destination_y = output_y as usize;
779        for output_x in left..right {
780            let source_x = (output_x - geometry.image_x) as usize;
781            let destination_x = output_x as usize;
782            let source_offset = source_y * source_stride + source_x * 4;
783            let destination_offset = destination_y * destination_stride + destination_x * 4;
784            let source_pixel = &source_data[source_offset..source_offset + 4];
785            let destination_pixel =
786                &mut destination_data[destination_offset..destination_offset + 4];
787            let alpha = (f32::from(source_pixel[3]) / 255.0) * opacity;
788            let inverse = 1.0 - alpha;
789            for channel in 0..3 {
790                destination_pixel[channel] = (f32::from(source_pixel[channel]) * alpha
791                    + f32::from(destination_pixel[channel]) * inverse)
792                    .round()
793                    .clamp(0.0, 255.0) as u8;
794            }
795            destination_pixel[3] = 255;
796        }
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use std::{
803        sync::{
804            Mutex as StdMutex,
805            atomic::{AtomicBool, Ordering as AtomicOrdering},
806        },
807        thread,
808    };
809
810    use super::*;
811
812    struct CapturingSink {
813        pp_log: PpLog,
814        received: Arc<StdMutex<Vec<MediaBuffer>>>,
815    }
816
817    impl Element for CapturingSink {
818        fn name(&self) -> Arc<str> {
819            "capture".into()
820        }
821
822        fn element_type(&self) -> ElementType {
823            ElementType::Other
824        }
825
826        fn pp_log(&self) -> &PpLog {
827            &self.pp_log
828        }
829
830        fn pp_log_mut(&mut self) -> &mut PpLog {
831            &mut self.pp_log
832        }
833    }
834
835    impl Sink for CapturingSink {
836        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
837            self.received.lock().unwrap().push(buf);
838            Ok(())
839        }
840
841        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
842            Ok(())
843        }
844    }
845
846    fn options(width: u32, height: u32) -> VideoCompositorOptions {
847        VideoCompositorOptions {
848            width,
849            height,
850            frame_rate: ffmpeg::Rational::new(30, 1),
851            background: Color::BLACK,
852        }
853    }
854
855    fn solid_frame(
856        width: u32,
857        height: u32,
858        color: Color,
859    ) -> Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>> {
860        let pool = UnboundObjectPool::new(
861            0,
862            move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
863            |_| {},
864        );
865        let mut frame = pool.get();
866        fill_background(&mut frame, color);
867        Arc::new(frame)
868    }
869
870    fn pixel(frame: &ffmpeg::frame::Video, x: usize, y: usize) -> [u8; 4] {
871        let offset = y * frame.stride(0) + x * 4;
872        frame.data(0)[offset..offset + 4].try_into().unwrap()
873    }
874
875    fn input(
876        handle: &SwVideoCompositorHandle,
877        name: &str,
878        layer: VideoLayer,
879    ) -> (Box<dyn Sink>, SwVideoLayerHandle) {
880        let input = handle.add_source(name, layer).unwrap().unwrap();
881        (input.sink, input.layer)
882    }
883
884    #[test]
885    fn composes_inputs_in_z_order_and_preserves_output_contract() {
886        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(4, 4)).unwrap();
887        let mut background = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
888        background.fit = VideoFit::Stretch;
889        let (mut red_sink, _) = input(&handle, "red", background);
890        let mut overlay = VideoLayer::new(VideoRect::new(1, 1, 2, 2));
891        overlay.z_index = 1;
892        overlay.fit = VideoFit::Stretch;
893        let (mut blue_sink, _) = input(&handle, "blue", overlay);
894        red_sink
895            .consume(MediaBuffer::Video(solid_frame(4, 4, Color::new(255, 0, 0))))
896            .unwrap();
897        blue_sink
898            .consume(MediaBuffer::Video(solid_frame(2, 2, Color::new(0, 0, 255))))
899            .unwrap();
900
901        let frame = compositor.compose_frame().unwrap();
902        assert_eq!(frame.format(), ffmpeg::format::Pixel::BGRA);
903        assert_eq!((frame.width(), frame.height()), (4, 4));
904        assert_eq!(frame.pts(), Some(0));
905        assert_eq!(pixel(&frame, 0, 0), [0, 0, 255, 255]);
906        assert_eq!(pixel(&frame, 1, 1), [255, 0, 0, 255]);
907    }
908
909    #[test]
910    fn layer_handle_moves_blends_and_hides_a_live_source() {
911        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(3, 1)).unwrap();
912        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
913        let (mut sink, layer_handle) = input(&handle, "white", layer);
914        sink.consume(MediaBuffer::Video(solid_frame(1, 1, Color::WHITE)))
915            .unwrap();
916
917        layer_handle.set_rect(VideoRect::new(1, 0, 1, 1)).unwrap();
918        layer_handle.set_opacity(0.5).unwrap();
919        let blended = compositor.compose_frame().unwrap();
920        assert_eq!(pixel(&blended, 0, 0), [0, 0, 0, 255]);
921        assert_eq!(pixel(&blended, 1, 0), [128, 128, 128, 255]);
922
923        layer_handle.set_visible(false).unwrap();
924        let hidden = compositor.compose_frame().unwrap();
925        assert_eq!(pixel(&hidden, 1, 0), [0, 0, 0, 255]);
926        assert_eq!(hidden.pts(), Some(1));
927    }
928
929    #[test]
930    fn input_keeps_only_the_latest_frame() {
931        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
932        let (mut sink, _) = input(
933            &handle,
934            "latest",
935            VideoLayer::new(VideoRect::new(0, 0, 1, 1)),
936        );
937        sink.consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(255, 0, 0))))
938            .unwrap();
939        sink.consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(0, 255, 0))))
940            .unwrap();
941
942        let frame = compositor.compose_frame().unwrap();
943        assert_eq!(pixel(&frame, 0, 0), [0, 255, 0, 255]);
944    }
945
946    #[test]
947    fn frame_replacement_and_composition_run_concurrently() {
948        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
949        let (mut sink, _) = input(&handle, "live", VideoLayer::new(VideoRect::new(0, 0, 1, 1)));
950        let red = solid_frame(1, 1, Color::new(255, 0, 0));
951        let green = solid_frame(1, 1, Color::new(0, 255, 0));
952        let done = Arc::new(AtomicBool::new(false));
953        let producer_done = done.clone();
954        let producer = thread::spawn(move || {
955            for index in 0..2_000 {
956                let frame = if index % 2 == 0 {
957                    red.clone()
958                } else {
959                    green.clone()
960                };
961                sink.consume(MediaBuffer::Video(frame)).unwrap();
962            }
963            // Make the final observable value deterministic after the
964            // concurrent replacement phase ends.
965            sink.consume(MediaBuffer::Video(green)).unwrap();
966            producer_done.store(true, AtomicOrdering::Release);
967        });
968
969        while !done.load(AtomicOrdering::Acquire) {
970            let frame = compositor.compose_frame().unwrap();
971            assert!(matches!(
972                pixel(&frame, 0, 0),
973                [0, 0, 0, 255] | [0, 0, 255, 255] | [0, 255, 0, 255]
974            ));
975        }
976        producer.join().unwrap();
977        let final_frame = compositor.compose_frame().unwrap();
978        assert_eq!(pixel(&final_frame, 0, 0), [0, 255, 0, 255]);
979    }
980
981    #[test]
982    fn replacing_a_name_invalidates_old_sink_and_layer_handle() {
983        let (mut compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
984        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
985        let (mut old_sink, old_layer) = input(&handle, "camera", layer);
986        let (mut new_sink, _) = input(&handle, "camera", layer);
987        assert!(matches!(
988            old_layer.set_visible(false),
989            Err(SwVideoCompositorError::SourceRemoved)
990        ));
991        old_sink
992            .consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(255, 0, 0))))
993            .unwrap();
994        new_sink
995            .consume(MediaBuffer::Video(solid_frame(1, 1, Color::new(0, 0, 255))))
996            .unwrap();
997
998        let frame = compositor.compose_frame().unwrap();
999        assert_eq!(pixel(&frame, 0, 0), [255, 0, 0, 255]);
1000        assert_eq!(handle.source_count(), 1);
1001    }
1002
1003    #[test]
1004    fn stop_removes_only_the_current_registration() {
1005        let (_compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1006        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1007        let (mut old_sink, _) = input(&handle, "camera", layer);
1008        let (_new_sink, _) = input(&handle, "camera", layer);
1009        old_sink.control(ControlMsg::Stop).unwrap();
1010        assert_eq!(handle.source_count(), 1);
1011
1012        handle.remove_source("camera");
1013        assert_eq!(handle.source_count(), 0);
1014    }
1015
1016    #[test]
1017    fn contain_and_cover_preserve_aspect_ratio() {
1018        let rect = VideoRect::new(10, 20, 100, 100);
1019        let contain = layer_geometry(160, 90, rect, VideoFit::Contain).unwrap();
1020        assert_eq!((contain.image_width, contain.image_height), (100, 56));
1021        assert_eq!((contain.image_x, contain.image_y), (10, 42));
1022
1023        let cover = layer_geometry(160, 90, rect, VideoFit::Cover).unwrap();
1024        assert_eq!((cover.image_width, cover.image_height), (178, 100));
1025        assert_eq!((cover.image_x, cover.image_y), (-29, 20));
1026    }
1027
1028    #[test]
1029    fn rejects_invalid_layers_and_non_video_buffers() {
1030        let (_compositor, handle) = SwVideoCompositor::new("compositor", options(1, 1)).unwrap();
1031        let invalid = VideoLayer {
1032            opacity: 1.5,
1033            ..VideoLayer::new(VideoRect::new(0, 0, 1, 1))
1034        };
1035        assert!(matches!(
1036            handle.add_source("invalid", invalid),
1037            Err(SwVideoCompositorError::InvalidOpacity(1.5))
1038        ));
1039
1040        let (mut sink, _) = input(
1041            &handle,
1042            "valid",
1043            VideoLayer::new(VideoRect::new(0, 0, 1, 1)),
1044        );
1045        let error = sink
1046            .consume(MediaBuffer::Packet(Arc::new(ffmpeg::Packet::empty())))
1047            .unwrap_err();
1048        assert!(matches!(
1049            error,
1050            crate::Error::SwVideoCompositorError(SwVideoCompositorError::UnsupportedBuffer(
1051                "Packet"
1052            ))
1053        ));
1054    }
1055
1056    #[test]
1057    fn pushes_fixed_format_frames_with_contiguous_pts() {
1058        let (mut compositor, _) = SwVideoCompositor::new("compositor", options(2, 2)).unwrap();
1059        let received = Arc::new(StdMutex::new(Vec::new()));
1060        compositor.src_pads()[0].link(Box::new(CapturingSink {
1061            received: received.clone(),
1062            pp_log: element_pp_log(ElementType::Other, "capture", None),
1063        }));
1064        let (bus, _) = Bus::new();
1065        compositor.push_frame(&bus).unwrap();
1066        compositor.push_frame(&bus).unwrap();
1067
1068        let received = received.lock().unwrap();
1069        let pts: Vec<_> = received
1070            .iter()
1071            .filter_map(|buffer| match buffer {
1072                MediaBuffer::Video(frame) => Some(frame.pts()),
1073                _ => None,
1074            })
1075            .collect();
1076        assert_eq!(pts, vec![Some(0), Some(1)]);
1077    }
1078
1079    struct TimestampSink {
1080        pp_log: PpLog,
1081        tx: crossbeam_channel::Sender<Instant>,
1082    }
1083
1084    impl Element for TimestampSink {
1085        fn name(&self) -> Arc<str> {
1086            "timestamp-recorder".into()
1087        }
1088        fn element_type(&self) -> ElementType {
1089            ElementType::Other
1090        }
1091        fn pp_log(&self) -> &PpLog {
1092            &self.pp_log
1093        }
1094        fn pp_log_mut(&mut self) -> &mut PpLog {
1095            &mut self.pp_log
1096        }
1097    }
1098
1099    impl Sink for TimestampSink {
1100        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1101            if matches!(buf, MediaBuffer::Video(_)) {
1102                let _ = self.tx.send(Instant::now());
1103            }
1104            Ok(())
1105        }
1106        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1107            Ok(())
1108        }
1109    }
1110
1111    /// Regression test: `SwVideoCompositor::run` never folded
1112    /// `ControlOutcome::paused_for` back into `next_due` — a `Pause` let
1113    /// real time blow straight past the stale deadline, so the loop
1114    /// iteration right after `Resume` always found `next_due` already in
1115    /// the past and pushed immediately, resetting the output cadence's
1116    /// phase to the resume instant instead of preserving wherever it was
1117    /// before the freeze. A slow 10fps (100ms/frame) rate keeps the
1118    /// expected gap (near-zero vs. near-one-interval) well clear of
1119    /// scheduling jitter. Pausing is triggered synchronously right after a
1120    /// frame is observed, so `next_due` is a known ~100ms away the instant
1121    /// `Pause` is drained (`run`'s own control check only happens at the
1122    /// top of the loop, after that deadline has already been advanced).
1123    #[test]
1124    fn resuming_after_a_pause_preserves_output_phase() {
1125        use crate::pipeline::Pipeline;
1126
1127        let (tx, rx) = crossbeam_channel::unbounded();
1128        let sink = TimestampSink {
1129            tx,
1130            pp_log: element_pp_log(ElementType::Other, "timestamp-recorder", None),
1131        };
1132        let (compositor, _handle) = SwVideoCompositor::new(
1133            "compositor",
1134            VideoCompositorOptions {
1135                frame_rate: ffmpeg::Rational::new(10, 1),
1136                ..options(2, 2)
1137            },
1138        )
1139        .unwrap();
1140
1141        let pipeline = Pipeline::new("phase-test", compositor, |source, ctx| {
1142            let branch = ctx.branch().to(Box::new(sink))?;
1143            ctx.attach(source, 0, branch)?;
1144            Ok(())
1145        })
1146        .expect("test pipeline wiring must succeed");
1147
1148        pipeline.run().unwrap();
1149        // Warm up, then pause the instant a frame is observed — `next_due`
1150        // is then a known one interval away.
1151        for _ in 0..2 {
1152            rx.recv_timeout(Duration::from_millis(500))
1153                .expect("expected steady frames before pausing");
1154        }
1155        pipeline.pause();
1156        thread::sleep(Duration::from_millis(500));
1157
1158        let resumed_at = Instant::now();
1159        pipeline.resume();
1160        let first_after_resume = rx
1161            .recv_timeout(Duration::from_millis(500))
1162            .expect("expected a frame after resume");
1163        pipeline.stop();
1164        pipeline.bus().log_events();
1165
1166        let gap = first_after_resume.saturating_duration_since(resumed_at);
1167        assert!(
1168            gap >= Duration::from_millis(50),
1169            "expected the post-pause frame to land close to a full 100ms \
1170             interval after resume (phase preserved from before the \
1171             pause), not almost immediately (phase reset to the resume \
1172             instant): got {gap:?}"
1173        );
1174    }
1175}