Skip to main content

ffcine_render/
layer.rs

1//! Picture layers composited over a pipeline's graded frames in the same GPU pass: split-screen
2//! panels, picture-in-picture, stickers and logos, the incoming side of a transition.
3//!
4//! Each [`Layer`] has its own source (a video decoded alongside the main input, or a still
5//! image) and its own [`Grade`]. The backend renders the layer's frame into an RGBA texture of
6//! the grade's `size` (its hooks can write alpha: [`crate::shaders::alpha`] for opacity and
7//! rounded corners), then draws it over the main picture at the frame's placement, under the
8//! text [`Overlay`](crate::Overlay)s. A layer that is turned or not [`BlendMode::Normal`] is
9//! composited by a shader pass that reads the picture under it.
10
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use crate::{BlendMode, Crop, Grade};
16
17/// An RGBA image, 8 bits per channel, not premultiplied, row-major.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Image {
20    pub width: u32,
21    pub height: u32,
22    pub data: Vec<u8>,
23}
24
25/// The largest still a [`LayerSource::Still`] decodes, px a side.
26pub const STILL_SIDE: u32 = 16384;
27/// The largest still a [`LayerSource::Still`] decodes, px in all (256 MP).
28pub const STILL_PIXELS: u64 = 1 << 28;
29
30/// Whether a `w` x `h` still is within [`STILL_SIDE`] and [`STILL_PIXELS`].
31pub fn still_fits(w: u32, h: u32) -> bool {
32    w > 0 && h > 0 && w.max(h) <= STILL_SIDE && u64::from(w) * u64::from(h) <= STILL_PIXELS
33}
34
35/// Where a layer's pictures come from.
36#[derive(Debug, Clone, PartialEq)]
37pub enum LayerSource {
38    /// A video file, decoded from `start` and conformed to the pipeline's frame rate. Past its
39    /// end the last frame holds.
40    ///
41    /// With a `map` (a time remap, `docs/compositing.md#time-remapping`), layer frame `i` shows
42    /// source frame `map[i]`, counted from the first frame at or after `start` (0) on the
43    /// source's own rate, and nothing is conformed. A non-decreasing map decodes forward,
44    /// repeating and dropping frames; a step back seeks (cheap on an all-intra file).
45    Video {
46        input: PathBuf,
47        start: Duration,
48        map: Option<Arc<[i64]>>,
49    },
50    /// A still picture, drawn on every frame. Its values are taken as the output's colour
51    /// space (no conversion) for an SDR output, so a logo keeps its colours, and as sRGB with
52    /// white at the HDR reference white (203 nits, BT.2408) for a PQ or HLG one.
53    Image(Arc<Image>),
54    /// A still file (PNG, JPEG, WebP, HEIC with tile grids and `irot`), decoded through libav
55    /// once per layer of a rendered piece. A tagged file (primaries or transfer set: HEIC
56    /// `colr`, PNG `cICP` or `sRGB`) is converted from its tags; an untagged one is taken as an
57    /// [`Image`](Self::Image) is. JPEG EXIF orientation is not applied; at most
58    /// [`STILL_SIDE`] px a side and [`STILL_PIXELS`] in all.
59    Still(PathBuf),
60    /// A nested composition rendered in the same pass into its own texture.
61    Scene(Arc<Scene>),
62    /// A video through a libavfilter graph (`docs/compositing.md#libavfilter-escape-hatch`):
63    /// the renderer runs the [`Prep`](crate::filter_node::Prep) into a lossless intermediate and
64    /// replaces this with a [`Video`](Self::Video) of it before the pipeline runs (a backend
65    /// refuses it unprepared).
66    Filtered(Arc<crate::filter_node::Prep>),
67}
68
69/// A composition drawn into a layer's source texture: its `grade` (the background hook, `size`
70/// the scene's size) over a black frame, then `layers` bottom first and `overlays`, frame
71/// ranges relative to the using layer's `from`.
72#[derive(Debug, Clone, PartialEq)]
73pub struct Scene {
74    pub size: crate::Size,
75    pub grade: Grade,
76    pub layers: Vec<Layer>,
77    pub overlays: Vec<crate::Overlay>,
78}
79
80/// One layer over output frames `from..to`.
81#[derive(Debug, Clone, PartialEq)]
82pub struct Layer {
83    pub source: LayerSource,
84    /// The layer's grade; `size` is its texture size (default: the source's). Hook tracks and
85    /// `crops` are indexed by layer frame (`0` = output frame `from`).
86    pub grade: Grade,
87    pub from: i64,
88    pub to: i64,
89    /// Placement in output pixels per layer frame (the last one holds); empty fills the frame.
90    pub rects: Vec<Crop>,
91    /// Rotation in degrees per layer frame, counter-clockwise on screen about the rectangle's
92    /// middle (the last one holds); empty is unrotated. Corners outside the picture stay
93    /// transparent.
94    pub rotation: Vec<f32>,
95    pub blend: BlendMode,
96}
97
98impl Layer {
99    /// The placement at output frame `frame`, `None` outside `from..to`.
100    pub fn rect_at(&self, frame: i64, width: u32, height: u32) -> Option<Crop> {
101        if frame < self.from || frame >= self.to {
102            return None;
103        }
104        let i = usize::try_from(frame - self.from).unwrap_or(0);
105        #[allow(clippy::cast_precision_loss)]
106        Some(
107            self.rects
108                .get(i)
109                .or(self.rects.last())
110                .copied()
111                .unwrap_or(Crop {
112                    x: 0.0,
113                    y: 0.0,
114                    width: width as f32,
115                    height: height as f32,
116                }),
117        )
118    }
119
120    /// The rotation at output frame `frame` (0 outside `from..to`).
121    pub fn rotation_at(&self, frame: i64) -> f32 {
122        if frame < self.from || frame >= self.to {
123            return 0.0;
124        }
125        let i = usize::try_from(frame - self.from).unwrap_or(0);
126        self.rotation
127            .get(i)
128            .or(self.rotation.last())
129            .copied()
130            .unwrap_or(0.0)
131    }
132
133    /// Whether frame `frame` needs the composite pass (turned, or blended other than normal).
134    pub fn needs_composite(&self, frame: i64) -> bool {
135        self.blend != BlendMode::Normal || self.rotation_at(frame) % 360.0 != 0.0
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn rect_holds_and_ranges() {
145        let r = |x| Crop {
146            x,
147            y: 0.0,
148            width: 10.0,
149            height: 10.0,
150        };
151        let l = Layer {
152            source: LayerSource::Image(Arc::new(Image {
153                width: 1,
154                height: 1,
155                data: vec![0; 4],
156            })),
157            grade: Grade::default(),
158            from: 5,
159            to: 10,
160            rects: vec![r(0.0), r(1.0)],
161            rotation: vec![0.0, 30.0],
162            blend: BlendMode::Normal,
163        };
164        assert_eq!(l.rect_at(4, 100, 50), None);
165        assert_eq!(l.rect_at(5, 100, 50), Some(r(0.0)));
166        assert_eq!(l.rect_at(9, 100, 50), Some(r(1.0)));
167        assert_eq!(l.rect_at(10, 100, 50), None);
168        let full = Layer {
169            rects: Vec::new(),
170            ..l
171        };
172        assert_eq!(full.rect_at(5, 100, 50).map(|c| c.width), Some(100.0));
173        assert!(!full.needs_composite(5));
174        assert!(full.needs_composite(7));
175        assert!((full.rotation_at(9) - 30.0).abs() < f32::EPSILON);
176        assert!(full.rotation_at(10).abs() < f32::EPSILON);
177        let multiply = Layer {
178            rotation: Vec::new(),
179            blend: BlendMode::Multiply,
180            ..full
181        };
182        assert!(multiply.needs_composite(5));
183    }
184}