1pub 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
29pub type BeatsFn = fn(&Path) -> Option<Vec<f64>>;
31
32#[derive(Debug, Clone, Copy, Default)]
34pub struct ImportOptions {
35 pub frame_size: Option<(u32, u32)>,
37 pub rate: Option<f64>,
39 pub media_duration: Option<fn(&Path) -> Option<f64>>,
43 pub beats: Option<BeatsFn>,
46 pub strict: bool,
48}
49
50#[derive(Debug, Clone)]
52pub struct Imported {
53 pub timeline: Timeline,
54 pub report: ImportReport,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct ImportReport {
60 pub warnings: Vec<String>,
62 pub unmapped: Vec<String>,
64 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum Format {
109 EditYaml,
111 Otio,
113 Fcpxml,
115 Mlt,
117 Resolve,
119 Fcp7,
121 Edl,
123 CapCut,
125}
126
127impl Format {
128 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
200pub 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
231fn 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}