1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
//! XML importer for Final Cut Pro, Premiere, and Resolve timelines.
use crate::error::{ConformError, ConformResult};
use crate::importers::fcpxml::FcpxmlParser;
use crate::importers::TimelineImporter;
use crate::types::{ClipReference, FrameRate, Timecode, TrackType};
use std::path::Path;
/// XML importer for various NLE formats.
pub struct XmlImporter {
/// XML format type.
format: XmlFormat,
}
/// XML format type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum XmlFormat {
/// Final Cut Pro XML.
FcpXml,
/// Adobe Premiere Pro XML.
PremiereXml,
/// `DaVinci` Resolve XML.
ResolveXml,
/// Auto-detect format.
Auto,
}
impl XmlImporter {
/// Create a new XML importer with the specified format.
#[must_use]
pub const fn new(format: XmlFormat) -> Self {
Self { format }
}
/// Detect XML format from content by sniffing well-known root-element
/// signatures.
///
/// Returns [`XmlFormat::FcpXml`]/[`XmlFormat::PremiereXml`] when a
/// confident signature is found. Returns [`XmlFormat::Auto`] — rather
/// than guessing — when no known signature is found, so
/// [`XmlImporter::import`] surfaces an honest "could not auto-detect"
/// error instead of silently routing unrecognized content through the
/// wrong parser. An earlier revision always returned `XmlFormat::FcpXml`
/// regardless of content, which made the `XmlFormat::Auto => Err(..)`
/// arm in `import()` permanently unreachable dead code.
fn detect_format(content: &str) -> XmlFormat {
if content.contains("<fcpxml") {
XmlFormat::FcpXml
} else if content.contains("<xmeml") {
// Adobe Premiere Pro (and legacy Final Cut Pro 7) both export an
// <xmeml> root; Premiere is the far more common source for this
// importer today, so classify <xmeml> as Premiere.
XmlFormat::PremiereXml
} else {
XmlFormat::Auto
}
}
/// Parse Final Cut Pro XML (FCPXML).
///
/// Delegates to [`FcpxmlParser`] — the crate's real hand-written FCPXML
/// parser (also used directly via [`crate::FcpxmlParser`]) — and maps
/// its `ConformProject { sequences: [ConformSequence { clips:
/// [ConformClip] }] }` model onto [`ClipReference`]. An earlier
/// revision ignored `content` entirely and returned `Ok(Vec::new())`,
/// reporting "0 clips found" even for a well-formed FCPXML file.
fn parse_fcpxml(&self, content: &str) -> ConformResult<Vec<ClipReference>> {
let project = FcpxmlParser::parse(content)?;
let mut clips = Vec::new();
for sequence in &project.sequences {
// frame_rate == 0.0 means the parser could not resolve a
// <format> frameDuration; fall back to a conservative default
// (matching EdlImporter's convention) rather than dividing by
// zero when converting seconds to frames below.
let fps = if sequence.frame_rate > 0.0 {
FrameRate::Custom(f64::from(sequence.frame_rate))
} else {
FrameRate::Fps25
};
for clip in &sequence.clips {
let mut metadata = std::collections::HashMap::new();
metadata.insert("format".to_string(), "fcpxml".to_string());
metadata.insert("sequence".to_string(), sequence.name.clone());
// Preserve full sub-frame precision alongside the
// frame-rounded Timecode fields below.
metadata.insert("offset_s".to_string(), clip.offset_s.to_string());
metadata.insert("duration_s".to_string(), clip.duration_s.to_string());
clips.push(ClipReference {
id: clip.name.clone(),
source_file: Some(clip.name.clone()),
source_in: seconds_to_timecode(clip.src_in_s, fps),
source_out: seconds_to_timecode(clip.src_out_s, fps),
record_in: seconds_to_timecode(clip.offset_s, fps),
record_out: seconds_to_timecode(clip.offset_s + clip.duration_s, fps),
// The lean hand-written FCPXML parser does not currently
// distinguish video-only / audio-only spine clips; FCP X
// `asset-clip` elements are typically synchronized
// audio+video, so AudioVideo is the honest default
// rather than guessing Video specifically.
track: TrackType::AudioVideo,
fps,
metadata,
});
}
}
Ok(clips)
}
/// Parse Premiere XML.
///
/// # Errors
///
/// Always returns [`ConformError::UnsupportedFormat`]: a real Adobe
/// Premiere Pro `<xmeml>` importer is not implemented.
// TODO(0.2.x): real Adobe Premiere Pro XML importer — parse the
// <xmeml>/<sequence>/<track>/<clipitem> structure into ClipReference
// (see FcpxmlParser in importers/fcpxml.rs for the parsing style used
// elsewhere in this crate).
fn parse_premiere_xml(&self, _content: &str) -> ConformResult<Vec<ClipReference>> {
Err(ConformError::UnsupportedFormat(
"Premiere XML importer is not yet implemented".to_string(),
))
}
/// Parse Resolve XML.
///
/// # Errors
///
/// Always returns [`ConformError::UnsupportedFormat`]: a real `DaVinci`
/// Resolve timeline XML importer is not implemented.
// TODO(0.2.x): real DaVinci Resolve timeline XML importer.
fn parse_resolve_xml(&self, _content: &str) -> ConformResult<Vec<ClipReference>> {
Err(ConformError::UnsupportedFormat(
"Resolve XML importer is not yet implemented".to_string(),
))
}
}
/// Convert a time offset in seconds to a [`Timecode`] at `fps`.
fn seconds_to_timecode(seconds: f64, fps: FrameRate) -> Timecode {
let total_frames = (seconds.max(0.0) * fps.as_f64()).round() as u64;
Timecode::from_frames(total_frames, fps)
}
impl Default for XmlImporter {
fn default() -> Self {
Self::new(XmlFormat::Auto)
}
}
impl TimelineImporter for XmlImporter {
fn import<P: AsRef<Path>>(&self, path: P) -> ConformResult<Vec<ClipReference>> {
let content = std::fs::read_to_string(path)?;
let format = if self.format == XmlFormat::Auto {
Self::detect_format(&content)
} else {
self.format
};
match format {
XmlFormat::FcpXml => self.parse_fcpxml(&content),
XmlFormat::PremiereXml => self.parse_premiere_xml(&content),
XmlFormat::ResolveXml => self.parse_resolve_xml(&content),
XmlFormat::Auto => Err(ConformError::UnsupportedFormat(
"Could not auto-detect XML format".to_string(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xml_importer_creation() {
let importer = XmlImporter::new(XmlFormat::FcpXml);
assert_eq!(importer.format, XmlFormat::FcpXml);
}
#[test]
fn test_xml_importer_default() {
let importer = XmlImporter::default();
assert_eq!(importer.format, XmlFormat::Auto);
}
const MINIMAL_FCPXML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.9">
<resources>
<format id="r1" name="FFVideoFormat1080p24" frameDuration="100/2400s" width="1920" height="1080"/>
</resources>
<library>
<event name="My Event">
<project name="My Project">
<sequence format="r1" tcStart="0s" tcFormat="NDF">
<spine>
<asset-clip name="Shot_001.mov" offset="0s" duration="96/2400s" start="86400s"/>
<asset-clip name="Shot_002.mov" offset="96/2400s" duration="144/2400s" start="86400s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>"#;
fn tmp_xml_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("oximedia-conform-xml-importer-{name}"))
}
#[test]
fn test_detect_format_sniffs_fcpxml() {
assert_eq!(
XmlImporter::detect_format(MINIMAL_FCPXML),
XmlFormat::FcpXml
);
}
#[test]
fn test_detect_format_unknown_content_is_auto_not_a_guess() {
// CHANGED: detect_format() previously always guessed FcpXml
// regardless of content, which made the `XmlFormat::Auto =>
// Err(..)` arm in `import()` permanently unreachable dead code.
// Unrecognized content must now come back as Auto so that arm
// actually fires instead of silently misrouting the file through
// the FCPXML parser.
assert_eq!(
XmlImporter::detect_format("<not-a-timeline-xml/>"),
XmlFormat::Auto
);
}
#[test]
fn test_parse_fcpxml_real_extraction() {
// CHANGED: parse_fcpxml() previously ignored `content` and returned
// `Ok(Vec::new())`, reporting "0 clips" for any FCPXML file
// including well-formed ones. It now delegates to the crate's real
// FcpxmlParser and must extract the real clips.
let importer = XmlImporter::new(XmlFormat::FcpXml);
let clips = importer
.parse_fcpxml(MINIMAL_FCPXML)
.expect("real FCPXML content should parse");
assert_eq!(
clips.len(),
2,
"expected 2 real clips extracted, not a fabricated empty Ok(vec![])"
);
assert_eq!(clips[0].id, "Shot_001.mov");
assert_eq!(clips[1].id, "Shot_002.mov");
assert_eq!(clips[0].source_file.as_deref(), Some("Shot_001.mov"));
}
#[test]
fn test_import_fcpxml_end_to_end_from_file() -> ConformResult<()> {
let path = tmp_xml_path("minimal.fcpxml");
std::fs::write(&path, MINIMAL_FCPXML)?;
let importer = XmlImporter::new(XmlFormat::FcpXml);
let clips = importer.import(&path)?;
assert_eq!(clips.len(), 2);
std::fs::remove_file(&path).ok();
Ok(())
}
#[test]
fn test_parse_premiere_xml_is_honest_err() {
// CHANGED: previously returned `Ok(Vec::new())` ("0 clips found")
// for any Premiere XML content instead of reporting that Premiere
// import is unimplemented.
let importer = XmlImporter::new(XmlFormat::PremiereXml);
let result = importer.parse_premiere_xml("<xmeml/>");
assert!(
result.is_err(),
"Premiere XML importer must not report Ok(vec![]) as if 0 clips were found"
);
}
#[test]
fn test_parse_resolve_xml_is_honest_err() {
// CHANGED: previously returned `Ok(Vec::new())` ("0 clips found")
// for any Resolve XML content instead of reporting that Resolve
// import is unimplemented.
let importer = XmlImporter::new(XmlFormat::ResolveXml);
let result = importer.parse_resolve_xml("<resolve-timeline/>");
assert!(
result.is_err(),
"Resolve XML importer must not report Ok(vec![]) as if 0 clips were found"
);
}
#[test]
fn test_import_auto_detect_unknown_format_is_honest_err() {
let path = tmp_xml_path("unknown.xml");
std::fs::write(&path, "<not-a-timeline-xml/>").expect("write temp file");
let importer = XmlImporter::default(); // Auto
let result = importer.import(&path);
assert!(
result.is_err(),
"unrecognized XML must not silently import as FCPXML"
);
std::fs::remove_file(&path).ok();
}
}