Skip to main content

ffcine_text/
style.rs

1//! Resolving an `ffcine.text` clip's look: `project.styles.base` <- its `style` (and that
2//! style's `based_on` chain) <- the clip's own parameters, then read into a [`TextStyle`].
3//!
4//! Besides the parameters in `docs/timeline.md`, styles take the additive keys in
5//! `docs/text.md` (`anchor`, `shadow`, `outline`, `box`, `fade_in`, ...) and the prototype's
6//! `edit.yaml` keys (`size` as a fraction of the frame height, `color` 0..255, `x`/`y`,
7//! `shadow_opacity`, `pill_opacity`, `delay`), so imported projects render as they were.
8
9use std::path::{Path, PathBuf};
10
11use serde_json::{Map, Value};
12
13/// Horizontal alignment of lines within the text box.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Align {
16    Left,
17    #[default]
18    Center,
19    Right,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct Shadow {
24    pub colour: [f32; 4],
25    /// Offset in px at `frame_size`.
26    pub offset: [f32; 2],
27    /// Blur radius in px at `frame_size`.
28    pub blur: f32,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct Outline {
33    pub colour: [f32; 4],
34    /// Width in px at `frame_size`.
35    pub width: f32,
36}
37
38/// A box behind the text (caption pill, card scrim).
39#[derive(Debug, Clone, PartialEq)]
40pub struct BoxStyle {
41    pub colour: [f32; 4],
42    /// Padding around the text in px at `frame_size`.
43    pub padding: f32,
44}
45
46/// A fully resolved text look. Lengths are px at the timeline's `frame_size`.
47#[derive(Debug, Clone, PartialEq)]
48pub struct TextStyle {
49    pub font: Option<String>,
50    pub face: Option<String>,
51    /// A font file to load (absolute, or relative to the timeline's directory).
52    pub font_file: Option<PathBuf>,
53    /// Face index in a collection (`.ttc`).
54    pub font_index: u32,
55    pub size: f32,
56    pub colour: [f32; 4],
57    /// Colour of `*marked*` spans; `None` leaves asterisks as text.
58    pub highlight: Option<[f32; 4]>,
59    /// Where `anchor` lands: offset from the frame's middle, fractions of the frame, y down.
60    pub position: [f32; 2],
61    /// Point of the text box placed at `position`: fractions of the box, `[0, 0]` top left.
62    pub anchor: [f32; 2],
63    pub alignment: Align,
64    pub bold: bool,
65    pub italic: bool,
66    /// Line height as a multiple of `size`.
67    pub line_height: f32,
68    /// Wrap width, fraction of the frame width.
69    pub max_width: f32,
70    /// Extra space between letters, in ems.
71    pub tracking: f32,
72    pub uppercase: bool,
73    pub shadow: Option<Shadow>,
74    pub outline: Option<Outline>,
75    pub box_: Option<BoxStyle>,
76    /// Seconds from the clip's start before the text shows.
77    pub delay: f64,
78    pub fade_in: f64,
79    pub fade_out: f64,
80}
81
82impl TextStyle {
83    /// Defaults at a frame `height` px tall: white sans at 5 % of the height, centred.
84    pub fn default_for(height: f32) -> Self {
85        Self {
86            font: None,
87            face: None,
88            font_file: None,
89            font_index: 0,
90            size: 0.05 * height,
91            colour: [1.0; 4],
92            highlight: None,
93            position: [0.0, 0.0],
94            anchor: [0.5, 0.5],
95            alignment: Align::Center,
96            bold: false,
97            italic: false,
98            line_height: 1.2,
99            max_width: 0.9,
100            tracking: 0.0,
101            uppercase: false,
102            shadow: None,
103            outline: None,
104            box_: None,
105            delay: 0.0,
106            fade_in: 0.0,
107            fade_out: 0.0,
108        }
109    }
110}
111
112/// Deep-merges `top` over `base` (objects merge key by key, anything else replaces).
113pub fn merge(base: &mut Map<String, Value>, top: &Map<String, Value>) {
114    for (k, v) in top {
115        match (base.get_mut(k), v) {
116            (Some(Value::Object(b)), Value::Object(t)) => merge(b, t),
117            _ => {
118                base.insert(k.clone(), v.clone());
119            }
120        }
121    }
122}
123
124/// The merged parameter map for a clip: `base`, the `style` chain, then `params`.
125pub fn cascade(styles: &Map<String, Value>, params: &Map<String, Value>) -> Map<String, Value> {
126    let mut chain = Vec::new();
127    let mut name = params.get("style").and_then(Value::as_str);
128    while let Some(n) = name {
129        if n == "base" || chain.iter().any(|(c, _)| *c == n) || chain.len() > 16 {
130            break;
131        }
132        let Some(s) = styles.get(n).and_then(Value::as_object) else {
133            break;
134        };
135        chain.push((n, s));
136        name = s.get("based_on").and_then(Value::as_str);
137    }
138    let mut out = styles
139        .get("base")
140        .and_then(Value::as_object)
141        .cloned()
142        .unwrap_or_default();
143    for (_, s) in chain.iter().rev() {
144        merge(&mut out, s);
145    }
146    merge(&mut out, params);
147    out
148}
149
150fn f(v: Option<&Value>) -> Option<f32> {
151    #[allow(clippy::cast_possible_truncation)]
152    v.and_then(Value::as_f64).map(|x| x as f32)
153}
154
155fn vec2(v: Option<&Value>) -> Option<[f32; 2]> {
156    let a = v?.as_array()?;
157    Some([f(a.first())?, f(a.get(1))?])
158}
159
160/// `[r, g, b]` or `[r, g, b, a]`, 0..1, or 0..255 when any channel is above 1 (`edit.yaml`).
161pub fn colour(v: Option<&Value>) -> Option<[f32; 4]> {
162    let a = v?.as_array()?;
163    let mut c: Vec<f32> = a.iter().map(|x| f(Some(x))).collect::<Option<_>>()?;
164    if c.len() < 3 {
165        return None;
166    }
167    if c.iter().take(3).any(|x| *x > 1.0) {
168        for x in c.iter_mut().take(3) {
169            *x /= 255.0;
170        }
171    }
172    Some([c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0)])
173}
174
175fn bool_(v: Option<&Value>) -> Option<bool> {
176    v.and_then(|v| v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)))
177}
178
179/// A length: px at `frame_size`, or a fraction of the frame height when 0 < v <= 1 and
180/// `fractions` (the prototype's sizes).
181fn length(v: Option<&Value>, height: f32, fractions: bool) -> Option<f32> {
182    let x = f(v)?;
183    Some(if fractions && x > 0.0 && x <= 1.0 {
184        x * height
185    } else {
186        x
187    })
188}
189
190/// Fade seconds from `animation.{in,out}` or the flat `fade_in`/`fade_out`.
191fn fade(m: &Map<String, Value>, dir: &str, flat: &str) -> Option<f64> {
192    let a = m.get("animation").and_then(|a| a.get(dir));
193    if let Some(a) = a {
194        // The plain fade path; other kinds are drawn by `anim` from the same keys.
195        return a.get("duration").and_then(Value::as_f64);
196    }
197    m.get(flat).and_then(Value::as_f64)
198}
199
200#[allow(clippy::too_many_lines)] // one key after another
201/// Reads a merged parameter map (see [`cascade`]) for a `frame` of `[w, h]` px.
202pub fn resolve(m: &Map<String, Value>, frame: [f32; 2], name: Option<&str>) -> TextStyle {
203    let [_, fh] = frame;
204    let mut s = TextStyle::default_for(fh);
205    s.font = m.get("font").and_then(Value::as_str).map(str::to_owned);
206    s.face = m.get("face").and_then(Value::as_str).map(str::to_owned);
207    let style_name = m
208        .get("style")
209        .and_then(Value::as_str)
210        .or(name)
211        .unwrap_or("");
212    // The prototype names font files per element in its `style` section (now `base`).
213    let legacy = match style_name {
214        _ if m.contains_key("font_file") || m.contains_key("font") => None,
215        "card" => m.get("serif_card").or_else(|| m.get("serif")),
216        "location" => m.get("serif_sc"),
217        "title" => m.get("serif"),
218        "endcard" | "endscreen" => m.get("serif_italic"),
219        "caption" => m.get("sans"),
220        _ => None,
221    };
222    s.font_file = m
223        .get("font_file")
224        .or(legacy)
225        .and_then(Value::as_str)
226        .map(|p| {
227            let dir = m.get("fonts_dir").and_then(Value::as_str).unwrap_or("");
228            Path::new(dir).join(p)
229        });
230    // Captions default to Inter Bold (face 14 of Inter.ttc) as in the prototype.
231    #[allow(clippy::cast_possible_truncation)]
232    let index = m
233        .get("font_index")
234        .and_then(Value::as_u64)
235        .map(|i| i as u32)
236        .or((style_name == "caption" && legacy.is_some()).then_some(14));
237    s.font_index = index.unwrap_or(0);
238    if let Some(x) = length(m.get("size"), fh, true) {
239        s.size = x;
240    }
241    if let Some(c) = colour(m.get("colour").or_else(|| m.get("color"))) {
242        s.colour = c;
243    }
244    s.highlight = colour(m.get("highlight"));
245    if let Some(o) = f(m.get("opacity")) {
246        s.colour[3] *= o;
247    }
248    // The prototype's placement: `x`/`y` from the top left as fractions of the frame; `y` is the
249    // baseline (cards, locations) or the block's bottom (captions); locations measure `x` from
250    // the right edge.
251    let (lx, ly) = (f(m.get("x")), f(m.get("y")));
252    if lx.is_some() || ly.is_some() {
253        let y = ly.unwrap_or(0.9) - 0.5;
254        (s.position, s.anchor, s.alignment) = match style_name {
255            "location" => ([0.5 - lx.unwrap_or(0.05), y], [1.0, 1.0], Align::Right),
256            "caption" | "subs" | "subtitle" => ([0.0, y], [0.5, 1.0], Align::Center),
257            _ if lx.is_none() => ([0.0, y], [0.5, 1.0], Align::Center),
258            _ => ([lx.unwrap_or(0.05) - 0.5, y], [0.0, 1.0], Align::Left),
259        };
260    }
261    if let Some(p) = vec2(m.get("position")) {
262        s.position = p;
263    }
264    if let Some(a) = vec2(m.get("anchor")) {
265        s.anchor = a;
266    }
267    match m.get("alignment").and_then(Value::as_str) {
268        Some("left") => s.alignment = Align::Left,
269        Some("right") => s.alignment = Align::Right,
270        Some("center" | "centre") => s.alignment = Align::Center,
271        _ => {}
272    }
273    s.bold = bool_(m.get("bold")).unwrap_or(false);
274    s.italic = bool_(m.get("italic")).unwrap_or(false);
275    s.uppercase = bool_(m.get("uppercase")).unwrap_or(false);
276    if let Some(x) = f(m.get("line_height").or_else(|| m.get("line_spacing"))) {
277        s.line_height = x;
278    }
279    if let Some(x) = f(m.get("max_width")) {
280        s.max_width = x;
281    }
282    if let Some(x) = f(m.get("tracking")) {
283        s.tracking = x;
284    }
285    s.shadow = match m.get("shadow") {
286        Some(Value::Object(o)) => Some(Shadow {
287            colour: colour(o.get("colour").or_else(|| o.get("color")))
288                .unwrap_or([0.0, 0.0, 0.0, 0.5]),
289            offset: vec2(o.get("offset")).unwrap_or([0.0, 0.002 * fh]),
290            blur: f(o.get("blur")).unwrap_or(0.006 * fh),
291        }),
292        Some(Value::Bool(false) | Value::Null) => None,
293        _ => f(m.get("shadow_opacity"))
294            .filter(|o| *o > 0.0)
295            .map(|o| Shadow {
296                colour: [0.0, 0.0, 0.0, o],
297                offset: [0.0, 0.002 * fh],
298                blur: length(m.get("shadow_blur"), fh, true).unwrap_or(0.006 * fh),
299            }),
300    };
301    s.outline = match m.get("outline") {
302        Some(Value::Object(o)) => Some(Outline {
303            colour: colour(o.get("colour").or_else(|| o.get("color")))
304                .unwrap_or([0.0, 0.0, 0.0, 1.0]),
305            width: f(o.get("width")).unwrap_or(0.002 * fh),
306        }),
307        _ => None,
308    };
309    s.box_ = match m.get("box") {
310        Some(Value::Object(o)) => Some(BoxStyle {
311            colour: colour(o.get("colour").or_else(|| o.get("color")))
312                .unwrap_or([0.0, 0.0, 0.0, 0.4]),
313            padding: f(o.get("padding")).unwrap_or(0.25 * s.size),
314        }),
315        _ => f(m.get("pill_opacity").or_else(|| m.get("scrim")))
316            .filter(|o| *o > 0.0)
317            .map(|o| BoxStyle {
318                colour: [0.0, 0.0, 0.0, o],
319                padding: 0.25 * s.size,
320            }),
321    };
322    s.delay = m.get("delay").and_then(Value::as_f64).unwrap_or(0.0);
323    s.fade_in = fade(m, "in", "fade_in").unwrap_or(0.0);
324    s.fade_out = fade(m, "out", "fade_out").unwrap_or(0.0);
325    s
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use serde_json::json;
332
333    #[allow(clippy::needless_pass_by_value)]
334    fn obj(v: Value) -> Map<String, Value> {
335        v.as_object().cloned().unwrap_or_default()
336    }
337
338    #[test]
339    fn cascade_order_and_based_on() {
340        let styles = obj(json!({
341            "base": {"font": "Inter", "colour": [1, 1, 1, 1], "size": 40, "shadow": {"blur": 3}},
342            "card": {"based_on": "place", "size": 80},
343            "place": {"alignment": "left", "font": "Cormorant", "shadow": {"offset": [0, 2]}},
344        }));
345        let m = cascade(
346            &styles,
347            &obj(json!({"style": "card", "text": "Arrival", "bold": true})),
348        );
349        assert_eq!(m["font"], "Cormorant");
350        assert_eq!(m["size"], 80);
351        assert_eq!(m["alignment"], "left");
352        assert_eq!(m["shadow"], json!({"blur": 3, "offset": [0, 2]}));
353        let s = resolve(&m, [3840.0, 2160.0], None);
354        assert!(s.bold);
355        assert!((s.size - 80.0).abs() < 1e-6);
356        assert_eq!(s.alignment, Align::Left);
357        assert_eq!(s.shadow.map(|s| s.offset), Some([0.0, 2.0]));
358    }
359
360    #[test]
361    fn prototype_keys() {
362        let m = obj(json!({
363            "style": "card", "size": 0.10, "x": 0.055, "y": 0.175, "fade_in": 0.7,
364            "fade_out": 0.6, "color": [250, 246, 238], "shadow_opacity": 0.55, "scrim": 0.34,
365        }));
366        let s = resolve(&m, [1920.0, 1080.0], None);
367        assert!((s.size - 108.0).abs() < 1e-3);
368        assert_eq!(s.anchor, [0.0, 1.0]);
369        assert!((s.position[0] - (0.055 - 0.5)).abs() < 1e-6);
370        assert!((s.colour[0] - 250.0 / 255.0).abs() < 1e-6);
371        assert!(s.shadow.is_some() && s.box_.is_some());
372        assert!((s.fade_in - 0.7).abs() < 1e-9);
373    }
374
375    #[test]
376    fn animation_fade_wins() {
377        let m =
378            obj(json!({"fade_in": 1.0, "animation": {"in": {"kind": "fade", "duration": 0.25}}}));
379        let s = resolve(&m, [1920.0, 1080.0], None);
380        assert!((s.fade_in - 0.25).abs() < 1e-9);
381    }
382}