Skip to main content

media_pp/elements/source/compositor/
video_layer.rs

1//! Shared, backend-agnostic layer types/math for anything that composites
2//! multiple video inputs into one output — [`crate::elements::SwVideoCompositor`]
3//! (CPU, `libswscale`) and [`crate::elements::D3d11VideoCompositor`] (GPU,
4//! D3D11) both use these exact same types, so a caller's layer-control code
5//! doesn't change shape when switching between them. Only the actual pixel
6//! work (scale+blend vs. shader draw) differs per backend.
7
8use thiserror::Error as ThisError;
9
10pub(crate) const MAX_DIMENSION: u32 = 16_384;
11
12/// An opaque, stable identity for one compositor input registration.
13/// Replacing an input with the same name creates a different identity, so
14/// an old sink or layer handle can never affect its replacement.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct VideoInputId(pub(crate) u64);
17
18/// An output-space rectangle. Signed coordinates allow a layer to be
19/// moved partially outside the canvas while its size remains positive.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct VideoRect {
22    /// Horizontal offset of the rectangle's left edge, in output pixels.
23    pub x: i32,
24    /// Vertical offset of the rectangle's top edge, in output pixels.
25    pub y: i32,
26    /// Rectangle width in output pixels; must be nonzero.
27    pub width: u32,
28    /// Rectangle height in output pixels; must be nonzero.
29    pub height: u32,
30}
31
32impl VideoRect {
33    /// Creates an output-space rectangle without validating its dimensions.
34    pub const fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
35        Self {
36            x,
37            y,
38            width,
39            height,
40        }
41    }
42}
43
44/// An input-space rectangle: the part of a frame a layer draws.
45///
46/// Unsigned, unlike [`VideoRect`]: a layer may hang off the canvas, but there
47/// is nothing outside a frame to take.
48///
49/// Set on the layer rather than asked of the frame, and therefore possibly
50/// out of range — a layer is placed before any frame arrives, and the frame
51/// can change size underneath it when a captured window is resized. What
52/// falls outside is brought back in when it is drawn, not refused when it is
53/// set; the compositors bring it inside the frame when they draw.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct VideoSourceRect {
56    /// Distance from the frame's left edge, in input pixels.
57    pub x: u32,
58    /// Distance from the frame's top edge, in input pixels.
59    pub y: u32,
60    /// Width of the region, in input pixels; must be nonzero.
61    pub width: u32,
62    /// Height of the region, in input pixels; must be nonzero.
63    pub height: u32,
64}
65
66impl VideoSourceRect {
67    /// Creates an input-space rectangle without validating it against any
68    /// frame — there is not one yet when a layer is placed.
69    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
70        Self {
71            x,
72            y,
73            width,
74            height,
75        }
76    }
77}
78
79/// How an input's aspect ratio is mapped into its [`VideoRect`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum VideoFit {
82    /// Distort the input to exactly fill the rectangle.
83    Stretch,
84    /// Preserve aspect ratio and letterbox/pillarbox inside the rectangle.
85    Contain,
86    /// Preserve aspect ratio, fill the rectangle, and crop overflow.
87    Cover,
88}
89
90/// Runtime-adjustable spatial settings for one compositor input.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct VideoLayer {
93    /// Output-space destination and clipping rectangle.
94    pub rect: VideoRect,
95    /// Stacking order; larger values are drawn over smaller values.
96    pub z_index: i32,
97    /// Layer alpha in the inclusive range `0.0..=1.0`.
98    pub opacity: f32,
99    /// Whether the compositor draws this layer.
100    pub visible: bool,
101    /// Aspect-ratio policy used to map the input into [`Self::rect`].
102    pub fit: VideoFit,
103    /// The part of the input to draw, or `None` for all of it.
104    ///
105    /// Applied *before* [`Self::fit`]: what is drawn is this region, and the
106    /// aspect ratio the fit preserves is this region's rather than the whole
107    /// frame's. Cropping a 16:9 capture to a square and containing it in a
108    /// square rectangle therefore fills it, which is what asking for both
109    /// means.
110    pub source: Option<VideoSourceRect>,
111}
112
113impl VideoLayer {
114    /// Creates a visible, fully opaque contained layer at `rect` and z-index zero.
115    pub const fn new(rect: VideoRect) -> Self {
116        Self {
117            rect,
118            z_index: 0,
119            opacity: 1.0,
120            visible: true,
121            fit: VideoFit::Contain,
122            source: None,
123        }
124    }
125}
126
127/// Validation/geometry failures shared by every compositor backend. Each
128/// backend's own `{Backend}CompositorError` maps these into its own
129/// variants (see e.g. [`crate::elements::SwVideoCompositorError`]) rather
130/// than exposing this type directly, so a caller matching on a specific
131/// backend's error type sees only that backend's own enum.
132#[derive(Debug, Clone, Copy, PartialEq, ThisError)]
133pub(crate) enum VideoLayerError {
134    #[error(
135        "invalid layer dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
136    )]
137    InvalidDimensions { width: u32, height: u32 },
138
139    #[error("layer opacity must be finite and between 0.0 and 1.0, got {0}")]
140    InvalidOpacity(f32),
141
142    #[error("input frame has invalid dimensions {width}x{height}")]
143    InvalidInputDimensions { width: u32, height: u32 },
144
145    #[error("scaled layer would exceed {MAX_DIMENSION}px: {width}x{height}")]
146    ScaledLayerTooLarge { width: u32, height: u32 },
147
148    #[error("layer source region has invalid dimensions {width}x{height}")]
149    InvalidSourceRegion { width: u32, height: u32 },
150}
151
152pub(crate) fn validate_layer(layer: VideoLayer) -> Result<(), VideoLayerError> {
153    validate_rect(layer.rect)?;
154    validate_source(layer.source)?;
155    validate_opacity(layer.opacity)
156}
157
158/// The one thing about a source region that can be judged without a frame:
159/// an empty region is a layer that draws nothing, which is a mistake rather
160/// than a way to hide it — [`VideoLayer::visible`] is that.
161pub(crate) fn validate_source(source: Option<VideoSourceRect>) -> Result<(), VideoLayerError> {
162    match source {
163        Some(source) if source.width == 0 || source.height == 0 => {
164            Err(VideoLayerError::InvalidSourceRegion {
165                width: source.width,
166                height: source.height,
167            })
168        }
169        _ => Ok(()),
170    }
171}
172
173/// The part of a frame this layer draws, brought inside what the frame
174/// actually has.
175///
176/// `None` when the region falls entirely outside the frame — a layer with
177/// nothing to draw, which a caller skips rather than reports: a capture that
178/// came back smaller than the crop that was set on it is a frame arriving
179/// late to a decision, not a fault.
180pub(crate) fn source_region(
181    source: Option<VideoSourceRect>,
182    frame_width: u32,
183    frame_height: u32,
184) -> Option<VideoSourceRect> {
185    let Some(source) = source else {
186        return Some(VideoSourceRect::new(0, 0, frame_width, frame_height));
187    };
188    let width = frame_width.checked_sub(source.x)?.min(source.width);
189    let height = frame_height.checked_sub(source.y)?.min(source.height);
190    (width > 0 && height > 0).then_some(VideoSourceRect::new(source.x, source.y, width, height))
191}
192
193pub(crate) fn validate_rect(rect: VideoRect) -> Result<(), VideoLayerError> {
194    if rect.width == 0
195        || rect.height == 0
196        || rect.width > MAX_DIMENSION
197        || rect.height > MAX_DIMENSION
198    {
199        Err(VideoLayerError::InvalidDimensions {
200            width: rect.width,
201            height: rect.height,
202        })
203    } else {
204        Ok(())
205    }
206}
207
208pub(crate) fn validate_opacity(opacity: f32) -> Result<(), VideoLayerError> {
209    if opacity.is_finite() && (0.0..=1.0).contains(&opacity) {
210        Ok(())
211    } else {
212        Err(VideoLayerError::InvalidOpacity(opacity))
213    }
214}
215
216#[derive(Debug, Clone, Copy)]
217pub(crate) struct LayerGeometry {
218    pub(crate) image_x: i64,
219    pub(crate) image_y: i64,
220    pub(crate) image_width: u32,
221    pub(crate) image_height: u32,
222    pub(crate) clip: VideoRect,
223}
224
225/// Computes where and at what size an input actually gets drawn inside its
226/// [`VideoRect`], given [`VideoFit`] — shared, backend-agnostic pixel-space
227/// math. A CPU backend blits with this directly; a GPU backend turns it
228/// into vertex positions/UVs for the same quad.
229pub(crate) fn layer_geometry(
230    source_width: u32,
231    source_height: u32,
232    rect: VideoRect,
233    fit: VideoFit,
234) -> Result<LayerGeometry, VideoLayerError> {
235    if source_width == 0 || source_height == 0 {
236        return Err(VideoLayerError::InvalidInputDimensions {
237            width: source_width,
238            height: source_height,
239        });
240    }
241
242    let source_is_wider = u128::from(rect.width) * u128::from(source_height)
243        <= u128::from(rect.height) * u128::from(source_width);
244    let (image_width, image_height) = match fit {
245        VideoFit::Stretch => (rect.width, rect.height),
246        VideoFit::Contain if source_is_wider => (
247            rect.width,
248            scaled_dimension(source_height, rect.width, source_width),
249        ),
250        VideoFit::Contain => (
251            scaled_dimension(source_width, rect.height, source_height),
252            rect.height,
253        ),
254        VideoFit::Cover if source_is_wider => (
255            scaled_dimension(source_width, rect.height, source_height),
256            rect.height,
257        ),
258        VideoFit::Cover => (
259            rect.width,
260            scaled_dimension(source_height, rect.width, source_width),
261        ),
262    };
263    if image_width > MAX_DIMENSION || image_height > MAX_DIMENSION {
264        return Err(VideoLayerError::ScaledLayerTooLarge {
265            width: image_width,
266            height: image_height,
267        });
268    }
269
270    Ok(LayerGeometry {
271        image_x: i64::from(rect.x) + (i64::from(rect.width) - i64::from(image_width)) / 2,
272        image_y: i64::from(rect.y) + (i64::from(rect.height) - i64::from(image_height)) / 2,
273        image_width,
274        image_height,
275        clip: rect,
276    })
277}
278
279pub(crate) fn scaled_dimension(source: u32, target: u32, divisor: u32) -> u32 {
280    let scaled =
281        (u128::from(source) * u128::from(target) + u128::from(divisor) / 2) / u128::from(divisor);
282    scaled.max(1).min(u128::from(u32::MAX)) as u32
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn contain_and_cover_preserve_aspect_ratio() {
291        let rect = VideoRect::new(10, 20, 100, 100);
292        let contain = layer_geometry(160, 90, rect, VideoFit::Contain).unwrap();
293        assert_eq!((contain.image_width, contain.image_height), (100, 56));
294        assert_eq!((contain.image_x, contain.image_y), (10, 42));
295
296        let cover = layer_geometry(160, 90, rect, VideoFit::Cover).unwrap();
297        assert_eq!((cover.image_width, cover.image_height), (178, 100));
298        assert_eq!((cover.image_x, cover.image_y), (-29, 20));
299    }
300}