Skip to main content

ffcine_import/
lib.rs

1//! Importers: foreign projects -> an ffcine [`Timeline`] (`docs/import.md`).
2//!
3//! Each format is a module with `pub fn import(input: &Path, opts: &ImportOptions) ->
4//! Result<Imported>`. Anything a module doesn't map goes to `metadata.ffcine.foreign.<app>` on
5//! the object it came from and is listed in [`ImportReport::unmapped`]. Readers are lenient by
6//! default: what they can't read at all (an unknown element, effect or event) is skipped and
7//! listed in [`ImportReport::skipped`]; [`ImportOptions::strict`] turns any skip into an error.
8
9pub mod capcut;
10pub mod cdl;
11mod common;
12pub mod edit_yaml;
13pub mod edl;
14pub mod export;
15pub mod fcp7;
16pub mod fcpxml;
17pub mod mlt;
18pub mod resolve;
19mod xml;
20
21use std::collections::BTreeSet;
22use std::fmt;
23use std::path::Path;
24use std::str::FromStr;
25
26use ffcine_core::{Error, Result};
27use ffcine_timeline::Timeline;
28
29/// Beat times (s) of a music file, `None` if it can't be analysed.
30pub type BeatsFn = fn(&Path) -> Option<Vec<f64>>;
31
32/// Settings the source format may lack.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct ImportOptions {
35    /// Frame size of the timeline, for formats that don't store one (Resolve OTIO).
36    pub frame_size: Option<(u32, u32)>,
37    /// Timeline frame rate; overrides the source's.
38    pub rate: Option<f64>,
39    /// Media duration in seconds, for formats that leave lengths to the media (`edit.yaml` shots
40    /// without `out`, sound effects, `in: end` music cues). Without it, importers fall back to
41    /// what they can read themselves (WAV headers) and warn.
42    pub media_duration: Option<fn(&Path) -> Option<f64>>,
43    /// Beat times (seconds into the file) of a music track, for formats that snap cuts to the
44    /// music (`edit.yaml`). Without it cuts land unsnapped, with a warning.
45    pub beats: Option<BeatsFn>,
46    /// Fail instead of skipping what can't be read ([`ImportReport::skipped`]).
47    pub strict: bool,
48}
49
50/// An imported timeline and what didn't carry over.
51#[derive(Debug, Clone)]
52pub struct Imported {
53    pub timeline: Timeline,
54    pub report: ImportReport,
55}
56
57/// What an import couldn't map exactly.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct ImportReport {
60    /// Approximations and fallbacks, one line each.
61    pub warnings: Vec<String>,
62    /// Source fields kept only under `metadata.ffcine.foreign`, as paths (`shots[].snap`).
63    pub unmapped: Vec<String>,
64    /// Things that couldn't be read at all and were dropped (lenient mode), one line each.
65    pub skipped: Vec<String>,
66}
67
68impl ImportReport {
69    pub fn warn(&mut self, msg: impl Into<String>) {
70        let msg = msg.into();
71        if !self.warnings.contains(&msg) {
72            self.warnings.push(msg);
73        }
74    }
75
76    /// Records something skipped; also a warning.
77    pub fn skip(&mut self, msg: impl Into<String>) {
78        let msg = msg.into();
79        if !self.skipped.contains(&msg) {
80            self.skipped.push(msg.clone());
81        }
82        self.warn(format!("skipped: {msg}"));
83    }
84
85    /// Records unmapped field paths, keeping the list sorted and unique.
86    pub fn unmapped<I: IntoIterator<Item = String>>(&mut self, paths: I) {
87        let mut all: BTreeSet<String> = self.unmapped.drain(..).collect();
88        all.extend(paths);
89        self.unmapped = all.into_iter().collect();
90    }
91}
92
93impl fmt::Display for ImportReport {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        for w in &self.warnings {
96            writeln!(f, "warning: {w}")?;
97        }
98        if !self.unmapped.is_empty() {
99            writeln!(f, "kept under ffcine.foreign: {}", self.unmapped.join(", "))?;
100        }
101        Ok(())
102    }
103}
104
105/// Source formats.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum Format {
109    /// The Python prototype's `edit.yaml`.
110    EditYaml,
111    /// OTIO JSON, read as-is (a Resolve export is read as [`Format::Resolve`]).
112    Otio,
113    /// Final Cut Pro X `.fcpxml` / `.fcpxmld` ([`fcpxml`]).
114    Fcpxml,
115    /// MLT XML: Kdenlive `.kdenlive`, Shotcut / `melt` `.mlt` ([`mlt`]).
116    Mlt,
117    /// `DaVinci Resolve`'s native OTIO export ([`resolve`]).
118    Resolve,
119    /// Final Cut Pro 7 / Premiere Pro XML (`xmeml`, [`fcp7`]).
120    Fcp7,
121    /// CMX 3600 EDL, with ASC CDL comments ([`edl`]).
122    Edl,
123    /// `CapCut` / `JianYing` `draft_content.json` ([`capcut`]).
124    CapCut,
125}
126
127impl Format {
128    /// Guesses the format from the file name, and for `.xml` and `.json` from the content.
129    pub fn detect(path: &Path) -> Option<Self> {
130        let ext = path
131            .extension()
132            .and_then(|e| e.to_str())
133            .unwrap_or_default()
134            .to_ascii_lowercase();
135        match ext.as_str() {
136            "yaml" | "yml" => Some(Format::EditYaml),
137            "otio" => Some(Format::Otio),
138            "fcpxml" | "fcpxmld" => Some(Format::Fcpxml),
139            "mlt" | "kdenlive" => Some(Format::Mlt),
140            "edl" => Some(Format::Edl),
141            "xml" => {
142                let head = sniff(path)?;
143                if head.contains("<xmeml") {
144                    Some(Format::Fcp7)
145                } else if head.contains("<fcpxml") {
146                    Some(Format::Fcpxml)
147                } else if head.contains("<mlt") {
148                    Some(Format::Mlt)
149                } else {
150                    None
151                }
152            }
153            "json" => {
154                let head = sniff(path)?;
155                (head.contains("\"materials\"") || head.contains("\"canvas_config\""))
156                    .then_some(Format::CapCut)
157            }
158            _ if path.is_dir() => capcut::draft_file(path).map(|_| Format::CapCut),
159            _ => None,
160        }
161    }
162}
163
164impl FromStr for Format {
165    type Err = Error;
166
167    fn from_str(s: &str) -> Result<Self> {
168        match s {
169            "edit-yaml" | "edit_yaml" | "yaml" => Ok(Format::EditYaml),
170            "otio" => Ok(Format::Otio),
171            "fcpxml" | "fcpxmld" | "fcpx" => Ok(Format::Fcpxml),
172            "mlt" | "kdenlive" | "shotcut" => Ok(Format::Mlt),
173            "resolve" => Ok(Format::Resolve),
174            "fcp7" | "xmeml" | "premiere" | "fcp-xml" | "fcp_xml" => Ok(Format::Fcp7),
175            "edl" | "cmx3600" | "cmx_3600" => Ok(Format::Edl),
176            "capcut" | "jianying" => Ok(Format::CapCut),
177            _ => Err(Error::Invalid {
178                what: "import format",
179                value: s.to_owned(),
180            }),
181        }
182    }
183}
184
185impl fmt::Display for Format {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.write_str(match self {
188            Format::EditYaml => "edit-yaml",
189            Format::Otio => "otio",
190            Format::Fcpxml => "fcpxml",
191            Format::Mlt => "mlt",
192            Format::Resolve => "resolve",
193            Format::Fcp7 => "fcp7",
194            Format::Edl => "edl",
195            Format::CapCut => "capcut",
196        })
197    }
198}
199
200/// Imports `input` as `format`, or as the format its name suggests.
201pub fn import(input: &Path, format: Option<Format>, opts: &ImportOptions) -> Result<Imported> {
202    let format = format
203        .or_else(|| Format::detect(input))
204        .ok_or_else(|| Error::Invalid {
205            what: "import format (pass one)",
206            value: input.display().to_string(),
207        })?;
208    let imported = match format {
209        Format::EditYaml => edit_yaml::import(input, opts),
210        Format::Fcpxml => fcpxml::import(input, opts),
211        Format::Mlt => mlt::import(input, opts),
212        Format::Resolve => resolve::import(input, opts),
213        Format::Fcp7 => fcp7::import(input, opts),
214        Format::Edl => edl::import(input, opts),
215        Format::CapCut => capcut::import(input, opts),
216        Format::Otio if resolve::detect(input) => resolve::import(input, opts),
217        Format::Otio => Ok(Imported {
218            timeline: Timeline::from_file(input)?,
219            report: ImportReport::default(),
220        }),
221    }?;
222    if opts.strict && !imported.report.skipped.is_empty() {
223        return Err(Error::Invalid {
224            what: "input (strict import)",
225            value: imported.report.skipped.join("; "),
226        });
227    }
228    Ok(imported)
229}
230
231/// The first 4 KiB of a file, lossily as UTF-8.
232fn sniff(path: &Path) -> Option<String> {
233    use std::io::Read;
234    let mut buf = vec![0; 4096];
235    let n = std::fs::File::open(path).ok()?.read(&mut buf).ok()?;
236    buf.truncate(n);
237    Some(String::from_utf8_lossy(&buf).into_owned())
238}