diff --git a/crates/align-core/src/xml.rs b/crates/align-core/src/xml.rs index 8ae8e3f..65c7f5b 100644 --- a/crates/align-core/src/xml.rs +++ b/crates/align-core/src/xml.rs @@ -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::() + .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, @@ -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", + "\n\n", + true, + ), + ( + "resolve.xml", + "\n\n", + true, + ), + ( + "fcp.xml", + "\n\n", + true, + ), + ( + "A001C001_CANON.XML", + "\nA001C001", + false, + ), + ( + "C0001M01.XML", + "\n", + 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 = "\n"; + let camera = "\n"; + 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#" diff --git a/crates/align-decode/src/pipeline.rs b/crates/align-decode/src/pipeline.rs index 2af4ab5..4af246c 100644 --- a/crates/align-decode/src/pipeline.rs +++ b/crates/align-decode/src/pipeline.rs @@ -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}."), } @@ -1269,25 +1269,16 @@ fn expand(inputs: &[PipelineInput]) -> Result { 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)); } } } @@ -2829,16 +2820,76 @@ 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, + "\nA001C001\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( + "\n" + .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, "").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, @@ -2846,6 +2897,10 @@ mod tests { ) .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); } diff --git a/crates/align-decode/src/timeline.rs b/crates/align-decode/src/timeline.rs index 3217410..c4800b2 100644 --- a/crates/align-decode/src/timeline.rs +++ b/crates/align-decode/src/timeline.rs @@ -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 { diff --git a/crates/align-gpui/src/state.rs b/crates/align-gpui/src/state.rs index e44f11a..aa13a21 100644 --- a/crates/align-gpui/src/state.rs +++ b/crates/align-gpui/src/state.rs @@ -241,6 +241,10 @@ pub struct AppData { pub show_sequence_results: bool, pub appearance: crate::theme::AppearancePreference, pub inputs: Vec, + /// Inputs recognized as editing timelines when they were added. + /// Recognizing an XML timeline reads the file, so rendering and later + /// checks use this instead. + pub timeline_inputs: HashSet, pub clips: Vec, pub selection: HashSet, pub island_count: usize, @@ -320,6 +324,7 @@ impl Default for AppData { fn default() -> Self { Self { inputs: Vec::new(), + timeline_inputs: HashSet::new(), appearance: crate::theme::AppearancePreference::load(), common_settings: Default::default(), sequence_settings: HashMap::new(), @@ -701,11 +706,13 @@ impl AppData { pub fn add_paths(&mut self, paths: Vec) { let existing: HashSet = self.clips.iter().map(|c| c.url.clone()).collect(); let mut additions = Vec::new(); + let mut timelines = Vec::new(); for path in paths.iter().cloned() { if existing.contains(&path) || additions.iter().any(|r: &ClipRow| r.url == path) { continue; } - if is_timeline(&path) + let timeline = is_timeline(&path); + if timeline && !self.timeline_choices.contains_key(&path) && let Ok(summaries) = align_decode::timeline::sequences( &path, @@ -719,6 +726,9 @@ impl AppData { }); continue; } + if timeline { + timelines.push(path.clone()); + } additions.push(ClipRow { clip_id: None, url: path, @@ -736,6 +746,7 @@ impl AppData { return; } self.inputs.extend(additions.iter().map(|r| r.url.clone())); + self.timeline_inputs.extend(timelines); self.clips.extend(additions.iter().cloned()); self.clips .sort_by(|a, b| a.url.to_string_lossy().cmp(&b.url.to_string_lossy())); @@ -774,6 +785,7 @@ impl AppData { self.waveform_previews .retain(|clip_id, _| !removed_ids.contains(clip_id)); self.inputs.retain(|u| !selected.contains(u)); + self.timeline_inputs.retain(|u| !selected.contains(u)); for url in &selected { self.timeline_choices.remove(url); } @@ -862,6 +874,7 @@ impl AppData { }); self.clips .sort_by(|a, b| a.url.to_string_lossy().cmp(&b.url.to_string_lossy())); + self.timeline_inputs.insert(url.clone()); self.inputs.push(url); self.pending_count += 1; } @@ -1797,7 +1810,7 @@ impl AppData { } pub fn can_locate_timeline_media(&self) -> bool { - self.inputs.iter().any(|path| is_timeline(path)) + !self.timeline_inputs.is_empty() && (self.has_missing_media_warning() || self.error.as_deref() == Some("No readable audio or video media was found.")) } @@ -1878,7 +1891,7 @@ impl AppData { .chain( self.clips .iter() - .filter(|clip| !is_timeline(&clip.url)) + .filter(|clip| !self.timeline_inputs.contains(&clip.url)) .map(|clip| clip.url.clone()), ) .collect(); @@ -1895,7 +1908,7 @@ impl AppData { pub fn path_repair_source(&self) -> Option<&std::path::Path> { self.inputs .iter() - .find(|path| align_decode::timeline::is_supported(path)) + .find(|path| self.timeline_inputs.contains(*path)) .map(PathBuf::as_path) } @@ -1962,7 +1975,7 @@ impl AppData { self.inputs .iter() .map(|path| { - if !is_timeline(path) { + if !self.timeline_inputs.contains(path) { return PipelineInput::Media(path.clone()); } match selected.or_else(|| { @@ -2559,6 +2572,8 @@ mod tests { packed_other_source.source_key = "/other".to_string(); let mut data = AppData::default(); data.inputs.push(PathBuf::from("/tmp/project.xml")); + data.timeline_inputs + .insert(PathBuf::from("/tmp/project.xml")); data.timeline_choices .insert(PathBuf::from("/tmp/project.xml"), vec![3, 8]); data.lanes = vec![LaneVisual { @@ -2665,6 +2680,7 @@ mod tests { let mut data = AppData { inputs: vec![PathBuf::from("/tmp/project.xml")], + timeline_inputs: [PathBuf::from("/tmp/project.xml")].into_iter().collect(), timeline_choices: [(PathBuf::from("/tmp/project.xml"), vec![3, 8])] .into_iter() .collect(), @@ -2952,6 +2968,7 @@ mod tests { vec!["jpg", "png", "wav"] ); data.inputs.insert(0, PathBuf::from("/v/edit.fcpxml")); + data.timeline_inputs.insert(PathBuf::from("/v/edit.fcpxml")); assert_eq!( data.path_repair_source(), Some(std::path::Path::new("/v/edit.fcpxml")) @@ -2986,6 +3003,7 @@ mod tests { fn only_actionable_missing_media_warnings_offer_relink() { let mut data = AppData::default(); data.inputs.push(PathBuf::from("/v/cut.xml")); + data.timeline_inputs.insert(PathBuf::from("/v/cut.xml")); data.warnings .push("camera.mov: Variable frame rate detected.".into()); assert!(!data.has_missing_media_warning()); diff --git a/crates/align-gpui/src/views.rs b/crates/align-gpui/src/views.rs index b5e9c34..de5cdc8 100644 --- a/crates/align-gpui/src/views.rs +++ b/crates/align-gpui/src/views.rs @@ -1820,7 +1820,7 @@ impl AlignApp { .data .inputs .iter() - .any(|path| align_decode::timeline::is_supported(path)) + .any(|path| self.data.timeline_inputs.contains(path)) && self.data.can_synchronize() { self.start_sync(cx); diff --git a/docs/formats.md b/docs/formats.md index 6e570fc..c9563c3 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -18,6 +18,10 @@ platform; other containers need FFmpeg. ## Timeline interchange +An XML file is imported as a timeline only when it is an FCP 7 XML or FCPXML +document. The XML metadata that cameras write beside each clip is skipped, +like other files that are not media. + | Format | Import | Export | Notes | | --- | --- | --- | --- | | FCP 7 XML | Yes | Yes | Separate Premiere and Resolve writers; selected editing metadata can be preserved. |