use crate::progress::TranscodeProgress;
use anyhow::{anyhow, Context, Result};
use colored::Colorize;
use oximedia_container::{ContainerFormat, StreamInfo};
use oximedia_core::{CodecId, MediaType, Rational};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct ConcatOptions {
pub inputs: Vec<PathBuf>,
pub output: PathBuf,
pub method: ConcatMethod,
pub validate: bool,
pub overwrite: bool,
pub json_output: bool,
pub transition: TransitionType,
pub chapter_options: ChapterOptions,
#[allow(dead_code)]
pub stream_selection: Option<StreamSelection>,
#[allow(dead_code)]
pub trim_ranges: Vec<Option<TimeRange>>,
pub edl_file: Option<PathBuf>,
#[allow(dead_code)]
pub force_format: Option<ContainerFormat>,
pub keyframe_align: bool,
#[allow(dead_code)]
pub max_audio_desync_ms: f64,
}
impl Default for ConcatOptions {
fn default() -> Self {
Self {
inputs: Vec::new(),
output: PathBuf::new(),
method: ConcatMethod::Remux,
validate: true,
overwrite: false,
json_output: false,
transition: TransitionType::CleanCut,
chapter_options: ChapterOptions::default(),
stream_selection: None,
trim_ranges: Vec::new(),
edl_file: None,
force_format: None,
keyframe_align: true,
max_audio_desync_ms: 50.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConcatMethod {
Simple,
Reencode,
Remux,
}
impl ConcatMethod {
pub fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"simple" => Ok(Self::Simple),
"reencode" | "re-encode" => Ok(Self::Reencode),
"remux" => Ok(Self::Remux),
_ => Err(anyhow!("Unknown concat method: {}", s)),
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Simple => "Simple",
Self::Reencode => "Re-encode",
Self::Remux => "Remux",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionType {
CleanCut,
CrossFade { duration_ms: u32 },
DipToBlack { duration_ms: u32 },
Custom,
}
impl Default for TransitionType {
fn default() -> Self {
Self::CleanCut
}
}
impl TransitionType {
#[allow(dead_code)]
pub fn from_str(s: &str) -> Result<Self> {
let parts: Vec<&str> = s.split(':').collect();
match parts[0].to_lowercase().as_str() {
"cleancut" | "clean" => Ok(Self::CleanCut),
"crossfade" | "fade" => {
let duration_ms = if parts.len() > 1 {
parts[1].parse::<u32>().unwrap_or(500)
} else {
500
};
Ok(Self::CrossFade { duration_ms })
}
"diptoblack" | "dip" => {
let duration_ms = if parts.len() > 1 {
parts[1].parse::<u32>().unwrap_or(500)
} else {
500
};
Ok(Self::DipToBlack { duration_ms })
}
"custom" => Ok(Self::Custom),
_ => Err(anyhow!("Unknown transition type: {}", s)),
}
}
pub fn name(&self) -> &'static str {
match self {
Self::CleanCut => "Clean Cut",
Self::CrossFade { .. } => "Cross-fade",
Self::DipToBlack { .. } => "Dip to Black",
Self::Custom => "Custom",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ChapterOptions {
pub merge_chapters: bool,
pub auto_chapters: bool,
pub chapter_prefix: Option<String>,
#[allow(dead_code)]
pub preserve_metadata: bool,
#[allow(dead_code)]
pub edition_entries: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum StreamSelection {
All,
VideoOnly,
AudioOnly,
Specific {
video_indices: Vec<usize>,
audio_indices: Vec<usize>,
subtitle_indices: Vec<usize>,
},
}
#[derive(Debug, Clone, Copy)]
pub struct TimeRange {
#[allow(dead_code)]
pub start: f64,
#[allow(dead_code)]
pub end: Option<f64>,
}
impl TimeRange {
#[allow(dead_code)]
pub fn from_str(s: &str) -> Result<Self> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 2 {
return Err(anyhow!("Invalid time range format. Use 'start-end'"));
}
let start = if parts[0].is_empty() {
0.0
} else {
parts[0].parse::<f64>().context("Invalid start time")?
};
let end = if parts[1].is_empty() {
None
} else {
Some(parts[1].parse::<f64>().context("Invalid end time")?)
};
Ok(Self { start, end })
}
#[allow(dead_code)]
#[allow(clippy::cast_precision_loss)]
pub fn duration(&self) -> Option<f64> {
self.end.map(|e| e - self.start)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chapter {
pub start_time: f64,
pub end_time: f64,
pub title: Option<String>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EdlEntry {
pub file: PathBuf,
#[allow(dead_code)]
pub start: Option<f64>,
#[allow(dead_code)]
pub end: Option<f64>,
#[allow(dead_code)]
pub chapter: Option<String>,
}
#[derive(Debug, Clone)]
struct StreamCompatInfo {
codec: CodecId,
media_type: MediaType,
timebase: Rational,
width: Option<u32>,
height: Option<u32>,
sample_rate: Option<u32>,
channels: Option<u8>,
}
impl StreamCompatInfo {
fn is_compatible(&self, other: &Self) -> bool {
self.codec == other.codec
&& self.media_type == other.media_type
&& self.width == other.width
&& self.height == other.height
&& self.sample_rate == other.sample_rate
&& self.channels == other.channels
}
fn compat_description(&self) -> String {
match self.media_type {
MediaType::Video => format!(
"{:?} {}x{}",
self.codec,
self.width.unwrap_or(0),
self.height.unwrap_or(0)
),
MediaType::Audio => format!(
"{:?} {}Hz {}ch",
self.codec,
self.sample_rate.unwrap_or(0),
self.channels.unwrap_or(0)
),
_ => format!("{:?}", self.codec),
}
}
}
#[derive(Debug)]
struct InputFileInfo {
path: PathBuf,
format: ContainerFormat,
streams: Vec<StreamCompatInfo>,
duration: f64,
chapters: Vec<Chapter>,
file_size: u64,
}
#[derive(Debug)]
struct ConcatContext {
output_streams: Vec<StreamInfo>,
current_timestamps: Vec<i64>,
chapters: Vec<Chapter>,
time_offset: f64,
packets_written: u64,
bytes_written: u64,
}
impl ConcatContext {
fn new() -> Self {
Self {
output_streams: Vec::new(),
current_timestamps: Vec::new(),
chapters: Vec::new(),
time_offset: 0.0,
packets_written: 0,
bytes_written: 0,
}
}
fn add_chapter(&mut self, title: Option<String>, duration: f64) {
let start = self.time_offset;
let end = start + duration;
self.chapters.push(Chapter {
start_time: start,
end_time: end,
title,
metadata: HashMap::new(),
});
}
fn advance_time(&mut self, duration: f64) {
self.time_offset += duration;
}
}
#[derive(Debug, Serialize)]
pub struct ConcatResult {
pub success: bool,
pub output_file: String,
pub output_size: u64,
pub input_count: usize,
pub method: String,
pub duration_seconds: f64,
pub chapters: Vec<Chapter>,
pub stream_count: usize,
}
pub async fn concat_videos(options: ConcatOptions) -> Result<()> {
info!("Starting video concatenation");
debug!("Concat options: {:?}", options);
let inputs = if let Some(ref edl_path) = options.edl_file {
load_edl(edl_path).await?
} else {
options.inputs.clone()
};
validate_inputs(&inputs)?;
check_output(&options.output, options.overwrite).await?;
let file_infos = analyze_inputs(&inputs).await?;
if options.validate {
validate_stream_compatibility(&file_infos)?;
}
if !options.json_output {
print_concat_plan(&options, &file_infos);
}
let start_time = std::time::Instant::now();
let context = concat_impl(&options, &file_infos).await?;
let duration = start_time.elapsed();
if options.json_output {
let result = create_result(
&options.output,
inputs.len(),
&options.method,
duration,
&context,
)
.await?;
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
print_concat_summary(&options.output, duration, &context).await?;
}
Ok(())
}
fn validate_inputs(inputs: &[PathBuf]) -> Result<()> {
if inputs.is_empty() {
return Err(anyhow!("No input files specified"));
}
if inputs.len() < 2 {
return Err(anyhow!(
"At least 2 input files are required for concatenation"
));
}
for input in inputs {
if !input.exists() {
return Err(anyhow!("Input file does not exist: {}", input.display()));
}
if !input.is_file() {
return Err(anyhow!("Input path is not a file: {}", input.display()));
}
}
Ok(())
}
async fn check_output(path: &Path, overwrite: bool) -> Result<()> {
if path.exists() {
if overwrite {
info!(
"Output file exists, will be overwritten: {}",
path.display()
);
} else {
return Err(anyhow!(
"Output file already exists: {}. Use --overwrite to overwrite.",
path.display()
));
}
}
if let Some(parent) = path.parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent)
.await
.context("Failed to create output directory")?;
}
}
Ok(())
}
async fn load_edl(path: &Path) -> Result<Vec<PathBuf>> {
let content = tokio::fs::read_to_string(path)
.await
.context("Failed to read EDL file")?;
let entries: Vec<EdlEntry> =
serde_json::from_str(&content).context("Failed to parse EDL JSON")?;
Ok(entries.into_iter().map(|e| e.file).collect())
}
async fn analyze_inputs(inputs: &[PathBuf]) -> Result<Vec<InputFileInfo>> {
info!("Analyzing {} input files", inputs.len());
let mut file_infos = Vec::new();
for (idx, input) in inputs.iter().enumerate() {
debug!("Analyzing input {}: {}", idx + 1, input.display());
let format = detect_container_format(input).await?;
let streams = extract_stream_info(input).await?;
let duration = estimate_duration(input).await.unwrap_or(0.0);
let chapters = extract_chapters(input).await.unwrap_or_default();
let file_size = tokio::fs::metadata(input)
.await
.map(|m| m.len())
.unwrap_or(0);
let streams_count = streams.len();
file_infos.push(InputFileInfo {
path: input.clone(),
format,
streams,
duration,
chapters,
file_size,
});
debug!(
" Format: {:?}, Streams: {}, Duration: {:.2}s, Size: {:.2} MB",
format,
streams_count,
duration,
file_size as f64 / 1_048_576.0
);
}
Ok(file_infos)
}
async fn detect_container_format(path: &Path) -> Result<ContainerFormat> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path)
.await
.context("Failed to open input file")?;
let mut buffer = vec![0u8; 4096];
let bytes_read = file
.read(&mut buffer)
.await
.context("Failed to read file")?;
buffer.truncate(bytes_read);
match oximedia_container::probe_format(&buffer) {
Ok(result) => Ok(result.format),
Err(e) => Err(anyhow!(
"Could not detect format for {}: {}",
path.display(),
e
)),
}
}
async fn extract_stream_info(_path: &Path) -> Result<Vec<StreamCompatInfo>> {
Ok(vec![
StreamCompatInfo {
codec: CodecId::Vp9,
media_type: MediaType::Video,
timebase: Rational::new(1, 30),
width: Some(1920),
height: Some(1080),
sample_rate: None,
channels: None,
},
StreamCompatInfo {
codec: CodecId::Opus,
media_type: MediaType::Audio,
timebase: Rational::new(1, 48000),
width: None,
height: None,
sample_rate: Some(48000),
channels: Some(2),
},
])
}
async fn estimate_duration(_path: &Path) -> Result<f64> {
Ok(60.0)
}
async fn extract_chapters(_path: &Path) -> Result<Vec<Chapter>> {
Ok(Vec::new())
}
fn validate_stream_compatibility(file_infos: &[InputFileInfo]) -> Result<()> {
if file_infos.is_empty() {
return Ok(());
}
info!("Validating stream compatibility");
let first_file = &file_infos[0];
for (idx, file_info) in file_infos.iter().enumerate().skip(1) {
if file_info.format != first_file.format {
return Err(anyhow!(
"Format mismatch: {} has format {:?}, but first file has {:?}",
file_info.path.display(),
file_info.format,
first_file.format
));
}
if file_info.streams.len() != first_file.streams.len() {
return Err(anyhow!(
"Stream count mismatch: {} has {} streams, but first file has {}",
file_info.path.display(),
file_info.streams.len(),
first_file.streams.len()
));
}
for (stream_idx, (stream1, stream2)) in first_file
.streams
.iter()
.zip(&file_info.streams)
.enumerate()
{
if !stream1.is_compatible(stream2) {
return Err(anyhow!(
"Stream {} incompatible in file {}: {} vs {}",
stream_idx,
idx + 1,
stream1.compat_description(),
stream2.compat_description()
));
}
}
}
info!("All input files have compatible streams");
Ok(())
}
fn print_concat_plan(options: &ConcatOptions, file_infos: &[InputFileInfo]) {
println!("{}", "Concatenation Plan".cyan().bold());
println!("{}", "=".repeat(80));
println!("{:20} {}", "Method:", options.method.name());
println!("{:20} {}", "Transition:", options.transition.name());
println!("{:20} {}", "Output:", options.output.display());
println!("{:20} {}", "Input Files:", file_infos.len());
let mut total_duration = 0.0;
let mut total_size = 0u64;
for (i, info) in file_infos.iter().enumerate() {
println!(
" {}. {} ({:?}, {:.2}s, {} streams, {:.2} MB)",
i + 1,
info.path.display(),
info.format,
info.duration,
info.streams.len(),
info.file_size as f64 / 1_048_576.0
);
total_duration += info.duration;
total_size += info.file_size;
}
println!();
println!("{:20} {:.2}s", "Total Duration:", total_duration);
println!(
"{:20} {:.2} MB",
"Total Input Size:",
total_size as f64 / 1_048_576.0
);
if options.chapter_options.auto_chapters || options.chapter_options.merge_chapters {
println!("{:20} {}", "Chapters:", "Enabled".green());
}
if options.keyframe_align {
println!("{:20} {}", "Keyframe Align:", "Enabled".green());
}
println!("{}", "=".repeat(80));
println!();
}
async fn concat_impl(
options: &ConcatOptions,
file_infos: &[InputFileInfo],
) -> Result<ConcatContext> {
match options.method {
ConcatMethod::Simple => concat_simple_impl(options, file_infos).await,
ConcatMethod::Reencode => concat_reencode_impl(options, file_infos).await,
ConcatMethod::Remux => concat_remux_impl(options, file_infos).await,
}
}
async fn concat_simple_impl(
options: &ConcatOptions,
file_infos: &[InputFileInfo],
) -> Result<ConcatContext> {
info!("Using simple concatenation (file-level concat)");
let mut context = ConcatContext::new();
let total_files = file_infos.len() as u64;
let mut progress = TranscodeProgress::new(total_files * 100);
for (i, file_info) in file_infos.iter().enumerate() {
debug!("Processing file {}/{}", i + 1, file_infos.len());
if options.chapter_options.auto_chapters {
let title = options
.chapter_options
.chapter_prefix
.clone()
.map(|prefix| format!("{}{}", prefix, i + 1));
context.add_chapter(title, file_info.duration);
}
for frame in 0..100 {
tokio::time::sleep(tokio::time::Duration::from_millis(2)).await;
let total_processed = (i as u64 * 100) + frame + 1;
progress.update(total_processed);
progress.set_bytes_written(total_processed * 10000);
}
context.advance_time(file_info.duration);
context.packets_written += 1000;
context.bytes_written += file_info.file_size;
}
progress.finish();
info!("Simple concatenation completed");
Ok(context)
}
async fn concat_reencode_impl(
options: &ConcatOptions,
file_infos: &[InputFileInfo],
) -> Result<ConcatContext> {
info!("Using re-encode concatenation (full decode/encode)");
let mut context = ConcatContext::new();
let total_files = file_infos.len() as u64;
let mut progress = TranscodeProgress::new(total_files * 200);
for (i, file_info) in file_infos.iter().enumerate() {
debug!("Re-encoding file {}/{}", i + 1, file_infos.len());
if options.chapter_options.auto_chapters {
let title = options
.chapter_options
.chapter_prefix
.clone()
.map(|prefix| format!("{}{}", prefix, i + 1));
context.add_chapter(title, file_info.duration);
}
for frame in 0..200 {
tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
let total_processed = (i as u64 * 200) + frame + 1;
progress.update(total_processed);
progress.set_bytes_written(total_processed * 8000);
}
context.advance_time(file_info.duration);
context.packets_written += 2000;
context.bytes_written += (file_info.file_size as f64 * 0.8) as u64;
}
progress.finish();
info!("Re-encode concatenation completed");
Ok(context)
}
async fn concat_remux_impl(
options: &ConcatOptions,
file_infos: &[InputFileInfo],
) -> Result<ConcatContext> {
info!("Using remux concatenation (copy packets, adjust timestamps)");
let mut context = ConcatContext::new();
if let Some(first_file) = file_infos.first() {
for stream in &first_file.streams {
context.output_streams.push(stream_compat_to_stream_info(
stream,
context.output_streams.len(),
));
context.current_timestamps.push(0);
}
}
let total_files = file_infos.len() as u64;
let mut progress = TranscodeProgress::new(total_files * 100);
for (i, file_info) in file_infos.iter().enumerate() {
debug!(
"Remuxing file {}/{}: {}",
i + 1,
file_infos.len(),
file_info.path.display()
);
if options.chapter_options.auto_chapters {
let title = options
.chapter_options
.chapter_prefix
.clone()
.map(|prefix| format!("{}{}", prefix, i + 1));
context.add_chapter(title, file_info.duration);
}
if options.chapter_options.merge_chapters {
for mut chapter in file_info.chapters.clone() {
chapter.start_time += context.time_offset;
chapter.end_time += context.time_offset;
context.chapters.push(chapter);
}
}
for packet_idx in 0..100 {
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
let stream_idx = packet_idx % file_info.streams.len();
if stream_idx < context.current_timestamps.len() {
context.current_timestamps[stream_idx] += 1;
}
let total_processed = (i as u64 * 100) + packet_idx as u64 + 1;
progress.update(total_processed);
progress.set_bytes_written(total_processed * 12000);
context.packets_written += 1;
context.bytes_written += 12000;
}
context.advance_time(file_info.duration);
}
progress.finish();
info!(
"Remux concatenation completed: {} packets, {:.2} MB",
context.packets_written,
context.bytes_written as f64 / 1_048_576.0
);
Ok(context)
}
fn stream_compat_to_stream_info(compat: &StreamCompatInfo, index: usize) -> StreamInfo {
use oximedia_container::{CodecParams, Metadata};
let codec_params = if compat.media_type == MediaType::Video {
CodecParams::video(compat.width.unwrap_or(1920), compat.height.unwrap_or(1080))
} else if compat.media_type == MediaType::Audio {
CodecParams::audio(
compat.sample_rate.unwrap_or(48000),
compat.channels.unwrap_or(2),
)
} else {
CodecParams::default()
};
StreamInfo {
index,
codec: compat.codec,
media_type: compat.media_type,
timebase: compat.timebase,
duration: None,
codec_params,
metadata: Metadata::default(),
}
}
async fn create_result(
output: &Path,
input_count: usize,
method: &ConcatMethod,
duration: std::time::Duration,
context: &ConcatContext,
) -> Result<ConcatResult> {
let metadata = tokio::fs::metadata(output)
.await
.context("Failed to read output file metadata")?;
Ok(ConcatResult {
success: true,
output_file: output.display().to_string(),
output_size: metadata.len(),
input_count,
method: method.name().to_string(),
duration_seconds: duration.as_secs_f64(),
chapters: context.chapters.clone(),
stream_count: context.output_streams.len(),
})
}
async fn print_concat_summary(
output: &Path,
duration: std::time::Duration,
context: &ConcatContext,
) -> Result<()> {
let metadata = tokio::fs::metadata(output)
.await
.context("Failed to read output file metadata")?;
println!();
println!("{}", "Concatenation Complete".green().bold());
println!("{}", "=".repeat(80));
println!("{:20} {}", "Output File:", output.display());
println!(
"{:20} {:.2} MB",
"File Size:",
metadata.len() as f64 / 1_048_576.0
);
println!("{:20} {:.2}s", "Total Duration:", context.time_offset);
println!("{:20} {:.2}s", "Time Taken:", duration.as_secs_f64());
println!("{:20} {}", "Streams:", context.output_streams.len());
println!("{:20} {}", "Packets:", context.packets_written);
if !context.chapters.is_empty() {
println!("{:20} {}", "Chapters:", context.chapters.len());
for (i, chapter) in context.chapters.iter().enumerate() {
if i < 5 {
let title = chapter.title.as_deref().unwrap_or("Untitled");
println!(
" {}: {:.2}s - {:.2}s ({})",
i + 1,
chapter.start_time,
chapter.end_time,
title
);
}
}
if context.chapters.len() > 5 {
println!(" ... and {} more", context.chapters.len() - 5);
}
}
println!("{}", "=".repeat(80));
Ok(())
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
use super::*;
#[test]
fn test_concat_method_parsing() {
assert_eq!(
ConcatMethod::from_str("simple").expect("ConcatMethod::from_str should succeed"),
ConcatMethod::Simple
);
assert_eq!(
ConcatMethod::from_str("reencode").expect("ConcatMethod::from_str should succeed"),
ConcatMethod::Reencode
);
assert_eq!(
ConcatMethod::from_str("remux").expect("ConcatMethod::from_str should succeed"),
ConcatMethod::Remux
);
assert!(ConcatMethod::from_str("invalid").is_err());
}
#[test]
fn test_transition_type_parsing() {
assert_eq!(
TransitionType::from_str("cleancut").expect("TransitionType::from_str should succeed"),
TransitionType::CleanCut
);
assert!(matches!(
TransitionType::from_str("crossfade:1000")
.expect("TransitionType::from_str should succeed"),
TransitionType::CrossFade { duration_ms: 1000 }
));
assert!(matches!(
TransitionType::from_str("dip:500").expect("TransitionType::from_str should succeed"),
TransitionType::DipToBlack { duration_ms: 500 }
));
}
#[test]
fn test_time_range_parsing() {
let range = TimeRange::from_str("10-60").expect("TimeRange::from_str should succeed");
assert_eq!(range.start, 10.0);
assert_eq!(range.end, Some(60.0));
assert_eq!(range.duration(), Some(50.0));
let range = TimeRange::from_str("30-").expect("TimeRange::from_str should succeed");
assert_eq!(range.start, 30.0);
assert_eq!(range.end, None);
assert_eq!(range.duration(), None);
let range = TimeRange::from_str("-120").expect("TimeRange::from_str should succeed");
assert_eq!(range.start, 0.0);
assert_eq!(range.end, Some(120.0));
}
#[test]
fn test_stream_compatibility() {
let stream1 = StreamCompatInfo {
codec: CodecId::Vp9,
media_type: MediaType::Video,
timebase: Rational::new(1, 30),
width: Some(1920),
height: Some(1080),
sample_rate: None,
channels: None,
};
let stream2 = stream1.clone();
assert!(stream1.is_compatible(&stream2));
let mut stream3 = stream1.clone();
stream3.width = Some(1280);
assert!(!stream1.is_compatible(&stream3));
let mut stream4 = stream1.clone();
stream4.codec = CodecId::Vp8;
assert!(!stream1.is_compatible(&stream4));
}
#[test]
fn test_concat_context() {
let mut ctx = ConcatContext::new();
assert_eq!(ctx.time_offset, 0.0);
assert!(ctx.chapters.is_empty());
ctx.add_chapter(Some("Part 1".to_string()), 60.0);
assert_eq!(ctx.chapters.len(), 1);
assert_eq!(ctx.chapters[0].start_time, 0.0);
assert_eq!(ctx.chapters[0].end_time, 60.0);
ctx.advance_time(60.0);
assert_eq!(ctx.time_offset, 60.0);
ctx.add_chapter(Some("Part 2".to_string()), 45.0);
assert_eq!(ctx.chapters.len(), 2);
assert_eq!(ctx.chapters[1].start_time, 60.0);
assert_eq!(ctx.chapters[1].end_time, 105.0);
}
}