#![allow(dead_code)]
use crate::Subtitle;
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)]
pub struct Chapter {
pub start_ms: i64,
pub end_ms: i64,
pub title: String,
pub number: Option<u32>,
pub language: Option<String>,
}
impl Chapter {
#[must_use]
pub fn new(start_ms: i64, end_ms: i64, title: impl Into<String>) -> Self {
Self {
start_ms,
end_ms,
title: title.into(),
number: None,
language: None,
}
}
#[must_use]
pub fn with_number(mut self, n: u32) -> Self {
self.number = Some(n);
self
}
#[must_use]
pub fn with_language(mut self, lang: impl Into<String>) -> Self {
self.language = Some(lang.into());
self
}
#[must_use]
pub fn duration_ms(&self) -> i64 {
self.end_ms - self.start_ms
}
#[must_use]
pub fn contains(&self, ts_ms: i64) -> bool {
ts_ms >= self.start_ms && ts_ms < self.end_ms
}
}
#[derive(Clone, Debug, Default)]
pub struct ChapterTrack {
chapters: Vec<Chapter>,
}
impl ChapterTrack {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_vec(mut chapters: Vec<Chapter>) -> Self {
chapters.sort_by_key(|c| c.start_ms);
Self { chapters }
}
pub fn add(&mut self, chapter: Chapter) {
let pos = self
.chapters
.partition_point(|c| c.start_ms <= chapter.start_ms);
self.chapters.insert(pos, chapter);
}
#[must_use]
pub fn len(&self) -> usize {
self.chapters.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.chapters.is_empty()
}
#[must_use]
pub fn get(&self, index: usize) -> Option<&Chapter> {
self.chapters.get(index)
}
pub fn iter(&self) -> impl Iterator<Item = &Chapter> {
self.chapters.iter()
}
#[must_use]
pub fn chapter_at(&self, ts_ms: i64) -> Option<usize> {
if self.chapters.is_empty() {
return None;
}
let idx = self
.chapters
.partition_point(|c| c.start_ms <= ts_ms);
let idx = idx.checked_sub(1)?;
let chapter = &self.chapters[idx];
if chapter.contains(ts_ms) {
Some(idx)
} else {
None
}
}
#[must_use]
pub fn as_slice(&self) -> &[Chapter] {
&self.chapters
}
#[must_use]
pub fn total_span_ms(&self) -> i64 {
match (self.chapters.first(), self.chapters.last()) {
(Some(first), Some(last)) => last.end_ms - first.start_ms,
_ => 0,
}
}
#[must_use]
pub fn retime(&self, scale: f64, offset_ms: i64) -> Self {
let chapters = self
.chapters
.iter()
.map(|c| {
let new_start = ((c.start_ms as f64 * scale) as i64) + offset_ms;
let new_end = ((c.end_ms as f64 * scale) as i64) + offset_ms;
let mut nc = c.clone();
nc.start_ms = new_start;
nc.end_ms = new_end;
nc
})
.collect();
Self { chapters }
}
}
#[derive(Clone, Debug)]
pub struct ChapterIndex {
chapter_to_cues: HashMap<usize, Vec<usize>>,
cue_to_chapter: Vec<Option<usize>>,
unassigned_count: usize,
}
impl ChapterIndex {
#[must_use]
pub fn build(track: &ChapterTrack, subtitles: &[Subtitle]) -> Self {
let mut chapter_to_cues: HashMap<usize, Vec<usize>> = HashMap::new();
let mut cue_to_chapter: Vec<Option<usize>> = Vec::with_capacity(subtitles.len());
let mut unassigned_count = 0;
for (cue_idx, sub) in subtitles.iter().enumerate() {
let chapter_idx = track.chapter_at(sub.start_time);
cue_to_chapter.push(chapter_idx);
if let Some(cidx) = chapter_idx {
chapter_to_cues.entry(cidx).or_default().push(cue_idx);
} else {
unassigned_count += 1;
}
}
Self {
chapter_to_cues,
cue_to_chapter,
unassigned_count,
}
}
#[must_use]
pub fn cues_in_chapter(&self, chapter_idx: usize) -> &[usize] {
self.chapter_to_cues
.get(&chapter_idx)
.map(Vec::as_slice)
.unwrap_or(&[])
}
#[must_use]
pub fn chapter_of_cue(&self, cue_idx: usize) -> Option<usize> {
self.cue_to_chapter.get(cue_idx).copied().flatten()
}
#[must_use]
pub fn unassigned_count(&self) -> usize {
self.unassigned_count
}
#[must_use]
pub fn non_empty_chapter_count(&self) -> usize {
self.chapter_to_cues.len()
}
}
#[must_use]
pub fn extract_chapter_subtitles(
track: &ChapterTrack,
subtitles: &[Subtitle],
chapter_idx: usize,
) -> Vec<Subtitle> {
let chapter = match track.get(chapter_idx) {
Some(c) => c,
None => return Vec::new(),
};
let offset = chapter.start_ms;
subtitles
.iter()
.filter(|sub| {
sub.start_time < chapter.end_ms && sub.end_time > chapter.start_ms
})
.map(|sub| {
let new_start = (sub.start_time - offset).max(0);
let new_end = (sub.end_time - offset).max(1);
let mut out = sub.clone();
out.start_time = new_start;
out.end_time = new_end;
out
})
.collect()
}
#[derive(Clone, Debug)]
pub struct ChapterSubtitleStats {
pub chapter_index: usize,
pub chapter_title: String,
pub cue_count: usize,
pub total_chars: usize,
pub avg_duration_ms: f64,
pub chars_per_minute: f64,
}
#[must_use]
pub fn chapter_stats(
track: &ChapterTrack,
subtitles: &[Subtitle],
index: &ChapterIndex,
) -> Vec<ChapterSubtitleStats> {
track
.iter()
.enumerate()
.map(|(cidx, chapter)| {
let cue_indices = index.cues_in_chapter(cidx);
let cue_count = cue_indices.len();
let total_chars: usize = cue_indices
.iter()
.filter_map(|&i| subtitles.get(i))
.map(|s| s.text.chars().count())
.sum();
let avg_duration_ms = if cue_count == 0 {
0.0
} else {
let total_dur: i64 = cue_indices
.iter()
.filter_map(|&i| subtitles.get(i))
.map(|s| s.duration())
.sum();
total_dur as f64 / cue_count as f64
};
let chapter_dur_min = chapter.duration_ms() as f64 / 60_000.0;
let chars_per_minute = if chapter_dur_min > 0.0 {
total_chars as f64 / chapter_dur_min
} else {
0.0
};
ChapterSubtitleStats {
chapter_index: cidx,
chapter_title: chapter.title.clone(),
cue_count,
total_chars,
avg_duration_ms,
chars_per_minute,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Subtitle;
fn make_track() -> ChapterTrack {
ChapterTrack::from_vec(vec![
Chapter::new(0, 60_000, "Intro"),
Chapter::new(60_000, 180_000, "Act 1"),
Chapter::new(180_000, 300_000, "Act 2"),
])
}
fn make_subs() -> Vec<Subtitle> {
vec![
Subtitle::new(5_000, 8_000, "Hello, intro!".to_string()),
Subtitle::new(10_000, 12_000, "Still in intro.".to_string()),
Subtitle::new(65_000, 68_000, "Act 1 begins.".to_string()),
Subtitle::new(100_000, 102_000, "Middle of act 1.".to_string()),
Subtitle::new(250_000, 253_000, "Act 2 scene.".to_string()),
Subtitle::new(500_000, 503_000, "After all chapters.".to_string()),
]
}
#[test]
fn test_chapter_at_binary_search() {
let track = make_track();
assert_eq!(track.chapter_at(0), Some(0));
assert_eq!(track.chapter_at(30_000), Some(0));
assert_eq!(track.chapter_at(60_000), Some(1));
assert_eq!(track.chapter_at(179_999), Some(1));
assert_eq!(track.chapter_at(180_000), Some(2));
assert_eq!(track.chapter_at(500_000), None);
}
#[test]
fn test_chapter_contains() {
let ch = Chapter::new(60_000, 120_000, "Chapter");
assert!(ch.contains(60_000));
assert!(ch.contains(90_000));
assert!(!ch.contains(120_000)); assert!(!ch.contains(59_999));
}
#[test]
fn test_chapter_duration() {
let ch = Chapter::new(0, 90_000, "Test");
assert_eq!(ch.duration_ms(), 90_000);
}
#[test]
fn test_track_total_span() {
let track = make_track();
assert_eq!(track.total_span_ms(), 300_000);
}
#[test]
fn test_chapter_index_build() {
let track = make_track();
let subs = make_subs();
let index = ChapterIndex::build(&track, &subs);
assert_eq!(index.cues_in_chapter(0).len(), 2);
assert_eq!(index.cues_in_chapter(1).len(), 2);
assert_eq!(index.cues_in_chapter(2).len(), 1);
assert_eq!(index.unassigned_count(), 1);
}
#[test]
fn test_chapter_of_cue() {
let track = make_track();
let subs = make_subs();
let index = ChapterIndex::build(&track, &subs);
assert_eq!(index.chapter_of_cue(0), Some(0)); assert_eq!(index.chapter_of_cue(2), Some(1)); assert_eq!(index.chapter_of_cue(4), Some(2)); assert_eq!(index.chapter_of_cue(5), None); }
#[test]
fn test_extract_chapter_subtitles_retime() {
let track = make_track();
let subs = make_subs();
let intro_subs = extract_chapter_subtitles(&track, &subs, 0);
assert_eq!(intro_subs.len(), 2);
assert_eq!(intro_subs[0].start_time, 5_000);
let act1_subs = extract_chapter_subtitles(&track, &subs, 1);
assert_eq!(act1_subs.len(), 2);
assert_eq!(act1_subs[0].start_time, 5_000); }
#[test]
fn test_chapter_track_add_maintains_order() {
let mut track = ChapterTrack::new();
track.add(Chapter::new(60_000, 120_000, "Second"));
track.add(Chapter::new(0, 60_000, "First"));
assert_eq!(track.get(0).map(|c| c.title.as_str()), Some("First"));
assert_eq!(track.get(1).map(|c| c.title.as_str()), Some("Second"));
}
#[test]
fn test_chapter_retime() {
let track = ChapterTrack::from_vec(vec![Chapter::new(0, 60_000, "Ch1")]);
let retimed = track.retime(2.0, 1_000);
let ch = retimed.get(0).expect("chapter");
assert_eq!(ch.start_ms, 1_000);
assert_eq!(ch.end_ms, 121_000);
}
#[test]
fn test_chapter_stats() {
let track = make_track();
let subs = make_subs();
let index = ChapterIndex::build(&track, &subs);
let stats = chapter_stats(&track, &subs, &index);
assert_eq!(stats.len(), 3);
let intro_stats = &stats[0];
assert_eq!(intro_stats.chapter_title, "Intro");
assert_eq!(intro_stats.cue_count, 2);
assert!(intro_stats.chars_per_minute > 0.0);
}
#[test]
fn test_empty_track_chapter_at() {
let track = ChapterTrack::new();
assert_eq!(track.chapter_at(5_000), None);
}
#[test]
fn test_chapter_with_number_and_language() {
let ch = Chapter::new(0, 1000, "Opening")
.with_number(1)
.with_language("en");
assert_eq!(ch.number, Some(1));
assert_eq!(ch.language.as_deref(), Some("en"));
}
#[test]
fn test_non_empty_chapter_count() {
let track = make_track();
let subs = make_subs();
let index = ChapterIndex::build(&track, &subs);
assert_eq!(index.non_empty_chapter_count(), 3);
}
#[test]
fn test_extract_invalid_chapter_returns_empty() {
let track = make_track();
let subs = make_subs();
let result = extract_chapter_subtitles(&track, &subs, 99);
assert!(result.is_empty());
}
}