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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/align-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
236 changes: 228 additions & 8 deletions crates/align-core/src/xml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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::<String>()
.into_bytes()
};
let head = match head.as_slice() {
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
bytes if bytes.contains(&0) => return true,
_ => head,
};
let mut reader = Reader::from_reader(head.as_slice());
let mut buffer = Vec::new();
loop {
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(element) | Event::Empty(element)) => {
let root = element.local_name();
return root.as_ref().eq_ignore_ascii_case(b"xmeml")
|| root.as_ref().eq_ignore_ascii_case(b"fcpxml");
}
Ok(Event::Eof) | Err(_) => return true,
Ok(_) => buffer.clear(),
}
}
}

/// Dispatch by root element (`fcpxml` or FCP 7 XML).
pub fn read_timeline(
path: &Path,
Expand Down Expand Up @@ -842,23 +900,52 @@ 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<Vec<u8>, 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| {
let value = value.trim();
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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<String, AssetInfo> = HashMap::new();
let mut stack = vec![doc.root];
while let Some(n) = stack.pop() {
Expand Down Expand Up @@ -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"),
},
Expand Down Expand Up @@ -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",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"4\"><sequence/></xmeml>",
true,
),
(
"resolve.xml",
"<?xml version=\"1.0\"?>\n<!-- exported -->\n<XMEML version=\"5\"/>",
true,
),
(
"fcp.xml",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.10\"/>",
true,
),
(
"A001C001_CANON.XML",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Clip><ClipName>A001C001</ClipName></Clip>",
false,
),
(
"C0001M01.XML",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<NonRealTimeMeta xmlns=\"urn:schemas-professionalDisc:nonRealTimeMeta:ver.2.00\"/>",
false,
),
("broken.xml", "not xml at all", true),
];
for (name, text, timeline) in cases {
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
assert_eq!(is_timeline_document(&path), timeline, "{name}");
}
// UTF-16 with a byte order mark is decoded to find the root; without
// one, NUL bytes keep the file a timeline.
let utf16 = |text: &str, bom: [u8; 2], unit: fn(u16) -> [u8; 2]| {
let mut bytes = bom.to_vec();
bytes.extend(text.encode_utf16().flat_map(unit));
bytes
};
let project = "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<xmeml version=\"5\"/>";
let camera = "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<Clip/>";
let encoded = [
(
"le.xml",
utf16(project, [0xFF, 0xFE], u16::to_le_bytes),
true,
),
(
"be.xml",
utf16(project, [0xFE, 0xFF], u16::to_be_bytes),
true,
),
(
"camera-le.xml",
utf16(camera, [0xFF, 0xFE], u16::to_le_bytes),
false,
),
(
"no-bom.xml",
project.encode_utf16().flat_map(u16::to_le_bytes).collect(),
true,
),
];
for (name, bytes, timeline) in encoded {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
assert_eq!(is_timeline_document(&path), timeline, "{name}");
}
assert!(is_timeline_document(&dir.join("missing.xml")));
let _ = std::fs::remove_dir_all(&dir);
}

const FCP7: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<xmeml version="4">
<sequence>
Expand Down Expand Up @@ -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("<pathurl>file:///new/A%20Clip.mov</pathurl>"));
assert!(fixed.contains("<![CDATA[file:///new/rec.wav]]>"));
assert!(fixed.contains("<name>Keep &amp; Me</name>"));
Expand All @@ -3606,7 +3822,11 @@ mod tests {
<asset id="b"><media-rep kind="original-media" src="file:///old/rec.wav"/></asset>
<asset id="c" src="file:///unchanged.mov"/>
</resources></fcpxml>"#;
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("<asset id=\"c\" src=\"file:///unchanged.mov\"/>"));
Expand Down
Loading
Loading