use crate::alignment::CaptionBlock;
use crate::CaptionGenError;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SyncAnchor {
pub time_ms: u64,
}
#[derive(Debug, Clone)]
pub struct LangTrack {
pub lang: String,
pub blocks: Vec<CaptionBlock>,
}
impl LangTrack {
pub fn new(lang: &str, blocks: Vec<CaptionBlock>) -> Self {
Self {
lang: lang.to_string(),
blocks,
}
}
pub fn total_chars(&self) -> usize {
self.blocks
.iter()
.flat_map(|b| b.lines.iter())
.map(|l| l.chars().count())
.sum()
}
}
#[derive(Debug, Clone)]
pub struct MultiLangSyncConfig {
pub max_stretch_ratio: f64,
pub min_block_duration_ms: u64,
pub keep_out_of_range_blocks: bool,
}
impl Default for MultiLangSyncConfig {
fn default() -> Self {
Self {
max_stretch_ratio: 2.0,
min_block_duration_ms: 100,
keep_out_of_range_blocks: true,
}
}
}
#[derive(Debug, Clone)]
pub struct SyncedLangTrack {
pub lang: String,
pub blocks: Vec<CaptionBlock>,
pub clamp_count: usize,
}
#[derive(Debug, Clone)]
pub struct SyncedMultiLangOutput {
pub tracks: Vec<SyncedLangTrack>,
pub anchors: Vec<SyncAnchor>,
}
impl SyncedMultiLangOutput {
pub fn track_for_lang(&self, lang: &str) -> Option<&SyncedLangTrack> {
self.tracks.iter().find(|t| t.lang == lang)
}
pub fn active_at(&self, time_ms: u64) -> Vec<(&str, &CaptionBlock)> {
self.tracks
.iter()
.flat_map(|t| {
t.blocks
.iter()
.filter(move |b| b.start_ms <= time_ms && b.end_ms > time_ms)
.map(move |b| (t.lang.as_str(), b))
})
.collect()
}
}
pub struct MultiLangSyncer {
config: MultiLangSyncConfig,
}
impl MultiLangSyncer {
pub fn new(config: MultiLangSyncConfig) -> Self {
Self { config }
}
pub fn sync(
&self,
tracks: &[LangTrack],
anchors: &[SyncAnchor],
) -> Result<SyncedMultiLangOutput, CaptionGenError> {
if anchors.len() < 2 {
return Err(CaptionGenError::InvalidParameter(
"at least 2 anchor points are required for synchronization".to_string(),
));
}
if tracks.is_empty() {
return Err(CaptionGenError::EmptyTranscript);
}
let mut sorted_anchors = anchors.to_vec();
sorted_anchors.sort();
sorted_anchors.dedup_by_key(|a| a.time_ms);
let mut synced_tracks = Vec::with_capacity(tracks.len());
for track in tracks {
let synced = self.sync_track(track, &sorted_anchors)?;
synced_tracks.push(synced);
}
Ok(SyncedMultiLangOutput {
tracks: synced_tracks,
anchors: sorted_anchors,
})
}
fn sync_track(
&self,
track: &LangTrack,
anchors: &[SyncAnchor],
) -> Result<SyncedLangTrack, CaptionGenError> {
let mut adjusted = Vec::with_capacity(track.blocks.len());
let mut clamp_count = 0usize;
for block in &track.blocks {
let mid = block.start_ms + block.duration_ms() / 2;
let segment = find_anchor_segment(anchors, mid);
match segment {
Some((seg_start, seg_end)) => {
let seg_duration = seg_end - seg_start;
if seg_duration == 0 {
adjusted.push(block.clone());
continue;
}
let (new_start, new_end, clamped) = self.remap_block(block, seg_start, seg_end);
if clamped {
clamp_count += 1;
}
let mut new_block = block.clone();
new_block.start_ms = new_start;
new_block.end_ms = new_end;
adjusted.push(new_block);
}
None => {
if self.config.keep_out_of_range_blocks {
adjusted.push(block.clone());
}
}
}
}
Ok(SyncedLangTrack {
lang: track.lang.clone(),
blocks: adjusted,
clamp_count,
})
}
fn remap_block(&self, block: &CaptionBlock, seg_start: u64, seg_end: u64) -> (u64, u64, bool) {
let seg_duration = (seg_end - seg_start) as f64;
let clamped_start = block.start_ms.max(seg_start).min(seg_end);
let clamped_end = block.end_ms.max(seg_start).min(seg_end);
let t_start = (clamped_start - seg_start) as f64 / seg_duration;
let t_end = (clamped_end - seg_start) as f64 / seg_duration;
let orig_duration = block.duration_ms() as f64;
let new_duration_raw = (t_end - t_start) * seg_duration;
let ratio = if orig_duration > 0.0 {
new_duration_raw / orig_duration
} else {
1.0
};
let clamped = ratio > self.config.max_stretch_ratio
|| (orig_duration > 0.0 && ratio < 1.0 / self.config.max_stretch_ratio);
let new_start = seg_start + (t_start * seg_duration).round() as u64;
let new_end_unclamped = seg_start + (t_end * seg_duration).round() as u64;
let new_end = new_end_unclamped.max(new_start + self.config.min_block_duration_ms);
let new_end = new_end.min(seg_end);
(new_start, new_end, clamped)
}
}
fn find_anchor_segment(anchors: &[SyncAnchor], time_ms: u64) -> Option<(u64, u64)> {
for pair in anchors.windows(2) {
let start = pair[0].time_ms;
let end = pair[1].time_ms;
if time_ms >= start && time_ms < end {
return Some((start, end));
}
}
if let (Some(last), Some(second_last)) = (anchors.last(), anchors.iter().rev().nth(1)) {
if time_ms == last.time_ms {
return Some((second_last.time_ms, last.time_ms));
}
}
None
}
pub fn interleave_tracks<'a>(
primary: &'a SyncedLangTrack,
secondary: &'a SyncedLangTrack,
) -> Vec<(/* lang */ &'a str, &'a CaptionBlock)> {
let mut entries: Vec<(&str, &CaptionBlock)> = Vec::new();
for b in &primary.blocks {
entries.push((primary.lang.as_str(), b));
}
for b in &secondary.blocks {
entries.push((secondary.lang.as_str(), b));
}
entries.sort_by_key(|(_, b)| b.start_ms);
entries
}
pub fn average_timing_offset(a: &SyncedLangTrack, b: &SyncedLangTrack) -> Option<f64> {
if a.blocks.is_empty() || b.blocks.is_empty() || a.blocks.len() != b.blocks.len() {
return None;
}
let total: i64 = a
.blocks
.iter()
.zip(b.blocks.iter())
.map(|(ba, bb)| ba.start_ms as i64 - bb.start_ms as i64)
.sum();
Some(total as f64 / a.blocks.len() as f64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alignment::CaptionPosition;
fn make_block(id: u32, start_ms: u64, end_ms: u64, text: &str) -> CaptionBlock {
CaptionBlock {
id,
start_ms,
end_ms,
lines: vec![text.to_string()],
speaker_id: None,
position: CaptionPosition::Bottom,
}
}
fn make_anchors(times: &[u64]) -> Vec<SyncAnchor> {
times.iter().map(|&t| SyncAnchor { time_ms: t }).collect()
}
#[test]
fn sync_single_segment_identity() {
let blocks = vec![make_block(1, 0, 2000, "Hello.")];
let track = LangTrack::new("en", blocks.clone());
let anchors = make_anchors(&[0, 10_000]);
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let out = syncer.sync(&[track], &anchors).unwrap();
assert_eq!(out.tracks.len(), 1);
assert_eq!(out.tracks[0].blocks[0].start_ms, 0);
assert_eq!(out.tracks[0].blocks[0].end_ms, 2000);
}
#[test]
fn requires_at_least_two_anchors() {
let track = LangTrack::new("en", vec![make_block(1, 0, 1000, "Test")]);
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let result = syncer.sync(&[track], &make_anchors(&[0]));
assert!(result.is_err());
}
#[test]
fn empty_tracks_returns_error() {
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let result = syncer.sync(&[], &make_anchors(&[0, 5000]));
assert!(result.is_err());
}
#[test]
fn two_language_tracks_synced() {
let blocks_en = vec![make_block(1, 100, 2000, "Hello.")];
let blocks_fr = vec![make_block(1, 200, 2200, "Bonjour.")];
let tracks = vec![
LangTrack::new("en", blocks_en),
LangTrack::new("fr", blocks_fr),
];
let anchors = make_anchors(&[0, 10_000]);
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let out = syncer.sync(&tracks, &anchors).unwrap();
assert_eq!(out.tracks.len(), 2);
assert_eq!(out.track_for_lang("en").unwrap().lang, "en");
assert_eq!(out.track_for_lang("fr").unwrap().lang, "fr");
}
#[test]
fn active_at_returns_correct_blocks() {
let blocks_en = vec![make_block(1, 0, 3000, "Hello.")];
let blocks_fr = vec![make_block(1, 500, 2500, "Bonjour.")];
let tracks = vec![
LangTrack::new("en", blocks_en),
LangTrack::new("fr", blocks_fr),
];
let anchors = make_anchors(&[0, 10_000]);
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let out = syncer.sync(&tracks, &anchors).unwrap();
let active = out.active_at(1000);
assert_eq!(active.len(), 2);
}
#[test]
fn active_at_outside_returns_empty() {
let blocks_en = vec![make_block(1, 0, 1000, "Hello.")];
let tracks = vec![LangTrack::new("en", blocks_en)];
let anchors = make_anchors(&[0, 5000]);
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let out = syncer.sync(&tracks, &anchors).unwrap();
let active = out.active_at(9000);
assert!(active.is_empty());
}
#[test]
fn interleave_tracks_sorted_by_start() {
let t1 = SyncedLangTrack {
lang: "en".to_string(),
blocks: vec![make_block(1, 0, 1000, "A"), make_block(2, 3000, 4000, "C")],
clamp_count: 0,
};
let t2 = SyncedLangTrack {
lang: "fr".to_string(),
blocks: vec![make_block(1, 1500, 2500, "B")],
clamp_count: 0,
};
let merged = interleave_tracks(&t1, &t2);
assert_eq!(merged.len(), 3);
assert!(merged[0].1.start_ms <= merged[1].1.start_ms);
assert!(merged[1].1.start_ms <= merged[2].1.start_ms);
}
#[test]
fn average_timing_offset_same_tracks_is_zero() {
let t1 = SyncedLangTrack {
lang: "en".to_string(),
blocks: vec![
make_block(1, 1000, 2000, "A"),
make_block(2, 3000, 4000, "B"),
],
clamp_count: 0,
};
let t2 = SyncedLangTrack {
lang: "fr".to_string(),
blocks: vec![
make_block(1, 1000, 2000, "A"),
make_block(2, 3000, 4000, "B"),
],
clamp_count: 0,
};
let offset = average_timing_offset(&t1, &t2).unwrap();
assert!((offset).abs() < 1e-6);
}
#[test]
fn average_timing_offset_none_for_different_lengths() {
let t1 = SyncedLangTrack {
lang: "en".to_string(),
blocks: vec![make_block(1, 0, 1000, "A")],
clamp_count: 0,
};
let t2 = SyncedLangTrack {
lang: "fr".to_string(),
blocks: vec![make_block(1, 0, 1000, "A"), make_block(2, 1000, 2000, "B")],
clamp_count: 0,
};
assert!(average_timing_offset(&t1, &t2).is_none());
}
#[test]
fn anchors_sorted_on_input() {
let blocks = vec![make_block(1, 1000, 2000, "Hello.")];
let track = LangTrack::new("en", blocks);
let anchors = vec![SyncAnchor { time_ms: 5000 }, SyncAnchor { time_ms: 0 }];
let syncer = MultiLangSyncer::new(MultiLangSyncConfig::default());
let out = syncer.sync(&[track], &anchors).unwrap();
let times: Vec<u64> = out.anchors.iter().map(|a| a.time_ms).collect();
assert!(times.windows(2).all(|w| w[0] <= w[1]));
}
#[test]
fn lang_track_total_chars() {
let blocks = vec![
make_block(1, 0, 1000, "Hello"),
make_block(2, 1000, 2000, "World"),
];
let track = LangTrack::new("en", blocks);
assert_eq!(track.total_chars(), 10);
}
}