Skip to main content

ffcine_text/
ass.rs

1//! `.ass` / `.ssa` subtitles -> `ffcine.text` clips, so existing subtitle files render
2//! through the same GPU text path as cards.
3//!
4//! Each ASS style becomes `project.styles."ass:<Name>"` (font, size, colours, bold/italic,
5//! alignment and margins, outline or box, shadow); each `Dialogue` becomes a text clip on a
6//! `Subtitles` track (overlapping lines spill onto `Subtitles 2`, ...). Line-wide overrides at
7//! the start of a line are kept: `\pos`, `\an`, `\fad`, `\b`, `\i`. Other override tags
8//! (karaoke, per-span colours, transforms, drawing) are dropped and counted in the report.
9
10use ffcine_core::{Error, Result};
11use ffcine_timeline::{
12    Clip, Gap, GeneratorReference, HasMetadata, Item, MediaReference, Project, RationalTime,
13    TimeRange, Timeline, Track, track_kind,
14};
15use serde_json::{Map, Value, json};
16
17use crate::GENERATOR;
18
19/// A parsed `[V4+ Styles]` entry, fields by name.
20#[derive(Debug, Clone, PartialEq)]
21pub struct AssStyle {
22    pub name: String,
23    pub fields: Map<String, Value>,
24}
25
26/// A `Dialogue` line.
27#[derive(Debug, Clone, PartialEq)]
28pub struct Event {
29    pub start: f64,
30    pub end: f64,
31    pub style: String,
32    /// Raw text with override tags.
33    pub text: String,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct Ass {
38    pub play_res: [f64; 2],
39    pub styles: Vec<AssStyle>,
40    pub events: Vec<Event>,
41}
42
43/// What [`import`] did.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct Report {
46    pub events: usize,
47    pub styles: usize,
48    pub tracks: usize,
49    /// Override tags that aren't rendered (dropped).
50    pub dropped_tags: usize,
51}
52
53fn invalid(value: impl Into<String>) -> Error {
54    Error::Invalid {
55        what: "ASS subtitles",
56        value: value.into(),
57    }
58}
59
60/// `H:MM:SS.cc` -> seconds.
61fn time(s: &str) -> Result<f64> {
62    let mut parts = s.trim().split(':').rev();
63    let sec: f64 = parts
64        .next()
65        .and_then(|v| v.parse().ok())
66        .ok_or_else(|| invalid(s))?;
67    let min: f64 = parts
68        .next()
69        .map_or(Ok(0.0), |v| v.parse().map_err(|_| invalid(s)))?;
70    let hour: f64 = parts
71        .next()
72        .map_or(Ok(0.0), |v| v.parse().map_err(|_| invalid(s)))?;
73    Ok(hour * 3600.0 + min * 60.0 + sec)
74}
75
76/// Parses an `.ass` file's text.
77pub fn parse(src: &str) -> Result<Ass> {
78    let mut section = String::new();
79    let mut play_res = [0.0, 0.0];
80    let mut style_fmt: Vec<String> = Vec::new();
81    let mut event_fmt: Vec<String> = Vec::new();
82    let (mut styles, mut events) = (Vec::new(), Vec::new());
83    for line in src.lines() {
84        let line = line.trim_start_matches('\u{feff}').trim();
85        if line.starts_with('[') && line.ends_with(']') {
86            section = line[1..line.len() - 1].to_ascii_lowercase();
87            continue;
88        }
89        let Some((key, value)) = line.split_once(':') else {
90            continue;
91        };
92        let value = value.trim();
93        match (section.as_str(), key.trim()) {
94            ("script info", "PlayResX") => play_res[0] = value.parse().unwrap_or(0.0),
95            ("script info", "PlayResY") => play_res[1] = value.parse().unwrap_or(0.0),
96            (s, "Format") if s.contains("styles") => {
97                style_fmt = value.split(',').map(|f| f.trim().to_owned()).collect();
98            }
99            ("events", "Format") => {
100                event_fmt = value.split(',').map(|f| f.trim().to_owned()).collect();
101            }
102            (s, "Style") if s.contains("styles") => {
103                let vals = value.splitn(style_fmt.len().max(1), ',');
104                let fields: Map<String, Value> = style_fmt
105                    .iter()
106                    .zip(vals)
107                    .map(|(k, v)| (k.clone(), Value::String(v.trim().to_owned())))
108                    .collect();
109                let name = fields
110                    .get("Name")
111                    .and_then(Value::as_str)
112                    .unwrap_or("Default")
113                    .to_owned();
114                styles.push(AssStyle { name, fields });
115            }
116            ("events", "Dialogue") => {
117                if event_fmt.is_empty() {
118                    return Err(invalid("Dialogue before the [Events] Format line"));
119                }
120                // Text is last and may contain commas.
121                let vals: Vec<&str> = value.splitn(event_fmt.len(), ',').collect();
122                let get = |name: &str| {
123                    event_fmt
124                        .iter()
125                        .position(|f| f == name)
126                        .and_then(|i| vals.get(i))
127                        .copied()
128                        .unwrap_or("")
129                };
130                events.push(Event {
131                    start: time(get("Start"))?,
132                    end: time(get("End"))?,
133                    style: get("Style").trim().trim_start_matches('*').to_owned(),
134                    text: get("Text").to_owned(),
135                });
136            }
137            _ => {}
138        }
139    }
140    if play_res == [0.0, 0.0] {
141        // libass's defaults when the script has none.
142        play_res = [384.0, 288.0];
143    } else if play_res[0] == 0.0 {
144        play_res[0] = play_res[1] * 4.0 / 3.0;
145    } else if play_res[1] == 0.0 {
146        play_res[1] = play_res[0] * 3.0 / 4.0;
147    }
148    Ok(Ass {
149        play_res,
150        styles,
151        events,
152    })
153}
154
155/// `&HAABBGGRR` (alpha 0 = opaque) -> `[r, g, b, a]`.
156fn ass_colour(s: &str) -> Option<[f64; 4]> {
157    let h = s
158        .trim()
159        .trim_start_matches("&H")
160        .trim_start_matches("&h")
161        .trim_end_matches('&');
162    let v = u32::from_str_radix(h, 16).ok()?;
163    let b = |shift: u32| f64::from((v >> shift) & 0xff) / 255.0;
164    Some([b(0), b(8), b(16), 1.0 - b(24)])
165}
166
167/// An ASS alignment number, 1..9.
168#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
169fn numpad(v: f64) -> u8 {
170    v.clamp(1.0, 9.0) as u8
171}
172
173/// Placement for numpad alignment `an` with margins, in [`crate::TextStyle`] terms.
174#[allow(clippy::many_single_char_names)]
175fn placement(an: u8, ml: f64, mr: f64, mv: f64, [w, h]: [f64; 2]) -> Map<String, Value> {
176    let col = (an.clamp(1, 9) - 1) % 3;
177    let row = (an.clamp(1, 9) - 1) / 3; // 0 bottom, 1 middle, 2 top
178    let (x, ax, align) = match col {
179        0 => (-0.5 + ml / w, 0.0, "left"),
180        2 => (0.5 - mr / w, 1.0, "right"),
181        _ => ((ml - mr) / (2.0 * w), 0.5, "center"),
182    };
183    let (y, ay) = match row {
184        0 => (0.5 - mv / h, 1.0),
185        2 => (-0.5 + mv / h, 0.0),
186        _ => (0.0, 0.5),
187    };
188    let mut m = Map::new();
189    m.insert("position".into(), json!([x, y]));
190    m.insert("anchor".into(), json!([ax, ay]));
191    m.insert("alignment".into(), align.into());
192    m.insert("max_width".into(), json!(1.0 - (ml + mr) / w));
193    m
194}
195
196/// An ASS style as `project.styles` parameters. Lengths are scaled by `k` (frame px per
197/// script px); `frac` writes `size` as a fraction of the height instead (no `frame_size`).
198fn style_params(s: &AssStyle, play_res: [f64; 2], k: f64, frac: bool) -> Map<String, Value> {
199    let num = |n: &str| {
200        s.fields
201            .get(n)
202            .and_then(Value::as_str)
203            .and_then(|v| v.parse::<f64>().ok())
204    };
205    let col = |n: &str| s.fields.get(n).and_then(Value::as_str).and_then(ass_colour);
206    let mut m = Map::new();
207    if let Some(f) = s.fields.get("Fontname").and_then(Value::as_str) {
208        m.insert("font".into(), f.into());
209    }
210    if let Some(size) = num("Fontsize") {
211        let v = if frac { size / play_res[1] } else { size * k };
212        m.insert("size".into(), json!(v));
213    }
214    if let Some(c) = col("PrimaryColour") {
215        m.insert("colour".into(), json!(c));
216    }
217    m.insert("bold".into(), json!(num("Bold").is_some_and(|b| b != 0.0)));
218    m.insert(
219        "italic".into(),
220        json!(num("Italic").is_some_and(|b| b != 0.0)),
221    );
222    let px = |v: f64| {
223        if frac {
224            v / play_res[1] * 1080.0
225        } else {
226            v * k
227        }
228    };
229    let outline = num("Outline").unwrap_or(0.0);
230    let back = col("BackColour").unwrap_or([0.0, 0.0, 0.0, 0.5]);
231    if num("BorderStyle") == Some(3.0) {
232        let c = col("OutlineColour").unwrap_or(back);
233        m.insert(
234            "box".into(),
235            json!({"colour": c, "padding": px(outline.max(2.0))}),
236        );
237    } else if outline > 0.0 {
238        let c = col("OutlineColour").unwrap_or([0.0, 0.0, 0.0, 1.0]);
239        m.insert("outline".into(), json!({"colour": c, "width": px(outline)}));
240    }
241    if let Some(d) = num("Shadow").filter(|d| *d > 0.0) {
242        m.insert(
243            "shadow".into(),
244            json!({"colour": back, "offset": [px(d), px(d)], "blur": 0.0}),
245        );
246    }
247    if let Some(sp) = num("Spacing").filter(|v| *v != 0.0)
248        && let Some(size) = num("Fontsize").filter(|v| *v > 0.0)
249    {
250        m.insert("tracking".into(), json!(sp / size));
251    }
252    let an = num("Alignment").map_or(2, numpad);
253    let margin = |n: &str| num(n).unwrap_or(10.0);
254    m.extend(placement(
255        an,
256        margin("MarginL"),
257        margin("MarginR"),
258        margin("MarginV"),
259        play_res,
260    ));
261    m
262}
263
264/// Line-wide overrides at the start of `raw` (`{\an8\pos(..)\fad(..)}`) applied to `params`;
265/// returns the plain text and the number of tags dropped.
266fn line(
267    raw: &str,
268    params: &mut Map<String, Value>,
269    base: &AssStyle,
270    play_res: [f64; 2],
271) -> (String, usize) {
272    let mut text = String::new();
273    let mut dropped = 0;
274    let mut rest = raw;
275    let mut leading = true;
276    let mut an: Option<u8> = None;
277    let mut pos: Option<[f64; 2]> = None;
278    while !rest.is_empty() {
279        if let Some(after) = rest.strip_prefix('{') {
280            let end = after.find('}').unwrap_or(after.len());
281            for tag in after[..end].split('\\').filter(|t| !t.is_empty()) {
282                let arg = |p: &str| {
283                    tag.strip_prefix(p)
284                        .map(|a| a.trim_matches(|c| c == '(' || c == ')'))
285                };
286                let nums = |a: &str| -> Vec<f64> {
287                    a.split(',').filter_map(|v| v.trim().parse().ok()).collect()
288                };
289                if !leading {
290                    dropped += 1;
291                } else if let Some(a) = arg("an") {
292                    an = a.parse().ok();
293                } else if let Some(a) = arg("pos") {
294                    let n = nums(a);
295                    if n.len() == 2 {
296                        pos = Some([n[0], n[1]]);
297                    }
298                } else if let Some(a) = arg("fade").or_else(|| arg("fad")) {
299                    let n = nums(a);
300                    if n.len() >= 2 {
301                        params.insert(
302                            "animation".into(),
303                            json!({"in": {"kind": "fade", "duration": n[0] / 1000.0},
304                                   "out": {"kind": "fade", "duration": n[1] / 1000.0}}),
305                        );
306                    }
307                } else if let Some(a) = arg("b").filter(|a| a.len() <= 3) {
308                    params.insert("bold".into(), json!(a.parse::<u32>().is_ok_and(|v| v != 0)));
309                } else if let Some(a) = arg("i").filter(|a| a.len() <= 1) {
310                    params.insert("italic".into(), json!(a == "1"));
311                } else {
312                    dropped += 1;
313                }
314            }
315            rest = after.get(end + 1..).unwrap_or("");
316            continue;
317        }
318        leading = false;
319        let next = rest.find('{').unwrap_or(rest.len());
320        text.push_str(&rest[..next]);
321        rest = &rest[next..];
322    }
323    let text = text
324        .replace("\\N", "\n")
325        .replace("\\n", " ")
326        .replace("\\h", "\u{a0}");
327    if an.is_some() || pos.is_some() {
328        let num = |n: &str| {
329            base.fields
330                .get(n)
331                .and_then(Value::as_str)
332                .and_then(|v| v.parse::<f64>().ok())
333        };
334        let a = an.unwrap_or_else(|| num("Alignment").map_or(2, numpad));
335        let margin = |n: &str| num(n).unwrap_or(10.0);
336        let mut p = placement(
337            a,
338            margin("MarginL"),
339            margin("MarginR"),
340            margin("MarginV"),
341            play_res,
342        );
343        if let Some([x, y]) = pos {
344            p.insert(
345                "position".into(),
346                json!([x / play_res[0] - 0.5, y / play_res[1] - 0.5]),
347            );
348        }
349        params.extend(p);
350    }
351    (text.trim().to_owned(), dropped)
352}
353
354/// Adds `src`'s subtitles to `timeline`: styles into `project.styles`, lines as text clips on
355/// new `Subtitles` tracks, shifted by `offset` seconds.
356#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
357pub fn import(src: &str, timeline: &mut Timeline, offset: f64) -> Result<Report> {
358    let ass = parse(src)?;
359    let rate = timeline.rate().unwrap_or(24.0);
360    let frame_h = timeline
361        .metadata
362        .get("ffcine")
363        .and_then(|f| f.get("frame_size"))
364        .and_then(|s| s.get(1))
365        .and_then(Value::as_f64);
366    let k = frame_h.map_or(1.0, |h| h / ass.play_res[1]);
367    let mut project = timeline
368        .project()
369        .transpose()?
370        .unwrap_or_else(Project::default);
371    let mut report = Report {
372        styles: ass.styles.len(),
373        ..Report::default()
374    };
375    for s in &ass.styles {
376        project.styles.insert(
377            format!("ass:{}", s.name),
378            Value::Object(style_params(s, ass.play_res, k, frame_h.is_none())),
379        );
380    }
381    timeline.set_project(&project)?;
382
383    let default = AssStyle {
384        name: "Default".into(),
385        fields: Map::new(),
386    };
387    let mut events: Vec<&Event> = ass.events.iter().filter(|e| e.end > e.start).collect();
388    events.sort_by(|a, b| a.start.total_cmp(&b.start));
389    // Track lanes: (end frame, items).
390    let mut lanes: Vec<(i64, Vec<Item>)> = Vec::new();
391    for (n, e) in events.iter().enumerate() {
392        let base = ass
393            .styles
394            .iter()
395            .find(|s| s.name == e.style)
396            .unwrap_or(&default);
397        let mut params = Map::new();
398        params.insert("style".into(), format!("ass:{}", base.name).into());
399        let (text, dropped) = line(&e.text, &mut params, base, ass.play_res);
400        report.dropped_tags += dropped;
401        if text.is_empty() {
402            continue;
403        }
404        params.insert("text".into(), text.clone().into());
405        let f0 = ((e.start + offset) * rate).round().max(0.0) as i64;
406        let f1 = ((e.end + offset) * rate).round() as i64;
407        if f1 <= f0 {
408            continue;
409        }
410        let lane = if let Some(i) = lanes.iter().position(|(end, _)| *end <= f0) {
411            i
412        } else {
413            lanes.push((0, Vec::new()));
414            lanes.len() - 1
415        };
416        let (end, items) = &mut lanes[lane];
417        if f0 > *end {
418            #[allow(clippy::cast_precision_loss)]
419            items.push(Item::Gap(Gap::new(RationalTime::new(
420                (f0 - *end) as f64,
421                rate,
422            ))));
423        }
424        let name: String = text.chars().take(32).collect();
425        #[allow(clippy::cast_precision_loss)]
426        let mut clip = Clip::new(
427            name,
428            MediaReference::Generator(GeneratorReference::new(GENERATOR, params)),
429            Some(TimeRange::from_frames(0.0, (f1 - f0) as f64, rate)),
430        );
431        clip.set_ffcine_id(format!("ass{}", n + 1));
432        items.push(Item::Clip(clip));
433        *end = f1;
434        report.events += 1;
435    }
436    report.tracks = lanes.len();
437    for (i, (_, items)) in lanes.into_iter().enumerate() {
438        let name = if i == 0 {
439            "Subtitles".to_owned()
440        } else {
441            format!("Subtitles {}", i + 1)
442        };
443        let mut t = Track::new(name, track_kind::VIDEO);
444        t.children = items;
445        timeline.tracks.children.push(Item::Track(t));
446    }
447    Ok(report)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::TextLayer;
454
455    const SAMPLE: &str = "\u{feff}[Script Info]
456ScriptType: v4.00+
457PlayResX: 1920
458PlayResY: 1080
459
460[V4+ Styles]
461Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
462Style: Default,Inter,54,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,60,60,50,1
463Style: Place,Cormorant Garamond,90,&H00EEF6FA,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,0,0,7,100,40,90,1
464
465[Events]
466Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
467Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,Hello, world!\\Nsecond line
468Dialogue: 0,0:00:02.00,0:00:04.00,Place,,0,0,0,,{\\fad(300,500)\\an7}Old Town
469Comment: 0,0:00:05.00,0:00:06.00,Default,,0,0,0,,not shown
470Dialogue: 0,0:00:05.00,0:00:06.00,Default,,0,0,0,,{\\pos(960,100)}top {\\c&H0000FF&}red
471";
472
473    #[test]
474    fn parses_styles_and_events() {
475        let a = parse(SAMPLE).expect("parse");
476        assert_eq!(a.play_res, [1920.0, 1080.0]);
477        assert_eq!(a.styles.len(), 2);
478        assert_eq!(a.events.len(), 3);
479        assert_eq!(a.events[0].text, "Hello, world!\\Nsecond line");
480        assert!((a.events[1].start - 2.0).abs() < 1e-9);
481        assert_eq!(
482            ass_colour("&H80000000"),
483            Some([0.0, 0.0, 0.0, 1.0 - 128.0 / 255.0])
484        );
485        assert_eq!(ass_colour("&H00FF8000").map(|c| c[2]), Some(1.0));
486    }
487
488    #[test]
489    #[allow(clippy::many_single_char_names)]
490    fn imports_as_text_clips() {
491        let mut t = Timeline::default();
492        let mut md = Map::new();
493        md.insert("frame_size".into(), json!([3840, 2160]));
494        t.metadata.insert("ffcine".into(), Value::Object(md));
495        let r = import(SAMPLE, &mut t, 0.0).expect("import");
496        assert_eq!((r.events, r.styles, r.tracks), (3, 2, 2));
497        assert_eq!(r.dropped_tags, 1);
498        let p = t.project().expect("project").expect("ok");
499        let d = &p.styles["ass:Default"];
500        assert_eq!(d["size"], 108.0);
501        assert_eq!(d["bold"], true);
502        assert_eq!(d["anchor"], json!([0.5, 1.0]));
503        assert_eq!(d["outline"]["width"], 6.0);
504        let layer = TextLayer::from_timeline(&t, ".").expect("layer");
505        let c = layer.clips();
506        assert_eq!(c.len(), 3);
507        assert_eq!(c[0].text, "Hello, world!\nsecond line");
508        assert!((c[0].start - 1.0).abs() < 1e-9 && (c[0].end - 3.5).abs() < 1e-9);
509        // The overlapping line went to the second track, with its fade and top-left anchor.
510        let place = c.iter().find(|c| c.text == "Old Town").expect("place");
511        assert_eq!(place.track, 1);
512        assert_eq!(place.params["anchor"], json!([0.0, 0.0]));
513        let s = place.style([3840.0, 2160.0]);
514        assert!((s.fade_in - 0.3).abs() < 1e-9 && (s.fade_out - 0.5).abs() < 1e-9);
515        let top = c.iter().find(|c| c.text.starts_with("top")).expect("pos");
516        assert_eq!(top.params["position"], json!([0.0, 100.0 / 1080.0 - 0.5]));
517    }
518}