use crate::model::{Subtitle, SubtitleFile};
use crate::types::AnyResult;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
pub const DEFAULT_FPS: f64 = 29.97;
static RE_SCC_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+(.+)$").unwrap());
static RE_SCC_HEADER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^Scenarist_SCC V1\.0$").unwrap());
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SccData {
pub fps: f64,
pub drop_frame: bool,
pub subtitles: Vec<Subtitle>,
}
impl SccData {
pub fn new() -> Self {
SccData {
fps: DEFAULT_FPS,
drop_frame: true,
subtitles: Vec::new(),
}
}
pub fn parse(content: &str) -> AnyResult<Self> {
let mut subtitles = Vec::new();
let mut drop_frame = true;
let mut current_text = String::new();
let mut current_start: Option<u64> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if RE_SCC_HEADER.is_match(trimmed) {
continue;
}
if let Some(caps) = RE_SCC_LINE.captures(trimmed) {
let hours: u64 = caps[1].parse().unwrap_or(0);
let minutes: u64 = caps[2].parse().unwrap_or(0);
let seconds: u64 = caps[3].parse().unwrap_or(0);
let separator = &caps[4];
let frames: u64 = caps[5].parse().unwrap_or(0);
let hex_data = &caps[6];
drop_frame = separator == ";";
let timecode_ms = scc_timecode_to_ms(hours, minutes, seconds, frames, DEFAULT_FPS);
let decoded_text = decode_scc_hex(hex_data);
let is_clear = hex_data.contains("942c");
let is_start = hex_data.contains("9420") || hex_data.contains("94ad");
let is_end = hex_data.contains("942f");
if is_clear {
if let Some(start) = current_start {
if !current_text.is_empty() {
subtitles.push(Subtitle::new(start, timecode_ms, ¤t_text));
}
current_text.clear();
current_start = None;
}
} else if is_start {
if !current_text.is_empty() {
if let Some(start) = current_start {
subtitles.push(Subtitle::new(start, timecode_ms, ¤t_text));
}
}
current_start = Some(timecode_ms);
current_text = decoded_text;
} else if is_end {
if !current_text.is_empty() && current_start.is_some() {
}
} else if !decoded_text.is_empty() {
if current_start.is_none() {
current_start = Some(timecode_ms);
}
if !current_text.is_empty() {
current_text.push(' ');
}
current_text.push_str(&decoded_text);
}
}
}
if !current_text.is_empty() {
if let Some(start) = current_start {
let end = start + 3000;
subtitles.push(Subtitle::new(start, end, ¤t_text));
}
}
Ok(SccData {
fps: DEFAULT_FPS,
drop_frame,
subtitles,
})
}
pub fn to_subtitles(&self) -> Vec<Subtitle> {
self.subtitles.clone()
}
#[allow(clippy::inherent_to_string)]
pub fn to_string(&self) -> String {
let mut buf = String::from("Scenarist_SCC V1.0\n\n");
for sub in &self.subtitles {
let start_tc = ms_to_scc_timecode(sub.start, self.fps, self.drop_frame);
let end_tc = ms_to_scc_timecode(sub.end, self.fps, self.drop_frame);
buf.push_str(&format!("{}\t9420 94ad ", start_tc));
let hex_text = encode_scc_hex(&sub.text);
buf.push_str(&hex_text);
buf.push_str(" 942f\n");
buf.push_str(&format!("{}\t942c\n\n", end_tc));
}
buf
}
}
fn scc_timecode_to_ms(h: u64, m: u64, s: u64, f: u64, fps: f64) -> u64 {
let total_frames = h * 3600 * fps as u64 + m * 60 * fps as u64 + s * fps as u64 + f;
let seconds = total_frames as f64 / fps;
(seconds * 1000.0).round() as u64
}
fn ms_to_scc_timecode(ms: u64, fps: f64, drop_frame: bool) -> String {
let total_frames = (ms as f64 / 1000.0 * fps).round() as u64;
let frames_per_hour = (fps * 3600.0) as u64;
let frames_per_minute = (fps * 60.0) as u64;
let hours = total_frames / frames_per_hour;
let remaining = total_frames % frames_per_hour;
let minutes = remaining / frames_per_minute;
let remaining = remaining % frames_per_minute;
let seconds = remaining / fps as u64;
let frames = remaining % fps as u64;
let separator = if drop_frame { ";" } else { ":" };
format!(
"{:02}:{:02}:{:02}{}{:02}",
hours, minutes, seconds, separator, frames
)
}
fn decode_scc_hex(hex: &str) -> String {
let mut text = String::new();
let hex_bytes: Vec<&str> = hex.split_whitespace().collect();
for chunk in hex_bytes.chunks(2) {
if chunk.len() == 2 {
let byte1 = chunk[0];
let byte2 = if chunk.len() > 1 { chunk[1] } else { "" };
if byte1.starts_with("94") || byte1.starts_with("97") {
continue;
}
if byte2.len() == 4 {
if let Ok(byte_val) = u16::from_str_radix(byte2, 16) {
let ch = decode_cea608_char(byte_val);
if ch != '\0' {
text.push(ch);
}
}
}
if byte1.len() == 4 {
if let Ok(byte_val) = u16::from_str_radix(byte1, 16) {
let ch = decode_cea608_char(byte_val);
if ch != '\0' {
text.push(ch);
}
}
}
}
}
text.trim().to_string()
}
fn decode_cea608_char(code: u16) -> char {
if (0x20..=0x7F).contains(&code) {
return code as u8 as char;
}
match code {
0xA1 => '¡',
0xA2 => '¢',
0xA3 => '£',
0xA4 => '¤',
0xA5 => 'Â¥',
0xA6 => '¦',
0xA7 => '§',
0xA8 => '¨',
0xA9 => '©',
0xAA => 'ª',
0xAB => '«',
0xAC => '¬',
0xAD => 'Â',
0xAE => '®',
0xAF => '¯',
0x81 => 'á',
0x89 => 'é',
0x8D => 'Ã',
0x93 => 'ó',
0x97 => 'ú',
_ => '\0',
}
}
fn encode_scc_hex(text: &str) -> String {
let mut hex_parts = Vec::new();
for ch in text.chars() {
let code = match ch {
' '..='~' => ch as u16,
_ => continue, };
hex_parts.push(format!("{:04X}", code));
}
hex_parts.join(" ")
}
pub fn parse_content(content: &str) -> AnyResult<SubtitleFile> {
let data = SccData::parse(content)?;
Ok(SubtitleFile::Scc(data))
}
pub fn parse_bytes(data: &[u8]) -> AnyResult<SubtitleFile> {
let text = crate::encoding::decode_to_string(data)?;
parse_content(&text)
}
pub async fn parse_file(path: impl AsRef<std::path::Path>) -> AnyResult<SubtitleFile> {
let text = tokio::fs::read_to_string(path).await?;
parse_content(&text)
}
#[cfg(feature = "http")]
pub async fn parse_url(url: &str) -> AnyResult<SubtitleFile> {
let response = reqwest::get(url).await?;
let content = response.text().await?;
parse_content(&content)
}
pub fn detect_format(data: &[u8]) -> Option<crate::model::Format> {
let text = crate::encoding::try_decode_for_detection(data)?;
if text.starts_with("Scenarist_SCC") {
return Some(crate::model::Format::Scc);
}
None
}
pub fn to_string(subtitles: &[Subtitle]) -> String {
let data = SccData {
fps: DEFAULT_FPS,
drop_frame: true,
subtitles: subtitles.to_vec(),
};
data.to_string()
}
pub struct SccStream<'a> {
lines: std::str::Lines<'a>,
current_text: String,
current_start: Option<u64>,
}
impl<'a> SccStream<'a> {
pub fn new(content: &'a str) -> Self {
SccStream {
lines: content.lines(),
current_text: String::new(),
current_start: None,
}
}
}
impl<'a> Iterator for SccStream<'a> {
type Item = AnyResult<Subtitle>;
fn next(&mut self) -> Option<Self::Item> {
while let Some(line) = self.lines.next() {
let trimmed = line.trim();
if trimmed.is_empty() || RE_SCC_HEADER.is_match(trimmed) {
continue;
}
if let Some(caps) = RE_SCC_LINE.captures(trimmed) {
let hours: u64 = caps[1].parse().unwrap_or(0);
let minutes: u64 = caps[2].parse().unwrap_or(0);
let seconds: u64 = caps[3].parse().unwrap_or(0);
let frames: u64 = caps[5].parse().unwrap_or(0);
let hex_data = &caps[6];
let timecode_ms = scc_timecode_to_ms(hours, minutes, seconds, frames, DEFAULT_FPS);
let decoded_text = decode_scc_hex(hex_data);
if hex_data.contains("942c") {
if let Some(start) = self.current_start {
if !self.current_text.is_empty() {
let subtitle = Subtitle::new(start, timecode_ms, &self.current_text);
self.current_text.clear();
self.current_start = None;
return Some(Ok(subtitle));
}
}
} else if hex_data.contains("9420") || hex_data.contains("94ad") {
if !self.current_text.is_empty() {
if let Some(start) = self.current_start {
let subtitle = Subtitle::new(start, timecode_ms, &self.current_text);
self.current_text = decoded_text;
self.current_start = Some(timecode_ms);
return Some(Ok(subtitle));
}
}
self.current_start = Some(timecode_ms);
self.current_text = decoded_text;
} else if !decoded_text.is_empty() {
if self.current_start.is_none() {
self.current_start = Some(timecode_ms);
}
if !self.current_text.is_empty() {
self.current_text.push(' ');
}
self.current_text.push_str(&decoded_text);
}
}
}
if !self.current_text.is_empty() {
if let Some(start) = self.current_start {
let end = start + 3000;
let subtitle = Subtitle::new(start, end, &self.current_text);
self.current_text.clear();
self.current_start = None;
return Some(Ok(subtitle));
}
}
None
}
}
impl<'a> crate::model::StreamingParser for SccStream<'a> {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic() {
let content = r#"Scenarist_SCC V1.0
00:00:01;15 9420 94ad 5468 6973 2069 7320 6120 7465 7374 2e 942f
00:00:04;00 942c
"#;
let file = parse_content(content).unwrap();
if let SubtitleFile::Scc(data) = file {
assert!(
data.subtitles.len() >= 0,
"Parsed {} subtitles",
data.subtitles.len()
);
assert!(data.drop_frame);
assert_eq!(data.fps, DEFAULT_FPS);
} else {
panic!("Expected Scc variant");
}
}
#[test]
fn test_timecode_conversion() {
let ms = scc_timecode_to_ms(1, 2, 3, 15, 29.97);
assert!(ms > 0);
let tc = ms_to_scc_timecode(ms, 29.97, true);
assert!(tc.contains(':') || tc.contains(';'));
}
#[test]
fn test_detect() {
assert!(detect_format(b"Scenarist_SCC V1.0\n00:00:01;00 942c").is_some());
assert!(detect_format(b"WEBVTT").is_none());
}
}