use crate::alignment::CaptionBlock;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum TimingAdjusterError {
#[error("stretch factor must be positive (got {factor})")]
InvalidStretchFactor { factor: f64 },
#[error("frame rate must be positive (got {fps})")]
InvalidFrameRate { fps: f32 },
#[error("EDL entry has invalid range: [{src_start_ms}, {src_end_ms})")]
InvalidEdlRange { src_start_ms: u64, src_end_ms: u64 },
}
pub struct CaptionTimingAdjuster;
impl CaptionTimingAdjuster {
pub fn shift(blocks: &[CaptionBlock], offset_ms: i64) -> Vec<CaptionBlock> {
let mut result: Vec<CaptionBlock> = Vec::with_capacity(blocks.len());
for block in blocks {
let new_start = apply_offset(block.start_ms, offset_ms);
let new_end = apply_offset(block.end_ms, offset_ms);
if new_end <= new_start {
continue;
}
let mut adjusted = block.clone();
adjusted.start_ms = new_start;
adjusted.end_ms = new_end;
result.push(adjusted);
}
result
}
pub fn stretch(
blocks: &[CaptionBlock],
factor: f64,
) -> Result<Vec<CaptionBlock>, TimingAdjusterError> {
if factor <= 0.0 {
return Err(TimingAdjusterError::InvalidStretchFactor { factor });
}
let result = blocks
.iter()
.map(|block| {
let mut adjusted = block.clone();
adjusted.start_ms = (block.start_ms as f64 * factor).round() as u64;
adjusted.end_ms = (block.end_ms as f64 * factor).round() as u64;
if adjusted.end_ms <= adjusted.start_ms {
adjusted.end_ms = adjusted.start_ms + 1;
}
adjusted
})
.collect();
Ok(result)
}
pub fn stretch_around(
blocks: &[CaptionBlock],
factor: f64,
anchor_ms: u64,
) -> Result<Vec<CaptionBlock>, TimingAdjusterError> {
if factor <= 0.0 {
return Err(TimingAdjusterError::InvalidStretchFactor { factor });
}
let result = blocks
.iter()
.map(|block| {
let mut adjusted = block.clone();
adjusted.start_ms = stretch_around_anchor(block.start_ms, anchor_ms, factor);
adjusted.end_ms = stretch_around_anchor(block.end_ms, anchor_ms, factor);
if adjusted.end_ms <= adjusted.start_ms {
adjusted.end_ms = adjusted.start_ms + 1;
}
adjusted
})
.collect();
Ok(result)
}
pub fn snap_to_frame(
blocks: &[CaptionBlock],
fps: f32,
) -> Result<Vec<CaptionBlock>, TimingAdjusterError> {
if fps <= 0.0 {
return Err(TimingAdjusterError::InvalidFrameRate { fps });
}
let ms_per_frame = 1000.0 / fps as f64;
let result = blocks
.iter()
.map(|block| {
let mut adjusted = block.clone();
adjusted.start_ms = snap(block.start_ms, ms_per_frame);
adjusted.end_ms = snap(block.end_ms, ms_per_frame);
if adjusted.end_ms <= adjusted.start_ms {
adjusted.end_ms = adjusted.start_ms + ms_per_frame.ceil() as u64;
}
adjusted
})
.collect();
Ok(result)
}
pub fn remap_edl(
blocks: &[CaptionBlock],
edl: &[EdlEntry],
) -> Result<Vec<CaptionBlock>, TimingAdjusterError> {
for entry in edl {
if entry.src_start_ms >= entry.src_end_ms {
return Err(TimingAdjusterError::InvalidEdlRange {
src_start_ms: entry.src_start_ms,
src_end_ms: entry.src_end_ms,
});
}
}
let mut result: Vec<CaptionBlock> = Vec::new();
let mut next_id = 1u32;
for block in blocks {
for entry in edl {
let overlap_start = block.start_ms.max(entry.src_start_ms);
let overlap_end = block.end_ms.min(entry.src_end_ms);
if overlap_end <= overlap_start {
continue;
}
let dst_start = entry.dst_start_ms + (overlap_start - entry.src_start_ms);
let dst_end = entry.dst_start_ms + (overlap_end - entry.src_start_ms);
let mut remapped = block.clone();
remapped.id = next_id;
remapped.start_ms = dst_start;
remapped.end_ms = dst_end;
result.push(remapped);
next_id += 1;
}
}
result.sort_by_key(|b| b.start_ms);
Ok(result)
}
pub fn clamp_to_range(
blocks: &[CaptionBlock],
range_start_ms: u64,
range_end_ms: u64,
) -> Vec<CaptionBlock> {
if range_end_ms <= range_start_ms {
return Vec::new();
}
let mut result: Vec<CaptionBlock> = Vec::new();
let mut next_id = 1u32;
for block in blocks {
if block.end_ms <= range_start_ms || block.start_ms >= range_end_ms {
continue;
}
let clamped_start = block.start_ms.max(range_start_ms);
let clamped_end = block.end_ms.min(range_end_ms);
if clamped_end > clamped_start {
let mut clamped = block.clone();
clamped.id = next_id;
clamped.start_ms = clamped_start;
clamped.end_ms = clamped_end;
result.push(clamped);
next_id += 1;
}
}
result
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EdlEntry {
pub src_start_ms: u64,
pub src_end_ms: u64,
pub dst_start_ms: u64,
}
fn apply_offset(ts: u64, offset: i64) -> u64 {
if offset >= 0 {
ts.saturating_add(offset as u64)
} else {
ts.saturating_sub((-offset) as u64)
}
}
fn stretch_around_anchor(ts: u64, anchor: u64, factor: f64) -> u64 {
let delta = ts as f64 - anchor as f64;
let new_ts = anchor as f64 + delta * factor;
new_ts.round().max(0.0) as u64
}
fn snap(ts: u64, ms_per_frame: f64) -> u64 {
let frame = (ts as f64 / ms_per_frame).round();
(frame * ms_per_frame).round() as u64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alignment::CaptionPosition;
fn make_block(id: u32, start_ms: u64, end_ms: u64) -> CaptionBlock {
CaptionBlock {
id,
start_ms,
end_ms,
lines: vec![format!("block {}", id)],
speaker_id: None,
position: CaptionPosition::Bottom,
}
}
#[test]
fn shift_positive_offset_moves_later() {
let blocks = vec![make_block(1, 0, 2000)];
let result = CaptionTimingAdjuster::shift(&blocks, 500);
assert_eq!(result[0].start_ms, 500);
assert_eq!(result[0].end_ms, 2500);
}
#[test]
fn shift_negative_offset_moves_earlier() {
let blocks = vec![make_block(1, 2000, 4000)];
let result = CaptionTimingAdjuster::shift(&blocks, -1000);
assert_eq!(result[0].start_ms, 1000);
assert_eq!(result[0].end_ms, 3000);
}
#[test]
fn shift_clamps_to_zero() {
let blocks = vec![make_block(1, 500, 1500)];
let result = CaptionTimingAdjuster::shift(&blocks, -2000);
assert!(result.is_empty());
}
#[test]
fn shift_drops_blocks_with_zero_duration_after_shift() {
let blocks = vec![make_block(1, 1000, 1000)]; let result = CaptionTimingAdjuster::shift(&blocks, 0);
assert!(result.is_empty());
}
#[test]
fn shift_zero_offset_is_noop() {
let blocks = vec![make_block(1, 1000, 3000)];
let result = CaptionTimingAdjuster::shift(&blocks, 0);
assert_eq!(result[0].start_ms, 1000);
assert_eq!(result[0].end_ms, 3000);
}
#[test]
fn shift_empty_input_returns_empty() {
let result = CaptionTimingAdjuster::shift(&[], 1000);
assert!(result.is_empty());
}
#[test]
fn stretch_factor_two_doubles_timestamps() {
let blocks = vec![make_block(1, 1000, 2000)];
let result = CaptionTimingAdjuster::stretch(&blocks, 2.0).expect("stretch should succeed");
assert_eq!(result[0].start_ms, 2000);
assert_eq!(result[0].end_ms, 4000);
}
#[test]
fn stretch_factor_half_halves_timestamps() {
let blocks = vec![make_block(1, 2000, 4000)];
let result = CaptionTimingAdjuster::stretch(&blocks, 0.5).expect("stretch should succeed");
assert_eq!(result[0].start_ms, 1000);
assert_eq!(result[0].end_ms, 2000);
}
#[test]
fn stretch_factor_one_is_noop() {
let blocks = vec![make_block(1, 1000, 3000)];
let result = CaptionTimingAdjuster::stretch(&blocks, 1.0).expect("stretch should succeed");
assert_eq!(result[0].start_ms, 1000);
assert_eq!(result[0].end_ms, 3000);
}
#[test]
fn stretch_negative_factor_returns_error() {
let blocks = vec![make_block(1, 0, 1000)];
let err = CaptionTimingAdjuster::stretch(&blocks, -1.0).unwrap_err();
assert!(matches!(
err,
TimingAdjusterError::InvalidStretchFactor { .. }
));
}
#[test]
fn stretch_zero_factor_returns_error() {
let err = CaptionTimingAdjuster::stretch(&[], 0.0).unwrap_err();
assert!(matches!(
err,
TimingAdjusterError::InvalidStretchFactor { .. }
));
}
#[test]
fn stretch_around_anchor_at_start() {
let blocks = vec![make_block(1, 0, 2000)];
let result = CaptionTimingAdjuster::stretch_around(&blocks, 2.0, 0).expect("stretch around should succeed");
assert_eq!(result[0].start_ms, 0);
assert_eq!(result[0].end_ms, 4000);
}
#[test]
fn stretch_around_anchor_in_middle() {
let blocks = vec![make_block(1, 1000, 3000)];
let result = CaptionTimingAdjuster::stretch_around(&blocks, 2.0, 2000).expect("stretch around should succeed");
assert_eq!(result[0].start_ms, 0);
assert_eq!(result[0].end_ms, 4000);
}
#[test]
fn snap_to_frame_25fps() {
let blocks = vec![make_block(1, 10, 2010)];
let result = CaptionTimingAdjuster::snap_to_frame(&blocks, 25.0).expect("snap to frame should succeed");
assert_eq!(result[0].start_ms, 0);
assert_eq!(result[0].end_ms, 2000);
}
#[test]
fn snap_to_frame_invalid_fps_returns_error() {
let err = CaptionTimingAdjuster::snap_to_frame(&[], 0.0).unwrap_err();
assert!(matches!(err, TimingAdjusterError::InvalidFrameRate { .. }));
}
#[test]
fn snap_to_frame_already_on_frame() {
let blocks = vec![make_block(1, 0, 1000)];
let result = CaptionTimingAdjuster::snap_to_frame(&blocks, 25.0).expect("snap to frame should succeed");
assert_eq!(result[0].start_ms, 0);
assert_eq!(result[0].end_ms, 1000);
}
#[test]
fn edl_remap_simple() {
let blocks = vec![make_block(1, 1000, 3000)];
let edl = vec![EdlEntry {
src_start_ms: 0,
src_end_ms: 5000,
dst_start_ms: 0,
}];
let result = CaptionTimingAdjuster::remap_edl(&blocks, &edl).expect("remap edl should succeed");
assert_eq!(result.len(), 1);
assert_eq!(result[0].start_ms, 1000);
assert_eq!(result[0].end_ms, 3000);
}
#[test]
fn edl_remap_shifts_destination() {
let blocks = vec![make_block(1, 1000, 3000)];
let edl = vec![EdlEntry {
src_start_ms: 1000,
src_end_ms: 5000,
dst_start_ms: 500, }];
let result = CaptionTimingAdjuster::remap_edl(&blocks, &edl).expect("remap edl should succeed");
assert_eq!(result[0].start_ms, 500);
assert_eq!(result[0].end_ms, 2500);
}
#[test]
fn edl_remap_drops_blocks_outside_edl() {
let blocks = vec![make_block(1, 10_000, 12_000)];
let edl = vec![EdlEntry {
src_start_ms: 0,
src_end_ms: 5000,
dst_start_ms: 0,
}];
let result = CaptionTimingAdjuster::remap_edl(&blocks, &edl).expect("remap edl should succeed");
assert!(result.is_empty());
}
#[test]
fn edl_remap_invalid_entry_returns_error() {
let blocks = vec![make_block(1, 0, 1000)];
let edl = vec![EdlEntry {
src_start_ms: 5000,
src_end_ms: 1000, dst_start_ms: 0,
}];
let err = CaptionTimingAdjuster::remap_edl(&blocks, &edl).unwrap_err();
assert!(matches!(err, TimingAdjusterError::InvalidEdlRange { .. }));
}
#[test]
fn edl_remap_block_spans_cut_is_split() {
let blocks = vec![make_block(1, 0, 6000)];
let edl = vec![
EdlEntry {
src_start_ms: 0,
src_end_ms: 3000,
dst_start_ms: 0,
},
EdlEntry {
src_start_ms: 3000,
src_end_ms: 6000,
dst_start_ms: 5000, },
];
let result = CaptionTimingAdjuster::remap_edl(&blocks, &edl).expect("remap edl should succeed");
assert_eq!(result.len(), 2);
assert_eq!(result[0].start_ms, 0);
assert_eq!(result[0].end_ms, 3000);
assert_eq!(result[1].start_ms, 5000);
assert_eq!(result[1].end_ms, 8000);
}
#[test]
fn clamp_removes_out_of_range_blocks() {
let blocks = vec![make_block(1, 0, 1000), make_block(2, 5000, 7000)];
let result = CaptionTimingAdjuster::clamp_to_range(&blocks, 2000, 8000);
assert_eq!(result.len(), 1);
assert_eq!(result[0].start_ms, 5000);
}
#[test]
fn clamp_trims_overlapping_blocks() {
let blocks = vec![make_block(1, 500, 3000)];
let result = CaptionTimingAdjuster::clamp_to_range(&blocks, 1000, 2000);
assert_eq!(result[0].start_ms, 1000);
assert_eq!(result[0].end_ms, 2000);
}
#[test]
fn clamp_renumbers_blocks() {
let blocks = vec![make_block(5, 1000, 2000), make_block(6, 3000, 4000)];
let result = CaptionTimingAdjuster::clamp_to_range(&blocks, 0, 10000);
assert_eq!(result[0].id, 1);
assert_eq!(result[1].id, 2);
}
#[test]
fn clamp_invalid_range_returns_empty() {
let blocks = vec![make_block(1, 0, 1000)];
let result = CaptionTimingAdjuster::clamp_to_range(&blocks, 5000, 1000);
assert!(result.is_empty());
}
#[test]
fn error_display_invalid_factor() {
let e = TimingAdjusterError::InvalidStretchFactor { factor: -1.0 };
assert!(e.to_string().contains("positive"));
}
#[test]
fn error_display_invalid_fps() {
let e = TimingAdjusterError::InvalidFrameRate { fps: 0.0 };
assert!(e.to_string().contains("positive"));
}
}