use crate::config::ConformConfig;
use crate::error::{ConformError, ConformResult};
use crate::timeline::{Timeline, TimelineClip, Track, TrackKind, Transition};
use crate::types::{ClipMatch, FrameRate, Timecode};
use tracing::{debug, info, warn};
pub struct TimelineReconstructor {
config: ConformConfig,
fps: FrameRate,
}
impl TimelineReconstructor {
#[must_use]
pub fn new(fps: FrameRate, config: ConformConfig) -> Self {
Self { config, fps }
}
pub fn reconstruct(&self, name: String, matches: &[ClipMatch]) -> ConformResult<Timeline> {
info!("Reconstructing timeline: {}", name);
if matches.is_empty() {
return Err(ConformError::Other("No clips to reconstruct".to_string()));
}
let mut timeline = Timeline::new(name, self.fps);
let (video_clips, audio_clips) = self.group_clips_by_track(matches);
if !video_clips.is_empty() {
let video_tracks = self.build_video_tracks(&video_clips)?;
for track in video_tracks {
timeline.add_video_track(track);
}
}
if !audio_clips.is_empty() {
let audio_tracks = self.build_audio_tracks(&audio_clips)?;
for track in audio_tracks {
timeline.add_audio_track(track);
}
}
info!(
"Timeline reconstructed: {} video tracks, {} audio tracks",
timeline.video_tracks.len(),
timeline.audio_tracks.len()
);
Ok(timeline)
}
fn group_clips_by_track(
&self,
matches: &[ClipMatch],
) -> (Vec<TimelineClip>, Vec<TimelineClip>) {
let mut video_clips = Vec::new();
let mut audio_clips = Vec::new();
for clip_match in matches {
let timeline_clip = TimelineClip::from_match(clip_match);
match clip_match.clip.track {
crate::types::TrackType::Video => video_clips.push(timeline_clip),
crate::types::TrackType::Audio => audio_clips.push(timeline_clip),
crate::types::TrackType::AudioVideo => {
video_clips.push(timeline_clip.clone());
audio_clips.push(timeline_clip);
}
}
}
(video_clips, audio_clips)
}
fn build_video_tracks(&self, clips: &[TimelineClip]) -> ConformResult<Vec<Track>> {
let mut tracks = Vec::new();
let keys: Vec<u64> = clips
.iter()
.map(|c| c.timeline_in.to_frames(c.fps))
.collect();
let mut order: Vec<usize> = (0..clips.len()).collect();
order.sort_by_key(|&i| keys[i]);
let mut track = Track::new("V1".to_string(), TrackKind::Video);
track.name = Some("Video 1".to_string());
for i in order {
track.add_clip(clips[i].clone());
}
self.add_transitions(&mut track)?;
tracks.push(track);
Ok(tracks)
}
fn build_audio_tracks(&self, clips: &[TimelineClip]) -> ConformResult<Vec<Track>> {
let mut tracks = Vec::new();
let keys: Vec<u64> = clips
.iter()
.map(|c| c.timeline_in.to_frames(c.fps))
.collect();
let mut order: Vec<usize> = (0..clips.len()).collect();
order.sort_by_key(|&i| keys[i]);
let mut track = Track::new("A1".to_string(), TrackKind::Audio);
track.name = Some("Audio 1".to_string());
for i in order {
track.add_clip(clips[i].clone());
}
tracks.push(track);
Ok(tracks)
}
fn add_transitions(&self, track: &mut Track) -> ConformResult<()> {
if track.clips.len() < 2 {
return Ok(());
}
for i in 0..track.clips.len() - 1 {
let current = &track.clips[i];
let next = &track.clips[i + 1];
let current_out = current.timeline_out.to_frames(current.fps);
let next_in = next.timeline_in.to_frames(next.fps);
if current_out == next_in {
let transition = Transition::cut(current.timeline_out);
track.add_transition(transition);
} else if current_out > next_in {
let overlap_frames = (current_out - next_in) as u32;
let transition = Transition::dissolve(next.timeline_in, overlap_frames);
track.add_transition(transition);
debug!("Added dissolve transition: {} frames", overlap_frames);
} else {
let gap_frames = next_in - current_out;
warn!("Gap detected: {} frames between clips", gap_frames);
let transition = Transition::cut(current.timeline_out);
track.add_transition(transition);
}
}
Ok(())
}
pub fn validate_timeline(&self, timeline: &Timeline) -> ConformResult<()> {
for (i, track) in timeline.video_tracks.iter().enumerate() {
self.validate_track(track, i)?;
}
for (i, track) in timeline.audio_tracks.iter().enumerate() {
self.validate_track(track, i)?;
}
Ok(())
}
fn validate_track(&self, track: &Track, index: usize) -> ConformResult<()> {
if track.clips.is_empty() {
warn!("Track {} is empty", index);
return Ok(());
}
for i in 0..track.clips.len() - 1 {
let current = &track.clips[i];
let next = &track.clips[i + 1];
let current_out = current.timeline_out.to_frames(current.fps);
let next_in = next.timeline_in.to_frames(next.fps);
if current_out > next_in && self.config.strict_validation {
return Err(ConformError::Validation(format!(
"Overlapping clips in track {}: clip {} ends at {}, clip {} starts at {}",
index,
i,
current.timeline_out,
i + 1,
next.timeline_in
)));
}
}
Ok(())
}
pub fn split_into_segments(
&self,
timeline: &Timeline,
segment_duration: f64,
) -> ConformResult<Vec<Timeline>> {
let total_duration = timeline.duration_seconds();
let num_segments = (total_duration / segment_duration).ceil() as usize;
let mut segments = Vec::new();
for i in 0..num_segments {
let start_time = i as f64 * segment_duration;
let end_time = ((i + 1) as f64 * segment_duration).min(total_duration);
let segment = self.extract_segment(
timeline,
start_time,
end_time,
format!("{}_seg{}", timeline.name, i + 1),
)?;
segments.push(segment);
}
Ok(segments)
}
fn extract_segment(
&self,
timeline: &Timeline,
start_time: f64,
end_time: f64,
name: String,
) -> ConformResult<Timeline> {
let mut segment = Timeline::new(name, timeline.fps);
let start_tc = crate::utils::seconds_to_timecode(start_time, timeline.fps);
let end_tc = crate::utils::seconds_to_timecode(end_time, timeline.fps);
for track in &timeline.video_tracks {
let segment_track = self.extract_track_segment(track, start_tc, end_tc)?;
if !segment_track.clips.is_empty() {
segment.add_video_track(segment_track);
}
}
for track in &timeline.audio_tracks {
let segment_track = self.extract_track_segment(track, start_tc, end_tc)?;
if !segment_track.clips.is_empty() {
segment.add_audio_track(segment_track);
}
}
Ok(segment)
}
fn extract_track_segment(
&self,
track: &Track,
start_tc: Timecode,
end_tc: Timecode,
) -> ConformResult<Track> {
let mut segment_track = Track::new(track.id.clone(), track.kind);
segment_track.name = track.name.clone();
let start_frames = start_tc.to_frames(self.fps);
let end_frames = end_tc.to_frames(self.fps);
for clip in &track.clips {
let clip_in = clip.timeline_in.to_frames(clip.fps);
let clip_out = clip.timeline_out.to_frames(clip.fps);
if clip_out > start_frames && clip_in < end_frames {
let mut segment_clip = clip.clone();
if clip_in < start_frames {
let trim_frames = start_frames - clip_in;
segment_clip.source_in = Timecode::from_frames(
segment_clip.source_in.to_frames(clip.fps) + trim_frames,
clip.fps,
);
segment_clip.timeline_in = start_tc;
}
if clip_out > end_frames {
let trim_frames = clip_out - end_frames;
segment_clip.source_out = Timecode::from_frames(
segment_clip.source_out.to_frames(clip.fps) - trim_frames,
clip.fps,
);
segment_clip.timeline_out = end_tc;
}
segment_track.add_clip(segment_clip);
}
}
Ok(segment_track)
}
pub fn merge_timelines(&self, timelines: &[Timeline], name: String) -> ConformResult<Timeline> {
if timelines.is_empty() {
return Err(ConformError::Other("No timelines to merge".to_string()));
}
let fps = timelines[0].fps;
let mut merged = Timeline::new(name, fps);
let max_video_tracks = timelines
.iter()
.map(|t| t.video_tracks.len())
.max()
.unwrap_or(0);
for track_idx in 0..max_video_tracks {
let mut merged_track = Track::new(format!("V{}", track_idx + 1), TrackKind::Video);
merged_track.name = Some(format!("Video {}", track_idx + 1));
let mut current_offset = Timecode::new(0, 0, 0, 0);
for timeline in timelines {
if let Some(track) = timeline.video_tracks.get(track_idx) {
for clip in &track.clips {
let mut offset_clip = clip.clone();
let offset_frames = current_offset.to_frames(fps);
offset_clip.timeline_in = Timecode::from_frames(
clip.timeline_in.to_frames(fps) + offset_frames,
fps,
);
offset_clip.timeline_out = Timecode::from_frames(
clip.timeline_out.to_frames(fps) + offset_frames,
fps,
);
merged_track.add_clip(offset_clip);
}
}
let timeline_duration_frames = timeline.duration_frames();
current_offset = Timecode::from_frames(
current_offset.to_frames(fps) + timeline_duration_frames,
fps,
);
}
if !merged_track.clips.is_empty() {
merged.add_video_track(merged_track);
}
}
let max_audio_tracks = timelines
.iter()
.map(|t| t.audio_tracks.len())
.max()
.unwrap_or(0);
for track_idx in 0..max_audio_tracks {
let mut merged_track = Track::new(format!("A{}", track_idx + 1), TrackKind::Audio);
merged_track.name = Some(format!("Audio {}", track_idx + 1));
let mut current_offset = Timecode::new(0, 0, 0, 0);
for timeline in timelines {
if let Some(track) = timeline.audio_tracks.get(track_idx) {
for clip in &track.clips {
let mut offset_clip = clip.clone();
let offset_frames = current_offset.to_frames(fps);
offset_clip.timeline_in = Timecode::from_frames(
clip.timeline_in.to_frames(fps) + offset_frames,
fps,
);
offset_clip.timeline_out = Timecode::from_frames(
clip.timeline_out.to_frames(fps) + offset_frames,
fps,
);
merged_track.add_clip(offset_clip);
}
}
let timeline_duration_frames = timeline.duration_frames();
current_offset = Timecode::from_frames(
current_offset.to_frames(fps) + timeline_duration_frames,
fps,
);
}
if !merged_track.clips.is_empty() {
merged.add_audio_track(merged_track);
}
}
Ok(merged)
}
pub fn optimize_timeline(&self, timeline: &mut Timeline) -> ConformResult<()> {
for track in &mut timeline.video_tracks {
self.optimize_track(track)?;
}
for track in &mut timeline.audio_tracks {
self.optimize_track(track)?;
}
Ok(())
}
fn optimize_track(&self, track: &mut Track) -> ConformResult<()> {
if track.clips.is_empty() {
return Ok(());
}
track.sort_clips();
let mut current_time = Timecode::new(0, 0, 0, 0);
for clip in &mut track.clips {
let clip_duration_frames = clip.duration_frames();
clip.timeline_in = current_time;
clip.timeline_out = Timecode::from_frames(
current_time.to_frames(clip.fps) + clip_duration_frames,
clip.fps,
);
current_time = clip.timeline_out;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ClipReference, MatchMethod, MediaFile, TrackType};
use std::collections::HashMap;
use std::path::PathBuf;
fn create_test_match(id: &str, record_in: Timecode, record_out: Timecode) -> ClipMatch {
let clip = ClipReference {
id: id.to_string(),
source_file: Some(format!("{id}.mov")),
source_in: Timecode::new(1, 0, 0, 0),
source_out: Timecode::new(1, 0, 10, 0),
record_in,
record_out,
track: TrackType::Video,
fps: FrameRate::Fps25,
metadata: HashMap::new(),
};
ClipMatch {
clip,
media: MediaFile::new(PathBuf::from(format!("/test/{id}.mov"))),
score: 1.0,
method: MatchMethod::ExactFilename,
details: String::new(),
}
}
#[test]
fn test_reconstructor_creation() {
let reconstructor = TimelineReconstructor::new(FrameRate::Fps25, ConformConfig::default());
assert_eq!(reconstructor.fps.as_f64(), 25.0);
}
#[test]
fn test_reconstruct_simple_timeline() {
let reconstructor = TimelineReconstructor::new(FrameRate::Fps25, ConformConfig::default());
let matches = vec![
create_test_match(
"clip1",
Timecode::new(1, 0, 0, 0),
Timecode::new(1, 0, 10, 0),
),
create_test_match(
"clip2",
Timecode::new(1, 0, 10, 0),
Timecode::new(1, 0, 20, 0),
),
];
let timeline = reconstructor
.reconstruct("Test Timeline".to_string(), &matches)
.expect("test expectation failed");
assert_eq!(timeline.name, "Test Timeline");
assert_eq!(timeline.video_tracks.len(), 1);
assert_eq!(timeline.video_tracks[0].clips.len(), 2);
}
#[test]
fn test_validate_timeline() {
let reconstructor = TimelineReconstructor::new(FrameRate::Fps25, ConformConfig::default());
let matches = vec![create_test_match(
"clip1",
Timecode::new(1, 0, 0, 0),
Timecode::new(1, 0, 10, 0),
)];
let timeline = reconstructor
.reconstruct("Test".to_string(), &matches)
.expect("test expectation failed");
assert!(reconstructor.validate_timeline(&timeline).is_ok());
}
fn make_clip(id: &str, h: u8, m: u8, s: u8, f: u8) -> TimelineClip {
use std::path::PathBuf;
TimelineClip {
id: id.to_string(),
source_path: PathBuf::from(format!("/test/{id}.mov")),
source_in: Timecode::new(0, 0, 0, 0),
source_out: Timecode::new(0, 0, 1, 0),
timeline_in: Timecode::new(h, m, s, f),
timeline_out: Timecode::new(h, m, s + 1, f),
fps: FrameRate::Fps25,
name: Some(id.to_string()),
match_score: 1.0,
}
}
#[test]
fn test_reconstruction_sorts_1000_clips_correctly() {
use crate::config::ConformConfig;
let fps = FrameRate::Fps25;
let reconstructor = TimelineReconstructor::new(fps, ConformConfig::default());
let n: u32 = 1000;
let clips: Vec<TimelineClip> = (0..n)
.map(|i| {
let shuffled = {
let mut v: u32 = 0;
let mut x = i;
for _ in 0..10 {
v = (v << 1) | (x & 1);
x >>= 1;
}
v
};
let total_frames = shuffled;
let frame_f = (total_frames % 25) as u8;
let total_sec = total_frames / 25;
let sec_s = (total_sec % 60) as u8;
let total_min = total_sec / 60;
let min_m = (total_min % 60) as u8;
let hour_h = (total_min / 60) as u8;
make_clip(&format!("clip{i}"), hour_h, min_m, sec_s, frame_f)
})
.collect();
let video_tracks = reconstructor
.build_video_tracks(&clips)
.expect("build_video_tracks should succeed");
assert_eq!(video_tracks.len(), 1);
let track = &video_tracks[0];
assert_eq!(track.clips.len(), n as usize);
for window in track.clips.windows(2) {
let a = window[0].timeline_in.to_frames(fps);
let b = window[1].timeline_in.to_frames(fps);
assert!(
a <= b,
"clips out of order: frame {a} followed by frame {b}"
);
}
}
#[test]
fn test_reconstruction_sorted_output_matches_reference() {
use crate::config::ConformConfig;
let fps = FrameRate::Fps25;
let reconstructor = TimelineReconstructor::new(fps, ConformConfig::default());
let order = [9u8, 0, 5, 3, 7, 1, 8, 4, 6, 2];
let clips: Vec<TimelineClip> = order
.iter()
.map(|&s| make_clip(&format!("clip{s}"), 0, 0, s, 0))
.collect();
let video_tracks = reconstructor
.build_video_tracks(&clips)
.expect("build_video_tracks should succeed");
let mut reference = clips.clone();
reference.sort_by_key(|c| c.timeline_in.to_frames(c.fps));
let result_clips = &video_tracks[0].clips;
assert_eq!(result_clips.len(), reference.len());
for (got, expected) in result_clips.iter().zip(reference.iter()) {
assert_eq!(
got.timeline_in.to_frames(fps),
expected.timeline_in.to_frames(fps),
"timeline_in mismatch: got {}, expected {}",
got.timeline_in,
expected.timeline_in,
);
assert_eq!(
got.id, expected.id,
"clip id mismatch: got {}, expected {}",
got.id, expected.id,
);
}
}
#[test]
fn test_reconstruction_stable_sort_duplicate_timestamps() {
use crate::config::ConformConfig;
let fps = FrameRate::Fps25;
let reconstructor = TimelineReconstructor::new(fps, ConformConfig::default());
let clips: Vec<TimelineClip> = (0..10usize)
.map(|i| {
let s = if i < 5 { 0u8 } else { 1u8 };
make_clip(&format!("clip{i}"), 0, 0, s, 0)
})
.collect();
let video_tracks = reconstructor
.build_video_tracks(&clips)
.expect("build_video_tracks should succeed");
let result_clips = &video_tracks[0].clips;
assert_eq!(result_clips.len(), 10);
for (pos, expected_id) in ["clip0", "clip1", "clip2", "clip3", "clip4"]
.iter()
.enumerate()
{
assert_eq!(
result_clips[pos].id, *expected_id,
"stable sort violated at position {pos}: got {}, expected {expected_id}",
result_clips[pos].id,
);
}
for (pos, expected_id) in ["clip5", "clip6", "clip7", "clip8", "clip9"]
.iter()
.enumerate()
{
assert_eq!(
result_clips[5 + pos].id,
*expected_id,
"stable sort violated at position {}: got {}, expected {expected_id}",
5 + pos,
result_clips[5 + pos].id,
);
}
}
#[test]
fn test_reconstruction_1000_clips_fast() {
use crate::config::ConformConfig;
use std::time::Instant;
let fps = FrameRate::Fps25;
let reconstructor = TimelineReconstructor::new(fps, ConformConfig::default());
let clips: Vec<TimelineClip> = (0u32..1000)
.rev() .map(|i| {
let total_sec = i;
let sec_s = (total_sec % 60) as u8;
let total_min = total_sec / 60;
let min_m = (total_min % 60) as u8;
let hour_h = (total_min / 60) as u8;
make_clip(&format!("clip{i}"), hour_h, min_m, sec_s, 0)
})
.collect();
let start = Instant::now();
let video_tracks = reconstructor
.build_video_tracks(&clips)
.expect("build_video_tracks should succeed");
let elapsed = start.elapsed();
assert_eq!(video_tracks[0].clips.len(), 1000);
assert!(
elapsed.as_millis() < 100,
"build_video_tracks for 1000 clips took {}ms, expected < 100ms",
elapsed.as_millis()
);
}
}