ass_core/utils/time.rs
1//! ASS timing conversion helpers.
2//!
3//! Parses ASS `H:MM:SS.CC` time strings to centiseconds and formats
4//! centiseconds back into the ASS time representation.
5
6#[cfg(not(feature = "std"))]
7use alloc::{format, string::String, vec::Vec};
8#[cfg(feature = "std")]
9use std::{format, string::String, vec::Vec};
10
11use super::CoreError;
12
13/// Parse ASS time format (H:MM:SS.CC) to centiseconds, the libass way.
14///
15/// ASS uses centiseconds (1/100th second) for timing. The fractional field is
16/// read exactly as libass reads it: as an integer count of centiseconds via
17/// `sscanf("%d:%d:%d.%d")` (then `ms = field * 10` internally). The digit count
18/// is ignored and the value is not normalised, so `.5` is 5cs, `.054` is 54cs,
19/// and `.100` is 100cs (one second). This matches the reference player rather than
20/// interpreting a 3-digit field as true milliseconds.
21///
22/// # Example
23///
24/// ```rust
25/// # use ass_core::utils::parse_ass_time;
26/// assert_eq!(parse_ass_time("0:01:30.50")?, 9050); // 1:30.5 = 9050 centiseconds
27/// # Ok::<(), Box<dyn std::error::Error>>(())
28/// ```
29///
30/// # Errors
31///
32/// Returns an error if the time format is invalid or cannot be parsed.
33pub fn parse_ass_time(time_str: &str) -> Result<u32, CoreError> {
34 let parts: Vec<&str> = time_str.split(':').collect();
35 if parts.len() != 3 {
36 return Err(CoreError::InvalidTime(format!(
37 "Invalid time format: {time_str}"
38 )));
39 }
40
41 let hours: u32 = parts[0]
42 .parse()
43 .map_err(|_| CoreError::InvalidTime(format!("Invalid hours: {}", parts[0])))?;
44
45 let minutes: u32 = parts[1]
46 .parse()
47 .map_err(|_| CoreError::InvalidTime(format!("Invalid minutes: {}", parts[1])))?;
48
49 let seconds_parts: Vec<&str> = parts[2].split('.').collect();
50 let seconds: u32 = seconds_parts[0]
51 .parse()
52 .map_err(|_| CoreError::InvalidTime(format!("Invalid seconds: {}", seconds_parts[0])))?;
53
54 let centiseconds = if seconds_parts.len() > 1 {
55 let frac_str = seconds_parts[1];
56 if frac_str.is_empty() || !frac_str.bytes().all(|b| b.is_ascii_digit()) {
57 return Err(CoreError::InvalidTime(format!(
58 "Invalid centiseconds: {frac_str}"
59 )));
60 }
61 // libass reads the fractional field with `sscanf("%d:%d:%d.%d")` and forms
62 // `ms = field * 10` — i.e. it parses the whole fractional as an integer
63 // count of CENTISECONDS, regardless of digit count, and never normalises it.
64 // Replicate that exactly so event timing matches libass: `.5` -> 5cs,
65 // `.50` -> 50cs, `.054` -> 54cs, `.100` -> 100cs (rolls into the next
66 // second, as libass does). A 3-digit "millisecond" field is therefore read
67 // the same quirky way libass reads it, not as true milliseconds.
68 frac_str
69 .parse::<u32>()
70 .map_err(|_| CoreError::InvalidTime(format!("Invalid centiseconds: {frac_str}")))?
71 } else {
72 0
73 };
74
75 if minutes >= 60 {
76 return Err(CoreError::InvalidTime(format!(
77 "Minutes must be < 60: {minutes}"
78 )));
79 }
80 if seconds >= 60 {
81 return Err(CoreError::InvalidTime(format!(
82 "Seconds must be < 60: {seconds}"
83 )));
84 }
85
86 Ok(hours * 360_000 + minutes * 6_000 + seconds * 100 + centiseconds)
87}
88
89/// Format centiseconds back to ASS time format
90///
91/// Converts internal centisecond representation back to H:MM:SS.CC format.
92#[must_use]
93pub fn format_ass_time(centiseconds: u32) -> String {
94 let hours = centiseconds / 360_000;
95 let remainder = centiseconds % 360_000;
96 let minutes = remainder / 6000;
97 let remainder = remainder % 6000;
98 let seconds = remainder / 100;
99 let cs = remainder % 100;
100
101 format!("{hours}:{minutes:02}:{seconds:02}.{cs:02}")
102}