Skip to main content

oximedia_edit/edl/
ale.rs

1//! ALE (Avid Log Exchange) format parser and writer.
2//!
3//! ALE is a tab-delimited format used by Avid editing systems to exchange
4//! metadata about clips. It includes information like:
5//! - Clip names and tape IDs
6//! - Timecode in/out points
7//! - Scene/take/camera metadata
8//! - Sound roll information
9//! - Custom metadata fields
10//!
11//! # Format Structure
12//!
13//! An ALE file consists of:
14//! 1. Header section with format metadata
15//! 2. Column definition line
16//! 3. Data section with tab-separated values
17//!
18//! # Example
19//!
20//! ```text
21//! Heading
22//! FIELD_DELIM\tTABS
23//! VIDEO_FORMAT\t1080p
24//! AUDIO_FORMAT\t48kHz
25//! FPS\t24
26//!
27//! Column
28//! Name\tTape\tStart\tEnd\tDuration\tScene\tTake
29//!
30//! Data
31//! CLIP001\tA001\t01:00:00:00\t01:00:05:00\t00:00:05:00\t1\t1
32//! CLIP002\tA001\t01:00:10:00\t01:00:15:00\t00:00:05:00\t1\t2
33//! ```
34
35use super::{EditType, Edl, EdlEvent, EdlResult, Timecode};
36use oximedia_core::Rational;
37use std::collections::HashMap;
38
39/// ALE file structure.
40#[derive(Debug, Clone)]
41pub struct AleFile {
42    /// Header metadata.
43    pub header: HashMap<String, String>,
44    /// Column names.
45    pub columns: Vec<String>,
46    /// Data rows (each row is a map of column name to value).
47    pub data: Vec<HashMap<String, String>>,
48}
49
50/// ALE parser.
51pub struct AleParser {
52    frame_rate: Rational,
53    audio_format: String,
54    video_format: String,
55}
56
57impl AleParser {
58    /// Create a new ALE parser with default settings.
59    #[must_use]
60    pub fn new() -> Self {
61        Self {
62            frame_rate: Rational::new(24, 1),
63            audio_format: "48kHz".to_string(),
64            video_format: "1080p".to_string(),
65        }
66    }
67
68    /// Parse an ALE file.
69    pub fn parse(&mut self, content: &str) -> EdlResult<AleFile> {
70        let lines: Vec<&str> = content.lines().collect();
71        let mut header = HashMap::new();
72        let mut columns = Vec::new();
73        let mut data = Vec::new();
74
75        let mut section = Section::None;
76        let mut i = 0;
77
78        while i < lines.len() {
79            let line = lines[i].trim();
80
81            // Skip empty lines
82            if line.is_empty() {
83                i += 1;
84                continue;
85            }
86
87            // Detect sections
88            if line == "Heading" {
89                section = Section::Heading;
90                i += 1;
91                continue;
92            } else if line == "Column" {
93                section = Section::Column;
94                i += 1;
95                continue;
96            } else if line == "Data" {
97                section = Section::Data;
98                i += 1;
99                continue;
100            }
101
102            match section {
103                Section::None => {
104                    // Before any section marker
105                    i += 1;
106                }
107                Section::Heading => {
108                    self.parse_header_line(line, &mut header)?;
109                    i += 1;
110                }
111                Section::Column => {
112                    columns = self.parse_column_line(line);
113                    section = Section::Data;
114                    i += 1;
115                }
116                Section::Data => {
117                    if !columns.is_empty() {
118                        let row = self.parse_data_line(line, &columns)?;
119                        data.push(row);
120                    }
121                    i += 1;
122                }
123            }
124        }
125
126        // Update parser settings from header
127        if let Some(fps) = header.get("FPS") {
128            if let Ok(fps_val) = fps.parse::<i32>() {
129                self.frame_rate = Rational::new(i64::from(fps_val), 1);
130            }
131        }
132
133        if let Some(audio) = header.get("AUDIO_FORMAT") {
134            self.audio_format = audio.clone();
135        }
136
137        if let Some(video) = header.get("VIDEO_FORMAT") {
138            self.video_format = video.clone();
139        }
140
141        Ok(AleFile {
142            header,
143            columns,
144            data,
145        })
146    }
147
148    /// Parse a header line (key-value pair).
149    fn parse_header_line(&self, line: &str, header: &mut HashMap<String, String>) -> EdlResult<()> {
150        let parts: Vec<&str> = line.split('\t').collect();
151        if parts.len() >= 2 {
152            header.insert(parts[0].to_string(), parts[1].to_string());
153        }
154        Ok(())
155    }
156
157    /// Parse column definition line.
158    fn parse_column_line(&self, line: &str) -> Vec<String> {
159        line.split('\t').map(|s| s.trim().to_string()).collect()
160    }
161
162    /// Parse data line.
163    fn parse_data_line(
164        &self,
165        line: &str,
166        columns: &[String],
167    ) -> EdlResult<HashMap<String, String>> {
168        let values: Vec<&str> = line.split('\t').collect();
169        let mut row = HashMap::new();
170
171        for (i, column) in columns.iter().enumerate() {
172            if i < values.len() {
173                row.insert(column.clone(), values[i].trim().to_string());
174            }
175        }
176
177        Ok(row)
178    }
179
180    /// Convert ALE file to EDL.
181    pub fn to_edl(&self, ale: &AleFile) -> EdlResult<Edl> {
182        let title = ale
183            .header
184            .get("TITLE")
185            .or_else(|| ale.header.get("PROJECT"))
186            .unwrap_or(&String::from("Untitled"))
187            .clone();
188
189        let mut edl = Edl::new(title, self.frame_rate, false);
190
191        // Add metadata from header
192        for (key, value) in &ale.header {
193            edl.metadata.insert(key.clone(), value.clone());
194        }
195
196        // Convert each row to an event
197        for (idx, row) in ale.data.iter().enumerate() {
198            let event = self.row_to_event(row, idx + 1)?;
199            edl.add_event(event);
200        }
201
202        Ok(edl)
203    }
204
205    /// Convert a data row to an EDL event.
206    fn row_to_event(&self, row: &HashMap<String, String>, number: usize) -> EdlResult<EdlEvent> {
207        // Extract standard fields
208        let name = row.get("Name").unwrap_or(&String::new()).clone();
209        let tape = row
210            .get("Tape")
211            .or_else(|| row.get("Source File"))
212            .unwrap_or(&String::new())
213            .clone();
214
215        let start = row.get("Start").or_else(|| row.get("Mark In"));
216        let end = row.get("End").or_else(|| row.get("Mark Out"));
217
218        let source_in = if let Some(tc) = start {
219            Timecode::parse(tc, self.frame_rate)?
220        } else {
221            Timecode::new(0, 0, 0, 0, false, self.frame_rate)
222        };
223
224        let source_out = if let Some(tc) = end {
225            Timecode::parse(tc, self.frame_rate)?
226        } else {
227            Timecode::new(0, 0, 0, 0, false, self.frame_rate)
228        };
229
230        // For ALE, record timecode is typically sequential
231        let record_in = Timecode::from_frames((number as i64 - 1) * 150, self.frame_rate, false);
232        let duration = source_out.to_frames() - source_in.to_frames();
233        let record_out =
234            Timecode::from_frames(record_in.to_frames() + duration, self.frame_rate, false);
235
236        // Determine track type
237        let track = if let Some(tracks) = row.get("Tracks") {
238            tracks.clone()
239        } else {
240            "V".to_string()
241        };
242
243        // Create metadata from all fields
244        let mut metadata = HashMap::new();
245        for (key, value) in row {
246            metadata.insert(key.to_lowercase().replace(' ', "_"), value.clone());
247        }
248
249        // Add clip name if present
250        if !name.is_empty() {
251            metadata.insert("clip_name".to_string(), name);
252        }
253
254        Ok(EdlEvent {
255            number: number as u32,
256            reel: tape,
257            track,
258            edit_type: EditType::Cut,
259            source_in,
260            source_out,
261            record_in,
262            record_out,
263            transition_duration: None,
264            motion_effect: None,
265            comments: Vec::new(),
266            metadata,
267        })
268    }
269}
270
271impl Default for AleParser {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277/// ALE writer.
278pub struct AleWriter {
279    columns: Vec<String>,
280    include_header: bool,
281}
282
283impl AleWriter {
284    /// Create a new ALE writer with default columns.
285    #[must_use]
286    pub fn new() -> Self {
287        Self {
288            columns: vec![
289                "Name".to_string(),
290                "Tape".to_string(),
291                "Start".to_string(),
292                "End".to_string(),
293                "Duration".to_string(),
294                "Scene".to_string(),
295                "Take".to_string(),
296            ],
297            include_header: true,
298        }
299    }
300
301    /// Set custom columns.
302    #[must_use]
303    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
304        self.columns = columns;
305        self
306    }
307
308    /// Set whether to include header section.
309    #[must_use]
310    pub fn with_header(mut self, include: bool) -> Self {
311        self.include_header = include;
312        self
313    }
314
315    /// Write an EDL to ALE format.
316    pub fn write(&self, edl: &Edl) -> EdlResult<String> {
317        let mut output = String::new();
318
319        // Write header section
320        if self.include_header {
321            output.push_str("Heading\n");
322            output.push_str("FIELD_DELIM\tTABS\n");
323
324            // Extract frame rate
325            let fps = edl.frame_rate.to_f64() as i32;
326            output.push_str(&format!("FPS\t{}\n", fps));
327
328            // Add metadata
329            for (key, value) in &edl.metadata {
330                output.push_str(&format!("{}\t{}\n", key.to_uppercase(), value));
331            }
332
333            output.push('\n');
334        }
335
336        // Write column section
337        output.push_str("Column\n");
338        output.push_str(&self.columns.join("\t"));
339        output.push_str("\n\n");
340
341        // Write data section
342        output.push_str("Data\n");
343        for event in &edl.events {
344            self.write_event(&mut output, event);
345        }
346
347        Ok(output)
348    }
349
350    /// Write a single event as a data row.
351    fn write_event(&self, output: &mut String, event: &EdlEvent) {
352        let mut values = Vec::new();
353
354        for column in &self.columns {
355            let value = match column.as_str() {
356                "Name" => event
357                    .metadata
358                    .get("clip_name")
359                    .unwrap_or(&String::new())
360                    .clone(),
361                "Tape" | "Source File" => event.reel.clone(),
362                "Start" | "Mark In" => event.source_in.format(),
363                "End" | "Mark Out" => event.source_out.format(),
364                "Duration" => {
365                    let duration_frames =
366                        event.source_out.to_frames() - event.source_in.to_frames();
367                    Timecode::from_frames(
368                        duration_frames,
369                        event.source_in.frame_rate,
370                        event.source_in.drop_frame,
371                    )
372                    .format()
373                }
374                "Scene" => event
375                    .metadata
376                    .get("scene")
377                    .unwrap_or(&String::new())
378                    .clone(),
379                "Take" => event.metadata.get("take").unwrap_or(&String::new()).clone(),
380                "Tracks" => event.track.clone(),
381                _ => event
382                    .metadata
383                    .get(&column.to_lowercase().replace(' ', "_"))
384                    .unwrap_or(&String::new())
385                    .clone(),
386            };
387
388            values.push(value);
389        }
390
391        output.push_str(&values.join("\t"));
392        output.push('\n');
393    }
394}
395
396impl Default for AleWriter {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402/// Section types in ALE file.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404enum Section {
405    None,
406    Heading,
407    Column,
408    Data,
409}
410
411/// Parse an ALE file from string.
412pub fn parse(content: &str) -> EdlResult<Edl> {
413    let mut parser = AleParser::new();
414    let ale = parser.parse(content)?;
415    parser.to_edl(&ale)
416}
417
418/// Write an EDL to ALE format.
419pub fn write(edl: &Edl) -> EdlResult<String> {
420    AleWriter::new().write(edl)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_parse_ale() {
429        let content = r"Heading
430FIELD_DELIM	TABS
431FPS	24
432
433Column
434Name	Tape	Start	End	Duration
435
436Data
437CLIP001	A001	01:00:00:00	01:00:05:00	00:00:05:00
438CLIP002	A001	01:00:10:00	01:00:15:00	00:00:05:00
439";
440
441        let mut parser = AleParser::new();
442        let ale = parser.parse(content).expect("ale should be valid");
443
444        assert_eq!(ale.header.get("FPS"), Some(&"24".to_string()));
445        assert_eq!(ale.columns.len(), 5);
446        assert_eq!(ale.data.len(), 2);
447        assert_eq!(ale.data[0].get("Name"), Some(&"CLIP001".to_string()));
448    }
449
450    #[test]
451    fn test_ale_to_edl() {
452        let content = r"Heading
453FIELD_DELIM	TABS
454FPS	24
455
456Column
457Name	Tape	Start	End
458
459Data
460CLIP001	A001	01:00:00:00	01:00:05:00
461CLIP002	A001	01:00:10:00	01:00:15:00
462";
463
464        let edl = parse(content).expect("edl should be valid");
465        assert_eq!(edl.events.len(), 2);
466        assert_eq!(edl.events[0].reel, "A001");
467        assert_eq!(
468            edl.events[0].metadata.get("clip_name"),
469            Some(&"CLIP001".to_string())
470        );
471    }
472
473    #[test]
474    fn test_write_ale() {
475        let mut edl = Edl::new("Test Project".to_string(), Rational::new(24, 1), false);
476
477        let mut metadata = HashMap::new();
478        metadata.insert("clip_name".to_string(), "CLIP001".to_string());
479        metadata.insert("scene".to_string(), "1".to_string());
480        metadata.insert("take".to_string(), "1".to_string());
481
482        let event = EdlEvent {
483            number: 1,
484            reel: "A001".to_string(),
485            track: "V".to_string(),
486            edit_type: EditType::Cut,
487            source_in: Timecode::new(1, 0, 0, 0, false, Rational::new(24, 1)),
488            source_out: Timecode::new(1, 0, 5, 0, false, Rational::new(24, 1)),
489            record_in: Timecode::new(1, 0, 0, 0, false, Rational::new(24, 1)),
490            record_out: Timecode::new(1, 0, 5, 0, false, Rational::new(24, 1)),
491            transition_duration: None,
492            motion_effect: None,
493            comments: Vec::new(),
494            metadata,
495        };
496
497        edl.add_event(event);
498
499        let output = write(&edl).expect("output should be valid");
500        assert!(output.contains("Heading"));
501        assert!(output.contains("Column"));
502        assert!(output.contains("Data"));
503        assert!(output.contains("CLIP001"));
504        assert!(output.contains("A001"));
505    }
506
507    #[test]
508    fn test_ale_custom_columns() {
509        let mut edl = Edl::new("Test".to_string(), Rational::new(24, 1), false);
510
511        let mut metadata = HashMap::new();
512        metadata.insert("clip_name".to_string(), "CLIP001".to_string());
513        metadata.insert("camera".to_string(), "A".to_string());
514
515        let event = EdlEvent {
516            number: 1,
517            reel: "A001".to_string(),
518            track: "V".to_string(),
519            edit_type: EditType::Cut,
520            source_in: Timecode::new(1, 0, 0, 0, false, Rational::new(24, 1)),
521            source_out: Timecode::new(1, 0, 5, 0, false, Rational::new(24, 1)),
522            record_in: Timecode::new(1, 0, 0, 0, false, Rational::new(24, 1)),
523            record_out: Timecode::new(1, 0, 5, 0, false, Rational::new(24, 1)),
524            transition_duration: None,
525            motion_effect: None,
526            comments: Vec::new(),
527            metadata,
528        };
529
530        edl.add_event(event);
531
532        let writer = AleWriter::new().with_columns(vec![
533            "Name".to_string(),
534            "Tape".to_string(),
535            "Camera".to_string(),
536        ]);
537
538        let output = writer.write(&edl).expect("output should be valid");
539        assert!(output.contains("Camera"));
540    }
541}