use crate::config::ConformConfig;
use crate::types::{ClipMatch, ClipReference, FrameRate, MatchMethod, MediaFile, Timecode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SubFrameTimecode {
pub frames: u64,
pub sub_frame: u32,
pub sub_frames_per_frame: u32,
}
impl SubFrameTimecode {
#[must_use]
pub fn new(frames: u64, sub_frame: u32, sub_frames_per_frame: u32) -> Self {
let sfpf = sub_frames_per_frame.max(1);
Self {
frames,
sub_frame: sub_frame.min(sfpf - 1),
sub_frames_per_frame: sfpf,
}
}
#[must_use]
pub fn from_timecode(tc: Timecode, fps: FrameRate, sub_frames_per_frame: u32) -> Self {
Self::new(tc.to_frames(fps), 0, sub_frames_per_frame)
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn to_f64_frames(&self) -> f64 {
self.frames as f64 + self.sub_frame as f64 / self.sub_frames_per_frame as f64
}
#[must_use]
pub fn distance_sub_frames(&self, other: &Self) -> Option<u64> {
if self.sub_frames_per_frame != other.sub_frames_per_frame {
return None;
}
let sfpf = u64::from(self.sub_frames_per_frame);
let a = self.frames * sfpf + u64::from(self.sub_frame);
let b = other.frames * sfpf + u64::from(other.sub_frame);
Some(a.abs_diff(b))
}
}
#[derive(Debug, Clone, Copy)]
pub struct SubFrameMatchConfig {
pub sub_frames_per_frame: u32,
pub max_offset_sub_frames: u32,
}
impl Default for SubFrameMatchConfig {
fn default() -> Self {
Self {
sub_frames_per_frame: 80,
max_offset_sub_frames: 4, }
}
}
#[must_use]
pub fn sub_frame_timecode_match(
clip: &ClipReference,
candidates: &[MediaFile],
sfpf_config: &SubFrameMatchConfig,
) -> Vec<ClipMatch> {
let sfpf = sfpf_config.sub_frames_per_frame;
let max_offset = u64::from(sfpf_config.max_offset_sub_frames);
let clip_sf = SubFrameTimecode::from_timecode(clip.source_in, clip.fps, sfpf);
let mut matches = Vec::new();
for media in candidates {
let Some(media_tc) = media.timecode_start else {
continue;
};
let media_sf = SubFrameTimecode::from_timecode(media_tc, clip.fps, sfpf);
let Some(distance) = clip_sf.distance_sub_frames(&media_sf) else {
continue;
};
if distance <= max_offset {
#[allow(clippy::cast_precision_loss)]
let score = if max_offset == 0 {
1.0_f64
} else {
1.0 - distance as f64 / max_offset as f64
};
matches.push(ClipMatch {
clip: clip.clone(),
media: media.clone(),
score,
method: MatchMethod::Timecode,
details: format!(
"Sub-frame timecode match: offset {distance} sub-frames \
(max {max_offset}, sfpf {sfpf})"
),
});
}
}
matches
}
#[must_use]
pub fn is_hfr(fps: FrameRate) -> bool {
fps.as_f64() >= 47.9
}
#[must_use]
pub fn timecode_match(
clip: &ClipReference,
candidates: &[MediaFile],
config: &ConformConfig,
) -> Vec<ClipMatch> {
let mut matches = Vec::new();
for media in candidates {
if let Some(media_tc_start) = media.timecode_start {
if let Some(media_duration) = media.duration {
let media_frames = (media_duration * clip.fps.as_f64()) as u64;
let media_tc_end = Timecode::from_frames(
media_tc_start.to_frames(clip.fps) + media_frames,
clip.fps,
);
let clip_start_frames = clip.source_in.to_frames(clip.fps);
let clip_end_frames = clip.source_out.to_frames(clip.fps);
let media_start_frames = media_tc_start.to_frames(clip.fps);
let media_end_frames = media_tc_end.to_frames(clip.fps);
if media_start_frames <= clip_start_frames && clip_end_frames <= media_end_frames {
matches.push(ClipMatch {
clip: clip.clone(),
media: media.clone(),
score: 1.0,
method: MatchMethod::Timecode,
details: format!(
"Timecode match: clip [{}-{}] within media [{}-{}]",
clip.source_in, clip.source_out, media_tc_start, media_tc_end
),
});
} else if ranges_overlap(
media_start_frames,
media_end_frames,
clip_start_frames,
clip_end_frames,
) {
let overlap_score = calculate_overlap_score(
media_start_frames,
media_end_frames,
clip_start_frames,
clip_end_frames,
);
if overlap_score >= config.match_threshold {
matches.push(ClipMatch {
clip: clip.clone(),
media: media.clone(),
score: overlap_score,
method: MatchMethod::Timecode,
details: format!(
"Partial timecode overlap: {overlap_score:.2} [{}-{}] vs [{}-{}]",
media_tc_start, media_tc_end, clip.source_in, clip.source_out
),
});
}
}
}
}
}
matches
}
#[must_use]
pub fn timecode_offset_match(
clip: &ClipReference,
candidates: &[MediaFile],
tolerance_frames: u64,
) -> Vec<ClipMatch> {
let mut matches = Vec::new();
for media in candidates {
if let Some(media_tc_start) = media.timecode_start {
let media_start_frames = media_tc_start.to_frames(clip.fps);
let clip_start_frames = clip.source_in.to_frames(clip.fps);
let offset = media_start_frames.abs_diff(clip_start_frames);
if offset <= tolerance_frames {
let score = 1.0 - (offset as f64 / tolerance_frames as f64);
matches.push(ClipMatch {
clip: clip.clone(),
media: media.clone(),
score,
method: MatchMethod::Timecode,
details: format!(
"Timecode offset match: offset {offset} frames (tolerance: {tolerance_frames})"
),
});
}
}
}
matches
}
fn ranges_overlap(start1: u64, end1: u64, start2: u64, end2: u64) -> bool {
start1 <= end2 && start2 <= end1
}
fn calculate_overlap_score(start1: u64, end1: u64, start2: u64, end2: u64) -> f64 {
if !ranges_overlap(start1, end1, start2, end2) {
return 0.0;
}
let overlap_start = start1.max(start2);
let overlap_end = end1.min(end2);
let overlap_length = overlap_end - overlap_start;
let range1_length = end1 - start1;
let range2_length = end2 - start2;
let min_length = range1_length.min(range2_length);
if min_length == 0 {
0.0
} else {
overlap_length as f64 / min_length as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FrameRate, TrackType};
use std::path::PathBuf;
fn create_test_clip(source_in: Timecode, source_out: Timecode) -> ClipReference {
ClipReference {
id: "test".to_string(),
source_file: Some("test.mov".to_string()),
source_in,
source_out,
record_in: Timecode::new(1, 0, 0, 0),
record_out: Timecode::new(1, 0, 10, 0),
track: TrackType::Video,
fps: FrameRate::Fps25,
metadata: std::collections::HashMap::new(),
}
}
#[test]
fn test_timecode_match_perfect_containment() {
let clip = create_test_clip(Timecode::new(1, 0, 5, 0), Timecode::new(1, 0, 10, 0));
let mut media = MediaFile::new(PathBuf::from("/path/test.mov"));
media.timecode_start = Some(Timecode::new(1, 0, 0, 0));
media.duration = Some(20.0); media.fps = Some(FrameRate::Fps25);
let config = ConformConfig::default();
let matches = timecode_match(&clip, &[media], &config);
assert_eq!(matches.len(), 1);
assert!((matches[0].score - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_ranges_overlap() {
assert!(ranges_overlap(0, 10, 5, 15));
assert!(ranges_overlap(5, 15, 0, 10));
assert!(!ranges_overlap(0, 10, 11, 20));
}
#[test]
fn test_calculate_overlap_score() {
let score = calculate_overlap_score(0, 10, 0, 10);
assert!((score - 1.0).abs() < f64::EPSILON);
let score = calculate_overlap_score(0, 10, 5, 15);
assert!((score - 0.5).abs() < 0.01);
let score = calculate_overlap_score(0, 10, 11, 20);
assert!(score.abs() < f64::EPSILON);
}
#[test]
fn test_timecode_offset_match() {
let clip = create_test_clip(Timecode::new(1, 0, 0, 5), Timecode::new(1, 0, 10, 0));
let mut media = MediaFile::new(PathBuf::from("/path/test.mov"));
media.timecode_start = Some(Timecode::new(1, 0, 0, 0));
media.fps = Some(FrameRate::Fps25);
let matches = timecode_offset_match(&clip, &[media], 10);
assert_eq!(matches.len(), 1);
}
}