use crate::model::{Subtitle, SubtitleFile};
use crate::types::AnyResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct EbuStlData {
pub gsi: GsiBlock,
pub subtitles: Vec<Subtitle>,
pub tti_blocks: Vec<TtiBlock>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct GsiBlock {
pub code_page: u8,
pub disk_format_code: String,
pub display_standard: String,
pub character_code_table: u8,
pub language_code: String,
pub original_program_title: String,
pub original_episode_title: String,
pub translated_program_title: String,
pub translated_episode_title: String,
pub transmitter_name: String,
pub translator_contact: String,
pub subtitle_list_reference: String,
pub creation_date: String,
pub revision_date: String,
pub revision_number: String,
pub total_tti_blocks: u32,
pub total_subtitles: u16,
pub timecode_start: String,
pub timecode_first_incue: String,
pub total_duration: String,
pub publisher: String,
pub editor_name: String,
pub editor_contact: String,
pub spare_bytes: Vec<u8>,
pub user_defined_area: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct TtiBlock {
pub subtitle_group: u8,
pub subtitle_number: u16,
pub extension_block: u8,
pub cumulative_status: u8,
pub timecode_start: u32,
pub timecode_end: u32,
pub vertical_position: u8,
pub justification: u8,
pub comment_flag: bool,
pub text: String,
}
impl EbuStlData {
pub fn new() -> Self {
EbuStlData {
gsi: GsiBlock::default(),
subtitles: Vec::new(),
tti_blocks: Vec::new(),
}
}
pub fn parse(data: &[u8]) -> AnyResult<Self> {
if data.len() < 1024 {
return Err(anyhow::anyhow!("File too short for EBU STL format"));
}
let gsi = GsiBlock::parse(&data[0..1024])?;
let mut tti_blocks = Vec::new();
let mut subtitles = Vec::new();
let mut offset = 1024;
while offset + 128 <= data.len() {
let tti_data = &data[offset..offset + 128];
if let Some(tti) = TtiBlock::parse(tti_data)? {
tti_blocks.push(tti.clone());
if !tti.text.is_empty() {
let start_ms = tti_timecode_to_ms(tti.timecode_start);
let end_ms = tti_timecode_to_ms(tti.timecode_end);
subtitles.push(Subtitle::new(start_ms, end_ms, &tti.text));
}
}
offset += 128;
}
Ok(EbuStlData {
gsi,
subtitles,
tti_blocks,
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&self.gsi.to_bytes());
for tti in &self.tti_blocks {
data.extend_from_slice(&tti.to_bytes());
}
data
}
}
impl GsiBlock {
fn parse(data: &[u8]) -> AnyResult<Self> {
let language_code = String::from_utf8_lossy(&data[14..17]).to_string();
Ok(GsiBlock {
code_page: data[0],
disk_format_code: String::new(),
display_standard: String::new(),
character_code_table: 0,
language_code,
original_program_title: String::new(),
original_episode_title: String::new(),
translated_program_title: String::new(),
translated_episode_title: String::new(),
transmitter_name: String::new(),
translator_contact: String::new(),
subtitle_list_reference: String::new(),
creation_date: String::new(),
revision_date: String::new(),
revision_number: String::new(),
total_tti_blocks: 0,
total_subtitles: 0,
timecode_start: String::new(),
timecode_first_incue: String::new(),
total_duration: String::new(),
publisher: String::new(),
editor_name: String::new(),
editor_contact: String::new(),
spare_bytes: vec![0; 75],
user_defined_area: vec![0; 376],
})
}
fn to_bytes(&self) -> Vec<u8> {
vec![0; 1024]
}
}
impl TtiBlock {
fn parse(data: &[u8]) -> AnyResult<Option<Self>> {
if data.len() < 128 {
return Ok(None);
}
let subtitle_number = u16::from_be_bytes([data[1], data[2]]);
if subtitle_number == 0xFFFF {
return Ok(None);
}
let timecode_start = parse_smpte_timecode(&data[3..7])?;
let timecode_end = parse_smpte_timecode(&data[7..11])?;
let text_bytes = &data[16..128];
let text = decode_stl_text(text_bytes);
Ok(Some(TtiBlock {
subtitle_group: data[0],
subtitle_number,
extension_block: data[12],
cumulative_status: data[13],
timecode_start,
timecode_end,
vertical_position: data[14],
justification: data[15],
comment_flag: false,
text,
}))
}
fn to_bytes(&self) -> Vec<u8> {
let mut data = vec![0; 128];
data[0] = self.subtitle_group;
data[1..3].copy_from_slice(&self.subtitle_number.to_be_bytes());
encode_smpte_timecode(self.timecode_start, &mut data[3..7]);
encode_smpte_timecode(self.timecode_end, &mut data[7..11]);
data[12] = self.extension_block;
data[13] = self.cumulative_status;
data[14] = self.vertical_position;
data[15] = self.justification;
let text_bytes = self.text.as_bytes();
let len = text_bytes.len().min(112);
data[16..16 + len].copy_from_slice(&text_bytes[..len]);
data
}
}
fn parse_smpte_timecode(data: &[u8]) -> AnyResult<u32> {
if data.len() < 4 {
return Ok(0);
}
let hours = data[0] as u32;
let minutes = data[1] as u32;
let seconds = data[2] as u32;
let frames = data[3] as u32;
let total_frames = hours * 3600 * 25 + minutes * 60 * 25 + seconds * 25 + frames;
let seconds_total = total_frames as f64 / 25.0;
Ok((seconds_total * 1000.0).round() as u32)
}
fn encode_smpte_timecode(timecode: u32, data: &mut [u8]) {
let total_frames = (timecode as f64 / 1000.0 * 25.0).round() as u32;
let frames = total_frames % 25;
let seconds = (total_frames / 25) % 60;
let minutes = (total_frames / (25 * 60)) % 60;
let hours = total_frames / (25 * 3600);
data[0] = hours as u8;
data[1] = minutes as u8;
data[2] = seconds as u8;
data[3] = frames as u8;
}
fn decode_stl_text(data: &[u8]) -> String {
let mut text = String::new();
for byte in data {
if *byte >= 0x20 && *byte < 0x7F {
text.push(*byte as char);
} else if *byte == 0x8F {
text.push('<');
text.push('i');
text.push('>');
} else if *byte == 0x90 {
text.push('<');
text.push('/');
text.push('i');
text.push('>');
}
}
text.trim().to_string()
}
fn tti_timecode_to_ms(timecode: u32) -> u64 {
timecode as u64
}
pub fn parse_content(data: &[u8]) -> AnyResult<SubtitleFile> {
let stl_data = EbuStlData::parse(data)?;
Ok(SubtitleFile::EbuStl(stl_data))
}
pub fn parse_bytes(data: &[u8]) -> AnyResult<SubtitleFile> {
parse_content(data)
}
pub async fn parse_file(path: impl AsRef<std::path::Path>) -> AnyResult<SubtitleFile> {
let data = tokio::fs::read(path).await?;
parse_content(&data)
}
#[cfg(feature = "http")]
pub async fn parse_url(url: &str) -> AnyResult<SubtitleFile> {
let response = reqwest::get(url).await?;
let data = response.bytes().await?;
parse_content(&data)
}
pub fn detect_format(data: &[u8]) -> Option<crate::model::Format> {
if data.len() >= 1024 && (data.len() - 1024) % 128 == 0 {
if data[0] < 16 {
return Some(crate::model::Format::EbuStl);
}
}
None
}
pub fn to_string(subtitles: &[Subtitle]) -> Vec<u8> {
let gsi = GsiBlock::default();
let tti_blocks: Vec<TtiBlock> = subtitles
.iter()
.enumerate()
.map(|(i, sub)| {
let start_timecode = (sub.start / 40) as u32; let end_timecode = (sub.end / 40) as u32;
TtiBlock {
subtitle_group: 0,
subtitle_number: (i + 1) as u16,
extension_block: 0,
cumulative_status: 0,
timecode_start: start_timecode,
timecode_end: end_timecode,
vertical_position: 20,
justification: 0,
comment_flag: false,
text: sub.text.clone(),
}
})
.collect();
let stl_data = EbuStlData {
gsi,
subtitles: subtitles.to_vec(),
tti_blocks,
};
stl_data.to_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timecode_conversion() {
let ms = 10000u64; let frames = (ms as f64 / 1000.0 * 25.0).round() as u32;
assert_eq!(frames, 250);
let mut data = [0u8; 4];
encode_smpte_timecode(10000, &mut data);
let parsed = parse_smpte_timecode(&data).unwrap();
assert!(parsed > 0);
}
#[test]
fn test_detect() {
let mut data = vec![0u8; 1152];
data[0] = 5; assert!(detect_format(&data).is_some());
let data = vec![0u8; 100];
assert!(detect_format(&data).is_none());
}
}