From f9610215957a37ef4fb07f9d2b97fa1fc65f20d4 Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Wed, 11 Nov 2026 21:51:49 +0300 Subject: [PATCH 1/4] 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. | From 029f1394a5c9433750ab78629a2dea8c1fbc54f1 Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Thu, 12 Nov 2026 01:05:56 +0300 Subject: [PATCH 2/4] Import FCPXML bundles Final Cut Pro 10.6 and later export FCPXML as a .fcpxmld bundle: a directory whose Info.fcpxml is the document. Align did not recognize the extension, and expanding inputs scanned the bundle as a folder of media, so adding one gave "No readable audio or video media was found." A .fcpxmld bundle is now a timeline: it is read through its Info.fcpxml, and errors and warnings name the bundle. Folder scans do not enter bundles. FCPXML media URLs relative to the document now resolve against its folder (the bundle root) and are percent-decoded; they used to resolve against the working directory. A repaired copy saved as .fcpxmld is a bundle again: the source bundle is copied, links and folder permissions kept, with its document rewritten. It is built beside the destination and swapped in; an existing destination must be a bundle, is moved aside and moved back if the swap fails. The destination may neither lie inside the source nor contain it, and copying can be cancelled. Relative media that would no longer resolve from the copy's location become absolute file URLs. --- crates/align-core/src/lib.rs | 4 +- crates/align-core/src/xml.rs | 111 +++++++- crates/align-decode/src/pipeline.rs | 65 ++++- crates/align-decode/src/timeline.rs | 416 ++++++++++++++++++++++++++-- docs/formats.md | 2 +- 5 files changed, 566 insertions(+), 32 deletions(-) diff --git a/crates/align-core/src/lib.rs b/crates/align-core/src/lib.rs index 3c2d0d0..fb938ec 100644 --- a/crates/align-core/src/lib.rs +++ b/crates/align-core/src/lib.rs @@ -53,6 +53,6 @@ pub use spanned::{SpannedAnalysis, analyze_spans}; pub use timecode::analyze_timecodes; pub use timing::{RangeAccumulator, VideoTimingInspection, canonical_frame_duration, classify}; pub use xml::{ - DraftEdit, DraftMediaKind, DraftTransition, ImportError, TimelineDraft, XmlDoc, read_timeline, - rewrite_media_paths, timeline_sequence_summaries, + DraftEdit, DraftMediaKind, DraftTransition, ImportError, Relocation, TimelineDraft, XmlDoc, + read_timeline, rewrite_media_paths, timeline_sequence_summaries, }; diff --git a/crates/align-core/src/xml.rs b/crates/align-core/src/xml.rs index 65c7f5b..30b0c73 100644 --- a/crates/align-core/src/xml.rs +++ b/crates/align-core/src/xml.rs @@ -768,6 +768,19 @@ pub enum ImportError { } impl ImportError { + /// The same error naming `name` instead of the document read, e.g. an + /// FCPXML bundle instead of the `Info.fcpxml` inside it. + pub fn naming(self, name: &str) -> Self { + let name = name.to_string(); + match self { + Self::NoSequence(_) => Self::NoSequence(name), + Self::MultipleSequences(_, count) => Self::MultipleSequences(name, count), + Self::InvalidSequence(_, index) => Self::InvalidSequence(name, index), + Self::NoMedia(_) => Self::NoMedia(name), + Self::Unreadable(_) => Self::Unreadable(name), + } + } + fn filename(url: &Path) -> String { url.file_name() .and_then(|n| n.to_str()) @@ -887,11 +900,34 @@ pub fn timeline_sequence_summaries( /// Rewrite only media locations in FCP 7 XML or FCPXML. Unchanged events are /// copied verbatim by quick-xml; elements containing a changed location are /// re-escaped without rebuilding the timeline document. +/// Where a rewritten document is written. Relative FCPXML media URLs resolve +/// against `from`, the source document's folder. Those that would resolve +/// differently from `to` and do not point into `copied` (a tree copied +/// along with the document) become absolute file URLs. +pub struct Relocation<'a> { + pub from: &'a Path, + pub to: &'a Path, + pub copied: Option<&'a Path>, +} + +impl<'a> Relocation<'a> { + /// The document stays in its folder. + pub fn none(folder: &'a Path) -> Self { + Self { + from: folder, + to: folder, + copied: None, + } + } +} + pub fn rewrite_media_paths( source: &[u8], replacements: &[(PathBuf, PathBuf)], + relocation: &Relocation<'_>, ) -> Result, ImportError> { - if replacements.is_empty() { + let moves = relocation.from != relocation.to; + if replacements.is_empty() && !moves { return Ok(source.to_vec()); } let replacement_for = |value: &str, file_url_only: bool| { @@ -899,11 +935,17 @@ pub fn rewrite_media_paths( let path = if file_url_only { local_file_path(value)? } else { - fcpxml_url(value) + fcpxml_url(value, relocation.from) }; - replacements + if let Some(new) = replacements .iter() .find_map(|(old, new)| (old == &path).then(|| crate::model::file_url(new))) + { + return Some(new); + } + let relative = !file_url_only && !value.contains("://"); + let still_resolves = !moves || relocation.copied.is_some_and(|tree| path.starts_with(tree)); + (relative && !still_resolves).then(|| crate::model::file_url(&path)) }; let mut reader = Reader::from_reader(source); reader.config_mut().trim_text(false); @@ -2555,18 +2597,42 @@ fn inside_media(doc: &XmlDoc, mut idx: usize) -> bool { false } -fn fcpxml_url(src: &str) -> PathBuf { +/// An FCPXML media location: a file URL, or a URL relative to the folder of +/// the document (`base`), which FCPXML allows. Other URL schemes are kept. +fn fcpxml_url(src: &str, base: &Path) -> PathBuf { if let Some(rest) = src.strip_prefix("file://") { let rest = rest .strip_prefix("localhost/") .or_else(|| rest.strip_prefix("localhost")) .unwrap_or(rest); decoded_file_url_path(rest) - } else { + } else if src.contains("://") { PathBuf::from(src) + } else { + relative_to(base, &percent_decode(src)) } } +/// `relative` resolved lexically against `base` (`.` and `..` folded). +fn relative_to(base: &Path, relative: &str) -> PathBuf { + use std::path::Component; + let relative = Path::new(relative); + if relative.is_absolute() { + return relative.to_path_buf(); + } + let mut resolved = base.to_path_buf(); + for component in relative.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + resolved.pop(); + } + other => resolved.push(other), + } + } + resolved +} + fn read_fcpxml( doc: &XmlDoc, path: &Path, @@ -2637,6 +2703,8 @@ fn read_fcpxml( depth: usize, } + // FCPXML media URLs may be relative to the document's folder. + let base = path.parent().unwrap_or_else(|| Path::new("")); let mut assets: HashMap = HashMap::new(); let mut stack = vec![doc.root]; while let Some(n) = stack.pop() { @@ -2670,7 +2738,7 @@ fn read_fcpxml( assets.insert( id.to_string(), AssetInfo { - url: fcpxml_url(src), + url: fcpxml_url(src, base), has_video: doc.attr(c, "hasVideo") != Some("0"), has_audio: doc.attr(c, "hasAudio") != Some("0"), }, @@ -3426,6 +3494,26 @@ fn read_fcpxml( mod tests { use super::*; + /// FCPXML may locate media relative to the document's folder, with + /// percent-encoding; they must not resolve against the working directory. + #[test] + fn fcpxml_media_may_be_relative_to_the_document() { + let dir = std::env::temp_dir().join(format!("align-relative-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cut.fcpxml"); + std::fs::write( + &path, + FCPXML.replace("file:///tmp/c.mov", "Media/my%20clip.mov"), + ) + .unwrap(); + let draft = read_timeline(&path, None).unwrap(); + assert_eq!( + draft.media_urls(), + vec![dir.join("Media").join("my clip.mov")] + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// Editor exports start with a declaration, a DOCTYPE and sometimes /// comments; all of them must stay timelines, or projects would be /// skipped silently. @@ -3721,7 +3809,10 @@ mod tests { ), (PathBuf::from("/old/rec.wav"), PathBuf::from("/new/rec.wav")), ]; - let fixed = String::from_utf8(rewrite_media_paths(fcp7, &replacements).unwrap()).unwrap(); + let fixed = String::from_utf8( + rewrite_media_paths(fcp7, &replacements, &Relocation::none(Path::new("/old"))).unwrap(), + ) + .unwrap(); assert!(fixed.contains("file:///new/A%20Clip.mov")); assert!(fixed.contains("")); assert!(fixed.contains("Keep & Me")); @@ -3731,7 +3822,11 @@ mod tests { "#; - let fixed = String::from_utf8(rewrite_media_paths(fcpxml, &replacements).unwrap()).unwrap(); + let fixed = String::from_utf8( + rewrite_media_paths(fcpxml, &replacements, &Relocation::none(Path::new("/old"))) + .unwrap(), + ) + .unwrap(); assert!(fixed.contains("src=\"file:///new/A%20Clip.mov\"")); assert!(fixed.contains("src=\"file:///new/rec.wav\"")); assert!(fixed.contains("")); diff --git a/crates/align-decode/src/pipeline.rs b/crates/align-decode/src/pipeline.rs index 4af246c..1f81308 100644 --- a/crates/align-decode/src/pipeline.rs +++ b/crates/align-decode/src/pipeline.rs @@ -1247,7 +1247,7 @@ fn expand(inputs: &[PipelineInput]) -> Result { for input in inputs { match input { PipelineInput::Timeline(path) | PipelineInput::TimelineSequence(path, _) => { - if !path.is_file() { + if !(path.is_file() || crate::timeline::is_bundle(path) && path.is_dir()) { return Err(PipelineError::Inaccessible(path.clone())); } if timeline.is_some() { @@ -1265,7 +1265,8 @@ fn expand(inputs: &[PipelineInput]) -> Result { PipelineInput::Media(path) => { let meta = std::fs::metadata(path) .map_err(|_| PipelineError::Inaccessible(path.clone()))?; - if meta.is_dir() { + // An FCPXML bundle is a directory but one timeline. + if meta.is_dir() && !crate::timeline::is_bundle(path) { visit_dir(path, &mut media)?; continue; } @@ -1303,7 +1304,9 @@ fn visit_dir(dir: &Path, out: &mut Vec) -> Result<(), PipelineError> { let Ok(meta) = std::fs::symlink_metadata(&path) else { continue; }; - if meta.is_dir() { + // A folder scan collects media; timelines inside it, including + // FCPXML bundles, are not opened or scanned. + if meta.is_dir() && !crate::timeline::is_bundle(&path) { visit_dir(&path, out)?; } else if meta.is_file() && crate::is_supported(&path) { out.push(normalize(&path)); @@ -2879,6 +2882,62 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A `.fcpxmld` bundle is a directory, but one timeline, not a folder of + /// media to scan. + #[test] + fn fcpxml_bundles_beside_media_are_imported_as_timelines() { + let dir = fixture_dir("fcpxml-bundle"); + let media = write_mono_wav(&dir, "c.wav", &noise(48_000, 5)); + let bundle = dir.join("Cut.fcpxmld"); + std::fs::create_dir(&bundle).unwrap(); + std::fs::write( + bundle.join("Info.fcpxml"), + format!( + r#" + + + + + + + + + + +"#, + media.display() + ), + ) + .unwrap(); + let pipe = Pipeline::new_in(BackendKind::Portable, Some(dir.join(".cache"))); + let project = pipe + .open(&[ + PipelineInput::Media(bundle.clone()), + PipelineInput::Media(media), + ]) + .expect("bundle imports"); + let timeline = project.imported_timeline.expect("imported timeline"); + assert_eq!(timeline.name, "Proj", "read from the bundle's Info.fcpxml"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A folder scan collects media only; a bundle inside the folder is a + /// timeline, and its media are not loose clips. + #[test] + fn folder_scans_do_not_enter_fcpxml_bundles() { + let dir = fixture_dir("bundle-in-folder"); + let loose = write_mono_wav(&dir, "a.wav", &noise(48_000, 6)); + let bundle = dir.join("Cut.fcpxmld"); + std::fs::create_dir_all(bundle.join("Media")).unwrap(); + std::fs::write(bundle.join("Info.fcpxml"), "").unwrap(); + write_mono_wav(&bundle.join("Media"), "inside.wav", &noise(48_000, 7)); + let pipe = Pipeline::new_in(BackendKind::Portable, Some(dir.join(".cache"))); + let project = pipe.open(&[PipelineInput::Media(dir.clone())]).unwrap(); + let urls: Vec<_> = project.clips.iter().map(|clip| clip.url.clone()).collect(); + assert_eq!(urls, vec![loose]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn xml_inputs_are_explicit_not_silent() { let dir = fixture_dir("xml"); diff --git a/crates/align-decode/src/timeline.rs b/crates/align-decode/src/timeline.rs index c4800b2..ad8c4d8 100644 --- a/crates/align-decode/src/timeline.rs +++ b/crates/align-decode/src/timeline.rs @@ -19,14 +19,41 @@ 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. +/// The document inside an FCPXML bundle. +const BUNDLE_DOCUMENT: &str = "Info.fcpxml"; + +/// Final Cut Pro exports FCPXML as a bundle: a `.fcpxmld` directory whose +/// `Info.fcpxml` is the document. +pub fn is_bundle(path: &Path) -> bool { + path.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("fcpxmld")) +} + +/// The XML document a timeline path names. +fn document(path: &Path) -> PathBuf { + if is_bundle(path) { + path.join(BUNDLE_DOCUMENT) + } else { + path.to_path_buf() + } +} + +/// Errors and warnings name the bundle the user added, not its document. +fn named(path: &Path, error: align_core::xml::ImportError) -> align_core::xml::ImportError { + match path.file_name().and_then(|name| name.to_str()) { + Some(name) if is_bundle(path) => error.naming(name), + _ => error, + } +} + +/// AAF, FCPXML (file or bundle), 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 { let Some(extension) = path.extension().and_then(|e| e.to_str()) else { return false; }; - if ["fcpxml", "aaf"] + if ["fcpxml", "fcpxmld", "aaf"] .iter() .any(|timeline| extension.eq_ignore_ascii_case(timeline)) { @@ -40,20 +67,36 @@ fn is_aaf(path: &Path) -> bool { .is_some_and(|e| e.eq_ignore_ascii_case("aaf")) } +/// A path as the filesystem resolves it; a path that does not exist yet +/// resolves through its parent. +fn resolved(path: &Path) -> PathBuf { + if let Ok(path) = path.canonicalize() { + return path; + } + let parent = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + match (parent.canonicalize(), path.file_name()) { + (Ok(parent), Some(name)) => parent.join(name), + _ => path.to_path_buf(), + } +} + /// Save a repaired project as a new file while leaving the imported source /// untouched. XML is rewritten in-process; AAF delegates to the bundled -/// sidecar that owns its object graph. +/// sidecar that owns its object graph. A `.fcpxmld` destination is written +/// as a bundle: a copy of the source bundle with its document repaired. pub fn write_relinked_copy( source: &Path, destination: &Path, replacements: &[(PathBuf, PathBuf)], cancel: &AtomicBool, ) -> Result<(), TimelineError> { - let canonical_source = source.canonicalize().ok(); - let canonical_destination = destination.canonicalize().ok(); - if source == destination - || (canonical_source.is_some() && canonical_source == canonical_destination) - { + // Neither may contain the other: writing into a bundle would change the + // imported project, and replacing a folder around it would delete it. + let (from, to) = (resolved(source), resolved(destination)); + if from.starts_with(&to) || to.starts_with(&from) { return Err(TimelineError::Write( "choose a different destination so the imported project remains unchanged".into(), )); @@ -65,8 +108,29 @@ pub fn write_relinked_copy( if cancel.load(std::sync::atomic::Ordering::Relaxed) { return Err(TimelineError::Cancelled); } - let bytes = std::fs::read(source).map_err(|error| TimelineError::Write(error.to_string()))?; - let fixed = align_core::rewrite_media_paths(&bytes, replacements)?; + let document_path = document(source); + let bytes = + std::fs::read(&document_path).map_err(|error| TimelineError::Write(error.to_string()))?; + let source_folder = document_path.parent().unwrap_or_else(|| Path::new("")); + let destination_folder = if is_bundle(destination) { + destination + } else { + destination.parent().unwrap_or_else(|| Path::new("")) + }; + let same_folder = resolved(source_folder) == resolved(destination_folder); + let relocation = align_core::Relocation { + from: source_folder, + to: if same_folder { + source_folder + } else { + destination_folder + }, + copied: (is_bundle(source) && is_bundle(destination)).then_some(source), + }; + let fixed = align_core::rewrite_media_paths(&bytes, replacements, &relocation)?; + if is_bundle(destination) { + return write_bundle(source, destination, &fixed, cancel); + } let parent = destination.parent().unwrap_or_else(|| Path::new(".")); std::fs::create_dir_all(parent).map_err(|error| TimelineError::Write(error.to_string()))?; let mut temporary = tempfile::NamedTempFile::new_in(parent) @@ -81,6 +145,104 @@ pub fn write_relinked_copy( Ok(()) } +/// Builds the bundle beside `destination`, then swaps it in. An existing +/// destination must itself be a bundle; it is moved aside first and moved +/// back if the new bundle cannot take its place, so a failure never loses +/// either. Directory renames onto an existing path are not portable, which +/// the move aside also avoids. +fn write_bundle( + source: &Path, + destination: &Path, + document: &[u8], + cancel: &AtomicBool, +) -> Result<(), TimelineError> { + let write_error = |error: std::io::Error| TimelineError::Write(error.to_string()); + let existing = std::fs::symlink_metadata(destination).ok(); + if existing + .as_ref() + .is_some_and(|meta| !meta.is_dir() || !destination.join(BUNDLE_DOCUMENT).is_file()) + { + return Err(TimelineError::Write(format!( + "{} exists and is not an FCPXML bundle", + destination.display() + ))); + } + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent).map_err(write_error)?; + // Removed on every early return; renamed away on success. + let staging = tempfile::Builder::new() + .prefix(".align-bundle") + .tempdir_in(parent) + .map_err(write_error)?; + if is_bundle(source) { + copy_tree(source, staging.path(), Some(BUNDLE_DOCUMENT), cancel)?; + } + let mut file = + std::fs::File::create(staging.path().join(BUNDLE_DOCUMENT)).map_err(write_error)?; + file.write_all(document) + .and_then(|()| file.sync_all()) + .map_err(write_error)?; + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + return Err(TimelineError::Cancelled); + } + let Some(previous) = existing.map(|_| aside(destination)) else { + return std::fs::rename(staging.path(), destination).map_err(write_error); + }; + std::fs::rename(destination, &previous).map_err(write_error)?; + if let Err(error) = std::fs::rename(staging.path(), destination) { + let _ = std::fs::rename(&previous, destination); + return Err(write_error(error)); + } + let _ = std::fs::remove_dir_all(previous); + Ok(()) +} + +/// An unused name beside `path` for moving it aside. +fn aside(path: &Path) -> PathBuf { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + path.with_file_name(format!(".{name}.{}-{stamp}.previous", std::process::id())) +} + +/// Copies a directory tree, leaving out one top-level entry. Symbolic links +/// stay links on Unix, and folders keep their permissions. +fn copy_tree( + from: &Path, + to: &Path, + skip: Option<&str>, + cancel: &AtomicBool, +) -> Result<(), TimelineError> { + let write_error = |error: std::io::Error| TimelineError::Write(error.to_string()); + for entry in std::fs::read_dir(from).map_err(write_error)? { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + return Err(TimelineError::Cancelled); + } + let entry = entry.map_err(write_error)?; + if skip.is_some_and(|skip| entry.file_name() == skip) { + continue; + } + let target = to.join(entry.file_name()); + let kind = entry.file_type().map_err(write_error)?; + #[cfg(unix)] + if kind.is_symlink() { + let link = std::fs::read_link(entry.path()).map_err(write_error)?; + std::os::unix::fs::symlink(link, &target).map_err(write_error)?; + continue; + } + if entry.path().is_dir() { + std::fs::create_dir(&target).map_err(write_error)?; + copy_tree(&entry.path(), &target, None, cancel)?; + let permissions = entry.metadata().map_err(write_error)?.permissions(); + std::fs::set_permissions(&target, permissions).map_err(write_error)?; + } else { + std::fs::copy(entry.path(), target).map_err(write_error)?; + } + } + Ok(()) +} + pub fn read( path: &Path, sequence: Option, @@ -98,11 +260,17 @@ pub fn read_with_proxies( if is_aaf(path) { Ok(crate::aaf::read_timeline(path, cancel)?.into_draft(path, sequence)?) } else { - Ok(align_core::xml::read_timeline_with_proxies( - path, - sequence, - prefer_proxies, - )?) + let document = document(path); + let mut draft = + align_core::xml::read_timeline_with_proxies(&document, sequence, prefer_proxies) + .map_err(|error| named(path, error))?; + draft.source_url = path.to_path_buf(); + for warning in &mut draft.warnings { + if warning.url == document { + warning.url = path.to_path_buf(); + } + } + Ok(draft) } } @@ -113,7 +281,8 @@ pub fn sequences( if is_aaf(path) { Ok(crate::aaf::read_timeline(path, cancel)?.summaries()) } else { - Ok(align_core::timeline_sequence_summaries(path)?) + Ok(align_core::timeline_sequence_summaries(&document(path)) + .map_err(|error| named(path, error))?) } } @@ -121,6 +290,217 @@ pub fn sequences( mod tests { use super::*; + const FCPXML: &str = r#" + + + + + + + + + + + + +"#; + + fn bundle(dir: &Path, name: &str, document: &str) -> PathBuf { + let bundle = dir.join(name); + std::fs::create_dir_all(&bundle).unwrap(); + std::fs::write(bundle.join(BUNDLE_DOCUMENT), document).unwrap(); + bundle + } + + /// Final Cut Pro exports `.fcpxmld` bundles; their `Info.fcpxml` is the + /// timeline, and messages name the bundle the user added. + #[test] + fn fcpxml_bundles_are_read_through_their_document() { + let dir = tempfile::tempdir().unwrap(); + let cut = bundle(dir.path(), "Cut.fcpxmld", FCPXML); + assert!(is_supported(&cut)); + let cancel = AtomicBool::new(false); + assert_eq!(sequences(&cut, &cancel).unwrap().len(), 1); + let draft = read(&cut, None, &cancel).unwrap(); + assert_eq!(draft.source_url, cut); + assert_eq!(draft.media_urls(), vec![PathBuf::from("/old/card/c.mov")]); + + let empty = bundle(dir.path(), "Empty.fcpxmld", ""); + let error = read(&empty, None, &cancel).unwrap_err().to_string(); + assert!(error.contains("Empty.fcpxmld"), "{error}"); + assert!(!error.contains(BUNDLE_DOCUMENT), "{error}"); + } + + fn relative_bundle(dir: &Path, name: &str) -> PathBuf { + let bundle = bundle( + dir, + name, + &FCPXML.replace("file:///old/card/c.mov", "./Media/my%20clip.wav"), + ); + std::fs::create_dir(bundle.join("Media")).unwrap(); + std::fs::write(bundle.join("Media").join("my clip.wav"), b"").unwrap(); + bundle + } + + /// FCPXML may locate media relative to its document; inside a bundle that + /// is the bundle root, not the working directory. + #[test] + fn bundle_media_resolve_against_the_bundle() { + let dir = tempfile::tempdir().unwrap(); + let cut = relative_bundle(dir.path(), "Cut.fcpxmld"); + let draft = read(&cut, None, &AtomicBool::new(false)).unwrap(); + assert_eq!( + draft.media_urls(), + vec![cut.join("Media").join("my clip.wav")] + ); + } + + /// Relative media must still resolve after the repaired document moves: + /// a bundle copy carries its own media along; a document written elsewhere + /// points back at the originals. + #[test] + fn repaired_copies_keep_relative_media_resolving() { + let dir = tempfile::tempdir().unwrap(); + let source = relative_bundle(dir.path(), "Cut.fcpxmld"); + let cancel = AtomicBool::new(false); + let elsewhere = dir.path().join("exports"); + std::fs::create_dir(&elsewhere).unwrap(); + + let bundle_copy = elsewhere.join("Cut-fixed.fcpxmld"); + write_relinked_copy(&source, &bundle_copy, &[], &cancel).unwrap(); + let copied = std::fs::read_to_string(bundle_copy.join(BUNDLE_DOCUMENT)).unwrap(); + assert!(copied.contains("src=\"./Media/my%20clip.wav\""), "{copied}"); + assert!(bundle_copy.join("Media").join("my clip.wav").is_file()); + + let single = elsewhere.join("Cut-fixed.fcpxml"); + write_relinked_copy(&source, &single, &[], &cancel).unwrap(); + let draft = read(&single, None, &cancel).unwrap(); + assert_eq!( + draft.media_urls(), + vec![source.join("Media").join("my clip.wav")] + ); + } + + /// The destination may not lie inside the source, nor contain it, nor be + /// a folder that is not a bundle; each would destroy data. + #[test] + fn repaired_bundles_never_overwrite_the_source_or_other_folders() { + let dir = tempfile::tempdir().unwrap(); + let cancel = AtomicBool::new(false); + let cut = bundle(dir.path(), "Cut.fcpxmld", FCPXML); + for inside in [cut.join(BUNDLE_DOCUMENT), cut.join("Nested.fcpxmld")] { + assert!(write_relinked_copy(&cut, &inside, &[], &cancel).is_err()); + } + assert_eq!( + std::fs::read_to_string(cut.join(BUNDLE_DOCUMENT)).unwrap(), + FCPXML + ); + assert!(!cut.join("Nested.fcpxmld").exists()); + + let outer = bundle(dir.path(), "Outer.fcpxmld", FCPXML); + let inner = bundle(&outer, "Inner.fcpxmld", FCPXML); + assert!(write_relinked_copy(&inner, &outer, &[], &cancel).is_err()); + assert!(inner.join(BUNDLE_DOCUMENT).is_file()); + + let folder = dir.path().join("Folder.fcpxmld"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(folder.join("unique.txt"), "kept").unwrap(); + assert!(write_relinked_copy(&cut, &folder, &[], &cancel).is_err()); + assert!(folder.join("unique.txt").is_file()); + } + + /// Cancelling while a bundle is copied stops the copy and keeps the + /// existing destination, without leaving the staging folder behind. + #[test] + fn a_cancelled_bundle_copy_leaves_the_destination() { + let dir = tempfile::tempdir().unwrap(); + let source = bundle(dir.path(), "Cut.fcpxmld", FCPXML); + std::fs::write(source.join("note.txt"), "kept").unwrap(); + let existing = bundle( + dir.path(), + "Cut-fixed.fcpxmld", + "", + ); + let error = write_bundle( + &source, + &existing, + FCPXML.as_bytes(), + &AtomicBool::new(true), + ) + .unwrap_err(); + assert!(matches!(error, TimelineError::Cancelled)); + assert_eq!( + std::fs::read_to_string(existing.join(BUNDLE_DOCUMENT)).unwrap(), + "" + ); + let names: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(names.len(), 2, "{names:?}"); + } + + #[cfg(unix)] + #[test] + fn bundle_copies_keep_symbolic_links() { + let dir = tempfile::tempdir().unwrap(); + let source = bundle(dir.path(), "Cut.fcpxmld", FCPXML); + std::os::unix::fs::symlink("../shared.mov", source.join("link.mov")).unwrap(); + let copy = dir.path().join("Cut-fixed.fcpxmld"); + write_relinked_copy(&source, ©, &[], &AtomicBool::new(false)).unwrap(); + assert_eq!( + std::fs::read_link(copy.join("link.mov")).unwrap(), + PathBuf::from("../shared.mov") + ); + } + + /// A repaired copy of a bundle is a bundle: every other file is copied + /// and the document is rewritten; the source stays as it was. + #[test] + fn repaired_bundle_copies_keep_the_bundle() { + let dir = tempfile::tempdir().unwrap(); + let source = bundle(dir.path(), "Cut.fcpxmld", FCPXML); + std::fs::create_dir(source.join("Resources")).unwrap(); + std::fs::write(source.join("Resources").join("note.txt"), "kept").unwrap(); + let replacements = [( + PathBuf::from("/old/card/c.mov"), + PathBuf::from("/new/card/c.mov"), + )]; + let cancel = AtomicBool::new(false); + + let copy = bundle( + dir.path(), + "Cut-fixed.fcpxmld", + "", + ); + std::fs::write(copy.join("stale.txt"), "replaced").unwrap(); + write_relinked_copy(&source, ©, &replacements, &cancel).unwrap(); + let fixed = std::fs::read_to_string(copy.join(BUNDLE_DOCUMENT)).unwrap(); + assert!(fixed.contains("file:///new/card/c.mov"), "{fixed}"); + assert_eq!( + std::fs::read_to_string(copy.join("Resources").join("note.txt")).unwrap(), + "kept" + ); + assert!( + !copy.join("stale.txt").exists(), + "the old bundle is replaced" + ); + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name.to_string_lossy().starts_with('.')) + .collect(); + assert!(leftovers.is_empty(), "{leftovers:?}"); + assert_eq!( + std::fs::read_to_string(source.join(BUNDLE_DOCUMENT)).unwrap(), + FCPXML + ); + + let single = dir.path().join("Cut-fixed.fcpxml"); + write_relinked_copy(&source, &single, &replacements, &cancel).unwrap(); + assert_eq!(std::fs::read_to_string(&single).unwrap(), fixed); + } + #[test] fn repaired_copy_cannot_replace_its_source() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/formats.md b/docs/formats.md index c9563c3..232770d 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -25,7 +25,7 @@ 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. | -| FCPXML | Yes | Yes | Timeline and multicam projects; optional track storylines and audio roles. | +| FCPXML | Yes | Yes | Timeline and multicam projects; optional track storylines and audio roles. Imports `.fcpxml` files and `.fcpxmld` bundles. | | AAF | Yes | Yes | External picture/audio and embedded PCM audio import; linked media export. Requires `align-aaf`. | | OpenTimelineIO | No | Yes | Resolve timeline output. | | Resolve Python script | No | Yes | Import helper generated alongside Resolve exports; requires the editor's scripting interface. | From 03f2ee3438ca3fd9c345647cdb65d19961773db9 Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Thu, 12 Nov 2026 10:24:26 +0300 Subject: [PATCH 3/4] Read the file type only where symbolic links are copied On Windows the copied bundle follows links, and the unused file type failed clippy. --- crates/align-decode/src/timeline.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/align-decode/src/timeline.rs b/crates/align-decode/src/timeline.rs index ad8c4d8..4226513 100644 --- a/crates/align-decode/src/timeline.rs +++ b/crates/align-decode/src/timeline.rs @@ -224,9 +224,8 @@ fn copy_tree( continue; } let target = to.join(entry.file_name()); - let kind = entry.file_type().map_err(write_error)?; #[cfg(unix)] - if kind.is_symlink() { + if entry.file_type().map_err(write_error)?.is_symlink() { let link = std::fs::read_link(entry.path()).map_err(write_error)?; std::os::unix::fs::symlink(link, &target).map_err(write_error)?; continue; From 8f6d11829e9ba7b144eaac014782633091832add Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Thu, 12 Nov 2026 10:31:24 +0300 Subject: [PATCH 4/4] Close the bundle document before moving the bundle into place Windows refuses to rename a folder that holds an open file, so every repaired bundle failed there with "Access is denied." --- crates/align-decode/src/timeline.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/align-decode/src/timeline.rs b/crates/align-decode/src/timeline.rs index 4226513..4b25eee 100644 --- a/crates/align-decode/src/timeline.rs +++ b/crates/align-decode/src/timeline.rs @@ -177,11 +177,15 @@ fn write_bundle( if is_bundle(source) { copy_tree(source, staging.path(), Some(BUNDLE_DOCUMENT), cancel)?; } - let mut file = - std::fs::File::create(staging.path().join(BUNDLE_DOCUMENT)).map_err(write_error)?; - file.write_all(document) - .and_then(|()| file.sync_all()) - .map_err(write_error)?; + // Closed before the rename: Windows refuses to move a folder holding an + // open file. + { + let mut file = + std::fs::File::create(staging.path().join(BUNDLE_DOCUMENT)).map_err(write_error)?; + file.write_all(document) + .and_then(|()| file.sync_all()) + .map_err(write_error)?; + } if cancel.load(std::sync::atomic::Ordering::Relaxed) { return Err(TimelineError::Cancelled); }