Skip to main content

ffcine_text/
lib.rs

1//! `ffcine.text` generator clips (place cards, time cards, subtitles, titles) rendered as GPU
2//! overlays, only over their own time ranges: see `docs/text.md`.
3//!
4//! [`TextLayer`] collects a timeline's text clips with their resolved styles
5//! (`metadata.ffcine.project.styles`); [`TextLayer::overlays`] lays out the ones overlapping a
6//! segment with `cosmic-text` and returns [`Overlay`]s that the render pipeline composites in its
7//! libplacebo pass ([`ffcine_render::Pipeline::overlays`]). Segment renderers split at
8//! [`TextLayer::cuts`] and key their cache on [`TextLayer::fingerprint`], so a card edit
9//! re-renders only the seconds it covers. [`ass`] imports `.ass` subtitles as text clips;
10//! [`anim`] animates text (slide, pop, typewriter, wipe, keyframes) and [`title`] expands
11//! `ffcine.title` clips (title sequences, end screens) into text pieces.
12//!
13//! ```no_run
14//! use ffcine_render::{Pipeline, Size};
15//! use ffcine_text::TextLayer;
16//! use ffcine_timeline::Timeline;
17//!
18//! let t = Timeline::from_file("edit.otio")?;
19//! let mut text = TextLayer::from_timeline(&t, ".")?;
20//! // A segment of 96 frames starting 12.5 s into the timeline, rendered at 4K.
21//! let size = Size { width: 3840, height: 2160 };
22//! let overlays = text.overlays(12.5, 96, 24000.0 / 1001.0, size)?;
23//! let p = Pipeline::new("shot.mov", "seg.mkv").overlays(overlays);
24//! # Ok::<(), ffcine_core::Error>(())
25//! ```
26
27pub mod anim;
28pub mod ass;
29pub mod layout;
30pub mod style;
31pub mod title;
32
33use std::hash::{DefaultHasher, Hash, Hasher};
34use std::path::PathBuf;
35
36use ffcine_core::Result;
37use ffcine_render::{Overlay, Size};
38use ffcine_timeline::{HasMetadata, Item, MediaReference, Timeline};
39use serde_json::{Map, Value};
40
41pub use anim::Animation;
42pub use layout::{Layer, Rendered, Role, Typesetter};
43pub use style::{Align, TextStyle};
44
45/// `generator_kind` of text clips.
46pub const GENERATOR: &str = "ffcine.text";
47
48/// One `ffcine.text` clip on the timeline.
49#[derive(Debug, Clone, PartialEq)]
50pub struct TextClip {
51    /// `metadata.ffcine.id`, if any.
52    pub id: Option<String>,
53    pub name: String,
54    pub text: String,
55    /// Timeline seconds.
56    pub start: f64,
57    pub end: f64,
58    /// Top-level track index (higher draws on top).
59    pub track: usize,
60    /// Merged parameters: `styles.base` <- style chain <- the clip's (see [`style::cascade`]).
61    pub params: Map<String, Value>,
62}
63
64impl TextClip {
65    /// The style for a frame of `frame_size` px.
66    pub fn style(&self, frame_size: [f32; 2]) -> TextStyle {
67        style::resolve(&self.params, frame_size, None)
68    }
69}
70
71/// A timeline's text clips, ready to render per segment.
72#[derive(Debug)]
73pub struct TextLayer {
74    clips: Vec<TextClip>,
75    /// `metadata.ffcine.frame_size`: what `size` and other px lengths are relative to.
76    frame_size: Option<[f32; 2]>,
77    base_dir: PathBuf,
78    typesetter: Option<Typesetter>,
79    /// Output profile name and safe area, see [`TextLayer::set_profile`].
80    profile: Option<(String, [f32; 4])>,
81}
82
83impl TextLayer {
84    /// Collects the enabled `ffcine.text` clips of `timeline`; font files resolve against
85    /// `base_dir` (the timeline's directory).
86    pub fn from_timeline(timeline: &Timeline, base_dir: impl Into<PathBuf>) -> Result<Self> {
87        let styles = timeline
88            .project()
89            .transpose()?
90            .map(|p| p.styles)
91            .unwrap_or_default();
92        let frame_size = timeline
93            .metadata
94            .get("ffcine")
95            .and_then(|f| f.get("frame_size"))
96            .and_then(Value::as_array)
97            .and_then(|a| {
98                #[allow(clippy::cast_possible_truncation)]
99                let g = |i: usize| a.get(i).and_then(Value::as_f64).map(|v| v as f32);
100                Some([g(0)?, g(1)?])
101            });
102        let enabled = |i: usize| match timeline.tracks.children.get(i) {
103            Some(Item::Track(t)) => t.enabled,
104            _ => true,
105        };
106        let frame_h = frame_size.map_or(1080.0, |f| f64::from(f[1]));
107        let mut clips: Vec<TextClip> = timeline
108            .placed_clips()
109            .into_iter()
110            .filter(|p| p.clip.enabled && enabled(p.track))
111            .flat_map(|p| {
112                let Some(MediaReference::Generator(g)) = p.clip.media_reference() else {
113                    return Vec::new();
114                };
115                let id = p.clip.ffcine_id().map(str::to_owned);
116                let clip = |text: String, params, suffix: Option<&str>| TextClip {
117                    id: match suffix {
118                        Some(s) => Some(format!("{}:{s}", id.as_deref().unwrap_or(&p.clip.name))),
119                        None => id.clone(),
120                    },
121                    name: p.clip.name.clone(),
122                    text,
123                    start: p.start,
124                    end: p.end(),
125                    track: p.track,
126                    params,
127                };
128                match g.generator_kind.as_str() {
129                    GENERATOR => vec![clip(
130                        g.parameters
131                            .get("text")
132                            .and_then(Value::as_str)
133                            .unwrap_or_default()
134                            .to_owned(),
135                        style::cascade(&styles, &g.parameters),
136                        None,
137                    )],
138                    title::GENERATOR => title::expand(&styles, &g.parameters, frame_h)
139                        .into_iter()
140                        .map(|piece| clip(piece.text, piece.params, Some(piece.role)))
141                        .collect(),
142                    _ => Vec::new(),
143                }
144            })
145            .collect();
146        clips.sort_by(|a, b| a.track.cmp(&b.track).then(a.start.total_cmp(&b.start)));
147        Ok(Self {
148            clips,
149            frame_size,
150            base_dir: base_dir.into(),
151            typesetter: None,
152            profile: None,
153        })
154    }
155
156    /// Text `clips` (times in seconds of whatever clock [`Self::overlays`] is given) laid out on
157    /// a frame of `frame_size` px (`None`: lengths relative to the output); font files resolve
158    /// against `base_dir`.
159    pub fn new(
160        clips: Vec<TextClip>,
161        frame_size: Option<[f32; 2]>,
162        base_dir: impl Into<PathBuf>,
163    ) -> Self {
164        Self {
165            clips,
166            frame_size,
167            base_dir: base_dir.into(),
168            typesetter: None,
169            profile: None,
170        }
171    }
172
173    /// Renders for output profile `name` (`reel`, `feed`, ...): a clip's `profiles.<name>`
174    /// parameters override the rest. When the output's aspect differs from `frame_size`, lengths
175    /// scale with the short side instead of the height and each text box is moved inside `safe`
176    /// (top, right, bottom, left margins as fractions of the output).
177    pub fn set_profile(&mut self, name: &str, safe: [f32; 4]) {
178        self.profile = Some((name.to_owned(), safe));
179    }
180
181    pub fn clips(&self) -> &[TextClip] {
182        &self.clips
183    }
184
185    /// Clips overlapping `start..end` (timeline seconds).
186    pub fn in_range(&self, start: f64, end: f64) -> impl Iterator<Item = &TextClip> {
187        self.clips
188            .iter()
189            .filter(move |c| c.start < end && c.end > start)
190    }
191
192    /// Every text in and out point, sorted: where a segment renderer should cut so each
193    /// segment's text is fixed.
194    pub fn cuts(&self) -> Vec<f64> {
195        let mut v: Vec<f64> = self.clips.iter().flat_map(|c| [c.start, c.end]).collect();
196        v.sort_by(f64::total_cmp);
197        v.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
198        v
199    }
200
201    /// A hash of the text drawn over `start..end`: the clips' parameters and their times
202    /// relative to `start`. Equal fingerprints render equal pixels, so it belongs in a segment's
203    /// cache key.
204    pub fn fingerprint(&self, start: f64, end: f64) -> u64 {
205        let mut h = DefaultHasher::new();
206        for c in self.in_range(start, end) {
207            // Sizes and positions are relative to it.
208            self.frame_size.map(|f| f.map(f32::to_bits)).hash(&mut h);
209            ((c.start - start) * 1e4).round().to_bits().hash(&mut h);
210            ((c.end - start) * 1e4).round().to_bits().hash(&mut h);
211            c.track.hash(&mut h);
212            serde_json::to_string(&c.params)
213                .unwrap_or_default()
214                .hash(&mut h);
215        }
216        h.finish()
217    }
218
219    /// The overlays for a segment of `frames` frames at `rate` starting at timeline second
220    /// `start`, rendered at `out` px. Frame ranges are relative to the segment's first frame
221    /// (they may start before 0 or end past `frames`, so fades carry across segment cuts).
222    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
223    pub fn overlays(
224        &mut self,
225        start: f64,
226        frames: u64,
227        rate: f64,
228        out: Size,
229    ) -> Result<Vec<Overlay>> {
230        let end = start + frames as f64 / rate;
231        let frame = self
232            .frame_size
233            .unwrap_or([out.width as f32, out.height as f32]);
234        let (ow, oh) = (out.width as f32, out.height as f32);
235        let reshaped = (frame[0] / frame[1] - ow / oh).abs() > 0.01;
236        let scale = if reshaped {
237            ow.min(oh) / frame[0].min(frame[1])
238        } else {
239            oh / frame[1]
240        };
241        let ts = self
242            .typesetter
243            .get_or_insert_with(|| Typesetter::new(self.base_dir.clone()));
244        let mut v = Vec::new();
245        for c in self.clips.iter().filter(|c| c.start < end && c.end > start) {
246            let s = match &self.profile {
247                Some((name, _)) => style::resolve(&for_profile(&c.params, name), frame, None),
248                None => c.style(frame),
249            };
250            let from = ((c.start + s.delay - start) * rate).round() as i64;
251            let to = ((c.end - start) * rate).round() as i64;
252            if to <= from || to <= 0 || from >= i64::try_from(frames).unwrap_or(i64::MAX) {
253                continue;
254            }
255            let params = match &self.profile {
256                Some((name, _)) => for_profile(&c.params, name),
257                None => c.params.clone(),
258            };
259            let anim = Animation::parse(&params);
260            let rect = params.get("rect").and_then(Value::as_array).and_then(|a| {
261                #[allow(clippy::cast_possible_truncation)]
262                let g = |i: usize| a.get(i).and_then(Value::as_f64).map(|v| v as f32);
263                Some([g(0)? * ow, g(1)? * oh])
264            });
265            let raster = if rect.is_some() {
266                1.0
267            } else {
268                anim.raster_scale_over(c.end - c.start, rate)
269            };
270            let r = match rect {
271                Some(size) => anim::rect(size, s.position, s.anchor, [ow, oh], s.colour),
272                None => match ts.render(&c.text, &s, scale * raster, [out.width, out.height])? {
273                    Some(r) => r,
274                    None => continue,
275                },
276            };
277            let shift = match &self.profile {
278                Some((_, safe)) if reshaped => into_safe(&r, *safe, [ow, oh]),
279                _ => [0.0, 0.0],
280            };
281            let mut r = r;
282            r.origin = [r.origin[0] + shift[0], r.origin[1] + shift[1]];
283            for l in &mut r.layers {
284                for p in &mut l.parts {
285                    p.pos = [p.pos[0] + shift[0], p.pos[1] + shift[1]];
286                }
287            }
288            if !anim.is_plain_fade() {
289                let a = [
290                    (s.position[0] + 0.5) * ow + shift[0],
291                    (s.position[1] + 0.5) * oh + shift[1],
292                ];
293                v.extend(anim::animate(
294                    &r,
295                    &anim,
296                    raster,
297                    a,
298                    s.position,
299                    [ow, oh],
300                    anim::Span {
301                        from,
302                        to,
303                        frames: i64::try_from(frames).unwrap_or(i64::MAX),
304                        rate,
305                        delay: s.delay,
306                    },
307                ));
308                continue;
309            }
310            for l in r.layers {
311                v.push(Overlay {
312                    mask: l.mask,
313                    parts: l.parts,
314                    from,
315                    to,
316                    fade_in: s.fade_in * rate,
317                    fade_out: s.fade_out * rate,
318                    motion: None,
319                    under: None,
320                });
321            }
322        }
323        Ok(v)
324    }
325}
326
327/// `params` with its `profiles.<name>` object merged over it (and `profiles` dropped).
328fn for_profile(params: &Map<String, Value>, name: &str) -> Map<String, Value> {
329    let mut m = params.clone();
330    let over = m.remove("profiles");
331    if let Some(Value::Object(top)) = over.as_ref().and_then(|p| p.get(name)) {
332        style::merge(&mut m, top);
333    }
334    m
335}
336
337/// The move that puts a rendered text box inside the `safe` margins (top, right, bottom, left,
338/// fractions) of an `[w, h]` frame; a box bigger than the area is centred on it.
339fn into_safe(r: &layout::Rendered, safe: [f32; 4], [w, h]: [f32; 2]) -> [f32; 2] {
340    let [top, right, bottom, left] = safe;
341    let axis = |pos: f32, size: f32, lo: f32, hi: f32| {
342        if size > hi - lo {
343            (lo + hi - size) / 2.0 - pos
344        } else if pos < lo {
345            lo - pos
346        } else if pos + size > hi {
347            hi - (pos + size)
348        } else {
349            0.0
350        }
351    };
352    [
353        axis(r.origin[0], r.size[0], left * w, w - right * w),
354        axis(r.origin[1], r.size[1], top * h, h - bottom * h),
355    ]
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use ffcine_timeline::{
362        Clip, Gap, GeneratorReference, Project, RationalTime, TimeRange, Track, track_kind,
363    };
364    use serde_json::json;
365
366    pub(crate) fn text_clip(text: &str, style: &str, frames: f64) -> Item {
367        let p = json!({"text": text, "style": style});
368        Item::Clip(Clip::new(
369            text,
370            MediaReference::Generator(GeneratorReference::new(
371                GENERATOR,
372                p.as_object().cloned().unwrap_or_default(),
373            )),
374            Some(TimeRange::from_frames(0.0, frames, 24.0)),
375        ))
376    }
377
378    fn timeline() -> Timeline {
379        let mut tr = Track::new("Overlays 1", track_kind::VIDEO);
380        tr.children
381            .push(Item::Gap(Gap::new(RationalTime::new(48.0, 24.0))));
382        tr.children.push(text_clip("Arrival", "card", 72.0));
383        let mut t = Timeline::default();
384        t.tracks.children.push(Item::Track(tr));
385        let p = Project {
386            styles: json!({"card": {"size": 0.1, "x": 0.05, "y": 0.15, "fade_in": 0.5}})
387                .as_object()
388                .cloned()
389                .unwrap_or_default(),
390            ..Project::default()
391        };
392        t.set_project(&p).expect("project");
393        t
394    }
395
396    #[test]
397    fn collects_clips_cuts_and_fingerprints() {
398        let t = timeline();
399        let layer = TextLayer::from_timeline(&t, ".").expect("layer");
400        assert_eq!(layer.clips().len(), 1);
401        assert_eq!(layer.cuts(), [2.0, 5.0]);
402        assert_eq!(layer.clips()[0].params["size"], 0.1);
403        assert_eq!(layer.fingerprint(0.0, 1.0), DefaultHasher::new().finish());
404        // Same text at the same relative time: same fingerprint.
405        assert_eq!(layer.fingerprint(0.0, 3.0), layer.fingerprint(0.0, 3.5));
406        assert_ne!(layer.fingerprint(0.0, 3.0), layer.fingerprint(1.0, 3.0));
407        let mut sized = TextLayer::from_timeline(&t, ".").expect("layer");
408        sized.frame_size = Some([1080.0, 1920.0]);
409        assert_ne!(sized.fingerprint(0.0, 3.0), layer.fingerprint(0.0, 3.0));
410    }
411
412    #[test]
413    fn overlays_only_in_range() {
414        let t = timeline();
415        let mut layer = TextLayer::from_timeline(&t, ".").expect("layer");
416        let out = Size {
417            width: 1920,
418            height: 1080,
419        };
420        assert_eq!(layer.overlays(0.0, 24, 24.0, out).expect("ov"), []);
421        // cosmic-text panics shaping with no fonts at all.
422        if !Typesetter::new(".").has_fonts() {
423            eprintln!("no system fonts; skipped");
424            return;
425        }
426        let ov = layer.overlays(1.0, 48, 24.0, out).expect("ov");
427        assert_eq!((ov[0].from, ov[0].to), (24, 96));
428        assert!((ov[0].fade_in - 12.0).abs() < 1e-9);
429    }
430
431    #[test]
432    fn titles_and_animated_text_render_with_motion() {
433        let mut t = Timeline::default();
434        title::insert(
435            &mut t,
436            json!({"title": "Hello there world", "subtitle": "A test pattern"})
437                .as_object()
438                .cloned()
439                .unwrap_or_default(),
440            0.0,
441            4.0,
442        )
443        .expect("insert");
444        let mut tr = Track::new("Captions", track_kind::VIDEO);
445        let p = json!({"text": "typed out", "animation": {"in": {"kind": "typewriter", "duration": 1}}});
446        tr.children.push(Item::Clip(Clip::new(
447            "typed",
448            MediaReference::Generator(GeneratorReference::new(
449                GENERATOR,
450                p.as_object().cloned().unwrap_or_default(),
451            )),
452            Some(TimeRange::from_frames(0.0, 48.0, 24.0)),
453        )));
454        t.tracks.children.push(Item::Track(tr));
455        let mut layer = TextLayer::from_timeline(&t, ".").expect("layer");
456        let ids: Vec<_> = layer.clips().iter().filter_map(|c| c.id.clone()).collect();
457        assert_eq!(ids, ["title1:title", "title1:rule", "title1:subtitle"]);
458        if !Typesetter::new(".").has_fonts() {
459            eprintln!("no system fonts; skipped");
460            return;
461        }
462        let out = Size {
463            width: 640,
464            height: 360,
465        };
466        let ov = layer.overlays(0.0, 96, 24.0, out).expect("ov");
467        assert!(ov.iter().all(|o| o.motion.is_some()));
468        // Mid-stagger the title's words are separate pieces; settled, one again.
469        let title_fill = ov
470            .iter()
471            .filter(|o| o.mask.width > 1)
472            .max_by_key(|o| o.mask.height)
473            .expect("title");
474        assert!(
475            title_fill.parts_at(8).len() >= 2,
476            "{:?}",
477            title_fill.parts_at(8)
478        );
479        assert_eq!(title_fill.parts_at(60).len(), 1);
480        // Half way through typing, part of the caption shows.
481        let typed = ov.last().expect("caption");
482        let w = |f: i64| {
483            typed
484                .parts_at(f)
485                .iter()
486                .map(|p| p.size.unwrap()[0])
487                .sum::<f32>()
488        };
489        assert!(
490            w(0) < w(12) && w(12) < w(30),
491            "{} {} {}",
492            w(0),
493            w(12),
494            w(30)
495        );
496    }
497
498    #[test]
499    fn profile_overrides_and_safe_area() {
500        let p = json!({"size": 40, "position": [0.0, 0.4],
501                       "profiles": {"reel": {"position": [0.0, 0.2]}}});
502        let p = p.as_object().unwrap();
503        let reel = for_profile(p, "reel");
504        assert_eq!(reel.get("position"), Some(&json!([0.0, 0.2])));
505        assert!(!reel.contains_key("profiles"));
506        assert_eq!(
507            for_profile(p, "feed").get("position"),
508            Some(&json!([0.0, 0.4]))
509        );
510        // A box under the reels' bottom UI moves up into the safe area; one inside stays.
511        let r = |origin: [f32; 2], size: [f32; 2]| layout::Rendered {
512            layers: Vec::new(),
513            origin,
514            size,
515            pad: 0.0,
516            lines: Vec::new(),
517            glyphs: Vec::new(),
518        };
519        let safe = [0.115, 0.11, 0.22, 0.055];
520        let [dx, dy] = into_safe(&r([100.0, 1700.0], [300.0, 80.0]), safe, [1080.0, 1920.0]);
521        assert!(dx.abs() < 1e-3);
522        assert!((1700.0 + dy + 80.0 - 1920.0 * 0.78).abs() < 1e-2);
523        assert_eq!(
524            into_safe(&r([300.0, 900.0], [300.0, 80.0]), safe, [1080.0, 1920.0]),
525            [0.0, 0.0]
526        );
527    }
528}