Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions crates/align-core/src/xml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,51 @@ impl std::error::Error for ImportError {}

// ------------------------------------------------------------ importer

/// Whether an XML file holds an editing timeline: its root element is FCP 7
/// `xmeml` or `fcpxml`. Cameras write clip metadata as XML beside their
/// media (Canon `CLIPS001/*.XML`); those are not timelines. Reads at most
/// 16 KiB. A file that cannot be read or parsed counts as a timeline, so
/// importing it reports why.
pub fn is_timeline_document(path: &Path) -> bool {
use std::io::Read;
let mut head = Vec::new();
let read =
std::fs::File::open(path).and_then(|file| file.take(16 * 1024).read_to_end(&mut head));
if read.is_err() {
return true;
}
// Timelines import only as UTF-8, but a UTF-16 project must still be
// recognized, so that importing it says it cannot be read instead of the
// project being skipped. Other encodings with NUL bytes count as
// timelines for the same reason.
let utf16 = |bytes: &[u8], unit: fn([u8; 2]) -> u16| {
let units = bytes.chunks_exact(2).map(|pair| unit([pair[0], pair[1]]));
char::decode_utf16(units)
.map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect::<String>()
.into_bytes()
};
let head = match head.as_slice() {
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
bytes if bytes.contains(&0) => return true,
_ => head,
};
let mut reader = Reader::from_reader(head.as_slice());
let mut buffer = Vec::new();
loop {
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(element) | Event::Empty(element)) => {
let root = element.local_name();
return root.as_ref().eq_ignore_ascii_case(b"xmeml")
|| root.as_ref().eq_ignore_ascii_case(b"fcpxml");
}
Ok(Event::Eof) | Err(_) => return true,
Ok(_) => buffer.clear(),
}
}
}

/// Dispatch by root element (`fcpxml` or FCP 7 XML).
pub fn read_timeline(
path: &Path,
Expand Down Expand Up @@ -3381,6 +3426,86 @@ fn read_fcpxml(
mod tests {
use super::*;

/// Editor exports start with a declaration, a DOCTYPE and sometimes
/// comments; all of them must stay timelines, or projects would be
/// skipped silently.
#[test]
fn timeline_documents_are_recognized_by_root_element() {
let dir = std::env::temp_dir().join(format!("align-timeline-root-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cases = [
(
"premiere.xml",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"4\"><sequence/></xmeml>",
true,
),
(
"resolve.xml",
"<?xml version=\"1.0\"?>\n<!-- exported -->\n<XMEML version=\"5\"/>",
true,
),
(
"fcp.xml",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.10\"/>",
true,
),
(
"A001C001_CANON.XML",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Clip><ClipName>A001C001</ClipName></Clip>",
false,
),
(
"C0001M01.XML",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<NonRealTimeMeta xmlns=\"urn:schemas-professionalDisc:nonRealTimeMeta:ver.2.00\"/>",
false,
),
("broken.xml", "not xml at all", true),
];
for (name, text, timeline) in cases {
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
assert_eq!(is_timeline_document(&path), timeline, "{name}");
}
// UTF-16 with a byte order mark is decoded to find the root; without
// one, NUL bytes keep the file a timeline.
let utf16 = |text: &str, bom: [u8; 2], unit: fn(u16) -> [u8; 2]| {
let mut bytes = bom.to_vec();
bytes.extend(text.encode_utf16().flat_map(unit));
bytes
};
let project = "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<xmeml version=\"5\"/>";
let camera = "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<Clip/>";
let encoded = [
(
"le.xml",
utf16(project, [0xFF, 0xFE], u16::to_le_bytes),
true,
),
(
"be.xml",
utf16(project, [0xFE, 0xFF], u16::to_be_bytes),
true,
),
(
"camera-le.xml",
utf16(camera, [0xFF, 0xFE], u16::to_le_bytes),
false,
),
(
"no-bom.xml",
project.encode_utf16().flat_map(u16::to_le_bytes).collect(),
true,
),
];
for (name, bytes, timeline) in encoded {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
assert_eq!(is_timeline_document(&path), timeline, "{name}");
}
assert!(is_timeline_document(&dir.join("missing.xml")));
let _ = std::fs::remove_dir_all(&dir);
}

const FCP7: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<xmeml version="4">
<sequence>
Expand Down
97 changes: 76 additions & 21 deletions crates/align-decode/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ impl std::fmt::Display for PipelineError {
Self::NoMedia => write!(f, "No readable audio or video media was found."),
Self::Inaccessible(p) => write!(f, "Cannot access {}.", p.display()),
Self::MultipleTimelines => write!(f, "Add one timeline at a time."),
Self::Timeline(msg) => write!(f, "Timeline import: {msg}."),
Self::Timeline(msg) => write!(f, "Timeline import: {}.", msg.trim_end_matches('.')),
Self::Cancelled => write!(f, "Cancelled."),
Self::Decode(e) => write!(f, "Decode error: {e}."),
}
Expand Down Expand Up @@ -1269,25 +1269,16 @@ fn expand(inputs: &[PipelineInput]) -> Result<ExpandedInputs, PipelineError> {
visit_dir(path, &mut media)?;
continue;
}
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
{
Some(e) if e == "xml" || e == "fcpxml" || e == "aaf" => {
if timeline.is_some() {
return Err(PipelineError::MultipleTimelines);
}
timeline = Some(TimelineRequest {
path: path.clone(),
sequence: None,
});
}
_ => {
if crate::is_supported(path) {
media.push(normalize(path));
}
if crate::timeline::is_supported(path) {
if timeline.is_some() {
return Err(PipelineError::MultipleTimelines);
}
timeline = Some(TimelineRequest {
path: path.clone(),
sequence: None,
});
} else if crate::is_supported(path) {
media.push(normalize(path));
}
}
}
Expand Down Expand Up @@ -2829,23 +2820,87 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

/// Canon writes clip metadata as XML beside each clip. Selecting a clip
/// with its XML used to import the XML as a timeline and stop the run.
#[test]
fn camera_metadata_xml_beside_media_is_not_a_timeline() {
let dir = fixture_dir("camera-xml");
let body = noise(12 * 44100, 0xCA70);
let a = write_delayed_wav(&dir, "A001C001.wav", &body, 0.0);
let b = write_delayed_wav(&dir, "recorder.wav", &body, 1.024);
let xml = dir.join("A001C001_220129AB_CANON.XML");
std::fs::write(
&xml,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Clip><ClipName>A001C001</ClipName></Clip>\n",
)
.unwrap();
let pipe = Pipeline::new_in(BackendKind::Portable, Some(dir.join(".cache")));
let result = pipe
.synchronize(
&[
PipelineInput::Media(a),
PipelineInput::Media(xml),
PipelineInput::Media(b),
],
&[],
&PipelineOptions::default(),
None,
&std::sync::atomic::AtomicBool::new(false),
)
.expect("camera XML is skipped like other non-media files");
assert!(result.project.imported_timeline.is_none());
assert_eq!(result.matches.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}

/// Timeline import reads only UTF-8. A UTF-16 project added beside media
/// must say so rather than be skipped like camera metadata, or the run
/// would continue without the project.
#[test]
fn timelines_in_other_encodings_are_reported_not_skipped() {
let dir = fixture_dir("utf16-timeline");
let media = write_mono_wav(&dir, "tone.wav", &noise(48_000, 3));
let project = dir.join("edit.xml");
let mut bytes = vec![0xFF, 0xFE];
bytes.extend(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<xmeml version=\"5\"/>"
.encode_utf16()
.flat_map(u16::to_le_bytes),
);
std::fs::write(&project, bytes).unwrap();
let pipe = Pipeline::new_in(BackendKind::Portable, Some(dir.join(".cache")));
let error = pipe
.open(&[PipelineInput::Media(media), PipelineInput::Media(project)])
.expect_err("the project is not skipped");
assert_eq!(
error.to_string(),
"Timeline import: Cannot read timeline edit.xml."
);
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn xml_inputs_are_explicit_not_silent() {
let dir = fixture_dir("xml");
let xml = dir.join("edit.xml");
std::fs::write(&xml, "<xmeml></xmeml>").unwrap();
let pipe = Pipeline::new_in(BackendKind::Portable, Some(dir.join(".cache")));
// A sequence-less file errors explicitly (no silent empty result).
// A sequence-less file added as a path is still recognized as a
// timeline and errors explicitly (no silent empty result).
let err = pipe
.synchronize(
&[PipelineInput::Timeline(xml)],
&[PipelineInput::Media(xml)],
&[],
&PipelineOptions::default(),
None,
&std::sync::atomic::AtomicBool::new(false),
)
.expect_err("xml must not silently pass");
assert!(matches!(err, PipelineError::Timeline(_)));
assert_eq!(
err.to_string(),
"Timeline import: No FCP 7 XML sequence was found in edit.xml."
);
let _ = std::fs::remove_dir_all(&dir);
}

Expand Down
18 changes: 13 additions & 5 deletions crates/align-decode/src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ pub enum TimelineError {
Write(String),
}

/// AAF, FCPXML, and XML whose root is an FCP 7 or FCPXML document. Camera
/// metadata XML beside clips is not a timeline and is skipped like any other
/// file that is not media.
pub fn is_supported(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
["xml", "fcpxml", "aaf"]
.iter()
.any(|extension| e.eq_ignore_ascii_case(extension))
})
let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
if ["fcpxml", "aaf"]
.iter()
.any(|timeline| extension.eq_ignore_ascii_case(timeline))
{
return true;
}
extension.eq_ignore_ascii_case("xml") && align_core::xml::is_timeline_document(path)
}

fn is_aaf(path: &Path) -> bool {
Expand Down
Loading
Loading