alpha2_model/generator.rs
1//! Sources the engine draws itself: fills, gradients, shapes, and text.
2
3use crate::{
4 MAX_TEXT_CONTENT_BYTES, MAX_TEXT_GENERATOR_FONT_SIZE, ShapeIssue, ShapeItem, TextStyle, Vec2,
5};
6use serde::{Deserialize, Serialize};
7
8/// Most stops in one gradient.
9pub const MAX_GRADIENT_STOPS: usize = 8;
10
11/// A straight (not premultiplied), sRGB-encoded color with alpha, each
12/// component in `0..=1`.
13#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[serde(deny_unknown_fields)]
16pub struct Rgba {
17 /// Red component.
18 pub red: f32,
19 /// Green component.
20 pub green: f32,
21 /// Blue component.
22 pub blue: f32,
23 /// Coverage.
24 pub alpha: f32,
25}
26
27impl Rgba {
28 /// Creates a color from sRGB-encoded components and alpha.
29 #[must_use]
30 pub const fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
31 Self {
32 red,
33 green,
34 blue,
35 alpha,
36 }
37 }
38
39 pub(crate) fn is_valid(self) -> bool {
40 [self.red, self.green, self.blue, self.alpha]
41 .iter()
42 .all(|value| (0.0..=1.0).contains(value))
43 }
44}
45
46/// The picture of a generated source.
47///
48/// A generated picture has the canvas's size and aspect, like a source that
49/// fills the frame at scale 1. Positions are in its normalized space, `(0, 0)`
50/// top-left to `(1, 1)` bottom-right; lengths (radii, stroke widths) are
51/// fractions of its shorter side, so the picture is the same at any render
52/// resolution. A fill or text does not change over time (a clip animates it
53/// with its transform, effects, and masks); shapes may.
54#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
57pub enum Generator {
58 /// Paint over the whole picture.
59 Fill {
60 /// What covers the picture.
61 paint: Paint,
62 },
63 /// Shapes drawn from an ordered content tree, as an After Effects shape
64 /// layer or a Lottie shape layer draws them. Unlike the other
65 /// generators, its picture changes over time: every value in the tree
66 /// may be keyframed, in the picture's time.
67 Shapes {
68 /// The content tree, front first. See [`ShapeItem`].
69 items: Vec<ShapeItem>,
70 },
71 /// Text on a transparent picture, laid out and placed by its style as a
72 /// caption track's cues are: its size and wrapping width fractions of
73 /// the canvas, its anchor at `style.position`. The engine draws it with
74 /// the fonts its host registered.
75 Text {
76 /// The text; `\n` starts a line. Non-empty, at most
77 /// [`MAX_TEXT_CONTENT_BYTES`] bytes.
78 text: String,
79 /// How it looks and where it lies. The font size is at most
80 /// [`MAX_TEXT_GENERATOR_FONT_SIZE`].
81 #[serde(default)]
82 style: TextStyle,
83 },
84}
85
86/// A color or gradient.
87///
88/// Gradients interpolate between stops premultiplied, in linear light: the
89/// engine's working space, so a gradient between two colors passes through
90/// the physical mix of them, and a stop fading to transparent does not darken
91/// its neighbour.
92#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
95pub enum Paint {
96 /// One color.
97 Solid {
98 /// The color.
99 color: Rgba,
100 },
101 /// Colors along the line from `start` to `end`, constant across it.
102 LinearGradient {
103 /// Where offset 0 lies.
104 start: Vec2,
105 /// Where offset 1 lies. Distinct from `start`.
106 end: Vec2,
107 /// Two to [`MAX_GRADIENT_STOPS`] stops.
108 stops: Vec<GradientStop>,
109 /// What lies beyond the first and last offsets.
110 #[serde(default)]
111 spread: Spread,
112 },
113 /// Colors along the radius of a circle.
114 RadialGradient {
115 /// Where offset 0 lies.
116 center: Vec2,
117 /// The distance of offset 1 from the centre, as a fraction of the
118 /// picture's shorter side. Positive.
119 radius: f32,
120 /// Two to [`MAX_GRADIENT_STOPS`] stops.
121 stops: Vec<GradientStop>,
122 /// What lies beyond the first and last offsets.
123 #[serde(default)]
124 spread: Spread,
125 },
126 /// Colors around a point: offsets 0 to 1 are one clockwise turn.
127 ConicGradient {
128 /// The point the gradient turns around.
129 center: Vec2,
130 /// Where offset 0 lies, in degrees clockwise from straight up.
131 #[serde(default)]
132 angle_degrees: f32,
133 /// Two to [`MAX_GRADIENT_STOPS`] stops.
134 stops: Vec<GradientStop>,
135 },
136}
137
138/// One color of a gradient.
139#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[serde(deny_unknown_fields)]
142pub struct GradientStop {
143 /// Position along the gradient, in `0..=1`, not before the previous stop.
144 pub offset: f32,
145 /// Color at that position.
146 pub color: Rgba,
147}
148
149/// What a linear or radial gradient shows outside its first and last stops.
150#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[serde(rename_all = "snake_case")]
153pub enum Spread {
154 /// The end colors continue.
155 #[default]
156 Pad,
157 /// The gradient starts again.
158 Repeat,
159 /// The gradient runs back and forth.
160 Reflect,
161}
162
163impl Generator {
164 /// Whether every field is in range, as a project needs it to be.
165 #[must_use]
166 pub fn is_valid(&self) -> bool {
167 self.validate().is_ok()
168 }
169
170 /// Checks every field. A shapes generator's problem names where in its
171 /// tree it lies; another generator's is `None`.
172 ///
173 /// # Errors
174 /// Returns the first problem, if any.
175 pub fn validate(&self) -> Result<(), Option<ShapeIssue>> {
176 let valid = match self {
177 Self::Fill { paint } => paint.is_valid(),
178 Self::Shapes { items } => {
179 return crate::shapes::validate_shape_items(items).map_err(Some);
180 }
181 Self::Text { text, style } => {
182 !text.is_empty()
183 && text.len() <= MAX_TEXT_CONTENT_BYTES
184 && style.is_valid(MAX_TEXT_GENERATOR_FONT_SIZE)
185 }
186 };
187 valid.then_some(()).ok_or(None)
188 }
189}
190
191impl Paint {
192 pub(crate) fn is_valid(&self) -> bool {
193 match self {
194 Self::Solid { color } => color.is_valid(),
195 Self::LinearGradient {
196 start, end, stops, ..
197 } => finite(*start) && finite(*end) && start != end && valid_stops(stops),
198 Self::RadialGradient {
199 center,
200 radius,
201 stops,
202 ..
203 } => finite(*center) && radius.is_finite() && *radius > 0.0 && valid_stops(stops),
204 Self::ConicGradient {
205 center,
206 angle_degrees,
207 stops,
208 } => finite(*center) && angle_degrees.is_finite() && valid_stops(stops),
209 }
210 }
211}
212
213fn finite(value: Vec2) -> bool {
214 value.x.is_finite() && value.y.is_finite()
215}
216
217fn valid_stops(stops: &[GradientStop]) -> bool {
218 (2..=MAX_GRADIENT_STOPS).contains(&stops.len())
219 && stops
220 .iter()
221 .all(|stop| (0.0..=1.0).contains(&stop.offset) && stop.color.is_valid())
222 && stops
223 .windows(2)
224 .all(|pair| pair[0].offset <= pair[1].offset)
225}