From f9610215957a37ef4fb07f9d2b97fa1fc65f20d4 Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Wed, 11 Nov 2026 21:51:49 +0300 Subject: [PATCH] Skip camera metadata XML instead of importing it as a timeline Every .xml path was imported as a timeline. Canon writes clip metadata as XML beside each clip, so selecting a clip together with its XML, or two clips' XML files, stopped the whole run with "No FCP 7 XML sequence was found" or "Add one timeline at a time". Folder imports already skipped these files. An XML file is now a timeline only when its root element is FCP 7 xmeml or fcpxml; recognizing that reads at most 16 KiB. A UTF-16 file is decoded to find its root, so a UTF-16 project is still reported as unreadable instead of skipped. Files that cannot be read or parsed still count as timelines, so their import reports why. The app recognizes timeline inputs once, when they are added, and rendering no longer reads files. The timeline import error also lost its doubled final period. --- crates/align-core/src/xml.rs | 125 ++++++++++++++++++++++++++++ crates/align-decode/src/pipeline.rs | 97 ++++++++++++++++----- crates/align-decode/src/timeline.rs | 18 ++-- crates/align-gpui/src/state.rs | 28 +++++-- crates/align-gpui/src/views.rs | 2 +- docs/formats.md | 4 + 6 files changed, 242 insertions(+), 32 deletions(-) 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. |