subtitler 2.2.0

Parse, convert, validate, edit, and generate subtitles in 13 formats (SRT, VTT, ASS/SSA, MicroDVD, SubViewer, TTML, SBV, LRC, SAMI, MPL2, SCC, EBU STL). Full CLI included.
Documentation
pub fn parse_ass_color(color: &str) -> (u8, u8, u8, u8) {
  let hex = color.trim_start_matches("&H").trim_start_matches("&h");
  let parsed = u32::from_str_radix(hex, 16).unwrap_or(0x00FFFFFF);
  let b = (parsed >> 16 & 0xFF) as u8;
  let g = (parsed >> 8 & 0xFF) as u8;
  let r = (parsed & 0xFF) as u8;
  let a = (parsed >> 24 & 0xFF) as u8;
  (r, g, b, a)
}

pub fn format_ass_color(r: u8, g: u8, b: u8, a: u8) -> String {
  let value = ((a as u32) << 24) | ((b as u32) << 16) | ((g as u32) << 8) | (r as u32);
  format!("&H{:08X}", value)
}

pub fn ms_to_frames(ms: u64, fps: f64) -> u64 {
  ((ms as f64) * fps / 1000.0).round() as u64
}

pub fn frames_to_ms(frames: u64, fps: f64) -> u64 {
  ((frames as f64) * 1000.0 / fps).round() as u64
}

pub fn split_text_chunks(text: &str, max_chars: usize) -> Vec<String> {
  let mut chunks = Vec::new();
  let words: Vec<&str> = text.split_whitespace().collect();
  let mut current = String::with_capacity(max_chars);

  for word in words {
    let needed = if current.is_empty() {
      word.len()
    } else {
      current.len() + 1 + word.len()
    };

    if needed > max_chars && !current.is_empty() {
      chunks.push(std::mem::take(&mut current));
      current.push_str(word);
    } else {
      if !current.is_empty() {
        current.push(' ');
      }
      current.push_str(word);
    }
  }

  if !current.is_empty() {
    chunks.push(current);
  }

  chunks
}