use crate::utils::rgb_to_rgba;
#[derive(Debug, Clone)]
pub struct VobSubPalette {
pub rgba: [u32; 16],
}
impl Default for VobSubPalette {
fn default() -> Self {
Self {
rgba: [
0x00000000, 0xFFFFFFFF, 0xFF000000, 0xFF808080, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000,
0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000,
],
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct VobSubTimestamp {
pub timestamp_ms: u32,
pub file_position: u64,
}
#[derive(Debug, Clone)]
pub struct VobSubMetadata {
pub width: u16,
pub height: u16,
pub language: Option<String>,
pub id: Option<String>,
}
impl Default for VobSubMetadata {
fn default() -> Self {
Self {
width: 720,
height: 480,
language: None,
id: None,
}
}
}
#[derive(Debug, Clone)]
pub struct IdxParseResult {
pub palette: VobSubPalette,
pub timestamps: Vec<VobSubTimestamp>,
pub metadata: VobSubMetadata,
}
pub fn parse_idx(idx_content: &str) -> IdxParseResult {
let mut result = IdxParseResult {
palette: VobSubPalette::default(),
timestamps: Vec::new(),
metadata: VobSubMetadata::default(),
};
for line in idx_content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("size:") {
if let Some((w_str, h_str)) = rest.trim().split_once('x')
&& let (Ok(w), Ok(h)) = (w_str.trim().parse::<u16>(), h_str.trim().parse::<u16>())
{
result.metadata.width = w;
result.metadata.height = h;
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("palette:") {
let colors: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
for (i, color_hex) in colors.iter().enumerate().take(16) {
let hex = color_hex.trim_start_matches('#');
if hex.len() == 6
&& let Ok(rgb) = u32::from_str_radix(hex, 16)
{
let r = ((rgb >> 16) & 0xFF) as u8;
let g = ((rgb >> 8) & 0xFF) as u8;
let b = (rgb & 0xFF) as u8;
result.palette.rgba[i] = rgb_to_rgba(r, g, b, 255);
}
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("id:") {
let parts: Vec<&str> = rest.split(',').collect();
if !parts.is_empty() {
result.metadata.language = Some(parts[0].trim().to_string());
}
if let Some(idx_part) = parts.get(1)
&& let Some(idx_str) = idx_part.trim().strip_prefix("index:")
{
result.metadata.id = Some(idx_str.trim().to_string());
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("timestamp:")
&& let Some((time_part, filepos_part)) = rest.split_once(',')
{
let time_str = time_part.trim();
let filepos_str = filepos_part
.trim()
.strip_prefix("filepos:")
.map(|s| s.trim());
if let Some(filepos_hex) = filepos_str {
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() == 4
&& let (Ok(h), Ok(m), Ok(s), Ok(ms)) = (
parts[0].parse::<u32>(),
parts[1].parse::<u32>(),
parts[2].parse::<u32>(),
parts[3].parse::<u32>(),
)
{
let timestamp_ms = h * 3600000 + m * 60000 + s * 1000 + ms;
if let Ok(file_position) = u64::from_str_radix(filepos_hex, 16) {
result.timestamps.push(VobSubTimestamp {
timestamp_ms,
file_position,
});
}
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_idx_basic() {
let idx = r#"
# VobSub index file
size: 720x480
palette: 000000, ffffff, 808080, 404040, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000
id: en, index: 0
timestamp: 00:00:01:000, filepos: 00000000
timestamp: 00:00:05:500, filepos: 00001000
"#;
let result = parse_idx(idx);
assert_eq!(result.metadata.width, 720);
assert_eq!(result.metadata.height, 480);
assert_eq!(result.metadata.language, Some("en".to_string()));
assert_eq!(result.timestamps.len(), 2);
assert_eq!(result.timestamps[0].timestamp_ms, 1000);
assert_eq!(result.timestamps[1].timestamp_ms, 5500);
}
#[test]
fn test_parse_real_vobsub_durations() {
use crate::vobsub::parse_subtitle_packet;
let idx_content = include_str!("../testfiles/vobsub.idx");
let sub_data = include_bytes!("../testfiles/vobsub.sub");
let idx = parse_idx(idx_content);
println!("\n=== VobSub Duration Analysis ===");
println!("Total timestamps: {}", idx.timestamps.len());
for (i, ts) in idx.timestamps.iter().take(10).enumerate() {
let next_ts = idx.timestamps.get(i + 1);
let gap_to_next = next_ts.map(|n| n.timestamp_ms - ts.timestamp_ms);
if let Some((packet, _)) =
parse_subtitle_packet(sub_data, ts.file_position as usize, &idx.palette)
{
println!(
"Sub {}: start={}ms, ctrl_dur={}ms, gap={}ms",
i,
ts.timestamp_ms,
packet.duration_ms,
gap_to_next.unwrap_or(0)
);
if let Some(gap) = gap_to_next {
assert!(
packet.duration_ms <= gap || packet.duration_ms == 5000,
"Duration {} should be <= gap {} (or default 5000)",
packet.duration_ms,
gap
);
}
} else {
println!(
"Subtitle {}: FAILED TO PARSE at offset 0x{:X}",
i, ts.file_position
);
}
}
println!("\n=== Durations are now correctly parsed from control sequences ===");
}
}