Skip to main content

oximedia_edit/
export.rs

1//! Timeline export: CMX3600 EDL, FCP-compatible XML, and CSV clip-list.
2//!
3//! [`TimelineExporter`] wraps a [`Timeline`] reference and provides three
4//! serialisation methods that do not require I/O — they return `String` so the
5//! caller can write to a file, send over a network, etc.
6//!
7//! # Supported formats
8//!
9//! | Method | Format |
10//! |---|---|
11//! | [`export_edl`] | CMX 3600 (industry-standard linear EDL) |
12//! | [`export_xml`] | Basic FCP 7-style XML |
13//! | [`export_csv`] | Simple CSV clip list |
14//!
15//! [`export_edl`]: TimelineExporter::export_edl
16//! [`export_xml`]: TimelineExporter::export_xml
17//! [`export_csv`]: TimelineExporter::export_csv
18
19use crate::clip::Clip;
20use crate::timeline::{Timeline, TrackType};
21
22// ─────────────────────────────────────────────────────────────────────────────
23// Helpers
24// ─────────────────────────────────────────────────────────────────────────────
25
26/// Format a raw frame count as a SMPTE timecode string `HH:MM:SS:FF`.
27///
28/// `fps` is the frames-per-second denominator (e.g. 30).
29#[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/// Convert timeline units (milliseconds at default timebase) to frame count.
43///
44/// `timebase_num / timebase_den` gives seconds-per-unit.
45#[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    // seconds = units * (timebase_num / timebase_den)
49    let secs = units as f64 * (timebase_num as f64 / timebase_den as f64);
50    (secs * fps).round() as i64
51}
52
53// ─────────────────────────────────────────────────────────────────────────────
54// ExportClipInfo  (flattened view of a Clip used by all exporters)
55// ─────────────────────────────────────────────────────────────────────────────
56
57/// Flattened, serialisable view of one clip suitable for all export formats.
58#[derive(Debug, Clone)]
59pub struct ExportClipInfo {
60    /// Sequential 1-based event number.
61    pub event_number: u32,
62    /// Track index (0-based).
63    pub track_index: usize,
64    /// Track type label: `"V"`, `"A"`, or `"SUB"`.
65    pub track_label: String,
66    /// Reel / source name (file stem or "AX" if unknown).
67    pub reel_name: String,
68    /// Clip display name.
69    pub clip_name: String,
70    /// Source in-point as SMPTE timecode.
71    pub source_in_tc: String,
72    /// Source out-point as SMPTE timecode.
73    pub source_out_tc: String,
74    /// Record in-point (timeline) as SMPTE timecode.
75    pub record_in_tc: String,
76    /// Record out-point (timeline) as SMPTE timecode.
77    pub record_out_tc: String,
78    /// Speed multiplier (1.0 = normal).
79    pub speed: f64,
80    /// Reverse flag.
81    pub reverse: bool,
82    /// Clip opacity / volume (0.0–1.0).
83    pub opacity: f32,
84    /// Muted flag.
85    pub muted: bool,
86}
87
88// ─────────────────────────────────────────────────────────────────────────────
89// TimelineExporter
90// ─────────────────────────────────────────────────────────────────────────────
91
92/// Exports a [`Timeline`] to various interchange formats.
93pub struct TimelineExporter<'a> {
94    timeline: &'a Timeline,
95    /// Title embedded in EDL and XML headers.
96    pub title: String,
97    /// Nominal frame rate used for timecode arithmetic (default: 30).
98    pub fps: f64,
99    /// Drop-frame mode for CMX 3600 timecodes.
100    pub drop_frame: bool,
101}
102
103impl<'a> TimelineExporter<'a> {
104    /// Create an exporter for `timeline`.
105    ///
106    /// The frame rate is derived from `timeline.frame_rate`; the title
107    /// defaults to `"Untitled"`.
108    #[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    /// Override the title.
120    #[must_use]
121    pub fn with_title(mut self, title: impl Into<String>) -> Self {
122        self.title = title.into();
123        self
124    }
125
126    /// Override the frames-per-second value.
127    #[must_use]
128    pub fn with_fps(mut self, fps: f64) -> Self {
129        self.fps = fps.max(1.0);
130        self
131    }
132
133    /// Enable drop-frame mode in EDL output.
134    #[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    // ── Internal helpers ──────────────────────────────────────────────────
141
142    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    /// Collect all clips from the timeline in chronological order per track,
173    /// assigning sequential event numbers.
174    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            // Clips are already sorted by timeline_start
180            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    // ── Public export methods ─────────────────────────────────────────────
213
214    /// Export as CMX 3600 EDL.
215    ///
216    /// The output follows the standard header-then-events layout:
217    ///
218    /// ```text
219    /// TITLE: My Project
220    /// FCM: NON-DROP FRAME
221    ///
222    /// 001  AX       V     C        01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00
223    /// * FROM CLIP NAME: clip_1
224    /// ```
225    #[must_use]
226    pub fn export_edl(&self) -> String {
227        let clips = self.collect_clips();
228        let mut out = String::new();
229
230        // Header
231        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            // Event line
241            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            // Clip name comment
253            out.push_str(&format!("* FROM CLIP NAME: {}\n", info.clip_name));
254
255            // Speed / motion effects
256            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            // Mute comment
274            if info.muted {
275                out.push_str("* MUTED\n");
276            }
277
278            out.push('\n');
279        }
280
281        out
282    }
283
284    /// Export as a basic FCP 7-compatible XML string.
285    ///
286    /// The structure is intentionally minimal: `<xmeml>` → `<sequence>` →
287    /// `<media>` → one `<video>` block and one `<audio>` block each containing
288    /// `<track>` elements.  The format is readable by most NLEs that support
289    /// FCP XML.
290    #[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        // timebase denominator (e.g. 1000 for ms)
296        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        // ── video ─────────────────────────────────────────────────────────
319        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        // ── audio ─────────────────────────────────────────────────────────
333        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    /// Export as a CSV clip list.
354    ///
355    /// The header row is:
356    /// `Event,Track,Reel,Name,SourceIn,SourceOut,RecordIn,RecordOut,Speed,Reverse,Opacity,Muted`
357    #[must_use]
358    pub fn export_csv(&self) -> String {
359        let clips = self.collect_clips();
360        let mut out = String::new();
361
362        // Header
363        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
389// ─────────────────────────────────────────────────────────────────────────────
390// XML helpers
391// ─────────────────────────────────────────────────────────────────────────────
392
393fn xml_escape(s: &str) -> String {
394    s.replace('&', "&amp;")
395        .replace('<', "&lt;")
396        .replace('>', "&gt;")
397        .replace('"', "&quot;")
398        .replace('\'', "&apos;")
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    // Convert SMPTE timecodes back to raw frame counts for `<in>` / `<out>` tags.
418    // We use the record timecodes as a start/end pair in the sequence.
419    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    // Reel / file reference
449    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); // suppress unused warnings
458}
459
460/// Parse a SMPTE timecode string `HH:MM:SS:FF` (colon or semicolon) to frames.
461#[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// ─────────────────────────────────────────────────────────────────────────────
475// Tests
476// ─────────────────────────────────────────────────────────────────────────────
477
478#[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), // 1ms timebase
488            Rational::new(30, 1),   // 30 fps
489        );
490
491        // video track
492        let vt = tl.add_track(TrackType::Video);
493        let c1 = Clip::new(0, ClipType::Video, 0, 5000); // 0..5s
494        let c2 = Clip::new(0, ClipType::Video, 5000, 3000); // 5..8s
495        let _ = tl.add_clip(vt, c1);
496        let _ = tl.add_clip(vt, c2);
497
498        // audio track
499        let at = tl.add_track(TrackType::Audio);
500        let a1 = Clip::new(0, ClipType::Audio, 0, 8000); // 0..8s
501        let _ = tl.add_clip(at, a1);
502
503        tl
504    }
505
506    // ── frames_to_tc ─────────────────────────────────────────────────────
507
508    #[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        // 1h = 3600 s * 30 fps = 108_000 frames
516        assert_eq!(frames_to_tc(108_000, 30), "01:00:00:00");
517    }
518
519    #[test]
520    fn test_frames_to_tc_compound() {
521        // 01:02:03:04 → 1*3600*30 + 2*60*30 + 3*30 + 4 = 108_000 + 3_600 + 90 + 4 = 111_694
522        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    // ── export_edl ────────────────────────────────────────────────────────
534
535    #[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        // 2 video clips + 1 audio clip = 3 events
555        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    // ── export_xml ────────────────────────────────────────────────────────
602
603    #[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("&amp;"), "ampersand not escaped");
650        assert!(xml.contains("&lt;"), "< not escaped");
651    }
652
653    // ── export_csv ────────────────────────────────────────────────────────
654
655    #[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        // 1 header + 3 clip rows
671        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        // default speed is 1.0
680        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    // ── collect_clips ordering ────────────────────────────────────────────
708
709    #[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    // ── xml_escape helper ─────────────────────────────────────────────────
720
721    #[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("&amp;"));
730    }
731}