Skip to main content

ffcine_render/
overlay.rs

1//! Overlays composited onto the output in the grade pass (text cards, captions), backend-neutral.
2//!
3//! An [`Overlay`] is a coverage mask (8-bit alpha) drawn in one or more colours over a range of
4//! output frames, with linear fades at its ends. The backend uploads each mask once per run and
5//! hands the visible ones to libplacebo as `pl_overlay`s (`PL_OVERLAY_MONOCHROME`) on the render
6//! target, so overlays cost no extra pass and frames outside their ranges are untouched. Animated
7//! overlays (sliding, popping, typed text) carry a [`Motion`]: their placements per frame.
8
9use std::sync::Arc;
10
11/// An 8-bit coverage mask, row-major, `width * height` bytes.
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct Mask {
14    pub width: u32,
15    pub height: u32,
16    pub data: Vec<u8>,
17}
18
19impl Mask {
20    pub fn new(width: u32, height: u32) -> Self {
21        Self {
22            width,
23            height,
24            data: vec![0; width as usize * height as usize],
25        }
26    }
27
28    /// A 1×1 opaque mask: stretched over a part's `size`, a solid box.
29    pub fn solid() -> Self {
30        Self {
31            width: 1,
32            height: 1,
33            data: vec![255],
34        }
35    }
36}
37
38/// One placement of an overlay's mask.
39#[derive(Debug, Clone, Copy, PartialEq, Default)]
40pub struct OverlayPart {
41    /// Top left in output pixels.
42    pub pos: [f32; 2],
43    /// Size in output pixels; `None` draws the (cropped) mask at 1:1.
44    pub size: Option<[f32; 2]>,
45    /// The part of the mask drawn, `[x0, y0, x1, y1]` in mask pixels; `None` is all of it.
46    pub src: Option<[f32; 4]>,
47    /// `[r, g, b, a]`, sRGB, not premultiplied; multiplied by the mask and the fade.
48    pub colour: [f32; 4],
49}
50
51/// A mask drawn over output frames `from..to` (pipeline frame indices, 0 = the first frame
52/// output), fading in over the first `fade_in` frames and out over the last `fade_out`.
53#[derive(Debug, Clone, PartialEq, Default)]
54pub struct Overlay {
55    pub mask: Arc<Mask>,
56    pub parts: Vec<OverlayPart>,
57    pub from: i64,
58    pub to: i64,
59    pub fade_in: f64,
60    pub fade_out: f64,
61    /// Per-frame placements, replacing `parts` (the fades still apply).
62    pub motion: Option<Arc<Motion>>,
63    /// Drawn under the pipeline's layer `n` (and every one above it); `None` is above them all.
64    pub under: Option<usize>,
65}
66
67/// An animated overlay's placements: output frame `first + i` draws `frames[i]`. Frames before
68/// `first` draw the first entry, frames after the last the last one.
69#[derive(Debug, Clone, PartialEq, Default)]
70pub struct Motion {
71    pub first: i64,
72    pub frames: Vec<Vec<OverlayPart>>,
73}
74
75impl OverlayPart {
76    /// The mask crop and the output rectangle it lands on, both `[x0, y0, x1, y1]`.
77    pub fn rects(&self, mask: &Mask) -> ([f32; 4], [f32; 4]) {
78        #[allow(clippy::cast_precision_loss)]
79        let src = self
80            .src
81            .unwrap_or([0.0, 0.0, mask.width as f32, mask.height as f32]);
82        let [w, h] = self.size.unwrap_or([src[2] - src[0], src[3] - src[1]]);
83        let [x, y] = self.pos;
84        (src, [x, y, x + w, y + h])
85    }
86}
87
88impl Overlay {
89    /// The placements drawn on output frame `frame`.
90    pub fn parts_at(&self, frame: i64) -> &[OverlayPart] {
91        match &self.motion {
92            Some(m) if !m.frames.is_empty() => {
93                let last = m.frames.len() - 1;
94                let i = usize::try_from(frame - m.first).map_or(0, |i| i.min(last));
95                &m.frames[i]
96            }
97            _ => &self.parts,
98        }
99    }
100
101    /// Opacity at output frame `frame`, 0 outside `from..to`. Fades are linear and sampled at
102    /// the frame's middle, so a 1-frame fade still shows a half step.
103    #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
104    pub fn opacity(&self, frame: i64) -> f32 {
105        if frame < self.from || frame >= self.to {
106            return 0.0;
107        }
108        let t = (frame - self.from) as f64 + 0.5;
109        let left = (self.to - frame) as f64 - 0.5;
110        let a = if self.fade_in > 0.0 {
111            (t / self.fade_in).min(1.0)
112        } else {
113            1.0
114        };
115        let b = if self.fade_out > 0.0 {
116            (left / self.fade_out).min(1.0)
117        } else {
118            1.0
119        };
120        a.min(b).clamp(0.0, 1.0) as f32
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn opacity_fades_and_range() {
130        let o = Overlay {
131            mask: Arc::new(Mask::solid()),
132            parts: Vec::new(),
133            from: 10,
134            to: 20,
135            fade_in: 2.0,
136            fade_out: 4.0,
137            motion: None,
138            under: None,
139        };
140        assert!(o.opacity(9).abs() < f32::EPSILON);
141        assert!((o.opacity(10) - 0.25).abs() < 1e-6);
142        assert!((o.opacity(12) - 1.0).abs() < 1e-6);
143        assert!((o.opacity(19) - 0.125).abs() < 1e-6);
144        assert!(o.opacity(20).abs() < f32::EPSILON);
145    }
146
147    #[test]
148    fn motion_parts_and_crops() {
149        let part = |x: f32| OverlayPart {
150            pos: [x, 0.0],
151            ..OverlayPart::default()
152        };
153        let o = Overlay {
154            mask: Arc::new(Mask::new(8, 4)),
155            parts: vec![part(-1.0)],
156            from: 0,
157            to: 10,
158            motion: Some(Arc::new(Motion {
159                first: 2,
160                frames: vec![vec![part(2.0)], vec![part(3.0)]],
161            })),
162            ..Overlay::default()
163        };
164        assert_eq!(o.parts_at(0)[0].pos[0], 2.0);
165        assert_eq!(o.parts_at(3)[0].pos[0], 3.0);
166        assert_eq!(o.parts_at(9)[0].pos[0], 3.0);
167        let p = OverlayPart {
168            pos: [10.0, 20.0],
169            src: Some([2.0, 0.0, 6.0, 4.0]),
170            ..OverlayPart::default()
171        };
172        assert_eq!(
173            p.rects(&o.mask),
174            ([2.0, 0.0, 6.0, 4.0], [10.0, 20.0, 14.0, 24.0])
175        );
176    }
177}