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 8ae8e3f..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()) @@ -803,6 +816,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, @@ -842,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| { @@ -854,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); @@ -2510,16 +2597,40 @@ 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( @@ -2592,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() { @@ -2625,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"), }, @@ -3381,6 +3494,106 @@ 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. + #[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#" @@ -3596,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")); @@ -3606,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 2af4ab5..1f81308 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}."), } @@ -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,29 +1265,21 @@ 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; } - 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)); } } } @@ -1312,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)); @@ -2829,16 +2823,132 @@ 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); + } + + /// 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"); 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 +2956,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..4b25eee 100644 --- a/crates/align-decode/src/timeline.rs +++ b/crates/align-decode/src/timeline.rs @@ -19,12 +19,47 @@ pub enum TimelineError { Write(String), } +/// 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 { - 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", "fcpxmld", "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 { @@ -32,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(), )); @@ -57,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) @@ -73,6 +145,107 @@ 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)?; + } + // 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); + } + 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()); + #[cfg(unix)] + 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; + } + 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, @@ -90,11 +263,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) } } @@ -105,7 +284,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))?) } } @@ -113,6 +293,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/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..232770d 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -18,10 +18,14 @@ 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. | -| 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. |