1pub 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
45pub const GENERATOR: &str = "ffcine.text";
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct TextClip {
51 pub id: Option<String>,
53 pub name: String,
54 pub text: String,
55 pub start: f64,
57 pub end: f64,
58 pub track: usize,
60 pub params: Map<String, Value>,
62}
63
64impl TextClip {
65 pub fn style(&self, frame_size: [f32; 2]) -> TextStyle {
67 style::resolve(&self.params, frame_size, None)
68 }
69}
70
71#[derive(Debug)]
73pub struct TextLayer {
74 clips: Vec<TextClip>,
75 frame_size: Option<[f32; 2]>,
77 base_dir: PathBuf,
78 typesetter: Option<Typesetter>,
79 profile: Option<(String, [f32; 4])>,
81}
82
83impl TextLayer {
84 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 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 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 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 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 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 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 #[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(¶ms);
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
327fn 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
337fn 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 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 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 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 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 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}