use crate::clip::Clip;
fn frames_to_rational(frame: i64, num: i64, den: i64) -> String {
if num == 0 {
return "0/1".to_string();
}
format!("{}/{}", frame * den, num)
}
struct ClipItem<'a> {
clip: &'a Clip,
id: usize,
fps_num: i64,
fps_den: i64,
}
impl ClipItem<'_> {
fn render(&self) -> String {
let in_pt = self.clip.in_point.unwrap_or(0);
let out_pt = self.clip.out_point.or(self.clip.duration).unwrap_or(0);
let duration = out_pt - in_pt;
let src_in = frames_to_rational(in_pt, self.fps_num, self.fps_den);
let src_out = frames_to_rational(out_pt, self.fps_num, self.fps_den);
let rec_in = frames_to_rational(0, self.fps_num, self.fps_den);
let rec_out = frames_to_rational(duration, self.fps_num, self.fps_den);
let dur_str = frames_to_rational(duration, self.fps_num, self.fps_den);
let file_path = self.clip.file_path.to_string_lossy();
let name = xml_escape(&self.clip.name);
let mut s = String::new();
s.push_str(&format!(" <clipitem id=\"clipitem-{}\">\n", self.id));
s.push_str(&format!(" <name>{name}</name>\n"));
s.push_str(&format!(" <duration>{dur_str}</duration>\n"));
s.push_str(&format!(" <start>{rec_in}</start>\n"));
s.push_str(&format!(" <end>{rec_out}</end>\n"));
s.push_str(&format!(" <in>{src_in}</in>\n"));
s.push_str(&format!(" <out>{src_out}</out>\n"));
s.push_str(" <file>\n");
s.push_str(&format!(
" <pathurl>{}</pathurl>\n",
xml_escape(&file_path)
));
s.push_str(" </file>\n");
if !self.clip.keywords.is_empty() {
s.push_str(" <comments>\n");
let kws = self
.clip
.keywords
.iter()
.map(|k| xml_escape(k))
.collect::<Vec<_>>()
.join(", ");
s.push_str(&format!(
" <mastercomment1>{kws}</mastercomment1>\n"
));
s.push_str(" </comments>\n");
}
s.push_str(" </clipitem>\n");
s
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[derive(Debug, Clone)]
pub struct FcpXmlClipExporter {
fps_num: i64,
fps_den: i64,
project_name: String,
}
impl FcpXmlClipExporter {
#[must_use]
pub fn new(fps_num: i64, fps_den: i64, project_name: impl Into<String>) -> Self {
Self {
fps_num,
fps_den,
project_name: project_name.into(),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(24, 1, "Exported Sequence")
}
#[must_use]
pub fn export(&self, clips: &[Clip]) -> String {
let total_duration: i64 = clips
.iter()
.map(|c| {
let in_pt = c.in_point.unwrap_or(0);
let out_pt = c.out_point.or(c.duration).unwrap_or(0);
(out_pt - in_pt).max(0)
})
.sum();
let total_dur_str = frames_to_rational(total_duration, self.fps_num, self.fps_den);
let timebase = if self.fps_den == 1 {
self.fps_num.to_string()
} else {
format!("{}/{}", self.fps_num, self.fps_den)
};
let project_name = xml_escape(&self.project_name);
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<!DOCTYPE xmeml>\n");
xml.push_str("<xmeml version=\"5\">\n");
xml.push_str(" <sequence>\n");
xml.push_str(&format!(" <name>{project_name}</name>\n"));
xml.push_str(&format!(" <duration>{total_dur_str}</duration>\n"));
xml.push_str(" <rate>\n");
xml.push_str(&format!(" <timebase>{timebase}</timebase>\n"));
xml.push_str(" <ntsc>FALSE</ntsc>\n");
xml.push_str(" </rate>\n");
xml.push_str(" <media>\n");
xml.push_str(" <video>\n");
xml.push_str(" <track>\n");
for (idx, clip) in clips.iter().enumerate() {
let item = ClipItem {
clip,
id: idx + 1,
fps_num: self.fps_num,
fps_den: self.fps_den,
};
xml.push_str(&item.render());
}
xml.push_str(" </track>\n");
xml.push_str(" </video>\n");
xml.push_str(" </media>\n");
xml.push_str(" </sequence>\n");
xml.push_str("</xmeml>\n");
xml
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_clip(name: &str, duration: i64, in_pt: i64, out_pt: i64) -> Clip {
let mut c = Clip::new(PathBuf::from(format!("/media/{name}.mov")));
c.set_name(name);
c.set_duration(duration);
c.set_in_point(in_pt);
c.set_out_point(out_pt);
c
}
#[test]
fn test_export_produces_xml_declaration() {
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&[]);
assert!(xml.starts_with("<?xml version=\"1.0\""));
}
#[test]
fn test_export_contains_xmeml_root() {
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&[]);
assert!(xml.contains("<xmeml version=\"5\">"));
assert!(xml.contains("</xmeml>"));
}
#[test]
fn test_export_contains_sequence() {
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&[]);
assert!(xml.contains("<sequence>"));
assert!(xml.contains("</sequence>"));
}
#[test]
fn test_export_contains_project_name() {
let exporter = FcpXmlClipExporter::new(25, 1, "My Docfilm");
let xml = exporter.export(&[]);
assert!(xml.contains("<name>My Docfilm</name>"));
}
#[test]
fn test_export_timebase() {
let exporter = FcpXmlClipExporter::new(25, 1, "Seq");
let xml = exporter.export(&[]);
assert!(xml.contains("<timebase>25</timebase>"));
}
#[test]
fn test_export_single_clip_item() {
let clip = make_clip("Interview_001", 1000, 0, 240);
let exporter = FcpXmlClipExporter::new(24, 1, "Test");
let xml = exporter.export(&[clip]);
assert!(xml.contains("clipitem-1"));
assert!(xml.contains("<name>Interview_001</name>"));
}
#[test]
fn test_export_multiple_clips() {
let clips = vec![
make_clip("Clip_A", 500, 0, 120),
make_clip("Clip_B", 500, 0, 120),
];
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&clips);
assert!(xml.contains("clipitem-1"));
assert!(xml.contains("clipitem-2"));
assert!(xml.contains("Clip_A"));
assert!(xml.contains("Clip_B"));
}
#[test]
fn test_export_clip_with_keywords_in_comments() {
let mut clip = Clip::new(PathBuf::from("/media/shot.mov"));
clip.set_name("Shot");
clip.set_duration(100);
clip.set_in_point(0);
clip.set_out_point(100);
clip.add_keyword("outdoor");
clip.add_keyword("sunny");
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&[clip]);
assert!(xml.contains("<comments>"));
assert!(xml.contains("outdoor"));
assert!(xml.contains("sunny"));
}
#[test]
fn test_xml_escape_special_chars() {
let mut clip = Clip::new(PathBuf::from("/media/test.mov"));
clip.set_name("Scene <Ext> & \"Drama\"");
clip.set_duration(100);
clip.set_in_point(0);
clip.set_out_point(100);
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&[clip]);
assert!(xml.contains("<Ext>"));
assert!(xml.contains("&"));
assert!(xml.contains(""Drama""));
}
#[test]
fn test_export_empty_clips() {
let exporter = FcpXmlClipExporter::with_defaults();
let xml = exporter.export(&[]);
assert!(xml.contains("<track>"));
assert!(!xml.contains("clipitem"));
}
}