1use 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("--", "- -"); }
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 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
118fn write_filters(x: &mut String, project: &Project, c: &Clip) {
120 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("&"),
157 '<' => o.push_str("<"),
158 '>' => o.push_str(">"),
159 '"' => o.push_str("""),
160 '\'' => o.push_str("'"),
161 _ => o.push(ch),
162 }
163 }
164 o
165}
166
167fn 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 & 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); assert_eq!(count(&x, "<clipitem id="), 6); let fid = p.assets[0].id;
235 assert_eq!(count(&x, &format!("<file id=\"file-{fid}\">")), 1); 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 assert!(x.contains("<start>120</start>"), "{x}");
243 assert!(x.ends_with("</xmeml>\n"));
244 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 assert_eq!(pathurl("\\\\nas\\share\\a b\\v.mp4"), "file://nas/share/a%20b/v.mp4");
253 }
254}