1use crate::clip::Clip;
20use crate::timeline::{Timeline, TrackType};
21
22#[must_use]
30fn frames_to_tc(frames: i64, fps: i64) -> String {
31 let fps = fps.max(1);
32 let total_frames = frames.max(0);
33 let ff = total_frames % fps;
34 let total_secs = total_frames / fps;
35 let ss = total_secs % 60;
36 let total_mins = total_secs / 60;
37 let mm = total_mins % 60;
38 let hh = total_mins / 60;
39 format!("{hh:02}:{mm:02}:{ss:02}:{ff:02}")
40}
41
42#[must_use]
46#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
47fn units_to_frames(units: i64, timebase_num: i64, timebase_den: i64, fps: f64) -> i64 {
48 let secs = units as f64 * (timebase_num as f64 / timebase_den as f64);
50 (secs * fps).round() as i64
51}
52
53#[derive(Debug, Clone)]
59pub struct ExportClipInfo {
60 pub event_number: u32,
62 pub track_index: usize,
64 pub track_label: String,
66 pub reel_name: String,
68 pub clip_name: String,
70 pub source_in_tc: String,
72 pub source_out_tc: String,
74 pub record_in_tc: String,
76 pub record_out_tc: String,
78 pub speed: f64,
80 pub reverse: bool,
82 pub opacity: f32,
84 pub muted: bool,
86}
87
88pub struct TimelineExporter<'a> {
94 timeline: &'a Timeline,
95 pub title: String,
97 pub fps: f64,
99 pub drop_frame: bool,
101}
102
103impl<'a> TimelineExporter<'a> {
104 #[must_use]
109 pub fn new(timeline: &'a Timeline) -> Self {
110 let fps = timeline.frame_rate.to_f64().max(1.0);
111 Self {
112 timeline,
113 title: "Untitled".to_string(),
114 fps,
115 drop_frame: false,
116 }
117 }
118
119 #[must_use]
121 pub fn with_title(mut self, title: impl Into<String>) -> Self {
122 self.title = title.into();
123 self
124 }
125
126 #[must_use]
128 pub fn with_fps(mut self, fps: f64) -> Self {
129 self.fps = fps.max(1.0);
130 self
131 }
132
133 #[must_use]
135 pub fn with_drop_frame(mut self, drop_frame: bool) -> Self {
136 self.drop_frame = drop_frame;
137 self
138 }
139
140 fn clip_to_tc(&self, units: i64) -> String {
143 let tb = &self.timeline.timebase;
144 let f = units_to_frames(units, tb.num, tb.den, self.fps);
145 frames_to_tc(f, self.fps.round() as i64)
146 }
147
148 fn clip_reel_name(clip: &Clip) -> String {
149 clip.source
150 .as_ref()
151 .and_then(|p| p.file_stem())
152 .and_then(|s| s.to_str())
153 .unwrap_or("AX")
154 .to_string()
155 }
156
157 fn clip_display_name(clip: &Clip) -> String {
158 clip.metadata
159 .name
160 .clone()
161 .unwrap_or_else(|| format!("clip_{}", clip.id))
162 }
163
164 fn track_label(track_type: TrackType) -> &'static str {
165 match track_type {
166 TrackType::Video => "V",
167 TrackType::Audio => "A",
168 TrackType::Subtitle => "SUB",
169 }
170 }
171
172 fn collect_clips(&self) -> Vec<ExportClipInfo> {
175 let mut infos = Vec::new();
176 let mut event_num: u32 = 1;
177
178 for track in &self.timeline.tracks {
179 for clip in &track.clips {
181 let reel = Self::clip_reel_name(clip);
182 let name = Self::clip_display_name(clip);
183 let label = Self::track_label(track.track_type);
184
185 let src_in_tc = self.clip_to_tc(clip.source_in);
186 let src_out_tc = self.clip_to_tc(clip.source_out);
187 let rec_in_tc = self.clip_to_tc(clip.timeline_start);
188 let rec_out_tc = self.clip_to_tc(clip.timeline_end());
189
190 infos.push(ExportClipInfo {
191 event_number: event_num,
192 track_index: track.index,
193 track_label: label.to_string(),
194 reel_name: reel,
195 clip_name: name,
196 source_in_tc: src_in_tc,
197 source_out_tc: src_out_tc,
198 record_in_tc: rec_in_tc,
199 record_out_tc: rec_out_tc,
200 speed: clip.speed,
201 reverse: clip.reverse,
202 opacity: clip.opacity,
203 muted: clip.muted,
204 });
205 event_num += 1;
206 }
207 }
208
209 infos
210 }
211
212 #[must_use]
226 pub fn export_edl(&self) -> String {
227 let clips = self.collect_clips();
228 let mut out = String::new();
229
230 out.push_str(&format!("TITLE: {}\n", self.title));
232 let fcm = if self.drop_frame {
233 "DROP FRAME"
234 } else {
235 "NON-DROP FRAME"
236 };
237 out.push_str(&format!("FCM: {fcm}\n\n"));
238
239 for info in &clips {
240 out.push_str(&format!(
242 "{:03} {:<8} {:<5} C {} {} {} {}\n",
243 info.event_number,
244 info.reel_name,
245 info.track_label,
246 info.source_in_tc,
247 info.source_out_tc,
248 info.record_in_tc,
249 info.record_out_tc,
250 ));
251
252 out.push_str(&format!("* FROM CLIP NAME: {}\n", info.clip_name));
254
255 if (info.speed - 1.0).abs() > 1e-6 || info.reverse {
257 let speed_code = if info.reverse {
258 -info.speed.abs()
259 } else {
260 info.speed
261 };
262 out.push_str(&format!(
263 "M2 {:<8} {:03} {}\n",
264 info.reel_name,
265 (speed_code * 100.0).round() as i32,
266 info.record_in_tc,
267 ));
268 if info.reverse {
269 out.push_str("* REVERSE MOTION\n");
270 }
271 }
272
273 if info.muted {
275 out.push_str("* MUTED\n");
276 }
277
278 out.push('\n');
279 }
280
281 out
282 }
283
284 #[must_use]
291 pub fn export_xml(&self) -> String {
292 let clips = self.collect_clips();
293 let fps_int = self.fps.round() as u32;
294 let tb = &self.timeline.timebase;
295 let tb_den = tb.den;
297 let total_tc = self.clip_to_tc(self.timeline.duration);
298
299 let mut out = String::new();
300 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
301 out.push_str("<!DOCTYPE xmeml>\n");
302 out.push_str("<xmeml version=\"5\">\n");
303 out.push_str(" <sequence>\n");
304 out.push_str(&format!(" <name>{}</name>\n", xml_escape(&self.title)));
305 out.push_str(&format!(" <duration>{tb_den}</duration>\n"));
306 out.push_str(" <rate>\n");
307 out.push_str(&format!(" <timebase>{fps_int}</timebase>\n"));
308 out.push_str(&format!(
309 " <ntsc>{}</ntsc>\n",
310 if self.drop_frame { "TRUE" } else { "FALSE" }
311 ));
312 out.push_str(" </rate>\n");
313 out.push_str(" <timecode>\n");
314 out.push_str(&format!(" <string>{total_tc}</string>\n"));
315 out.push_str(" </timecode>\n");
316 out.push_str(" <media>\n");
317
318 let video_clips: Vec<&ExportClipInfo> =
320 clips.iter().filter(|c| c.track_label == "V").collect();
321
322 if !video_clips.is_empty() {
323 out.push_str(" <video>\n");
324 out.push_str(" <track>\n");
325 for info in &video_clips {
326 write_xml_clip_item(&mut out, info, self.fps, tb.num, tb.den);
327 }
328 out.push_str(" </track>\n");
329 out.push_str(" </video>\n");
330 }
331
332 let audio_clips: Vec<&ExportClipInfo> =
334 clips.iter().filter(|c| c.track_label == "A").collect();
335
336 if !audio_clips.is_empty() {
337 out.push_str(" <audio>\n");
338 out.push_str(" <track>\n");
339 for info in &audio_clips {
340 write_xml_clip_item(&mut out, info, self.fps, tb.num, tb.den);
341 }
342 out.push_str(" </track>\n");
343 out.push_str(" </audio>\n");
344 }
345
346 out.push_str(" </media>\n");
347 out.push_str(" </sequence>\n");
348 out.push_str("</xmeml>\n");
349
350 out
351 }
352
353 #[must_use]
358 pub fn export_csv(&self) -> String {
359 let clips = self.collect_clips();
360 let mut out = String::new();
361
362 out.push_str(
364 "Event,Track,Reel,Name,SourceIn,SourceOut,RecordIn,RecordOut,Speed,Reverse,Opacity,Muted\n",
365 );
366
367 for info in &clips {
368 out.push_str(&format!(
369 "{},{},{},{},{},{},{},{},{:.6},{},{:.4},{}\n",
370 info.event_number,
371 info.track_label,
372 csv_escape(&info.reel_name),
373 csv_escape(&info.clip_name),
374 info.source_in_tc,
375 info.source_out_tc,
376 info.record_in_tc,
377 info.record_out_tc,
378 info.speed,
379 info.reverse,
380 info.opacity,
381 info.muted,
382 ));
383 }
384
385 out
386 }
387}
388
389fn xml_escape(s: &str) -> String {
394 s.replace('&', "&")
395 .replace('<', "<")
396 .replace('>', ">")
397 .replace('"', """)
398 .replace('\'', "'")
399}
400
401fn csv_escape(s: &str) -> String {
402 if s.contains(',') || s.contains('"') || s.contains('\n') {
403 format!("\"{}\"", s.replace('"', "\"\""))
404 } else {
405 s.to_string()
406 }
407}
408
409#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
410fn write_xml_clip_item(
411 out: &mut String,
412 info: &ExportClipInfo,
413 fps: f64,
414 tb_num: i64,
415 tb_den: i64,
416) {
417 let rec_in_frames = tc_to_frames(&info.record_in_tc, fps.round() as i64);
420 let rec_out_frames = tc_to_frames(&info.record_out_tc, fps.round() as i64);
421 let src_in_frames = tc_to_frames(&info.source_in_tc, fps.round() as i64);
422 let src_out_frames = tc_to_frames(&info.source_out_tc, fps.round() as i64);
423 let duration_frames = (rec_out_frames - rec_in_frames).max(0);
424
425 out.push_str(" <clipitem>\n");
426 out.push_str(&format!(
427 " <name>{}</name>\n",
428 xml_escape(&info.clip_name)
429 ));
430 out.push_str(&format!(
431 " <duration>{duration_frames}</duration>\n"
432 ));
433 out.push_str(&format!(" <in>{src_in_frames}</in>\n"));
434 out.push_str(&format!(" <out>{src_out_frames}</out>\n"));
435 out.push_str(&format!(" <start>{rec_in_frames}</start>\n"));
436 out.push_str(&format!(" <end>{rec_out_frames}</end>\n"));
437 out.push_str(&format!(" <speed>{:.6}</speed>\n", info.speed));
438 if info.reverse {
439 out.push_str(" <reverse>TRUE</reverse>\n");
440 }
441 out.push_str(&format!(
442 " <opacity>{:.4}</opacity>\n",
443 info.opacity
444 ));
445 if info.muted {
446 out.push_str(" <enabled>FALSE</enabled>\n");
447 }
448 out.push_str(" <file>\n");
450 out.push_str(&format!(
451 " <name>{}</name>\n",
452 xml_escape(&info.reel_name)
453 ));
454 out.push_str(" </file>\n");
455 out.push_str(" </clipitem>\n");
456
457 let _ = (fps, tb_num, tb_den); }
459
460#[must_use]
462fn tc_to_frames(tc: &str, fps: i64) -> i64 {
463 let parts: Vec<&str> = tc.split(&[':', ';'][..]).collect();
464 if parts.len() != 4 {
465 return 0;
466 }
467 let hh: i64 = parts[0].parse().unwrap_or(0);
468 let mm: i64 = parts[1].parse().unwrap_or(0);
469 let ss: i64 = parts[2].parse().unwrap_or(0);
470 let ff: i64 = parts[3].parse().unwrap_or(0);
471 hh * 3600 * fps + mm * 60 * fps + ss * fps + ff
472}
473
474#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::clip::{Clip, ClipType};
482 use crate::timeline::{Timeline, TrackType};
483 use oximedia_core::Rational;
484
485 fn build_test_timeline() -> Timeline {
486 let mut tl = Timeline::new(
487 Rational::new(1, 1000), Rational::new(30, 1), );
490
491 let vt = tl.add_track(TrackType::Video);
493 let c1 = Clip::new(0, ClipType::Video, 0, 5000); let c2 = Clip::new(0, ClipType::Video, 5000, 3000); let _ = tl.add_clip(vt, c1);
496 let _ = tl.add_clip(vt, c2);
497
498 let at = tl.add_track(TrackType::Audio);
500 let a1 = Clip::new(0, ClipType::Audio, 0, 8000); let _ = tl.add_clip(at, a1);
502
503 tl
504 }
505
506 #[test]
509 fn test_frames_to_tc_zero() {
510 assert_eq!(frames_to_tc(0, 30), "00:00:00:00");
511 }
512
513 #[test]
514 fn test_frames_to_tc_one_hour() {
515 assert_eq!(frames_to_tc(108_000, 30), "01:00:00:00");
517 }
518
519 #[test]
520 fn test_frames_to_tc_compound() {
521 let f = 111_694_i64;
523 assert_eq!(frames_to_tc(f, 30), "01:02:03:04");
524 }
525
526 #[test]
527 fn test_frames_to_tc_roundtrip() {
528 let tc = "00:10:30:15";
529 let f = tc_to_frames(tc, 30);
530 assert_eq!(frames_to_tc(f, 30), tc);
531 }
532
533 #[test]
536 fn test_export_edl_has_title() {
537 let tl = build_test_timeline();
538 let exporter = TimelineExporter::new(&tl).with_title("TestProject");
539 let edl = exporter.export_edl();
540 assert!(edl.contains("TITLE: TestProject"), "missing TITLE");
541 }
542
543 #[test]
544 fn test_export_edl_has_fcm() {
545 let tl = build_test_timeline();
546 let edl = TimelineExporter::new(&tl).export_edl();
547 assert!(edl.contains("FCM:"), "missing FCM line");
548 }
549
550 #[test]
551 fn test_export_edl_event_count() {
552 let tl = build_test_timeline();
553 let edl = TimelineExporter::new(&tl).export_edl();
554 let event_count = edl
556 .lines()
557 .filter(|l| l.starts_with("001") || l.starts_with("002") || l.starts_with("003"))
558 .count();
559 assert_eq!(event_count, 3, "expected 3 events");
560 }
561
562 #[test]
563 fn test_export_edl_drop_frame_mode() {
564 let tl = build_test_timeline();
565 let edl = TimelineExporter::new(&tl)
566 .with_drop_frame(true)
567 .export_edl();
568 assert!(edl.contains("FCM: DROP FRAME"));
569 }
570
571 #[test]
572 fn test_export_edl_clip_name_comments() {
573 let tl = build_test_timeline();
574 let edl = TimelineExporter::new(&tl).export_edl();
575 assert!(
576 edl.contains("* FROM CLIP NAME:"),
577 "missing clip name comment"
578 );
579 }
580
581 #[test]
582 fn test_export_edl_track_label_video() {
583 let tl = build_test_timeline();
584 let edl = TimelineExporter::new(&tl).export_edl();
585 assert!(
586 edl.contains(" V ") || edl.contains(" V "),
587 "missing video track label"
588 );
589 }
590
591 #[test]
592 fn test_export_edl_track_label_audio() {
593 let tl = build_test_timeline();
594 let edl = TimelineExporter::new(&tl).export_edl();
595 assert!(
596 edl.contains(" A ") || edl.contains(" A "),
597 "missing audio track label"
598 );
599 }
600
601 #[test]
604 fn test_export_xml_has_xmeml_root() {
605 let tl = build_test_timeline();
606 let xml = TimelineExporter::new(&tl).export_xml();
607 assert!(xml.contains("<xmeml"), "missing <xmeml> root");
608 assert!(xml.contains("</xmeml>"), "missing </xmeml>");
609 }
610
611 #[test]
612 fn test_export_xml_has_sequence_name() {
613 let tl = build_test_timeline();
614 let xml = TimelineExporter::new(&tl).with_title("MySeq").export_xml();
615 assert!(xml.contains("<name>MySeq</name>"), "missing sequence name");
616 }
617
618 #[test]
619 fn test_export_xml_has_video_block() {
620 let tl = build_test_timeline();
621 let xml = TimelineExporter::new(&tl).export_xml();
622 assert!(xml.contains("<video>"), "missing <video> block");
623 }
624
625 #[test]
626 fn test_export_xml_has_audio_block() {
627 let tl = build_test_timeline();
628 let xml = TimelineExporter::new(&tl).export_xml();
629 assert!(xml.contains("<audio>"), "missing <audio> block");
630 }
631
632 #[test]
633 fn test_export_xml_clipitem_count() {
634 let tl = build_test_timeline();
635 let xml = TimelineExporter::new(&tl).export_xml();
636 let count = xml.matches("<clipitem>").count();
637 assert_eq!(count, 3, "expected 3 clipitems");
638 }
639
640 #[test]
641 fn test_export_xml_escapes_special_chars() {
642 let mut tl = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
643 let vt = tl.add_track(TrackType::Video);
644 let mut clip = Clip::new(0, ClipType::Video, 0, 1000);
645 clip.metadata.name = Some("Clip & <test>".to_string());
646 let _ = tl.add_clip(vt, clip);
647
648 let xml = TimelineExporter::new(&tl).export_xml();
649 assert!(xml.contains("&"), "ampersand not escaped");
650 assert!(xml.contains("<"), "< not escaped");
651 }
652
653 #[test]
656 fn test_export_csv_has_header() {
657 let tl = build_test_timeline();
658 let csv = TimelineExporter::new(&tl).export_csv();
659 let first_line = csv.lines().next().unwrap_or("");
660 assert!(
661 first_line.starts_with("Event,Track,Reel,Name,"),
662 "bad CSV header"
663 );
664 }
665
666 #[test]
667 fn test_export_csv_row_count() {
668 let tl = build_test_timeline();
669 let csv = TimelineExporter::new(&tl).export_csv();
670 let rows: Vec<&str> = csv.lines().collect();
672 assert_eq!(rows.len(), 4, "expected 4 rows (header + 3 clips)");
673 }
674
675 #[test]
676 fn test_export_csv_speed_column() {
677 let tl = build_test_timeline();
678 let csv = TimelineExporter::new(&tl).export_csv();
679 assert!(csv.contains("1.000000"), "speed column should contain 1.0");
681 }
682
683 #[test]
684 fn test_export_csv_muted_flag() {
685 let mut tl = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
686 let vt = tl.add_track(TrackType::Video);
687 let mut c = Clip::new(0, ClipType::Video, 0, 1000);
688 c.muted = true;
689 let _ = tl.add_clip(vt, c);
690
691 let csv = TimelineExporter::new(&tl).export_csv();
692 assert!(csv.contains(",true"), "muted=true should appear in CSV");
693 }
694
695 #[test]
696 fn test_export_csv_escapes_comma_in_name() {
697 let mut tl = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
698 let vt = tl.add_track(TrackType::Video);
699 let mut c = Clip::new(0, ClipType::Video, 0, 1000);
700 c.metadata.name = Some("hello, world".to_string());
701 let _ = tl.add_clip(vt, c);
702
703 let csv = TimelineExporter::new(&tl).export_csv();
704 assert!(csv.contains("\"hello, world\""), "comma in name not quoted");
705 }
706
707 #[test]
710 fn test_collect_clips_event_numbers_sequential() {
711 let tl = build_test_timeline();
712 let exporter = TimelineExporter::new(&tl);
713 let clips = exporter.collect_clips();
714 for (i, c) in clips.iter().enumerate() {
715 assert_eq!(c.event_number, (i + 1) as u32);
716 }
717 }
718
719 #[test]
722 fn test_xml_escape_all_chars() {
723 let s = r#"<"'>&"#;
724 let escaped = xml_escape(s);
725 assert!(!escaped.contains('<'));
726 assert!(!escaped.contains('>'));
727 assert!(!escaped.contains('"'));
728 assert!(!escaped.contains('\''));
729 assert!(!escaped.contains('&') || escaped.contains("&"));
730 }
731}