Files
oak-editor/crates/oak-otio/src/fcpxml.rs
T
Mike-Solar 280118a342
CI / Build & test (Linux) (push) Successful in 23m59s
CI / Build & test (Windows) (push) Successful in 31m18s
otio: write importable OTIO/FCPXML (DaVinci Resolve rejects both)
- available_range declared a zero duration for every clip (the media
  was 'unusable', so Resolve invalidated all of them): it now covers
  the whole media - video clips in the probed frame rate, audio clips
  in the probed sample rate (no more hardcoded 48000/0).
- Gap ranges mixed time bases (a 24 fps block start, a 1.0 trailing
  duration): gaps now stay in the sequence's time base, the trailing
  pad in whole frames.
- Empty sequences (no clips) are skipped, so a lone edited sequence
  exports as a bare Timeline root instead of a SerializableCollection
  Resolve cannot read.
- FCPXML: an asset used on both video and audio tracks now declares
  hasVideo AND hasAudio (the first registration's track kind used to
  hide the other stream); asset durations recover with available_range.
2026-09-10 16:55:12 +08:00

1583 lines
51 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FCPXML (Final Cut Pro X `.fcpxml`) interchange support for the oakotio
//! binding.
//!
//! This module reads and writes the FCPXML document format produced and
//! consumed by Final Cut Pro (FCP X, version 1.10 of the format) and, with
//! some tolerance, by DaVinci Resolve and other NLEs. It maps the FCPXML
//! structure onto the same [`crate::model`] types used for OpenTimelineIO
//! JSON, so an importer/exporter can offer `.fcpxml` alongside `.otio` with
//! a single object model:
//!
//! | FCPXML | OTIO model |
//! |-----------------------------------------|-------------------------|
//! | `<resources>`/`<format>` | frame rate (via `RationalTime::rate`) |
//! | `<resources>`/`<asset>` | `ExternalReference` (target_url + available_range) |
//! | `<library>`/`<event>`/`<project>` | timeline metadata + name |
//! | `<sequence>` | `Timeline` |
//! | `<spine>` | `Track` (kind "Video") |
//! | secondary `<video>` / `<audio>` | `Track` (kind "Video"/"Audio") |
//! | `<asset-clip>` | `Clip` |
//! | `<gap>` | `Gap` |
//! | `<transition>` | `Transition` (in_offset == out_offset == duration/2) |
//!
//! Time values ("100/3000s", "0s", "1001/30000s", ...) are rational
//! seconds; they are converted to/from `RationalTime` through
//! `oak_core::Rational` with exact rational arithmetic, so NTSC rates
//! (30000/1001, 60000/1001, ...) stay frame accurate in both directions.
//!
//! The reader is deliberately lenient: unknown elements and attributes are
//! skipped with a log line (`eprintln!`) instead of failing, so real-world
//! documents from FCP X / DaVinci Resolve import without crashing. Unknown
//! *timed* blocks inside a track are imported as `Gap`s that preserve their
//! timing. Structural errors (non-FCPXML root, unsupported version, missing
//! format resources) are reported as [`FcpxmlError`].
//!
//! The writer emits FCPXML 1.10 with 2-space indentation (the FCP X
//! convention). Time values are written as reduced rational seconds
//! ("48s", "1/30s", "1001/30000s") rather than FCP X's scaled
//! "value/rate*100" spellings; the two forms are semantically identical.
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use oak_core::Rational;
use quick_xml::escape;
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Reader;
use quick_xml::Writer;
use crate::model::{
Clip, Composable, ExternalReference, Gap, MediaReference, MissingReference, RationalTime,
TimeRange, Timeline, Track, Transition,
};
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Errors produced by the FCPXML layer.
#[derive(Debug)]
pub enum FcpxmlError {
/// The document could not be parsed as XML (malformed markup, encoding
/// errors, attribute errors).
Xml(String),
/// The document is structurally not a usable FCPXML document (wrong
/// root element, missing attributes, unknown resource references, bad
/// time values).
Malformed(String),
/// The document declares an FCPXML version this crate does not support.
UnsupportedVersion(String),
/// The underlying file could not be read or written.
Io(std::io::Error),
}
impl fmt::Display for FcpxmlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FcpxmlError::Xml(e) => write!(f, "FCPXML XML error: {e}"),
FcpxmlError::Malformed(e) => write!(f, "FCPXML document error: {e}"),
FcpxmlError::UnsupportedVersion(e) => write!(f, "FCPXML version error: {e}"),
FcpxmlError::Io(e) => write!(f, "FCPXML file error: {e}"),
}
}
}
impl std::error::Error for FcpxmlError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FcpxmlError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for FcpxmlError {
fn from(e: std::io::Error) -> FcpxmlError {
FcpxmlError::Io(e)
}
}
impl From<quick_xml::Error> for FcpxmlError {
fn from(e: quick_xml::Error) -> FcpxmlError {
FcpxmlError::Xml(e.to_string())
}
}
/// Convenience alias used by the FCPXML API.
pub type Result<T> = std::result::Result<T, FcpxmlError>;
// ---------------------------------------------------------------------------
// Time values
// ---------------------------------------------------------------------------
/// Parse an FCPXML time value into a rational number of seconds.
///
/// Accepts the canonical "value/rate s" form ("100/3000s", "1001/30000s"),
/// plain integer seconds ("48s", "0s"), a bare rational without the unit
/// ("1/24"), and decimal seconds ("1.5s"). Frame-count values ("24f")
/// cannot be converted without a rate and are rejected.
fn parse_time(s: &str) -> Result<Rational> {
let s = s.trim();
let s = s.strip_suffix('s').map(str::trim).unwrap_or(s);
if s.ends_with('f') {
return Err(FcpxmlError::Malformed(format!(
"frame-count time value \"{s}f\" cannot be converted without a rate"
)));
}
if let Some((num, den)) = s.split_once('/') {
let num = num
.trim()
.parse::<i64>()
.map_err(|_| FcpxmlError::Malformed(format!("invalid time value \"{s}\"")))?;
let den = den
.trim()
.parse::<i64>()
.map_err(|_| FcpxmlError::Malformed(format!("invalid time value \"{s}\"")))?;
if den == 0 {
return Err(FcpxmlError::Malformed(format!(
"time value \"{s}\" has a zero denominator"
)));
}
Ok(Rational::new(num, den))
} else if let Ok(whole) = s.parse::<i64>() {
Ok(Rational::new(whole, 1))
} else if let Ok(frac) = s.parse::<f64>() {
if !frac.is_finite() {
return Err(FcpxmlError::Malformed(format!(
"non-finite time value \"{s}\""
)));
}
Ok(Rational::from_double(frac))
} else {
Err(FcpxmlError::Malformed(format!(
"invalid time value \"{s}\""
)))
}
}
/// Format a rational number of seconds the way this crate writes FCPXML:
/// "0s", "48s" for whole seconds, "1001/30000s" otherwise (reduced form).
fn format_time(r: Rational) -> String {
let num = r.numerator();
let den = r.denominator();
if num == 0 {
"0s".to_string()
} else if den == 1 {
format!("{num}s")
} else {
format!("{num}/{den}s")
}
}
/// Seconds -> `RationalTime` at a given frame rate, using exact rational
/// arithmetic so frame counts stay integer for NTSC rates.
fn time_at_rate(seconds: Rational, rate: Rational) -> RationalTime {
let frames = seconds * rate;
RationalTime::new(frames.to_f64(), rate.to_f64())
}
/// `RationalTime` -> exact rational seconds (`value / rate`), recovered
/// with the continued-fraction `from_double` so "1001/30000s" round-trips.
fn seconds_of(time: RationalTime) -> Rational {
Rational::from_double(time.value() / time.rate())
}
/// Frames per second from a format's `frameDuration` ("1001/30000s" ->
/// 30000/1001 fps). `None` for a zero or null frame duration.
fn rate_of(frame_duration: Rational) -> Option<Rational> {
if frame_duration.numerator() == 0 || frame_duration.denominator() == 0 {
None
} else {
Some(Rational::new(
frame_duration.denominator(),
frame_duration.numerator(),
))
}
}
/// Frames per second -> `frameDuration` seconds (the inverse of
/// [`rate_of`]).
fn frame_duration_of(rate: Rational) -> Rational {
Rational::from_double(1.0 / rate.to_f64())
}
/// The FCPXML version this crate writes. FCP X reads it and it supports
/// the features used (a `name` attribute on `<sequence>`, `format`
/// attributes on `<asset-clip>`).
const FCPXML_VERSION: &str = "1.10";
/// The highest FCPXML version this crate reads.
const FCPXML_MAX_MINOR: u32 = 11;
/// Reject documents declaring a version this crate cannot read. The
/// version is parsed as "major.minor" (1.0 - 1.11); a plain float
/// comparison would misjudge "1.9" against "1.11".
fn check_version(version: &str) -> Result<()> {
let parts: Vec<&str> = version.trim().split('.').collect();
let parse = |s: &&str| s.parse::<u32>().ok();
let (Some(major), Some(minor)) = (parts.first().and_then(parse), parts.get(1).and_then(parse))
else {
return Err(FcpxmlError::UnsupportedVersion(format!(
"unparseable FCPXML version \"{version}\""
)));
};
if parts.len() > 2 || major != 1 || minor > FCPXML_MAX_MINOR {
return Err(FcpxmlError::UnsupportedVersion(format!(
"unsupported FCPXML version \"{version}\" (supported: 1.0 - 1.11)"
)));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Minimal XML tree
// ---------------------------------------------------------------------------
/// A lightweight XML tree built from the quick-xml event stream. Only the
/// structure FCPXML needs is kept: element name, attributes, child
/// elements and (trimmed) text content.
#[derive(Clone, Debug, Default)]
struct Node {
name: String,
attrs: Vec<(String, String)>,
children: Vec<Node>,
text: String,
}
impl Node {
/// The first attribute with the given name.
fn attr(&self, key: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
/// The first child element with the given (local) name.
fn child(&self, name: &str) -> Option<&Node> {
self.children.iter().find(|c| c.name == name)
}
}
/// Build a [`Node`] from a start/empty event (local name, unescaped
/// attributes).
fn node_from_start(e: &BytesStart) -> Result<Node> {
let name = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let mut attrs = Vec::new();
for attr in e.attributes() {
let attr =
attr.map_err(|err| FcpxmlError::Xml(format!("invalid attribute in <{name}>: {err}")))?;
let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned();
let value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map_err(|err| FcpxmlError::Xml(format!("invalid value for attribute {key}: {err}")))?
.into_owned();
attrs.push((key, value));
}
Ok(Node {
name,
attrs,
children: Vec::new(),
text: String::new(),
})
}
/// Attach `node` as a child of the current top of `stack` (no-op for the
/// document root).
fn attach(stack: &mut [Node], node: Node) {
if let Some(parent) = stack.last_mut() {
parent.children.push(node);
}
}
/// Parse `text` into a single XML tree (the document root). Comments,
/// processing instructions, the XML declaration and the DOCTYPE are
/// skipped; mismatched or unclosed tags are errors.
fn parse_xml(text: &str) -> Result<Node> {
let mut reader = Reader::from_str(text);
reader.config_mut().trim_text(true);
let mut stack: Vec<Node> = Vec::new();
let mut root: Option<Node> = None;
loop {
match reader.read_event()? {
Event::Eof => break,
Event::Start(e) => stack.push(node_from_start(&e)?),
Event::Empty(e) => {
let node = node_from_start(&e)?;
attach(&mut stack, node);
}
Event::End(e) => {
let name = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let node = stack.pop().ok_or_else(|| {
FcpxmlError::Malformed(format!("unexpected closing tag </{name}>"))
})?;
if node.name != name {
return Err(FcpxmlError::Malformed(format!(
"mismatched closing tag: expected </{}>, found </{name}>",
node.name
)));
}
if stack.is_empty() {
// The document root.
root = Some(node);
} else {
attach(&mut stack, node);
}
}
Event::Text(t) => {
// The reader resolves predefined entities; decode the
// (UTF-8 or declared-encoding) text content.
let text = t
.decode()
.map_err(|e| FcpxmlError::Xml(format!("invalid text content: {e}")))?;
if let Some(top) = stack.last_mut() {
top.text.push_str(&text);
}
}
Event::CData(t) => {
let text = String::from_utf8_lossy(t.as_ref()).into_owned();
if let Some(top) = stack.last_mut() {
top.text.push_str(&text);
}
}
_ => {}
}
}
match root {
Some(root) if stack.is_empty() => Ok(root),
_ => Err(FcpxmlError::Malformed(format!(
"document ended with {} unclosed element(s)",
stack.len()
))),
}
}
// ---------------------------------------------------------------------------
// Reader: resources
// ---------------------------------------------------------------------------
/// A parsed `<format>` resource.
#[derive(Clone, Debug)]
struct FormatInfo {
/// `frameDuration` in seconds ("1001/30000s").
frame_duration: Rational,
}
/// A parsed `<asset>` resource.
#[derive(Clone, Debug)]
struct AssetInfo {
/// The asset `src` (media URL).
src: String,
/// The referenced format id, if any.
format: Option<String>,
/// The asset `duration` in seconds.
duration: Rational,
}
/// Parse a `<format>` resource. Bad formats are logged and skipped so one
/// broken resource cannot sink the whole document.
fn parse_format(node: &Node, formats: &mut HashMap<String, FormatInfo>) {
let Some(id) = node.attr("id") else {
eprintln!("oakotio fcpxml: skipping <format> without an id");
return;
};
let frame_duration = match node.attr("frameDuration") {
Some(fd) => match parse_time(fd) {
Ok(fd) => fd,
Err(e) => {
eprintln!("oakotio fcpxml: skipping <format id=\"{id}\">: {e}");
return;
}
},
None => {
eprintln!("oakotio fcpxml: skipping <format id=\"{id}\"> without frameDuration");
return;
}
};
formats.insert(id.to_string(), FormatInfo { frame_duration });
}
/// Parse an `<asset>` resource. Unreadable durations fall back to 0s.
fn parse_asset(node: &Node, assets: &mut HashMap<String, AssetInfo>) {
let Some(id) = node.attr("id") else {
eprintln!("oakotio fcpxml: skipping <asset> without an id");
return;
};
let duration = match node.attr("duration") {
Some(d) => parse_time(d).unwrap_or_else(|e| {
eprintln!("oakotio fcpxml: <asset id=\"{id}\"> {e}; using 0s");
Rational::new(0, 1)
}),
None => Rational::new(0, 1),
};
assets.insert(
id.to_string(),
AssetInfo {
src: node.attr("src").unwrap_or("").to_string(),
format: node.attr("format").map(str::to_string),
duration,
},
);
}
// ---------------------------------------------------------------------------
// Reader: sequences and blocks
// ---------------------------------------------------------------------------
/// The rate of an `<asset-clip>`: its own `format` attribute, else the
/// referenced asset's format, else the sequence rate.
fn clip_rate_of(
node: &Node,
formats: &HashMap<String, FormatInfo>,
assets: &HashMap<String, AssetInfo>,
fallback: Rational,
) -> Rational {
if let Some(fmt_id) = node.attr("format") {
if let Some(fmt) = formats.get(fmt_id) {
if let Some(rate) = rate_of(fmt.frame_duration) {
return rate;
}
eprintln!("oakotio fcpxml: <asset-clip> format \"{fmt_id}\" has a zero frame duration");
} else {
eprintln!("oakotio fcpxml: <asset-clip> references unknown format \"{fmt_id}\"");
}
}
if let Some(ref_id) = node.attr("ref") {
if let Some(asset) = assets.get(ref_id) {
if let Some(fmt_id) = asset.format.as_deref() {
if let Some(fmt) = formats.get(fmt_id) {
if let Some(rate) = rate_of(fmt.frame_duration) {
return rate;
}
}
}
}
}
fallback
}
/// The rate of an `<asset>` (its format resource), falling back to the
/// sequence rate.
fn asset_rate_of(
asset: &AssetInfo,
formats: &HashMap<String, FormatInfo>,
fallback: Rational,
) -> Rational {
if let Some(fmt_id) = asset.format.as_deref() {
if let Some(fmt) = formats.get(fmt_id) {
if let Some(rate) = rate_of(fmt.frame_duration) {
return rate;
}
eprintln!("oakotio fcpxml: asset format \"{fmt_id}\" has a zero frame duration");
} else {
eprintln!("oakotio fcpxml: asset references unknown format \"{fmt_id}\"");
}
}
fallback
}
/// Parse one timed block inside a track container (spine / video / audio).
///
/// `pos` is the accumulated end of the previous blocks, used as the
/// element's offset when the element carries no explicit `offset`
/// attribute. Returns `None` for elements that should be skipped without
/// occupying timeline space.
fn parse_block(
node: &Node,
name: &str,
seq_rate: Rational,
formats: &HashMap<String, FormatInfo>,
assets: &HashMap<String, AssetInfo>,
pos: &mut Rational,
) -> Result<Option<Composable>> {
let offset = match node.attr("offset") {
Some(s) => parse_time(s)?,
None => *pos,
};
match name {
"asset-clip" | "clip" => {
let duration = match node.attr("duration") {
Some(d) => parse_time(d)?,
None => {
return Err(FcpxmlError::Malformed(format!(
"<{name}> is missing its duration attribute"
)));
}
};
let start = match node.attr("start") {
Some(s) => parse_time(s)?,
None => Rational::new(0, 1),
};
let clip_rate = clip_rate_of(node, formats, assets, seq_rate);
let mut clip = Clip::new(node.attr("name").unwrap_or(""));
clip.set_source_range(TimeRange::new(
time_at_rate(start, clip_rate),
time_at_rate(duration, clip_rate),
));
if let Some(enabled) = node.attr("enabled") {
clip.set_enabled(enabled != "0");
}
let reference = match node.attr("ref") {
Some(ref_id) => match assets.get(ref_id) {
Some(asset) => {
let asset_rate = asset_rate_of(asset, formats, seq_rate);
MediaReference::ExternalReference(ExternalReference::new(
asset.src.clone(),
Some(TimeRange::new(
time_at_rate(Rational::new(0, 1), asset_rate),
time_at_rate(asset.duration, asset_rate),
)),
))
}
None => {
eprintln!(
"oakotio fcpxml: <{name}> references unknown asset \"{ref_id}\"; using a MissingReference"
);
MediaReference::MissingReference(MissingReference::new())
}
},
None => {
eprintln!("oakotio fcpxml: <{name}> has no ref; using a MissingReference");
MediaReference::MissingReference(MissingReference::new())
}
};
clip.set_media_reference(reference);
*pos = offset + duration;
Ok(Some(Composable::Clip(clip)))
}
"gap" => {
let duration = match node.attr("duration") {
Some(d) => parse_time(d)?,
None => {
eprintln!("oakotio fcpxml: <gap> without duration treated as empty");
Rational::new(0, 1)
}
};
let range = TimeRange::new(
time_at_rate(offset, seq_rate),
time_at_rate(duration, seq_rate),
);
*pos = offset + duration;
Ok(Some(Composable::Gap(Gap::new(
range,
node.attr("name").unwrap_or(""),
))))
}
"transition" => {
let duration = match node.attr("duration") {
Some(d) => parse_time(d)?,
None => {
return Err(FcpxmlError::Malformed(
"<transition> is missing its duration attribute".to_string(),
));
}
};
// FCP X centers its transitions: each half of the duration
// reaches into the adjacent clip, so in_offset == out_offset
// == duration / 2.
let half = Rational::new(duration.numerator(), duration.denominator() * 2);
let mut transition = Transition::new(node.attr("name").unwrap_or(""));
transition.set_in_offset(time_at_rate(half, seq_rate));
transition.set_out_offset(time_at_rate(half, seq_rate));
*pos = offset + duration;
Ok(Some(Composable::Transition(transition)))
}
// FCP X containers this crate does not model. Their timing is
// preserved as a gap so the timeline layout stays intact.
"sync-clip" | "title" | "generator" | "caption" | "multicam" | "titles" => {
let duration = match node.attr("duration") {
Some(d) => parse_time(d)?,
None => Rational::new(0, 1),
};
eprintln!(
"oakotio fcpxml: <{name}> is not mapped to an OTIO block; importing its timing as a gap"
);
let range = TimeRange::new(
time_at_rate(offset, seq_rate),
time_at_rate(duration, seq_rate),
);
*pos = offset + duration;
Ok(Some(Composable::Gap(Gap::new(range, ""))))
}
// Metadata-ish children that occasionally sit inside a track
// container: nothing to do.
"marker" | "metadata" | "param" | "note" | "analysis" | "media" | "text" | "text-style" => {
Ok(None)
}
other => {
eprintln!("oakotio fcpxml: ignoring unknown track element <{other}>");
Ok(None)
}
}
}
/// Parse a track container (`spine`, `<video>` or `<audio>`) into a new
/// [`Track`] pushed onto `tracks`. Nested `<video>`/`<audio>` elements
/// become additional tracks, matching FCP X's multi-lane layout.
fn parse_container(
kind: &str,
node: &Node,
seq_rate: Rational,
formats: &HashMap<String, FormatInfo>,
assets: &HashMap<String, AssetInfo>,
tracks: &mut Vec<Track>,
) -> Result<()> {
let mut track = Track::new(kind);
let mut pos = Rational::new(0, 1);
for child in &node.children {
match child.name.as_str() {
"video" => parse_container("Video", child, seq_rate, formats, assets, tracks)?,
"audio" => parse_container("Audio", child, seq_rate, formats, assets, tracks)?,
other => {
if let Some(block) = parse_block(child, other, seq_rate, formats, assets, &mut pos)?
{
track.append_child(block);
}
}
}
}
tracks.push(track);
Ok(())
}
/// Store interchange hints (source version, tcFormat, event/project
/// names, audio layout/rate) under the nested "fcpxml" metadata key so a
/// later export can reproduce them.
fn set_fcpxml_metadata(
timeline: &mut Timeline,
sequence: &Node,
event: &Node,
project: &Node,
version: &str,
) {
let mut fcpx = serde_json::Map::new();
fcpx.insert("version".into(), serde_json::Value::String(version.into()));
if let Some(tc) = sequence.attr("tcFormat") {
fcpx.insert("tcFormat".into(), serde_json::Value::String(tc.into()));
}
if let Some(layout) = sequence.attr("audioLayout") {
fcpx.insert(
"audioLayout".into(),
serde_json::Value::String(layout.into()),
);
}
if let Some(rate) = sequence.attr("audioRate") {
fcpx.insert("audioRate".into(), serde_json::Value::String(rate.into()));
}
if let Some(e) = event.attr("name") {
fcpx.insert("event".into(), serde_json::Value::String(e.into()));
}
if let Some(p) = project.attr("name") {
fcpx.insert("project".into(), serde_json::Value::String(p.into()));
}
timeline
.metadata_mut()
.insert("fcpxml".into(), serde_json::Value::Object(fcpx));
}
/// Parse a `<sequence>` element into a [`Timeline`].
fn parse_sequence(
node: &Node,
event: &Node,
project: &Node,
version: &str,
formats: &HashMap<String, FormatInfo>,
assets: &HashMap<String, AssetInfo>,
) -> Result<Timeline> {
let format_id = node.attr("format").ok_or_else(|| {
FcpxmlError::Malformed("<sequence> is missing its format reference".into())
})?;
let seq_format = formats.get(format_id).ok_or_else(|| {
FcpxmlError::Malformed(format!(
"sequence references unknown format resource \"{format_id}\""
))
})?;
let rate = rate_of(seq_format.frame_duration).ok_or_else(|| {
FcpxmlError::Malformed(format!(
"format resource \"{format_id}\" has a zero frame duration"
))
})?;
let name = node
.attr("name")
.filter(|s| !s.is_empty())
.or_else(|| project.attr("name").filter(|s| !s.is_empty()))
.or_else(|| event.attr("name").filter(|s| !s.is_empty()))
.unwrap_or("")
.to_string();
let mut timeline = Timeline::new(name);
if let Some(tc_start) = node.attr("tcStart") {
let seconds = parse_time(tc_start)?;
timeline.set_global_start_time(time_at_rate(seconds, rate));
}
set_fcpxml_metadata(&mut timeline, node, event, project, version);
let mut tracks = Vec::new();
for child in &node.children {
match child.name.as_str() {
"spine" => parse_container("Video", child, rate, formats, assets, &mut tracks)?,
"video" => parse_container("Video", child, rate, formats, assets, &mut tracks)?,
"audio" => parse_container("Audio", child, rate, formats, assets, &mut tracks)?,
other => eprintln!("oakotio fcpxml: ignoring unknown <sequence> child <{other}>"),
}
}
for track in tracks {
timeline.tracks_mut().append_child(Composable::Track(track));
}
Ok(timeline)
}
/// Parse an FCPXML document into one [`Timeline`] per `<sequence>`.
///
/// # Errors
///
/// - `FcpxmlError::Xml` for malformed XML markup.
/// - `FcpxmlError::Malformed` when the root is not `<fcpxml>`, a required
/// attribute is missing, or a sequence references a format resource that
/// does not exist.
/// - `FcpxmlError::UnsupportedVersion` for a missing or out-of-range
/// `version` attribute.
pub fn from_fcpxml_string(text: &str) -> Result<Vec<Timeline>> {
let root = parse_xml(text)?;
if root.name != "fcpxml" {
return Err(FcpxmlError::Malformed(format!(
"document root is <{}>, expected <fcpxml>",
root.name
)));
}
let version = root.attr("version").ok_or_else(|| {
FcpxmlError::UnsupportedVersion("document has no version attribute".into())
})?;
check_version(version)?;
let mut formats = HashMap::new();
let mut assets = HashMap::new();
if let Some(resources) = root.child("resources") {
for child in &resources.children {
match child.name.as_str() {
"format" => parse_format(child, &mut formats),
"asset" => parse_asset(child, &mut assets),
other => eprintln!("oakotio fcpxml: ignoring unknown resource element <{other}>"),
}
}
}
let mut timelines = Vec::new();
if let Some(library) = root.child("library") {
for event in library.children.iter().filter(|c| c.name == "event") {
for project in event.children.iter().filter(|c| c.name == "project") {
for sequence in project.children.iter().filter(|c| c.name == "sequence") {
timelines.push(parse_sequence(
sequence, event, project, version, &formats, &assets,
)?);
}
}
}
}
Ok(timelines)
}
/// Read and parse an FCPXML document from a file.
pub fn from_fcpxml_file(path: impl AsRef<Path>) -> Result<Vec<Timeline>> {
from_fcpxml_string(&std::fs::read_to_string(path)?)
}
// ---------------------------------------------------------------------------
// Writer
// ---------------------------------------------------------------------------
/// Default frame size used for `<format>` resources (the OTIO model does
/// not carry video dimensions; see README "Deviations").
const DEFAULT_FORMAT_WIDTH: &str = "1920";
const DEFAULT_FORMAT_HEIGHT: &str = "1080";
/// A `<format>` resource ready to be written.
struct FormatEntry {
id: String,
name: String,
frame_duration: Rational,
width: String,
height: String,
}
/// An `<asset>` resource ready to be written.
struct AssetEntry {
id: String,
name: String,
src: String,
format_id: String,
duration: Rational,
has_video: bool,
has_audio: bool,
}
/// Resources accumulated while walking the timelines being exported.
/// Formats are deduplicated by frame duration, assets by `src`.
#[derive(Default)]
struct ResourceSet {
formats: Vec<FormatEntry>,
assets: Vec<AssetEntry>,
format_ids: HashMap<(i64, i64), String>,
asset_ids: HashMap<String, String>,
next_id: usize,
}
impl ResourceSet {
/// The `<format>` id for a frame duration, creating the resource (and
/// assigning an id) if it is not present yet.
fn format_id_for(&mut self, frame_duration: Rational) -> String {
let key = (frame_duration.numerator(), frame_duration.denominator());
if let Some(id) = self.format_ids.get(&key) {
return id.clone();
}
self.next_id += 1;
let id = format!("r{}", self.next_id);
let name = format_name(frame_duration);
self.formats.push(FormatEntry {
id: id.clone(),
name,
frame_duration,
width: DEFAULT_FORMAT_WIDTH.to_string(),
height: DEFAULT_FORMAT_HEIGHT.to_string(),
});
self.format_ids.insert(key, id.clone());
id
}
/// The `<asset>` id for a media URL, creating the resource if it is
/// not present yet.
fn asset_id_for(
&mut self,
src: &str,
format_id: &str,
duration: Rational,
has_video: bool,
has_audio: bool,
) -> String {
if let Some(id) = self.asset_ids.get(src) {
return id.clone();
}
self.next_id += 1;
let id = format!("r{}", self.next_id);
let name = Path::new(src)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| src.to_string());
self.assets.push(AssetEntry {
id: id.clone(),
name,
src: src.to_string(),
format_id: format_id.to_string(),
duration,
has_video,
has_audio,
});
self.asset_ids.insert(src.to_string(), id.clone());
id
}
/// Walk one timeline and register every format and asset it needs.
/// Formats are collected in a first pass so they are written before
/// assets (the FCP X resources ordering), with asset ids following.
fn collect_from(&mut self, timeline: &Timeline) {
let seq_rate = sequence_rate_of(timeline);
self.format_id_for(frame_duration_of(seq_rate));
for child in timeline.tracks().children() {
let Some(track) = child.as_track() else {
continue;
};
for block in track.children() {
let Some(clip) = block.as_clip() else {
continue;
};
if let Some(range) = clip.source_range() {
self.format_id_for(frame_duration_of(Rational::from_double(
range.duration().rate(),
)));
}
if let Some(external) = clip
.media_reference()
.and_then(MediaReference::as_external_reference)
{
if let Some(range) = external.available_range() {
self.format_id_for(frame_duration_of(Rational::from_double(
range.duration().rate(),
)));
}
}
}
}
for child in timeline.tracks().children() {
let Some(track) = child.as_track() else {
continue;
};
let is_video = track.kind() == "Video";
let is_audio = track.kind() == "Audio";
for block in track.children() {
let Some(clip) = block.as_clip() else {
continue;
};
let clip_rate = clip
.source_range()
.map(|r| Rational::from_double(r.duration().rate()))
.unwrap_or(seq_rate);
let clip_format_id = self.format_id_for(frame_duration_of(clip_rate));
let Some(external) = clip
.media_reference()
.and_then(MediaReference::as_external_reference)
else {
continue;
};
let src = external.target_url();
if self.asset_ids.contains_key(src) {
// Already registered from another track: union the
// stream flags so a file used on both a video and an
// audio track declares hasVideo AND hasAudio (the
// first registration's track kind must not hide the
// other stream from the importer).
if let Some(existing) = self.assets.iter_mut().find(|a| a.src == src) {
existing.has_video |= is_video;
existing.has_audio |= is_audio;
}
continue;
}
let (asset_format_id, asset_duration) = match external.available_range() {
Some(range) => {
let rate = Rational::from_double(range.duration().rate());
(
self.format_id_for(frame_duration_of(rate)),
seconds_of(range.duration()),
)
}
None => {
let duration = clip
.source_range()
.map(|r| seconds_of(r.duration()))
.unwrap_or(Rational::new(0, 1));
(clip_format_id, duration)
}
};
self.asset_id_for(src, &asset_format_id, asset_duration, is_video, is_audio);
}
}
}
}
/// FCP X-style format name, e.g. "FFVideoFormat1920x1080p30" or
/// "...p29_97". The name is informational; importers derive the rate from
/// `frameDuration`.
fn format_name(frame_duration: Rational) -> String {
let rate = rate_of(frame_duration).map(Rational::to_f64).unwrap_or(0.0);
let rate_str = if (rate - rate.round()).abs() < 1e-6 {
format!("{}", rate.round() as i64)
} else {
format!("{rate:.2}").replace('.', "_")
};
format!(
"FFVideoFormat{}x{}p{rate_str}",
DEFAULT_FORMAT_WIDTH, DEFAULT_FORMAT_HEIGHT
)
}
/// The timeline rate: the rate of the first block carrying a source
/// range, else 24 fps.
fn sequence_rate_of(timeline: &Timeline) -> Rational {
for child in timeline.tracks().children() {
if let Some(track) = child.as_track() {
for block in track.children() {
if let Some(range) = block.source_range() {
return Rational::from_double(range.duration().rate());
}
}
}
}
Rational::new(24, 1)
}
/// The duration of one block in seconds (transitions contribute
/// in_offset + out_offset).
fn block_duration(block: &Composable) -> Rational {
match block {
Composable::Clip(c) => c
.source_range()
.map(|r| seconds_of(r.duration()))
.unwrap_or(Rational::new(0, 1)),
Composable::Gap(g) => g
.source_range()
.map(|r| seconds_of(r.duration()))
.unwrap_or(Rational::new(0, 1)),
Composable::Transition(t) => seconds_of(t.in_offset()) + seconds_of(t.out_offset()),
_ => Rational::new(0, 1),
}
}
/// The end of one track, in seconds, walking the blocks the same way the
/// writer places them. Transitions overlap the tail of the previous block,
/// so they advance the position by their `out_offset` only (the `in_offset`
/// lies inside the previous block).
fn track_end(track: &Track) -> Rational {
let mut pos = Rational::new(0, 1);
for block in track.children() {
match &**block {
Composable::Transition(t) => pos = pos + seconds_of(t.out_offset()),
other => pos = pos + block_duration(other),
}
}
pos
}
/// The end of the longest track, in seconds (the `<sequence>`
/// `duration`).
fn timeline_duration(timeline: &Timeline) -> Rational {
let mut max_end = Rational::new(0, 1);
for child in timeline.tracks().children() {
if let Some(track) = child.as_track() {
let end = track_end(track);
if end > max_end {
max_end = end;
}
}
}
max_end
}
/// Push an (escaped) attribute onto a start element. quick-xml does not
/// escape attribute values itself, so the value is escaped here.
fn push_attr(start: &mut BytesStart, key: &str, value: &str) {
let escaped = escape::escape(value).into_owned();
start.push_attribute((key, escaped.as_str()));
}
fn write_event(writer: &mut Writer<Vec<u8>>, event: Event<'_>) -> Result<()> {
writer.write_event(event)?;
Ok(())
}
/// Write the `<resources>` element.
fn write_resources(writer: &mut Writer<Vec<u8>>, resources: &ResourceSet) -> Result<()> {
if resources.formats.is_empty() && resources.assets.is_empty() {
return Ok(());
}
write_event(writer, Event::Start(BytesStart::new("resources")))?;
for format in &resources.formats {
let mut el = BytesStart::new("format");
push_attr(&mut el, "id", &format.id);
push_attr(&mut el, "name", &format.name);
push_attr(
&mut el,
"frameDuration",
&format_time(format.frame_duration),
);
push_attr(&mut el, "width", &format.width);
push_attr(&mut el, "height", &format.height);
write_event(writer, Event::Empty(el))?;
}
for asset in &resources.assets {
let mut el = BytesStart::new("asset");
push_attr(&mut el, "id", &asset.id);
push_attr(&mut el, "name", &asset.name);
push_attr(&mut el, "src", &asset.src);
push_attr(&mut el, "format", &asset.format_id);
push_attr(&mut el, "duration", &format_time(asset.duration));
push_attr(&mut el, "hasVideo", if asset.has_video { "1" } else { "0" });
push_attr(&mut el, "hasAudio", if asset.has_audio { "1" } else { "0" });
write_event(writer, Event::Empty(el))?;
}
write_event(writer, Event::End(BytesEnd::new("resources")))?;
Ok(())
}
/// Read a string out of the "fcpxml" metadata object.
fn metadata_string(timeline: &Timeline, key: &str) -> Option<String> {
timeline
.metadata()
.get("fcpxml")
.and_then(|m| m.get(key))
.and_then(|v| v.as_str())
.map(str::to_string)
}
/// Write the blocks of one track into its FCPXML container.
fn write_blocks(
writer: &mut Writer<Vec<u8>>,
track: &Track,
seq_rate: Rational,
resources: &mut ResourceSet,
pos: &mut Rational,
) -> Result<()> {
for block in track.children() {
match &**block {
Composable::Clip(clip) => {
let (start, duration, clip_rate) = match clip.source_range() {
Some(range) => (
seconds_of(range.start_time()),
seconds_of(range.duration()),
Rational::from_double(range.duration().rate()),
),
None => (Rational::new(0, 1), Rational::new(0, 1), seq_rate),
};
let mut el = BytesStart::new("asset-clip");
push_attr(&mut el, "name", clip.name());
match clip.media_reference() {
Some(MediaReference::ExternalReference(external)) => {
let src = external.target_url();
match resources.asset_ids.get(src) {
Some(asset_id) => push_attr(&mut el, "ref", asset_id),
None => eprintln!(
"oakotio fcpxml: no asset registered for \"{src}\"; writing clip \"{}\" without a ref",
clip.name()
),
}
}
_ => eprintln!(
"oakotio fcpxml: clip \"{}\" has no external media reference; writing it without a ref",
clip.name()
),
}
push_attr(&mut el, "offset", &format_time(*pos));
push_attr(&mut el, "duration", &format_time(duration));
push_attr(&mut el, "start", &format_time(start));
if clip_rate != seq_rate {
let format_id = resources.format_id_for(frame_duration_of(clip_rate));
push_attr(&mut el, "format", &format_id);
}
if !clip.enabled() {
push_attr(&mut el, "enabled", "0");
}
write_event(writer, Event::Empty(el))?;
*pos = *pos + duration;
}
Composable::Gap(gap) => {
let duration = gap
.source_range()
.map(|r| seconds_of(r.duration()))
.unwrap_or(Rational::new(0, 1));
let mut el = BytesStart::new("gap");
push_attr(&mut el, "name", gap.name());
push_attr(&mut el, "offset", &format_time(*pos));
push_attr(&mut el, "duration", &format_time(duration));
write_event(writer, Event::Empty(el))?;
*pos = *pos + duration;
}
Composable::Transition(transition) => {
let in_s = seconds_of(transition.in_offset());
let out_s = seconds_of(transition.out_offset());
let duration = in_s + out_s;
// The transition starts in_offset before the end of the
// previous block (FCP X's centered transition layout).
let offset = *pos - in_s;
let mut el = BytesStart::new("transition");
push_attr(&mut el, "name", transition.name());
push_attr(&mut el, "offset", &format_time(offset));
push_attr(&mut el, "duration", &format_time(duration));
write_event(writer, Event::Empty(el))?;
*pos = offset + duration;
}
other => eprintln!(
"oakotio fcpxml: skipping nested {} block on track \"{}\"",
other.schema_name(),
track.kind()
),
}
}
Ok(())
}
/// Write one `<sequence>` element (and its tracks) for a timeline.
fn write_sequence(
writer: &mut Writer<Vec<u8>>,
timeline: &Timeline,
resources: &mut ResourceSet,
) -> Result<()> {
let mut video_tracks = Vec::new();
let mut audio_tracks = Vec::new();
for child in timeline.tracks().children() {
if let Some(track) = child.as_track() {
match track.kind() {
"Video" => video_tracks.push(track),
"Audio" => audio_tracks.push(track),
other => eprintln!("oakotio fcpxml: skipping track of unknown kind \"{other}\""),
}
}
}
// The first video track (or first audio track when there is no video)
// becomes the primary storyline (<spine>).
let spine = video_tracks
.first()
.copied()
.or_else(|| audio_tracks.first().copied());
let seq_rate = sequence_rate_of(timeline);
let duration = timeline_duration(timeline);
let tc_start = match timeline.global_start_time() {
Some(rt) => seconds_of(rt),
None => Rational::new(0, 1),
};
let tc_format = metadata_string(timeline, "tcFormat").unwrap_or_else(|| "NDF".to_string());
let mut seq = BytesStart::new("sequence");
let seq_format_id = resources.format_id_for(frame_duration_of(seq_rate));
push_attr(&mut seq, "format", &seq_format_id);
push_attr(&mut seq, "tcStart", &format_time(tc_start));
push_attr(&mut seq, "tcFormat", &tc_format);
push_attr(&mut seq, "duration", &format_time(duration));
if let Some(layout) = metadata_string(timeline, "audioLayout") {
push_attr(&mut seq, "audioLayout", &layout);
}
if let Some(rate) = metadata_string(timeline, "audioRate") {
push_attr(&mut seq, "audioRate", &rate);
}
push_attr(&mut seq, "name", timeline.name());
write_event(writer, Event::Start(seq))?;
if let Some(spine_track) = spine {
write_event(writer, Event::Start(BytesStart::new("spine")))?;
let mut pos = Rational::new(0, 1);
write_blocks(writer, spine_track, seq_rate, resources, &mut pos)?;
write_event(writer, Event::End(BytesEnd::new("spine")))?;
}
let mut spine_was_video = false;
if let Some(spine_track) = spine {
spine_was_video = spine_track.kind() == "Video";
}
for track in video_tracks.iter().skip(usize::from(spine_was_video)) {
write_event(writer, Event::Start(BytesStart::new("video")))?;
let mut pos = Rational::new(0, 1);
write_blocks(writer, track, seq_rate, resources, &mut pos)?;
write_event(writer, Event::End(BytesEnd::new("video")))?;
}
let spine_was_audio = !spine_was_video && spine.is_some();
for track in audio_tracks.iter().skip(usize::from(spine_was_audio)) {
write_event(writer, Event::Start(BytesStart::new("audio")))?;
let mut pos = Rational::new(0, 1);
write_blocks(writer, track, seq_rate, resources, &mut pos)?;
write_event(writer, Event::End(BytesEnd::new("audio")))?;
}
write_event(writer, Event::End(BytesEnd::new("sequence")))?;
Ok(())
}
/// Write one `<event>`/`<project>`/`<sequence>` group for a timeline.
fn write_project(
writer: &mut Writer<Vec<u8>>,
timeline: &Timeline,
resources: &mut ResourceSet,
) -> Result<()> {
let event_name = metadata_string(timeline, "event").unwrap_or_else(|| "Event".to_string());
let project_name = metadata_string(timeline, "project")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| timeline.name().to_string());
let mut event_el = BytesStart::new("event");
push_attr(&mut event_el, "name", &event_name);
write_event(writer, Event::Start(event_el))?;
let mut project_el = BytesStart::new("project");
push_attr(&mut project_el, "name", &project_name);
write_event(writer, Event::Start(project_el))?;
write_sequence(writer, timeline, resources)?;
write_event(writer, Event::End(BytesEnd::new("project")))?;
write_event(writer, Event::End(BytesEnd::new("event")))?;
Ok(())
}
/// Serialize timelines to an FCPXML 1.10 document string.
///
/// Each timeline becomes one `<project>`/`<sequence>` pair inside a single
/// `<library>`; formats and assets are collected and deduplicated across
/// all timelines.
pub fn to_fcpxml_string(timelines: &[Timeline]) -> Result<String> {
let mut resources = ResourceSet::default();
for timeline in timelines {
resources.collect_from(timeline);
}
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
write_event(
&mut writer,
Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)),
)?;
write_event(&mut writer, Event::DocType(BytesText::new("fcpxml")))?;
let mut root = BytesStart::new("fcpxml");
push_attr(&mut root, "version", FCPXML_VERSION);
write_event(&mut writer, Event::Start(root))?;
write_resources(&mut writer, &resources)?;
if !timelines.is_empty() {
write_event(&mut writer, Event::Start(BytesStart::new("library")))?;
for timeline in timelines {
write_project(&mut writer, timeline, &mut resources)?;
}
write_event(&mut writer, Event::End(BytesEnd::new("library")))?;
}
write_event(&mut writer, Event::End(BytesEnd::new("fcpxml")))?;
let bytes = writer.into_inner();
String::from_utf8(bytes)
.map_err(|e| FcpxmlError::Xml(format!("writer produced non-UTF-8 output: {e}")))
}
/// Serialize timelines to an FCPXML 1.10 document file.
pub fn to_fcpxml_file(timelines: &[Timeline], path: impl AsRef<Path>) -> Result<()> {
std::fs::write(path, to_fcpxml_string(timelines)?)?;
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as _;
#[test]
fn parse_time_forms() {
assert_eq!(parse_time("0s").unwrap(), Rational::new(0, 1));
assert_eq!(parse_time("48s").unwrap(), Rational::new(48, 1));
assert_eq!(parse_time("100/3000s").unwrap(), Rational::new(1, 30));
assert_eq!(
parse_time("1001/30000s").unwrap(),
Rational::new(1001, 30000)
);
assert_eq!(parse_time("1/24").unwrap(), Rational::new(1, 24));
assert_eq!(parse_time("0").unwrap(), Rational::new(0, 1));
assert_eq!(parse_time("1.5s").unwrap(), Rational::new(3, 2));
assert!(parse_time("abc").is_err());
assert!(parse_time("1/0s").is_err());
assert!(parse_time("24f").is_err());
assert!(parse_time("1/2/3s").is_err());
}
#[test]
fn format_time_forms() {
assert_eq!(format_time(Rational::new(0, 1)), "0s");
assert_eq!(format_time(Rational::new(48, 1)), "48s");
assert_eq!(format_time(Rational::new(1001, 30000)), "1001/30000s");
assert_eq!(format_time(Rational::new(2, 5)), "2/5s");
}
#[test]
fn ntsc_rate_round_trip() {
// 1 frame at 29.97fps: frameDuration 1001/30000s -> 30000/1001 fps.
let rate = rate_of(Rational::new(1001, 30000)).unwrap();
assert_eq!(rate, Rational::new(30000, 1001));
// 3 frames come back as an exact integer at that rate.
let t = time_at_rate(Rational::new(3003, 30000), rate);
assert!((t.value() - 3.0).abs() < 1e-9, "value = {}", t.value());
// ... and the seconds round-trip to the same rational.
assert_eq!(seconds_of(t), Rational::new(1001, 10000));
assert_eq!(frame_duration_of(rate), Rational::new(1001, 30000));
// 23.976 material (24000/1001) behaves the same.
let rate23976 = rate_of(Rational::new(1001, 24000)).unwrap();
assert_eq!(rate23976, Rational::new(24000, 1001));
assert_eq!(frame_duration_of(rate23976), Rational::new(1001, 24000));
}
#[test]
fn version_gates() {
assert!(check_version("1.0").is_ok());
assert!(check_version("1.9").is_ok());
assert!(check_version("1.10").is_ok());
assert!(check_version("1.11").is_ok());
assert!(check_version("2.0").is_err());
assert!(check_version("0.9").is_err());
assert!(check_version("1.12").is_err());
assert!(check_version("bogus").is_err());
}
#[test]
fn format_names() {
assert_eq!(
format_name(Rational::new(1, 30)),
"FFVideoFormat1920x1080p30"
);
assert_eq!(
format_name(Rational::new(1001, 30000)),
"FFVideoFormat1920x1080p29_97"
);
}
#[test]
fn block_durations() {
let mut clip = Clip::new("c");
clip.set_source_range(TimeRange::new(
RationalTime::new(0.0, 24.0),
RationalTime::new(48.0, 24.0),
));
assert_eq!(block_duration(&Composable::Clip(clip)), Rational::new(2, 1));
let mut t = Transition::new("t");
t.set_in_offset(RationalTime::new(12.0, 24.0));
t.set_out_offset(RationalTime::new(12.0, 24.0));
assert_eq!(
block_duration(&Composable::Transition(t)),
Rational::new(1, 1)
);
}
#[test]
fn error_display_and_source() {
assert!(FcpxmlError::Xml("x".into())
.to_string()
.contains("XML error"));
assert!(FcpxmlError::Malformed("x".into())
.to_string()
.contains("document error"));
assert!(FcpxmlError::UnsupportedVersion("x".into())
.to_string()
.contains("version error"));
let io = FcpxmlError::Io(std::io::Error::new(std::io::ErrorKind::Other, "io"));
assert!(io.to_string().contains("file error"));
assert!(io.source().is_some());
assert!(FcpxmlError::Xml("x".into()).source().is_none());
assert!(FcpxmlError::Malformed("x".into()).source().is_none());
}
#[test]
fn negative_and_decimal_time_values() {
assert_eq!(parse_time("-3/2s").unwrap(), Rational::new(-3, 2));
assert_eq!(parse_time("-0.5s").unwrap(), Rational::new(-1, 2));
assert_eq!(format_time(Rational::new(-3, 2)), "-3/2s");
}
#[test]
fn resource_error_branches_are_lenient() {
let doc = r#"<fcpxml version="1.10">
<resources>
<format frameDuration="100/3000s"/>
<format id="f2" frameDuration="bogus"/>
<format id="f3" frameDuration="1/24s"/>
<asset duration="junk"/>
<asset id="a2"/>
</resources>
<library><event name="e"><project name="p">
<sequence format="f3"><spine/></sequence>
</project></event></library>
</fcpxml>"#;
let timelines = from_fcpxml_string(doc).expect("broken resources are skipped");
assert_eq!(timelines.len(), 1);
assert_eq!(timelines[0].name(), "p");
}
#[test]
fn zero_frame_duration_is_an_error() {
let doc = r#"<fcpxml version="1.10">
<resources><format id="f1" frameDuration="0/3000s"/></resources>
<library><event name="e"><project name="p">
<sequence format="f1"><spine/></sequence>
</project></event></library>
</fcpxml>"#;
assert!(matches!(
from_fcpxml_string(doc),
Err(FcpxmlError::Malformed(_))
));
}
#[test]
fn unknown_asset_format_falls_back_to_sequence_rate() {
let doc = r#"<fcpxml version="1.10">
<resources>
<format id="f1" frameDuration="100/3000s"/>
<asset id="a1" src="file:///x.mov" format="ghost" duration="10s"/>
</resources>
<library><event name="e"><project name="p">
<sequence format="f1"><spine>
<asset-clip name="c" ref="a1" offset="0s" duration="300/3000s" start="0s"/>
</spine></sequence>
</project></event></library>
</fcpxml>"#;
let timelines = from_fcpxml_string(doc).expect("falls back to the sequence rate");
let clip = timelines[0].tracks().children()[0]
.as_track()
.unwrap()
.children()[0]
.as_clip()
.unwrap();
let range = clip.source_range().unwrap();
assert_eq!(range.duration().rate(), 30.0);
// The asset reference still resolves (src is kept).
let external = clip
.media_reference()
.unwrap()
.as_external_reference()
.unwrap();
assert_eq!(external.target_url(), "file:///x.mov");
}
#[test]
fn nested_track_block_and_unknown_kind_are_skipped() {
let mut timeline = Timeline::new("nested");
let mut track = Track::new("Video");
track.append_child(Composable::Track(Track::new("Video")));
timeline.tracks_mut().append_child(Composable::Track(track));
let xml = to_fcpxml_string(&[timeline]).expect("nested track block is skipped");
assert!(xml.contains("<spine>"), "{xml}");
assert!(xml.contains("</spine>"), "{xml}");
let mut timeline = Timeline::new("kinds");
let mut subtitles = Track::new("Subtitles");
subtitles.append_child(Composable::Clip(Clip::new("c")));
timeline
.tracks_mut()
.append_child(Composable::Track(subtitles));
let xml = to_fcpxml_string(&[timeline]).expect("unknown track kind is skipped");
assert!(!xml.contains("<spine>"), "{xml}");
assert!(xml.contains("<sequence"), "{xml}");
}
#[test]
fn clip_without_media_reference_writes_without_ref() {
let mut timeline = Timeline::new("t");
let mut track = Track::new("Video");
let mut clip = Clip::new("orphan");
clip.set_source_range(TimeRange::new(
RationalTime::new(0.0, 24.0),
RationalTime::new(24.0, 24.0),
));
track.append_child(Composable::Clip(clip));
timeline.tracks_mut().append_child(Composable::Track(track));
let xml = to_fcpxml_string(&[timeline]).expect("export");
assert!(
xml.contains(
"<asset-clip name=\"orphan\" offset=\"0s\" duration=\"1s\" start=\"0s\"/>"
),
"{xml}"
);
}
#[test]
fn file_io_errors() {
assert!(from_fcpxml_file("/nonexistent/oakotio-test.fcpxml").is_err());
assert!(to_fcpxml_file(&[], &std::env::temp_dir()).is_err());
}
}