1use 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#[derive(Debug, Clone, PartialEq)]
21pub struct AssStyle {
22 pub name: String,
23 pub fields: Map<String, Value>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct Event {
29 pub start: f64,
30 pub end: f64,
31 pub style: String,
32 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct Report {
46 pub events: usize,
47 pub styles: usize,
48 pub tracks: usize,
49 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
60fn 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
76pub 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 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 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
155fn 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#[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#[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; 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
196fn 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
264fn 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#[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 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 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}