use crate::error::{ConformError, ConformResult};
use crate::importers::TimelineImporter;
use crate::types::{ClipReference, FrameRate, Timecode, TrackType};
use oximedia_edl::event::EdlEvent;
use oximedia_edl::Edl;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EdlVariant {
#[default]
Cmx3600,
Cmx3400,
Cmx340,
File128,
}
impl EdlVariant {
#[must_use]
pub fn detect(content: &str) -> Self {
for line in content.lines().take(20) {
let upper = line.trim().to_uppercase();
if upper.starts_with("FORMAT:") {
if upper.contains("FILE128") {
return Self::File128;
}
if upper.contains("CMX 3400") {
return Self::Cmx3400;
}
if upper.contains("CMX 340") {
return Self::Cmx340;
}
if upper.contains("CMX 3600") {
return Self::Cmx3600;
}
}
}
Self::Cmx3600
}
#[must_use]
pub const fn max_name_length(&self) -> usize {
match self {
Self::Cmx340 => 8,
Self::Cmx3400 | Self::Cmx3600 => 32,
Self::File128 => 128,
}
}
#[must_use]
pub const fn supports_extended_audio(&self) -> bool {
matches!(self, Self::Cmx3400)
}
}
pub struct EdlImporter {
variant: Option<EdlVariant>,
}
impl EdlImporter {
#[must_use]
pub const fn new() -> Self {
Self { variant: None }
}
#[must_use]
pub const fn with_variant(variant: EdlVariant) -> Self {
Self {
variant: Some(variant),
}
}
fn effective_variant(content: &str, hint: Option<EdlVariant>) -> EdlVariant {
hint.unwrap_or_else(|| EdlVariant::detect(content))
}
#[must_use]
fn truncate_name(name: &str, variant: EdlVariant) -> String {
let max = variant.max_name_length();
if name.len() <= max {
name.to_string()
} else {
name[..max].to_string()
}
}
fn convert_frame_rate(edl_fps: oximedia_edl::timecode::EdlFrameRate) -> FrameRate {
match edl_fps {
oximedia_edl::timecode::EdlFrameRate::Fps24 => FrameRate::Fps24,
oximedia_edl::timecode::EdlFrameRate::Fps25 => FrameRate::Fps25,
oximedia_edl::timecode::EdlFrameRate::Fps2997DF => FrameRate::Fps2997DF,
oximedia_edl::timecode::EdlFrameRate::Fps2997NDF => FrameRate::Fps2997NDF,
oximedia_edl::timecode::EdlFrameRate::Fps30 => FrameRate::Fps30,
_ => FrameRate::Fps25, }
}
fn convert_timecode(edl_tc: &oximedia_edl::timecode::EdlTimecode) -> Timecode {
Timecode::new(
edl_tc.hours(),
edl_tc.minutes(),
edl_tc.seconds(),
edl_tc.frames(),
)
}
fn convert_track_type(edl_track: &oximedia_edl::event::TrackType) -> TrackType {
if edl_track.has_video() && edl_track.has_audio() {
TrackType::AudioVideo
} else if edl_track.has_video() {
TrackType::Video
} else {
TrackType::Audio
}
}
fn event_to_clip(event: &EdlEvent, fps: FrameRate) -> ClipReference {
let mut metadata: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
metadata.insert("reel".to_string(), event.reel.clone());
metadata.insert("event_number".to_string(), event.number.to_string());
metadata.insert("edit_type".to_string(), event.edit_type.to_string());
if let Some(clip_name) = &event.clip_name {
metadata.insert("clip_name".to_string(), clip_name.clone());
}
ClipReference {
id: format!("event_{}", event.number),
source_file: event.clip_name.clone(),
source_in: Self::convert_timecode(&event.source_in),
source_out: Self::convert_timecode(&event.source_out),
record_in: Self::convert_timecode(&event.record_in),
record_out: Self::convert_timecode(&event.record_out),
track: Self::convert_track_type(&event.track),
fps,
metadata,
}
}
pub fn import_from_edl(edl: &Edl) -> ConformResult<Vec<ClipReference>> {
let fps = Self::convert_frame_rate(edl.frame_rate);
let clips: Vec<ClipReference> = edl
.events
.iter()
.map(|event| Self::event_to_clip(event, fps))
.collect();
Ok(clips)
}
}
impl Default for EdlImporter {
fn default() -> Self {
Self::new()
}
}
impl TimelineImporter for EdlImporter {
fn import<P: AsRef<Path>>(&self, path: P) -> ConformResult<Vec<ClipReference>> {
let content = std::fs::read_to_string(path.as_ref()).map_err(ConformError::Io)?;
let variant = Self::effective_variant(&content, self.variant);
let edl = Edl::from_file(path.as_ref()).map_err(|e| ConformError::Edl(e.to_string()))?;
let mut clips = Self::import_from_edl(&edl)?;
for clip in &mut clips {
if let Some(reel) = clip.metadata.get("reel").cloned() {
clip.metadata
.insert("reel".to_string(), Self::truncate_name(&reel, variant));
}
if let Some(clip_name) = clip.metadata.get("clip_name").cloned() {
clip.metadata.insert(
"clip_name".to_string(),
Self::truncate_name(&clip_name, variant),
);
}
let variant_name = match variant {
EdlVariant::Cmx3600 => "CMX3600",
EdlVariant::Cmx3400 => "CMX3400",
EdlVariant::Cmx340 => "CMX340",
EdlVariant::File128 => "File128",
};
clip.metadata
.insert("edl_variant".to_string(), variant_name.to_string());
}
Ok(clips)
}
}
#[cfg(test)]
mod tests {
use super::*;
use oximedia_edl::event::{EditType, TrackType as EdlTrackType};
use oximedia_edl::timecode::{EdlFrameRate, EdlTimecode};
#[test]
fn test_convert_frame_rate() {
assert_eq!(
EdlImporter::convert_frame_rate(EdlFrameRate::Fps25),
FrameRate::Fps25
);
assert_eq!(
EdlImporter::convert_frame_rate(EdlFrameRate::Fps2997DF),
FrameRate::Fps2997DF
);
}
#[test]
fn test_convert_timecode() {
let edl_tc =
EdlTimecode::new(1, 23, 45, 12, EdlFrameRate::Fps25).expect("edl_tc should be valid");
let tc = EdlImporter::convert_timecode(&edl_tc);
assert_eq!(tc.hours, 1);
assert_eq!(tc.minutes, 23);
assert_eq!(tc.seconds, 45);
assert_eq!(tc.frames, 12);
}
#[test]
fn test_event_to_clip() {
let tc1 = EdlTimecode::new(1, 0, 0, 0, EdlFrameRate::Fps25).expect("tc1 should be valid");
let tc2 = EdlTimecode::new(1, 0, 10, 0, EdlFrameRate::Fps25).expect("tc2 should be valid");
let event = EdlEvent::new(
1,
"A001".to_string(),
EdlTrackType::Video,
EditType::Cut,
tc1,
tc2,
tc1,
tc2,
);
let clip = EdlImporter::event_to_clip(&event, FrameRate::Fps25);
assert_eq!(clip.id, "event_1");
assert_eq!(clip.track, TrackType::Video);
}
#[test]
fn test_edl_variant_detect_default() {
let content =
"TITLE: Test\n001 REEL1 V C 01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00\n";
let variant = EdlVariant::detect(content);
assert_eq!(variant, EdlVariant::Cmx3600);
}
#[test]
fn test_edl_variant_detect_file128() {
let content = "TITLE: Test\nFORMAT: FILE128\n001 REEL1 V C 01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00\n";
let variant = EdlVariant::detect(content);
assert_eq!(variant, EdlVariant::File128);
}
#[test]
fn test_edl_variant_detect_cmx340() {
let content = "TITLE: Test\nFORMAT: CMX 340\n001 REEL1 V C 01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00\n";
let variant = EdlVariant::detect(content);
assert_eq!(variant, EdlVariant::Cmx340);
}
#[test]
fn test_edl_variant_detect_cmx3400() {
let content = "TITLE: Test\nFORMAT: CMX 3400\n001 REEL1 V C 01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00\n";
let variant = EdlVariant::detect(content);
assert_eq!(variant, EdlVariant::Cmx3400);
}
#[test]
fn test_edl_variant_name_length() {
assert_eq!(EdlVariant::Cmx340.max_name_length(), 8);
assert_eq!(EdlVariant::Cmx3600.max_name_length(), 32);
assert_eq!(EdlVariant::File128.max_name_length(), 128);
}
#[test]
fn test_edl_truncate_name_cmx340() {
let long_name = "very_long_clip_name";
let truncated = EdlImporter::truncate_name(long_name, EdlVariant::Cmx340);
assert_eq!(truncated.len(), 8);
assert_eq!(truncated, "very_lon");
}
#[test]
fn test_edl_truncate_name_file128_no_truncation() {
let name = "A".repeat(100);
let result = EdlImporter::truncate_name(&name, EdlVariant::File128);
assert_eq!(result.len(), 100);
}
#[test]
fn test_edl_importer_with_variant() {
let importer = EdlImporter::with_variant(EdlVariant::Cmx340);
assert_eq!(importer.variant, Some(EdlVariant::Cmx340));
}
#[test]
fn test_edl_variant_extended_audio() {
assert!(EdlVariant::Cmx3400.supports_extended_audio());
assert!(!EdlVariant::Cmx3600.supports_extended_audio());
assert!(!EdlVariant::File128.supports_extended_audio());
}
#[test]
fn test_edl_import_export_roundtrip() {
use crate::types::Timecode as ConformTimecode;
use oximedia_edl::{Edl, EdlFormat, EdlGenerator};
let mut edl = Edl::new(EdlFormat::Cmx3600);
edl.set_title("RoundTripTest".to_string());
edl.set_frame_rate(EdlFrameRate::Fps25);
let src_in_1 =
EdlTimecode::new(1, 0, 0, 0, EdlFrameRate::Fps25).expect("tc creation must succeed");
let src_out_1 =
EdlTimecode::new(1, 0, 5, 0, EdlFrameRate::Fps25).expect("tc creation must succeed");
let rec_in_1 =
EdlTimecode::new(1, 0, 0, 0, EdlFrameRate::Fps25).expect("tc creation must succeed");
let rec_out_1 =
EdlTimecode::new(1, 0, 5, 0, EdlFrameRate::Fps25).expect("tc creation must succeed");
let mut event1 = EdlEvent::new(
1,
"REEL_A".to_string(),
EdlTrackType::Video,
EditType::Cut,
src_in_1,
src_out_1,
rec_in_1,
rec_out_1,
);
event1.clip_name = Some("shot_001.mov".to_string());
edl.add_event(event1).expect("add_event must succeed");
let src_in_2 =
EdlTimecode::new(1, 0, 10, 0, EdlFrameRate::Fps25).expect("tc creation must succeed");
let src_out_2 =
EdlTimecode::new(1, 0, 20, 12, EdlFrameRate::Fps25).expect("tc creation must succeed");
let rec_in_2 =
EdlTimecode::new(1, 0, 5, 0, EdlFrameRate::Fps25).expect("tc creation must succeed");
let rec_out_2 =
EdlTimecode::new(1, 0, 15, 12, EdlFrameRate::Fps25).expect("tc creation must succeed");
let mut event2 = EdlEvent::new(
2,
"REEL_B".to_string(),
EdlTrackType::Video,
EditType::Cut,
src_in_2,
src_out_2,
rec_in_2,
rec_out_2,
);
event2.clip_name = Some("shot_002.mov".to_string());
edl.add_event(event2).expect("add_event must succeed");
let generator = EdlGenerator::new();
let edl_string = generator.generate(&edl).expect("generate must succeed");
assert!(
edl_string.contains("RoundTripTest"),
"generated EDL must contain the title"
);
let parsed = oximedia_edl::parse_edl(&edl_string)
.expect("re-parsing the generated EDL must succeed");
let clips = EdlImporter::import_from_edl(&parsed).expect("import_from_edl must succeed");
assert_eq!(
clips.len(),
2,
"round-trip must preserve the two-clip timeline, got {} clips",
clips.len()
);
let clip1 = clips
.iter()
.find(|c| c.id == "event_1")
.expect("event_1 must be present after round-trip");
assert_eq!(
clip1.source_in,
ConformTimecode::new(1, 0, 0, 0),
"event_1 source_in mismatch"
);
assert_eq!(
clip1.source_out,
ConformTimecode::new(1, 0, 5, 0),
"event_1 source_out mismatch"
);
let clip2 = clips
.iter()
.find(|c| c.id == "event_2")
.expect("event_2 must be present after round-trip");
assert_eq!(
clip2.source_in,
ConformTimecode::new(1, 0, 10, 0),
"event_2 source_in mismatch"
);
assert_eq!(
clip2.source_out,
ConformTimecode::new(1, 0, 20, 12),
"event_2 source_out mismatch"
);
}
}