simple_editor\engine/
xmeml.rs

1//! Final Cut Pro 7 XML (xmeml v5) exporter — the interchange format both Premiere Pro
2//! (File → Import) and DaVinci Resolve (File → Import → Timeline) read.
3//! Video tracks (with clipitems: file path, in/out/start/end in frames, opacity/position if animated)
4//! and audio tracks (one per project audio track, channel-source by stream). Text clips are exported as
5//! a generator-less gap (Premiere/Resolve text isn't interchangeable) — mention it in a comment.
6//! Paths as `file://localhost/C:/...` pathurls. Timebase = round(fps), NTSC flag when fps is fractional.
7
8use crate::model::{Asset, Clip, ClipKind, Project, TrackKind};
9use std::collections::HashSet;
10use std::fmt::Write;
11
12pub fn export_xmeml(project: &Project) -> String {
13    let fps = project.fps.max(1.0);
14    let fr = |t: f64| (t * fps).round() as i64;
15    let rate = format!(
16        "<rate><timebase>{}</timebase><ntsc>{}</ntsc></rate>",
17        fps.round() as i64,
18        if (fps - fps.round()).abs() > 1e-3 { "TRUE" } else { "FALSE" }
19    );
20    let mut x = String::with_capacity(4096);
21    x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"5\">\n");
22    x.push_str("<sequence id=\"sequence-1\">\n");
23    let _ = writeln!(x, "<name>{}</name>", esc(&project.name));
24    let _ = writeln!(x, "<duration>{}</duration>\n{rate}", fr(project.duration()));
25    let _ = writeln!(
26        x,
27        "<timecode>{rate}<string>00:00:00:00</string><frame>0</frame><displayformat>NDF</displayformat></timecode>"
28    );
29    x.push_str("<media>\n<video>\n");
30    let _ = writeln!(
31        x,
32        "<format><samplecharacteristics><width>{}</width><height>{}</height>{rate}<pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>",
33        project.width, project.height
34    );
35    let mut defined: HashSet<u64> = HashSet::new();
36    let mut n_item = 0;
37    for kind in [TrackKind::Video, TrackKind::Audio] {
38        if kind == TrackKind::Audio {
39            x.push_str("</video>\n<audio>\n");
40            x.push_str("<format><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics></format>\n");
41        }
42        for (ti, track) in project.tracks.iter().enumerate().filter(|(_, t)| t.kind == kind) {
43            x.push_str("<track>\n");
44            for c in &track.clips {
45                let Some(asset) = project.asset(c.asset).filter(|_| c.uses_asset()) else {
46                    let mut name = esc(&c.name);
47                    while name.contains("--") {
48                        name = name.replace("--", "- -"); // "--" is illegal inside an XML comment
49                    }
50                    let _ = writeln!(
51                        x,
52                        "<!-- {} clip \"{}\" at {}..{} skipped (not interchangeable) -->",
53                        if c.kind == ClipKind::Text { "text" } else { "unresolved" },
54                        name,
55                        fr(c.start),
56                        fr(c.end())
57                    );
58                    continue;
59                };
60                n_item += 1;
61                let src_dur = if asset.kind == ClipKind::Image { c.duration } else { asset.duration };
62                let _ = writeln!(x, "<clipitem id=\"clipitem-{n_item}\">\n<name>{}</name>", esc(&c.name));
63                let _ =
64                    writeln!(x, "<enabled>{}</enabled>\n<duration>{}</duration>\n{rate}", tf(c.enabled), fr(src_dur));
65                let _ = writeln!(
66                    x,
67                    "<start>{}</start>\n<end>{}</end>\n<in>{}</in>\n<out>{}</out>",
68                    fr(c.start),
69                    fr(c.end()),
70                    fr(c.src_in),
71                    fr(c.src_end())
72                );
73                if defined.insert(asset.id) {
74                    write_file(&mut x, asset, &rate, fr(src_dur));
75                } else {
76                    let _ = writeln!(x, "<file id=\"file-{}\"/>", asset.id);
77                }
78                if kind == TrackKind::Video {
79                    write_filters(&mut x, project, c);
80                } else {
81                    let _ = writeln!(
82                        x,
83                        "<sourcetrack><mediatype>audio</mediatype><trackindex>{}</trackindex></sourcetrack>",
84                        c.audio_stream + 1
85                    );
86                }
87                x.push_str("</clipitem>\n");
88            }
89            let _ = writeln!(x, "<enabled>{}</enabled>\n<locked>FALSE</locked>\n</track>", tf(project.active(ti)));
90        }
91    }
92    x.push_str("</audio>\n</media>\n</sequence>\n</xmeml>\n");
93    x
94}
95
96fn write_file(x: &mut String, asset: &Asset, rate: &str, frames: i64) {
97    let _ = writeln!(
98        x,
99        "<file id=\"file-{}\">\n<name>{}</name>\n<pathurl>{}</pathurl>\n{rate}\n<duration>{frames}</duration>\n<media>",
100        asset.id,
101        esc(&asset.name()),
102        esc(&pathurl(&asset.path))
103    );
104    if asset.has_video() {
105        let _ = writeln!(
106            x,
107            "<video><samplecharacteristics><width>{}</width><height>{}</height>{rate}</samplecharacteristics></video>",
108            asset.width, asset.height
109        );
110    }
111    if !asset.audio_streams.is_empty() {
112        // ponytail: every stream reported as one stereo track — FCP XML has no notion of separate streams
113        x.push_str("<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>\n");
114    }
115    x.push_str("</media>\n</file>\n");
116}
117
118/// Static opacity / basic motion when non-default (keyframes flattened to the clip-start value).
119fn write_filters(x: &mut String, project: &Project, c: &Clip) {
120    // ponytail: animated properties are written as their value at clip start — emit <keyframe>s if needed
121    if !c.opacity.is_default(1.0) {
122        let _ = writeln!(
123            x,
124            "<filter><effect><name>Opacity</name><effectid>opacity</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>\
125<parameter><parameterid>opacity</parameterid><name>opacity</name><valuemin>0</valuemin><valuemax>100</valuemax><value>{}</value></parameter></effect></filter>",
126            (c.opacity.at(0.0) * 100.0).clamp(0.0, 100.0)
127        );
128    }
129    if !c.scale.is_default(1.0) || !c.x.is_default(0.0) || !c.y.is_default(0.0) || !c.rotation.is_default(0.0) {
130        let _ = writeln!(
131            x,
132            "<filter><effect><name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>\
133<parameter><parameterid>scale</parameterid><name>Scale</name><valuemin>0</valuemin><valuemax>1000</valuemax><value>{}</value></parameter>\
134<parameter><parameterid>rotation</parameterid><name>Rotation</name><valuemin>-8640</valuemin><valuemax>8640</valuemax><value>{}</value></parameter>\
135<parameter><parameterid>center</parameterid><name>Center</name><value><horiz>{}</horiz><vert>{}</vert></value></parameter></effect></filter>",
136            c.scale.at(0.0) * 100.0,
137            c.rotation.at(0.0),
138            c.x.at(0.0) / project.width.max(1) as f64,
139            c.y.at(0.0) / project.height.max(1) as f64
140        );
141    }
142}
143
144fn tf(b: bool) -> &'static str {
145    if b {
146        "TRUE"
147    } else {
148        "FALSE"
149    }
150}
151
152fn esc(s: &str) -> String {
153    let mut o = String::with_capacity(s.len());
154    for ch in s.chars() {
155        match ch {
156            '&' => o.push_str("&amp;"),
157            '<' => o.push_str("&lt;"),
158            '>' => o.push_str("&gt;"),
159            '"' => o.push_str("&quot;"),
160            '\'' => o.push_str("&apos;"),
161            _ => o.push(ch),
162        }
163    }
164    o
165}
166
167/// `C:\a b\v.mp4` → `file://localhost/C:/a%20b/v.mp4`; UNC `\\host\share\v.mp4` → `file://host/share/v.mp4`
168fn pathurl(path: &str) -> String {
169    let p = path.replace('\\', "/");
170    let (mut o, p) = match p.strip_prefix("//") {
171        Some(unc) => (String::from("file://"), unc),
172        None => (String::from("file://localhost/"), p.trim_start_matches('/')),
173    };
174    for b in p.bytes() {
175        match b {
176            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => o.push(b as char),
177            _ => {
178                let _ = write!(o, "%{b:02X}");
179            }
180        }
181    }
182    o
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::model::AudioStreamInfo;
189
190    fn asset() -> Asset {
191        Asset {
192            id: 0,
193            path: "C:\\My Videos\\clip & co.mp4".into(),
194            kind: ClipKind::Video,
195            duration: 10.0,
196            width: 1280,
197            height: 720,
198            fps: 29.97,
199            audio_streams: (0..2)
200                .map(|i| AudioStreamInfo { index: i, channels: 2, sample_rate: 48000, ..Default::default() })
201                .collect(),
202            codec: "h264".into(),
203            folder: String::new(),
204            tags: Vec::new(),
205            label: 0,
206            description: String::new(),
207        }
208    }
209
210    fn count(s: &str, pat: &str) -> usize {
211        s.matches(pat).count()
212    }
213
214    #[test]
215    fn xmeml_shape() {
216        let mut p = Project::from_media(asset());
217        p.split_at(4.0, None);
218        p.add_text_clip(1.0, 2.0);
219        p.tracks[0].clips[0].opacity.value = 0.5;
220        p.tracks[0].clips[1].scale.value = 1.5;
221        let x = export_xmeml(&p);
222        assert!(x.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"5\">"));
223        assert!(x.contains("<pathurl>file://localhost/C:/My%20Videos/clip%20%26%20co.mp4</pathurl>"), "{x}");
224        assert!(x.contains("<name>clip &amp; co.mp4</name>"));
225        assert!(x.contains("<timebase>30</timebase><ntsc>TRUE</ntsc>"));
226        for tag in
227            ["track", "clipitem", "sequence", "media", "video", "audio", "filter", "xmeml", "effect", "parameter"]
228        {
229            let open = count(&x, &format!("<{tag}>")) + count(&x, &format!("<{tag} "));
230            assert_eq!(open, count(&x, &format!("</{tag}>")), "{tag}");
231        }
232        assert_eq!(count(&x, "<track>"), 4); // V1 V2(text) A1 A2
233        assert_eq!(count(&x, "<clipitem id="), 6); // 2 video + 2×2 audio
234        let fid = p.assets[0].id;
235        assert_eq!(count(&x, &format!("<file id=\"file-{fid}\">")), 1); // defined once
236        assert_eq!(count(&x, &format!("<file id=\"file-{fid}\"/>")), 5);
237        assert!(x.contains("<!-- text clip \"Text\" at 30..90 skipped"));
238        assert!(x.contains("<effectid>opacity</effectid>") && x.contains("<value>50</value>"));
239        assert!(x.contains("<effectid>basic</effectid>") && x.contains("<value>150</value>"));
240        assert!(x.contains("<trackindex>2</trackindex>"));
241        // second clip: start 4 s → frame 120 at 29.97
242        assert!(x.contains("<start>120</start>"), "{x}");
243        assert!(x.ends_with("</xmeml>\n"));
244        // "--" never appears inside the skipped-clip comment
245        for c in p.tracks.iter_mut().flat_map(|t| t.clips.iter_mut()).filter(|c| c.kind == ClipKind::Text) {
246            c.name = "Intro --- v2".into();
247        }
248        let x = export_xmeml(&p);
249        assert!(!x.contains("---") && !x.contains("\"Intro --"), "{x}");
250        assert!(x.contains("clip \"Intro - - - v2\""), "{x}");
251        // UNC paths keep their host
252        assert_eq!(pathurl("\\\\nas\\share\\a b\\v.mp4"), "file://nas/share/a%20b/v.mp4");
253    }
254}