diff --git a/Cargo.lock b/Cargo.lock index 9e42c6b5..35c9cea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1343,6 +1343,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "effectcraft-interchange" +version = "0.1.1" +dependencies = [ + "effectcraft-color", + "effectcraft-keyframe", + "effectcraft-project", + "effectcraft-time", + "filmcraft-color", + "filmcraft-geom", + "filmcraft-interchange", + "filmcraft-media", + "filmcraft-project", + "filmcraft-time", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "effectcraft-keyframe" version = "0.1.1" @@ -2037,6 +2056,14 @@ dependencies = [ "serde", ] +[[package]] +name = "filmcraft-geom" +version = "0.1.0" +source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" +dependencies = [ + "serde", +] + [[package]] name = "filmcraft-h264" version = "0.1.0" @@ -2067,6 +2094,22 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-interchange" +version = "0.1.0" +source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" +dependencies = [ + "filmcraft-color", + "filmcraft-geom", + "filmcraft-media", + "filmcraft-project", + "filmcraft-time", + "roxmltree", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-isobmff" version = "0.1.0" @@ -2100,6 +2143,20 @@ name = "filmcraft-opus" version = "0.1.0" source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" +[[package]] +name = "filmcraft-project" +version = "0.1.0" +source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" +dependencies = [ + "filmcraft-color", + "filmcraft-geom", + "filmcraft-media", + "filmcraft-time", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-prores" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d3751c18..0b38eb2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" } effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } effectcraft-lottie = { path = "crates/lottie" } +effectcraft-interchange = { path = "crates/interchange" } effectcraft-pdf = { path = "crates/pdf" } effectcraft-psd = { path = "crates/psd" } effectcraft-vp9enc = { path = "crates/vp9enc" } @@ -68,7 +69,7 @@ exr = "1.74" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). -# Only `crates/media` and `crates/export` may depend on these. +# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } @@ -85,6 +86,10 @@ filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf2 # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/interchange/Cargo.toml b/crates/interchange/Cargo.toml new file mode 100644 index 00000000..11dfec33 --- /dev/null +++ b/crates/interchange/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "effectcraft-interchange" +description = "Premiere Pro interop through timeline interchange: Final Cut Pro 7 XML (xmeml), FCPXML, OpenTimelineIO and CMX 3600 EDL sequences to and from compositions (via filmcraft-interchange)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } +effectcraft-keyframe = { workspace = true } +effectcraft-project = { workspace = true } +filmcraft-interchange = { workspace = true } +filmcraft-project = { workspace = true } +filmcraft-media = { workspace = true } +filmcraft-time = { workspace = true } +filmcraft-geom = { workspace = true } +filmcraft-color = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs new file mode 100644 index 00000000..5480375d --- /dev/null +++ b/crates/interchange/src/export.rs @@ -0,0 +1,689 @@ +//! Compositions → timeline documents (File ▸ Export ▸ Adobe Premiere Pro Project…, which writes +//! Final Cut Pro XML that Premiere Pro imports; FCPXML, OTIO and EDL on request). + +use std::collections::HashMap; + +use effectcraft_keyframe::{Interp, Value}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, Property}; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_geom::Vec2; +use filmcraft_media::{AudioStreamInfo, Generator, MediaInfo, MediaKind, VideoStreamInfo}; +use filmcraft_project as fp; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result, TimelineFormat, par_fraction, rate_out, tick_out}; + +/// Which layers are pre-rendered to media (ProRes 4444 with alpha) instead of exported as clips. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrerenderMode { + /// Nothing is rendered: layers only EffectCraft can draw are left out (with a warning), and + /// effects, masks, parenting… of footage layers are dropped. + None, + /// Layers a timeline cannot represent (text, shapes, effects, masks, 3D, parenting, track + /// mattes, expressions, time remapping…) are pre-rendered. + #[default] + Unsupported, + /// Every visual layer is pre-rendered (what you see is what Premiere shows). + All, +} + +impl PrerenderMode { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "none" | "off" | "false" | "no" => Some(PrerenderMode::None), + "unsupported" | "renderedonly" | "auto" | "true" | "yes" => Some(PrerenderMode::Unsupported), + "all" | "everything" => Some(PrerenderMode::All), + _ => None, + } + } +} + +/// How precomposition layers are exported. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrecompMode { + /// As nested sequences (their layers become clips too). + #[default] + Nest, + /// Pre-rendered like other rendered-only layers. + Prerender, +} + +/// Export options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExportOptions { + pub format: TimelineFormat, + pub prerender: PrerenderMode, + pub precomps: PrecompMode, + /// Write media paths relative to this folder where the format allows (OTIO, EDL). + pub relative_to: Option, +} + +impl Default for ExportOptions { + fn default() -> Self { + ExportOptions { format: TimelineFormat::Fcp7Xml, prerender: PrerenderMode::Unsupported, precomps: PrecompMode::Nest, relative_to: None } + } +} + +/// A layer pre-rendered by the caller: a comp-sized movie of the layer alone (transform baked in) +/// covering the layer's in to out point. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Prerendered { + pub path: String, + pub width: u32, + pub height: u32, + pub frame_rate: FrameRate, + pub duration: Tick, +} + +/// The written document. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ExportOutput { + pub bytes: Vec, + pub warnings: Vec, + /// Sequences written (the comp and nested precomps). + pub sequences: usize, + pub video_tracks: usize, + pub audio_tracks: usize, + pub clips: usize, + /// Layers referenced as pre-rendered media. + pub prerendered: usize, +} + +/// How one layer is exported. +#[derive(Clone, Debug, PartialEq)] +enum Plan { + /// A clip of its footage / a colour matte, with Motion and Opacity. + Native, + /// A nested sequence of its precomp. + Nest, + /// Pre-rendered media (reason). + Render(String), + /// Audio only (no picture). + AudioOnly, + /// Left out (reason, or empty for silently skipped layers such as cameras). + Skip(String), +} + +/// Why a layer can't be a plain clip (None = it can), regardless of the mode. +fn unsupported(project: &Project, comp: &Comp, l: &Layer) -> Option { + let kind = match &l.source { + LayerSource::Text => Some("text layer"), + LayerSource::Shape => Some("shape layer"), + LayerSource::Model { .. } | LayerSource::Primitive { .. } => Some("3D model layer"), + LayerSource::Solid { item } => match project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Solid(s)) if s.width != comp.width || s.height != comp.height => Some("solid smaller or larger than the frame"), + _ => None, + }, + _ => None, + }; + if let Some(k) = kind { + return Some(k.into()); + } + if l.effects().is_some_and(|e| !e.children.is_empty()) { + return Some("effects".into()); + } + if l.masks().is_some_and(|m| !m.children.is_empty()) { + return Some("masks".into()); + } + if l.track_matte.is_some() { + return Some("track matte".into()); + } + if l.parent.is_some() { + return Some("parenting".into()); + } + if l.is_3d() { + return Some("3D layer".into()); + } + if l.props.sub("layerStyles").is_some_and(|g| !g.children.is_empty()) { + return Some("layer styles".into()); + } + if l.props.get("timeRemap").is_some_and(|p| p.is_animated()) { + return Some("time remapping".into()); + } + let mut expr = false; + if let Some(t) = l.transform() { + t.walk("", &mut |_, p| expr |= p.has_expression()); + } + if expr { + return Some("expressions".into()); + } + if l.blend_mode != effectcraft_color::BlendMode::Normal && !fp::effect::BLEND_MODES.iter().any(|m| m.eq_ignore_ascii_case(l.blend_mode.label())) { + return Some(format!("blend mode {}", l.blend_mode.label())); + } + // Premiere's Basic Motion in the interchange formats has one (uniform) scale. + if let Some(s) = l.transform().and_then(|t| t.get("scale")) + && std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() > 1e-9 + }) + { + return Some("non-uniform scale".into()); + } + if l.preserve_transparency { + return Some("preserve underlying transparency".into()); + } + None +} + +fn plan(project: &Project, comp: &Comp, l: &Layer, opts: &ExportOptions) -> Plan { + if l.switches.guide { + return Plan::Skip(String::new()); + } + match &l.source { + LayerSource::Camera | LayerSource::Light { .. } | LayerSource::Null => return Plan::Skip(String::new()), + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind == FootageKind::Audio || !f.has_video => return Plan::AudioOnly, + Some(ItemKind::Footage(f)) if f.kind == FootageKind::Data => return Plan::Skip("data footage has no picture".into()), + _ => {} + }, + _ => {} + } + if l.switches.adjustment { + return Plan::Skip("adjustment layer (its effects apply to the layers below)".into()); + } + let why = unsupported(project, comp, l); + match opts.prerender { + PrerenderMode::All => Plan::Render("pre-render all layers".into()), + PrerenderMode::Unsupported => match (&l.source, why) { + (_, Some(w)) => Plan::Render(w), + (LayerSource::Comp { .. }, None) if opts.precomps == PrecompMode::Prerender => Plan::Render("precomp".into()), + (LayerSource::Comp { .. }, None) => Plan::Nest, + _ => Plan::Native, + }, + PrerenderMode::None => match (&l.source, why) { + (LayerSource::Text | LayerSource::Shape | LayerSource::Model { .. } | LayerSource::Primitive { .. }, Some(w)) => Plan::Skip(w), + (LayerSource::Solid { .. }, Some(w)) => Plan::Skip(w), + (LayerSource::Comp { .. }, _) => Plan::Nest, + _ => Plan::Native, + }, + } +} + +/// Why `layer` would be pre-rendered under `opts` (None when it exports as a clip or is left out). +pub fn prerender_reason(project: &Project, comp: ItemId, layer: LayerId, opts: &ExportOptions) -> Option { + let c = project.comp(comp)?; + match plan(project, c, c.layer(layer)?, opts) { + Plan::Render(r) => Some(r), + _ => None, + } +} + +/// The layers the caller must pre-render before [`export`]: `(comp, layer)` pairs, nested +/// precomps included when they are exported as nested sequences. +pub fn plan_prerender(project: &Project, comp: ItemId, opts: &ExportOptions) -> Vec<(ItemId, LayerId)> { + let mut out = vec![]; + let mut seen = vec![]; + fn walk(p: &Project, cid: ItemId, opts: &ExportOptions, out: &mut Vec<(ItemId, LayerId)>, seen: &mut Vec) { + if seen.contains(&cid) { + return; + } + seen.push(cid); + let Some(c) = p.comp(cid) else { return }; + for l in &c.layers { + match plan(p, c, l, opts) { + Plan::Render(_) => out.push((cid, l.id)), + Plan::Nest => { + if let LayerSource::Comp { item } = l.source { + walk(p, item, opts, out, seen); + } + } + _ => {} + } + } + } + walk(project, comp, opts, &mut out, &mut seen); + out +} + +/// Export composition `comp` (with the precomps it nests) as a timeline document. +pub fn export(project: &Project, comp: ItemId, opts: &ExportOptions, prerendered: &HashMap<(ItemId, LayerId), Prerendered>) -> Result { + project.comp(comp).ok_or(Error::NoComp(comp.0))?; + let name = project.item(comp).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into()); + let mut ex = Exporter { + p: project, + opts, + pre: prerendered, + fp: fp::Project::new(&name), + out: ExportOutput::default(), + seqs: HashMap::new(), + media: HashMap::new(), + bins: HashMap::new(), + stack: vec![], + }; + let seq = ex.sequence(comp); + let fc_opts = filmcraft_interchange::ExportOptions { relative_to: opts.relative_to.clone(), name: Some(name), ..Default::default() }; + let (bytes, report) = filmcraft_interchange::export(&ex.fp, seq, opts.format.fc(), &fc_opts)?; + for e in report.entries { + let m = if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message }; + ex.warn(m); + } + ex.out.bytes = bytes; + Ok(ex.out) +} + +struct Exporter<'a> { + p: &'a Project, + opts: &'a ExportOptions, + pre: &'a HashMap<(ItemId, LayerId), Prerendered>, + fp: fp::Project, + out: ExportOutput, + seqs: HashMap, + /// Media items by (path or solid key). + media: HashMap, + bins: HashMap, + stack: Vec, +} + +/// Clip timing derived from a layer. +struct Timing { + start: Tick, + duration: Tick, + source_in: Tick, + speed: f64, + reverse: bool, +} + +fn timing(l: &Layer, comp: &Comp) -> Option { + let a = l.in_point.max(Tick::ZERO); + let b = l.out_point.min(comp.duration); + if b <= a { + return None; + } + let reverse = l.stretch < 0.0; + let speed = if l.stretch.abs() < 1e-9 { 1.0 } else { 100.0 / l.stretch.abs() }; + let (la, lb) = (l.layer_time(a), l.layer_time(b)); + Some(Timing { start: a, duration: b - a, source_in: la.min(lb), speed, reverse }) +} + +impl Exporter<'_> { + fn warn(&mut self, m: impl Into) { + let m = m.into(); + if !self.out.warnings.contains(&m) { + self.out.warnings.push(m); + } + } + + /// The bin mirroring a Project panel folder. + fn bin(&mut self, folder: Option) -> Option { + let f = folder?; + if let Some(b) = self.bins.get(&f) { + return Some(*b); + } + let it = self.p.item(f)?; + let parent = self.bin(it.parent); + let b = self.fp.add_bin(&it.name, parent); + self.bins.insert(f, b); + Some(b) + } + + fn footage_item(&mut self, item: ItemId, f: &Footage) -> fp::ItemId { + let path = if f.kind == FootageKind::Sequence { f.sequence.first().cloned().unwrap_or_else(|| f.path.clone()) } else { f.path.clone() }; + if let Some(id) = self.media.get(&path) { + return *id; + } + let it = self.p.item(item); + let name = it.map(|i| i.name.clone()).unwrap_or_else(|| path.clone()); + let kind = match f.kind { + FootageKind::Audio => MediaKind::AudioOnly, + FootageKind::Still => MediaKind::Still, + FootageKind::Sequence => MediaKind::ImageSequence, + _ if !f.has_video => MediaKind::AudioOnly, + _ => MediaKind::Movie, + }; + let duration = if f.kind == FootageKind::Still { Tick::from_seconds_f64(3600.0) } else { f.duration }; + let info = media_info( + &name, + kind, + duration, + f.has_video.then(|| (f.width, f.height, f.frame_rate, f.pixel_aspect, f.codec.clone(), f.alpha != effectcraft_project::AlphaMode::Ignore)), + f.has_audio, + ); + let mut clip = media_clip(fp::MediaRef::File { path: path.clone() }, info); + clip.offline = f.missing; + let bin = self.bin(it.and_then(|i| i.parent)); + let id = self.fp.add_item(&name, fp::Label::Iris, fp::ItemKind::Media(clip), bin); + self.media.insert(path, id); + id + } + + fn prerendered_item(&mut self, name: &str, r: &Prerendered) -> fp::ItemId { + if let Some(id) = self.media.get(&r.path) { + return *id; + } + let info = media_info(name, MediaKind::Movie, r.duration, Some((r.width, r.height, r.frame_rate, 1.0, "Apple ProRes 4444".into(), true)), false); + let clip = media_clip(fp::MediaRef::File { path: r.path.clone() }, info); + let bin = match self.bins.get(&ItemId(u64::MAX)) { + Some(b) => Some(*b), + None => { + let b = self.fp.add_bin("Pre-rendered Layers", None); + self.bins.insert(ItemId(u64::MAX), b); + Some(b) + } + }; + let id = self.fp.add_item(name, fp::Label::Violet, fp::ItemKind::Media(clip), bin); + self.media.insert(r.path.clone(), id); + id + } + + fn solid_item(&mut self, item: ItemId, color: [f32; 3], comp: &Comp) -> fp::ItemId { + let key = format!("solid:{}:{}x{}", item.0, comp.width, comp.height); + if let Some(id) = self.media.get(&key) { + return *id; + } + let it = self.p.item(item); + let name = it.map(|i| i.name.clone()).unwrap_or_else(|| "Color Matte".into()); + let info = media_info( + &name, + MediaKind::Synthetic, + Tick::from_seconds_f64(3600.0), + Some((comp.width, comp.height, comp.frame_rate, 1.0, String::new(), false)), + false, + ); + let clip = media_clip(fp::MediaRef::Generator(Generator::ColorMatte { color: [color[0], color[1], color[2], 1.0] }), info); + let bin = self.bin(it.and_then(|i| i.parent)); + let id = self.fp.add_item(&name, fp::Label::Rose, fp::ItemKind::Media(clip), bin); + self.media.insert(key, id); + id + } + + /// The sequence for a comp (created once). + fn sequence(&mut self, cid: ItemId) -> fp::ItemId { + if let Some(s) = self.seqs.get(&cid) { + return *s; + } + let comp = self.p.comp_arc(cid).expect("comp"); + let it = self.p.item(cid); + let name = it.map(|i| i.name.clone()).unwrap_or_default(); + let settings = fp::SequenceSettings { + width: comp.width, + height: comp.height, + frame_rate: rate_out(comp.frame_rate), + par: par_fraction(comp.pixel_aspect), + preset: "Custom".into(), + ..Default::default() + }; + let bin = if self.stack.is_empty() { None } else { self.bin(it.and_then(|i| i.parent)) }; + let sid = self.fp.new_sequence(&name, settings, 0, 0, bin); + self.seqs.insert(cid, sid); + self.stack.push(cid); + self.out.sequences += 1; + let rate = comp.frame_rate; + let mut video: Vec = vec![]; + let mut audio: Vec = vec![]; + for l in comp.layers.iter().rev() { + let pl = plan(self.p, &comp, l, self.opts); + let Some(tm) = timing(l, &comp) else { continue }; + let link = self.fp.alloc_id(); + let mut has_video_clip = false; + let clip = match &pl { + Plan::Skip(why) => { + if !why.is_empty() { + self.warn(format!("layer \"{}\" was left out ({why})", l.name)); + } + None + } + Plan::AudioOnly => None, + Plan::Render(why) => match self.pre.get(&(cid, l.id)).cloned() { + Some(r) => { + let item = self.prerendered_item(&format!("{} (pre-rendered)", l.name), &r); + let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate); + ti.source_in = filmcraft_time::Tick::ZERO; + ti.speed = 1.0; + ti.reverse = false; + self.out.prerendered += 1; + Some(ti) + } + None => { + self.warn(format!("layer \"{}\" was left out ({why}; pre-rendering is off or unavailable)", l.name)); + None + } + }, + Plan::Native | Plan::Nest => { + if self.opts.prerender == PrerenderMode::None + && let Some(w) = unsupported(self.p, &comp, l) + { + self.warn(format!("layer \"{}\": {w} not exported", l.name)); + } + let item = match &l.source { + LayerSource::Footage { item } => match self.p.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => Some(self.footage_item(*item, &f.clone())), + _ => None, + }, + LayerSource::Solid { item } => match self.p.item(*item).map(|i| &i.kind) { + Some(ItemKind::Solid(s)) => Some(self.solid_item(*item, s.color, &comp)), + _ => None, + }, + LayerSource::Comp { item } if !self.stack.contains(item) => Some(self.sequence(*item)), + _ => None, + }; + item.map(|item| { + let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate); + self.motion(&mut ti, l, &comp, &tm); + if let Some(h) = l.props.get("timeRemap").filter(|p| !p.is_animated()) { + ti.frame_hold = Some(tick_out(Tick::from_seconds_f64(h.value.as_f64()))); + } + ti + }) + } + }; + if let Some(mut ti) = clip { + ti.enabled = l.switches.video; + ti.link = Some(link); + let mut t = fp::Track::new(fp::TrackId(self.fp.alloc_id()), fp::TrackKind::Video, format!("Video {}", video.len() + 1)); + t.items.push(ti); + video.push(t); + has_video_clip = true; + self.out.clips += 1; + } + // Sound: footage and precomps with audio. + let sound = match &l.source { + LayerSource::Footage { item } => match self.p.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.has_audio => Some(self.footage_item(*item, &f.clone())), + _ => None, + }, + LayerSource::Comp { item } if self.p.comp(*item).is_some_and(|c| comp_has_audio(self.p, c)) && !self.stack.contains(item) => { + Some(self.sequence(*item)) + } + _ => None, + }; + if let Some(item) = sound + && !matches!(pl, Plan::Skip(_)) + { + let mut ti = self.track_item(item, &l.name, fp::TrackKind::Audio, &tm, rate); + ti.enabled = l.switches.audio; + ti.link = has_video_clip.then_some(link); + self.levels(&mut ti, l, &tm); + let mut t = fp::Track::new(fp::TrackId(self.fp.alloc_id()), fp::TrackKind::Audio, format!("Audio {}", audio.len() + 1)); + t.items.push(ti); + audio.push(t); + self.out.clips += 1; + } + } + self.out.video_tracks += video.len(); + self.out.audio_tracks += audio.len(); + if video.is_empty() { + video.push(fp::Track::new(fp::TrackId(self.fp.alloc_id()), fp::TrackKind::Video, "Video 1".into())); + } + let markers: Vec = comp + .markers + .iter() + .map(|m| fp::Marker { + id: fp::MarkerId(self.fp.alloc_id()), + start: tick_out(m.time), + duration: tick_out(m.duration), + name: if m.chapter.is_empty() { m.comment.clone() } else { m.chapter.clone() }, + comment: if m.chapter.is_empty() { String::new() } else { m.comment.clone() }, + kind: if m.chapter.is_empty() { fp::MarkerKind::Comment } else { fp::MarkerKind::Chapter }, + color: fp::Label::Green, + }) + .collect(); + if let Some(seq) = self.fp.sequence_mut(sid) { + seq.video_tracks = video; + seq.audio_tracks = audio; + seq.markers = markers; + seq.start_timecode = rate.frame_at(comp.display_start); + seq.work_area = None; + } + self.stack.pop(); + sid + } + + fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> fp::TrackItem { + let range = filmcraft_time::TimeRange::new(tick_out(tm.source_in), tick_out(Tick((tm.duration.0 as f64 * tm.speed).round() as i64))); + let mut ti = self.fp.make_track_item(item, kind, tick_out(tm.start), range, rate_out(rate)).expect("item exists"); + ti.name = name.to_string(); + ti.duration = tick_out(tm.duration); + ti.source_in = tick_out(tm.source_in); + ti.speed = tm.speed; + ti.reverse = tm.reverse; + ti + } + + /// Transform → Motion + Opacity (clip-time keyframes). + fn motion(&mut self, ti: &mut fp::TrackItem, l: &Layer, comp: &Comp, tm: &Timing) { + let Some(tr) = l.transform() else { return }; + let to_clip = |t: Tick| tick_out(l.comp_time(t) - tm.start); + let (sw, sh) = match &l.source { + LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => { + self.p.item(*item).and_then(|i| i.dimensions()).unwrap_or((comp.width, comp.height)) + } + _ => (comp.width, comp.height), + }; + let center = |x: f64, y: f64| -> fp::ParamValue { fp::ParamValue::Vec2(Vec2 { x, y }) }; + let mut m = ti.effect("motion").cloned().unwrap_or_else(|| fp::find_effect("motion").expect("motion").instance()); + if let Some(p) = tr.get("position") { + let def = [comp.width as f64 / 2.0, comp.height as f64 / 2.0]; + if p.is_animated() || differs(&p.value, def) { + m.params.insert( + "position".into(), + param_out(p, &to_clip, |v| { + let a = v.as_vec3(); + center(a[0], a[1]) + }), + ); + } + } + if let Some(p) = tr.get("anchor") { + let def = [sw as f64 / 2.0, sh as f64 / 2.0]; + if p.is_animated() || differs(&p.value, def) { + m.params.insert( + "anchor".into(), + param_out(p, &to_clip, |v| { + let a = v.as_vec3(); + center(a[0], a[1]) + }), + ); + } + } + if let Some(p) = tr.get("scale") { + let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() < 1e-9 + }); + m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1]))); + if !uniform { + m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0]))); + m.params.insert("uniform_scale".into(), fp::Param::new(fp::ParamValue::Bool(false))); + } + } + if let Some(p) = tr.get("rotation") { + m.params.insert("rotation".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64()))); + } + set_effect(ti, m); + if let Some(p) = tr.get("opacity") { + let mut o = ti.effect("opacity").cloned().unwrap_or_else(|| fp::find_effect("opacity").expect("opacity").instance()); + o.params.insert("opacity".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64()))); + if l.blend_mode != effectcraft_color::BlendMode::Normal + && let Some(i) = fp::effect::BLEND_MODES.iter().position(|m| m.eq_ignore_ascii_case(l.blend_mode.label())) + { + o.params.insert("blend".into(), fp::Param::new(fp::ParamValue::Choice(i as u32))); + } + set_effect(ti, o); + } + } + + fn levels(&mut self, ti: &mut fp::TrackItem, l: &Layer, tm: &Timing) { + let Some(p) = l.props.prop("audio/levels") else { return }; + let to_clip = |t: Tick| tick_out(l.comp_time(t) - tm.start); + if !p.is_animated() && p.value.as_vec2() == [0.0, 0.0] { + return; + } + if (p.value.as_vec2()[0] - p.value.as_vec2()[1]).abs() > 1e-9 { + self.warn("separate left/right audio levels were exported as the left level"); + } + let mut v = ti.effect("volume").cloned().unwrap_or_else(|| fp::find_effect("volume").expect("volume").instance()); + v.params.insert("level".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec2()[0]))); + set_effect(ti, v); + } +} + +fn comp_has_audio(p: &Project, c: &Comp) -> bool { + c.layers.iter().any(|l| match &l.source { + LayerSource::Footage { item } => matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio), + LayerSource::Comp { item } => p.comp(*item).is_some_and(|c| comp_has_audio(p, c)), + _ => false, + }) +} + +fn differs(v: &Value, def: [f64; 2]) -> bool { + let a = v.as_vec3(); + (a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6 +} + +fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) { + match ti.effects.iter_mut().find(|x| x.effect == e.effect) { + Some(x) => *x = e, + None => ti.effects.push(e), + } +} + +/// A property → a FilmCraft parameter (layer-time keys → clip-time keys). +fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param { + let mut out = fp::Param::new(conv(&p.value)); + for k in &p.keys { + let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value)); + fk.interp = match k.out_interp { + Interp::Linear => fp::Interpolation::Linear, + Interp::Hold => fp::Interpolation::Hold, + Interp::Bezier => fp::Interpolation::Bezier, + }; + out.keyframes.push(fk); + } + out.keyframes.sort_by_key(|k| k.time); + if let Some(k) = out.keyframes.first() { + out.value = k.value.clone(); + } + out +} + +type VideoSpec = (u32, u32, FrameRate, f64, String, bool); + +fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option, audio: bool) -> MediaInfo { + MediaInfo { + name: name.into(), + kind, + duration: tick_out(duration), + video: video.map(|(width, height, rate, par, codec, has_alpha)| VideoStreamInfo { + width, + height, + frame_rate: rate_out(rate), + par: par_fraction(par), + codec, + pixel_format: String::new(), + color: Default::default(), + has_alpha, + bitrate: None, + }), + audio: audio.then(|| AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + container: String::new(), + start_timecode: None, + file_size: None, + } +} + +fn media_clip(media: fp::MediaRef, info: MediaInfo) -> fp::MediaClip { + fp::MediaClip { media, info, interpret: Default::default(), mark_in: None, mark_out: None, markers: vec![], offline: false, proxy: None, identity: None } +} diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs new file mode 100644 index 00000000..0df79f1c --- /dev/null +++ b/crates/interchange/src/import.rs @@ -0,0 +1,611 @@ +//! Timeline documents → compositions (File ▸ Import ▸ Adobe Premiere Pro Project / Timeline +//! Interchange…). + +use std::collections::{BTreeMap, HashMap}; + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build::Ids; +use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Marker, Node, ParamUi, Project, Property, Solid}; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_media::{Generator, MediaKind}; +use filmcraft_project as fp; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result, TimelineFormat, rate_in, tick_in}; + +/// Import options. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ImportOptions { + /// Directory relative media paths are resolved against (the document's folder). + pub base_dir: Option, + /// Name of the Project panel folder the import goes in (the document's file name). + pub name: String, + /// EDLs carry no frame rate: the rate to read timecode at (default: guessed). + pub edl_frame_rate: Option, +} + +/// What an import created. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ImportResult { + pub format: Option, + /// The folder holding everything imported. + pub folder: ItemId, + /// Compositions of the document's top-level sequences, in document order. + pub comps: Vec, + /// Every composition created (nested sequences included). + pub all_comps: Vec, + /// Footage and solid items created. + pub items: Vec, + /// Media files that could not be found or read (imported as placeholders). + pub missing: Vec, + /// Everything that could not be represented exactly. + pub warnings: Vec, +} + +/// Import a timeline document into `project`. `probe` reads a media file's footage metadata +/// (None = missing or unreadable: the item becomes a placeholder with the document's metadata). +pub fn import( + project: &mut Project, + bytes: &[u8], + format: Option, + opts: &ImportOptions, + probe: &mut dyn FnMut(&str) -> Option, +) -> Result { + let format = match format { + Some(f) => f, + None => TimelineFormat::detect(bytes, Some(&opts.name)).ok_or(Error::UnknownFormat)?, + }; + let fc_opts = filmcraft_interchange::ImportOptions { + base_dir: opts.base_dir.clone(), + edl_frame_rate: opts.edl_frame_rate.map(|f| { + let r = FrameRate::from_f64(f); + crate::rate_out(r) + }), + name: Some(opts.name.trim_end_matches(|c| c != '.').trim_end_matches('.').to_string()).filter(|s| !s.is_empty()), + }; + let (imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?; + let mut res = ImportResult { format: Some(format), ..Default::default() }; + for e in &report.entries { + if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) } + } + let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; + res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder); + let mut im = Importer { src: &imported.project, project, res, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, bins: HashMap::new() }; + im.run(&imported.sequences, probe); + Ok(im.res) +} + +struct Importer<'a> { + src: &'a fp::Project, + project: &'a mut Project, + res: ImportResult, + /// Source item → created item (footage, comp). + items: HashMap, + /// Generator / adjustment solids by (source item, width, height). + solids: HashMap<(fp::ItemId, u32, u32), ItemId>, + solids_folder: Option, + /// Folder of each source item (from the bins). + bins: HashMap, +} + +impl Importer<'_> { + fn warn(&mut self, m: impl Into) { + let m = m.into(); + if !self.res.warnings.contains(&m) { + self.res.warnings.push(m); + } + } + + fn run(&mut self, top: &[fp::ItemId], probe: &mut dyn FnMut(&str) -> Option) { + // Bins → folders. + let root = self.src.root.clone(); + self.walk_bin(&root, self.res.folder); + // Media → footage; sequences → (empty) comps first, so nested sequences resolve. + let ids: Vec = self.src.items.keys().copied().collect(); + for id in &ids { + let it = &self.src.items[id]; + let folder = self.bins.get(id).copied().unwrap_or(self.res.folder); + match &it.kind { + fp::ItemKind::Media(m) => { + if let fp::MediaRef::File { path } = &m.media { + let f = self.footage(&it.name, path, m, probe); + let label = match f.kind { + FootageKind::Still | FootageKind::Sequence => Label::Lavender, + FootageKind::Audio => Label::SeaFoam, + _ => Label::Aqua, + }; + let nid = self.project.add_item(&it.name, label, Some(folder), ItemKind::Footage(f)); + self.items.insert(*id, nid); + self.res.items.push(nid); + } + } + fp::ItemKind::Sequence(seq) => { + let s = &seq.settings; + let rate = rate_in(s.frame_rate); + let dur = rate.snap_nearest(tick_in(seq.duration())).max(rate.frame_duration()); + let mut c = Comp::new(s.width.max(1), s.height.max(1), rate, dur); + c.pixel_aspect = if s.par.1 > 0 { s.par.0 as f64 / s.par.1 as f64 } else { 1.0 }; + c.display_start = rate.tick_of(seq.start_timecode); + let nid = self.project.add_item(&it.name, Label::Sandstone, Some(folder), ItemKind::Comp(std::sync::Arc::new(c))); + self.items.insert(*id, nid); + self.res.all_comps.push(nid); + } + _ => {} + } + } + for id in &ids { + if let fp::ItemKind::Sequence(seq) = &self.src.items[id].kind { + let cid = self.items[id]; + self.fill_comp(cid, seq); + } + } + self.res.comps = top.iter().filter_map(|s| self.items.get(s).copied()).collect(); + self.res.all_comps.sort_by_key(|c| (!self.res.comps.contains(c), self.res.comps.iter().position(|x| x == c), c.0)); + } + + fn walk_bin(&mut self, bin: &fp::Bin, folder: ItemId) { + for e in &bin.children { + match e { + fp::BinEntry::Item(i) => { + self.bins.insert(*i, folder); + } + fp::BinEntry::Bin(b) => { + let f = self.project.add_item(&b.name, Label::None, Some(folder), ItemKind::Folder); + self.walk_bin(b, f); + } + } + } + } + + fn footage(&mut self, name: &str, path: &str, m: &fp::MediaClip, probe: &mut dyn FnMut(&str) -> Option) -> Footage { + if !m.offline + && let Some(mut f) = probe(path) + { + if let Some(r) = m.interpret.frame_rate + && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) + { + f.native_rate = Some(f.frame_rate); + f.frame_rate = rate_in(r); + } + if m.interpret.ignore_alpha { + f.alpha = AlphaMode::Ignore; + } + f.invert_alpha |= m.interpret.invert_alpha; + return f; + } + // Placeholder from the document's metadata. + if !self.res.missing.iter().any(|p| p == path) { + self.res.missing.push(path.to_string()); + } + let info = &m.info; + let v = info.video.as_ref(); + let kind = match info.kind { + MediaKind::AudioOnly => FootageKind::Audio, + MediaKind::Still => FootageKind::Still, + MediaKind::ImageSequence => FootageKind::Sequence, + _ if v.is_none() && info.audio.is_some() => FootageKind::Audio, + _ => FootageKind::Video, + }; + let _ = name; + Footage { + path: path.to_string(), + kind, + width: v.map(|v| v.width).unwrap_or(0), + height: v.map(|v| v.height).unwrap_or(0), + pixel_aspect: v.map(|v| if v.par.1 > 0 { v.par.0 as f64 / v.par.1 as f64 } else { 1.0 }).unwrap_or(1.0), + frame_rate: rate_in(info.frame_rate()), + duration: tick_in(info.duration), + has_video: kind != FootageKind::Audio, + has_audio: info.audio.is_some() || kind == FootageKind::Audio, + alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, + codec: v.map(|v| v.codec.clone()).unwrap_or_default(), + missing: true, + ..Default::default() + } + } + + /// A solid for a generator / adjustment layer item at a sequence's frame size. + fn solid(&mut self, src: fp::ItemId, name: &str, color: [f32; 3], w: u32, h: u32) -> ItemId { + if let Some(id) = self.solids.get(&(src, w, h)) { + return *id; + } + let folder = match self.solids_folder { + Some(f) => f, + None => { + let f = self.project.add_item("Solids", Label::None, Some(self.res.folder), ItemKind::Folder); + self.solids_folder = Some(f); + f + } + }; + let id = self.project.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + self.solids.insert((src, w, h), id); + self.res.items.push(id); + id + } + + fn fill_comp(&mut self, cid: ItemId, seq: &fp::Sequence) { + let mut comp = self.project.comp(cid).cloned().unwrap_or_else(|| Comp::new(1920, 1080, FrameRate::new(30, 1), Tick(1))); + comp.markers = seq.markers.iter().map(marker_in).collect(); + // Video: the top track's clips first (layer #1); later clips of a track above earlier ones + // so incoming clips of dissolves cover outgoing ones. + for track in seq.video_tracks.iter().rev() { + let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.start)); + for ti in items { + if let Some(l) = self.clip_layer(&comp, seq, track, ti) { + comp.layers.push(l); + } + } + } + for track in &seq.audio_tracks { + let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.start)); + for ti in items { + if let Some(l) = self.clip_layer(&comp, seq, track, ti) { + comp.layers.push(l); + } + } + } + if !seq.caption_tracks.is_empty() { + self.warn("caption tracks are not imported"); + } + if let Some(c) = self.project.comp_mut(cid) { + *c = comp; + } + } + + /// The layer source for a clip's item (creating solids on demand). + fn source_of(&mut self, ti: &fp::TrackItem, w: u32, h: u32) -> Option<(LayerSource, (u32, u32), bool)> { + let mut id = ti.item; + if let Some(fp::ItemKind::Subclip { parent, .. }) = self.src.item(id).map(|i| &i.kind) { + id = *parent; + } + let it = self.src.item(id)?; + match &it.kind { + fp::ItemKind::Media(m) => match &m.media { + fp::MediaRef::File { .. } => { + let nid = *self.items.get(&id)?; + let (sz, has_audio) = match self.project.item(nid).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => ((f.width, f.height), f.has_audio), + _ => ((w, h), false), + }; + Some((LayerSource::Footage { item: nid }, sz, has_audio)) + } + fp::MediaRef::Generator(g) => { + let color = match g { + Generator::ColorMatte { color } => [color[0], color[1], color[2]], + Generator::BlackVideo => [0.0; 3], + Generator::TransparentVideo | Generator::Tone { .. } => { + self.warn(format!("\"{}\" ({}) has no picture in a composition and was left out", ti.name, g.label())); + return None; + } + _ => { + self.warn(format!("generator \"{}\" was imported as a grey solid", g.label())); + [0.5; 3] + } + }; + let sid = self.solid(id, &it.name, color, w, h); + Some((LayerSource::Solid { item: sid }, (w, h), false)) + } + }, + fp::ItemKind::Sequence(s) => { + let nid = *self.items.get(&id)?; + if s.multicam.is_some() { + self.warn("multi-camera clips were imported as precomps of their source sequence"); + } + Some((LayerSource::Comp { item: nid }, (s.settings.width, s.settings.height), !s.audio_tracks.is_empty())) + } + fp::ItemKind::AdjustmentLayer { .. } => { + let sid = self.solid(id, &it.name, [1.0; 3], w, h); + Some((LayerSource::Solid { item: sid }, (w, h), false)) + } + fp::ItemKind::Graphic { .. } => { + self.warn("graphics (titles, shapes) are not imported"); + None + } + fp::ItemKind::Subclip { .. } => None, + } + } + + fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem) -> Option { + let audio_track = track.kind == fp::TrackKind::Audio; + let (source, size, has_audio) = self.source_of(ti, comp.width, comp.height)?; + let is_adjustment = matches!(self.src.item(ti.item).map(|i| &i.kind), Some(fp::ItemKind::AdjustmentLayer { .. })); + if audio_track && !has_audio { + return None; + } + let mut l = effectcraft_project::build::layer(self.project, comp, &ti.name, source.clone(), size, None); + if is_adjustment { + l.switches.adjustment = true; + } + // ---- timing + let start = tick_in(ti.start); + let dur = tick_in(ti.duration); + let speed = if ti.speed.abs() < 1e-9 { 1.0 } else { ti.speed.abs() }; + let reverse = ti.reverse || ti.speed < 0.0; + let src_in = tick_in(ti.source_in); + let span = Tick((dur.0 as f64 * speed).round() as i64); + l.stretch = if reverse { -100.0 / speed } else { 100.0 / speed }; + l.start_time = + if reverse { start + Tick(((src_in + span).0 as f64 / speed).round() as i64) } else { start - Tick((src_in.0 as f64 / speed).round() as i64) }; + l.in_point = start; + l.out_point = start + dur; + if (speed - 1.0).abs() > 1e-9 || reverse { + // Keep the layer time of the in point exact (rounding of the start time). + let want = if reverse { src_in + span } else { src_in }; + let got = l.layer_time(start); + if got != want { + l.start_time += Tick(((got - want).0 as f64 * l.stretch / 100.0).round() as i64); + } + } + // Transitions extend the layer over their handles. + let mut fades: Vec<(Tick, Tick, f64, f64)> = vec![]; + for tr in &track.transitions { + let (ts, te) = (tick_in(tr.start), tick_in(tr.end())); + let id = tr.effect.effect.as_str(); + let dip = id.starts_with("dip_to"); + if !audio_track + && !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") + { + self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); + } + if dip && id == "dip_to_white" { + self.warn("Dip to White was imported as a dip to the background"); + } + let mid = ts + Tick((te - ts).0 / 2); + if tr.to == Some(ti.id) { + l.in_point = l.in_point.min(ts); + if dip && tr.from.is_some() { + fades.push((mid, te, 0.0, 1.0)); + } else { + fades.push((ts, te, 0.0, 1.0)); + } + } + if tr.from == Some(ti.id) { + l.out_point = l.out_point.max(te); + if tr.to.is_none() || audio_track { + fades.push((ts, te, 1.0, 0.0)); + } else if dip { + fades.push((ts, mid, 1.0, 0.0)); + } + } + } + l.in_point = l.in_point.max(Tick::ZERO); + l.out_point = l.out_point.min(comp.duration).max(l.in_point + comp.frame_duration()); + // ---- switches + if audio_track { + l.switches.video = false; + l.switches.audio = ti.enabled && !track.muted; + l.name = comp.unique_layer_name(&format!("{} (audio)", ti.name)); + } else { + l.switches.video = ti.enabled && track.enabled; + if has_audio { + // The clip's sound comes from its audio-track layer. + l.switches.audio = false; + } + } + if ti.frame_hold.is_some() || ti.effects.iter().any(|e| e.effect == "time_remap" && e.params.values().any(|p| p.is_animated())) { + self.time_remap(&mut l, ti); + } + // Keyframe times: clip time → layer time. + let to_layer = |l: &Layer, k: filmcraft_time::Tick| l.layer_time(start + tick_in(k)); + if audio_track { + self.audio_levels(&mut l, ti, track, &fades, to_layer); + } else { + self.motion(&mut l, ti, comp, size, to_layer); + self.opacity(&mut l, ti, &fades, to_layer); + let other: Vec<&str> = ti + .effects + .iter() + .filter(|e| !matches!(e.effect.as_str(), "motion" | "opacity" | "time_remap")) + .filter_map(|e| e.def().map(|d| d.name)) + .collect(); + for n in other { + self.warn(format!("effect \"{n}\" is not imported")); + } + if ti.effects.iter().any(|e| !e.masks.is_empty()) { + self.warn("effect masks are not imported"); + } + } + let _ = seq; + Some(l) + } + + fn time_remap(&mut self, l: &mut Layer, ti: &fp::TrackItem) { + if !matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) { + return; + } + let Some(h) = ti.frame_hold else { + self.warn("variable speed (time remapping keyframes) is not imported; the clip's average speed was used"); + return; + }; + let mut ids = Ids(&mut self.project.next_id); + let mut p = ids.prop("timeRemap", "Time Remap", Value::Scalar(tick_in(h).seconds())).with_ui(ParamUi::Number); + let (a, b) = (l.layer_time(l.in_point), l.layer_time(l.out_point)); + p.keys = vec![Keyframe::new(a.min(b), Value::Scalar(tick_in(h).seconds())).hold(), Keyframe::new(a.max(b), Value::Scalar(tick_in(h).seconds())).hold()]; + l.props.children.insert(0, Node::Prop(p)); + } + + fn motion(&mut self, l: &mut Layer, ti: &fp::TrackItem, comp: &Comp, size: (u32, u32), to_layer: impl Fn(&Layer, filmcraft_time::Tick) -> Tick) { + let Some(m) = ti.effect("motion") else { return }; + if !m.enabled { + return; + } + let fit = if ti.scale_to_frame && size.0 > 0 && size.1 > 0 { (comp.width as f64 / size.0 as f64).min(comp.height as f64 / size.1 as f64) } else { 1.0 }; + let lc = l.clone(); + let tr = l.transform_mut().expect("transform"); + // Position / anchor (NaN = centred). + for (src, dst) in [("position", "position"), ("anchor", "anchor")] { + let Some(p) = m.param(src) else { continue }; + let v2 = |v: &fp::ParamValue| v.as_vec2().filter(|v| v.x.is_finite() && v.y.is_finite()).map(|v| Value::Vec3([v.x, v.y, 0.0])); + if let Some(dp) = tr.get_mut(dst) { + set_prop(dp, p, |v| v2(v), &lc, &to_layer); + } + } + // Scale: uniform, or Scale (height) + Scale Width. + if let (Some(s), Some(dp)) = (m.param("scale"), tr.get_mut("scale")) { + let uniform = m.param("uniform_scale").and_then(|p| p.value.as_bool()).unwrap_or(true); + let sw = m.param("scale_width").filter(|_| !uniform); + match sw { + None => set_prop(dp, s, |v| v.as_f64().map(|x| Value::Vec3([x * fit, x * fit, 100.0])), &lc, &to_layer), + Some(w) => { + let mut times: Vec = s.keyframes.iter().chain(&w.keyframes).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + if times.is_empty() { + dp.value = Value::Vec3([w.f64_at(filmcraft_time::Tick::ZERO) * fit, s.f64_at(filmcraft_time::Tick::ZERO) * fit, 100.0]); + } else { + dp.keys = + times.iter().map(|t| Keyframe::new(to_layer(&lc, *t), Value::Vec3([w.f64_at(*t) * fit, s.f64_at(*t) * fit, 100.0]))).collect(); + dp.value = dp.keys[0].value.clone(); + } + } + } + } + if let (Some(r), Some(dp)) = (m.param("rotation"), tr.get_mut("rotation")) { + set_prop(dp, r, |v| v.as_f64().map(Value::Scalar), &lc, &to_layer); + } + } + + fn opacity(&mut self, l: &mut Layer, ti: &fp::TrackItem, fades: &[(Tick, Tick, f64, f64)], to_layer: impl Fn(&Layer, filmcraft_time::Tick) -> Tick) { + let lc = l.clone(); + if let Some(o) = ti.effect("opacity").filter(|e| e.enabled) { + if let Some(b) = o.param("blend").and_then(|p| match p.value { + fp::ParamValue::Choice(i) => fp::effect::BLEND_MODES.get(i as usize).copied(), + _ => None, + }) { + match BlendMode::from_name(b) { + Some(m) => l.blend_mode = m, + None => self.warn(format!("blend mode \"{b}\" was imported as Normal")), + } + } + if let (Some(p), Some(dp)) = (o.param("opacity"), l.transform_mut().and_then(|t| t.get_mut("opacity"))) { + set_prop(dp, p, |v| v.as_f64().map(Value::Scalar), &lc, &to_layer); + } + } + if !fades.is_empty() { + let fades: Vec<(Tick, Tick, f64, f64)> = fades.iter().map(|f| (lc.layer_time(f.0), lc.layer_time(f.1), f.2, f.3)).collect(); + if let Some(dp) = l.transform_mut().and_then(|t| t.get_mut("opacity")) { + apply_fades(dp, &fades, |v, f| Value::Scalar(v.as_f64() * f)); + } + } + } + + fn audio_levels( + &mut self, + l: &mut Layer, + ti: &fp::TrackItem, + track: &fp::Track, + fades: &[(Tick, Tick, f64, f64)], + to_layer: impl Fn(&Layer, filmcraft_time::Tick) -> Tick, + ) { + let lc = l.clone(); + let base = ti.gain_db + track.volume_db; + let Some(dp) = l.props.prop_mut("audio/levels") else { return }; + if let Some(v) = ti.effect("volume").filter(|e| e.enabled).and_then(|e| e.param("level")) { + set_prop(dp, v, |v| v.as_f64().map(|db| Value::Vec2([db + base, db + base])), &lc, &to_layer); + } else if base != 0.0 { + dp.value = Value::Vec2([base, base]); + } + if !fades.is_empty() { + let fades: Vec<(Tick, Tick, f64, f64)> = fades.iter().map(|f| (lc.layer_time(f.0), lc.layer_time(f.1), f.2, f.3)).collect(); + apply_fades(dp, &fades, |v, f| { + let g = 20.0 * f.max(1e-3).log10(); + let [a, b] = v.as_vec2(); + Value::Vec2([a + g, b + g]) + }); + } + } +} + +fn marker_in(m: &fp::Marker) -> Marker { + let comment = match (m.name.is_empty(), m.comment.is_empty()) { + (false, false) => format!("{}: {}", m.name, m.comment), + (false, true) => m.name.clone(), + _ => m.comment.clone(), + }; + Marker { + time: tick_in(m.start), + duration: tick_in(m.duration), + comment, + chapter: if m.kind == fp::MarkerKind::Chapter { m.name.clone() } else { String::new() }, + ..Default::default() + } +} + +fn interp_in(k: Keyframe, i: fp::Interpolation) -> Keyframe { + match i { + fp::Interpolation::Linear => k, + fp::Interpolation::Hold => { + let mut k = k; + k.out_interp = effectcraft_keyframe::Interp::Hold; + k + } + _ => k.eased(), + } +} + +/// Copy a FilmCraft parameter (static value or clip-time keyframes) to a property. +fn set_prop( + dp: &mut Property, + p: &fp::Param, + conv: impl Fn(&fp::ParamValue) -> Option, + l: &Layer, + to_layer: &impl Fn(&Layer, filmcraft_time::Tick) -> Tick, +) { + if p.keyframes.is_empty() { + if let Some(v) = conv(&p.value) { + dp.value = v; + } + return; + } + let mut keys: BTreeMap = BTreeMap::new(); + for k in &p.keyframes { + if let Some(v) = conv(&k.value) { + let t = to_layer(l, k.time); + keys.insert(t, interp_in(Keyframe::new(t, v), k.interp)); + } + } + if keys.is_empty() { + return; + } + dp.keys = keys.into_values().collect(); + // Keys from a reversed layer arrive in reverse order of clip time; holds stay on the left key. + dp.value = dp.keys[0].value.clone(); +} + +/// Multiply a property by piecewise-linear fades `(t0, t1, f0, f1)` (layer time): keys at the fade +/// ends and at the property's own keys, valued `orig(t) ⊗ factor(t)`. +fn apply_fades(dp: &mut Property, fades: &[(Tick, Tick, f64, f64)], mul: impl Fn(&Value, f64) -> Value) { + let orig = dp.clone(); + let factor = |t: Tick| -> f64 { + let mut f = 1.0; + for &(a, b, fa, fb) in fades { + let (lo, hi, flo, fhi) = if a <= b { (a, b, fa, fb) } else { (b, a, fb, fa) }; + if t <= lo { + // Before a fade-in the layer is invisible; before a fade-out it is full. + if flo < fhi { + f *= flo; + } + } else if t >= hi { + if fhi < flo { + f *= fhi; + } + } else { + let u = (t - lo).0 as f64 / (hi - lo).0.max(1) as f64; + f *= flo + (fhi - flo) * u; + } + } + f + }; + let mut times: Vec = orig.keys.iter().map(|k| k.time).collect(); + for &(a, b, _, _) in fades { + times.push(a); + times.push(b); + } + times.sort(); + times.dedup(); + dp.keys = times.iter().map(|&t| Keyframe::new(t, mul(&orig.value_at(t), factor(t)))).collect(); + dp.value = dp.keys[0].value.clone(); +} diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs new file mode 100644 index 00000000..9632c468 --- /dev/null +++ b/crates/interchange/src/lib.rs @@ -0,0 +1,187 @@ +//! Premiere Pro interop through the public timeline interchange formats Premiere itself reads and +//! writes (After Effects' File ▸ Import / Export ▸ Adobe Premiere Pro Project…): +//! +//! - **Final Cut Pro 7 XML** (`xmeml`, `.xml`): Premiere's File ▸ Export ▸ Final Cut Pro XML and +//! File ▸ Import; the default for export; +//! - **FCPXML** 1.9–1.11 (`.fcpxml`); +//! - **OpenTimelineIO** (`.otio`); +//! - **CMX 3600 EDL** (`.edl`). +//! +//! Parsing and writing is FilmCraft's `filmcraft-interchange` (our first-party sibling project); +//! this crate maps its timeline model to compositions and back: +//! +//! - [`import`]: sequences → compositions (size, rate, duration, pixel aspect, start timecode, +//! markers); video tracks → layers stacked by track (top track = top layer); clips → footage +//! layers with in/out/start, speed and reverse (time stretch), frame holds (time remapping), +//! Motion (position/scale/rotation/anchor point) and Opacity with keyframes; cross dissolves and +//! dips → overlapping layers with opacity keyframes; nested sequences → precomps; audio tracks → +//! audio-only layers with Audio Levels; bins → Project panel folders; missing media → +//! placeholders. +//! - [`export`]: a composition → one sequence with one track per layer (bottom layer = V1), +//! footage clips with their timing, Motion and Opacity; precomps as nested sequences; solids as +//! colour mattes; layers only EffectCraft can draw (text, shapes, effects, masks, 3D…) are +//! either left out or pre-rendered by the caller (ProRes 4444 with alpha, see +//! [`plan_prerender`]) and referenced as media. +//! +//! The native `.prproj` format has no public specification and is not read or written. +//! +//! This crate is L3 and does no file I/O: media files are probed through a callback, pre-rendered +//! media come in as paths, and documents are bytes. + +mod export; +mod import; +#[cfg(test)] +mod tests; + +pub use export::{ExportOptions, ExportOutput, PrecompMode, PrerenderMode, Prerendered, export, plan_prerender, prerender_reason}; +pub use import::{ImportOptions, ImportResult, import}; + +/// FilmCraft's interchange crate (format readers/writers) and timeline model, re-exported so +/// tests and tools can build documents in code. +pub use filmcraft_interchange as fc; +pub use filmcraft_project as fc_project; + +use serde::{Deserialize, Serialize}; + +/// A timeline interchange format. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TimelineFormat { + /// Final Cut Pro 7 XML (`xmeml`), the format Premiere Pro imports and exports. + Fcp7Xml, + /// FCPXML 1.9–1.11. + Fcpxml, + /// OpenTimelineIO JSON. + Otio, + /// CMX 3600 edit decision list. + Edl, +} + +impl TimelineFormat { + pub const ALL: [TimelineFormat; 4] = [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio, TimelineFormat::Edl]; + + /// Id used by commands (`xml`, `fcpxml`, `otio`, `edl`). + pub fn id(self) -> &'static str { + match self { + TimelineFormat::Fcp7Xml => "xml", + TimelineFormat::Fcpxml => "fcpxml", + TimelineFormat::Otio => "otio", + TimelineFormat::Edl => "edl", + } + } + + pub fn label(self) -> &'static str { + match self { + TimelineFormat::Fcp7Xml => "Final Cut Pro XML (Premiere Pro)", + TimelineFormat::Fcpxml => "FCPXML", + TimelineFormat::Otio => "OpenTimelineIO", + TimelineFormat::Edl => "CMX 3600 EDL", + } + } + + /// File extension (without the dot). + pub fn extension(self) -> &'static str { + self.fc().extension() + } + + /// Parse a command parameter: a format id, a name or an extension. + pub fn parse(s: &str) -> Option { + match s.trim().trim_start_matches('.').to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "xml" | "fcp7" | "fcp7xml" | "xmeml" | "finalcutproxml" | "premiere" | "premierepro" | "prproj" => Some(TimelineFormat::Fcp7Xml), + "fcpxml" | "fcpxmld" | "fcpx" => Some(TimelineFormat::Fcpxml), + "otio" | "opentimelineio" => Some(TimelineFormat::Otio), + "edl" | "cmx3600" | "cmx" => Some(TimelineFormat::Edl), + _ => None, + } + } + + /// Format of a file from its extension. + pub fn from_path(path: &str) -> Option { + let ext = path.rsplit_once('.').map(|(_, e)| e)?; + if ext.contains('/') || ext.contains('\\') { + return None; + } + filmcraft_interchange::Format::from_extension(ext).map(TimelineFormat::from_fc) + } + + /// Sniff a document (bytes first, the extension as a hint for ambiguous `.xml`). + pub fn detect(bytes: &[u8], path: Option<&str>) -> Option { + let ext = path.and_then(|p| p.rsplit_once('.')).map(|(_, e)| e); + filmcraft_interchange::detect(bytes, ext).map(TimelineFormat::from_fc) + } + + pub(crate) fn fc(self) -> filmcraft_interchange::Format { + match self { + TimelineFormat::Fcp7Xml => filmcraft_interchange::Format::Fcp7Xml, + TimelineFormat::Fcpxml => filmcraft_interchange::Format::Fcpxml, + TimelineFormat::Otio => filmcraft_interchange::Format::Otio, + TimelineFormat::Edl => filmcraft_interchange::Format::Edl, + } + } + + pub(crate) fn from_fc(f: filmcraft_interchange::Format) -> TimelineFormat { + match f { + filmcraft_interchange::Format::Fcp7Xml => TimelineFormat::Fcp7Xml, + filmcraft_interchange::Format::Fcpxml => TimelineFormat::Fcpxml, + filmcraft_interchange::Format::Otio => TimelineFormat::Otio, + filmcraft_interchange::Format::Edl => TimelineFormat::Edl, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Interchange(#[from] filmcraft_interchange::Error), + #[error("not a timeline document (expected Final Cut Pro XML, FCPXML, OpenTimelineIO or EDL)")] + UnknownFormat, + #[error("no composition {0}")] + NoComp(u64), +} + +pub type Result = std::result::Result; + +// ---------------------------------------------------------------- shared conversions + +pub(crate) fn rate_in(r: filmcraft_time::FrameRate) -> effectcraft_time::FrameRate { + if r.num <= 0 || r.den <= 0 { effectcraft_time::FrameRate::new(30, 1) } else { effectcraft_time::FrameRate::new(r.num, r.den) } +} + +pub(crate) fn rate_out(r: effectcraft_time::FrameRate) -> filmcraft_time::FrameRate { + filmcraft_time::FrameRate { num: r.num, den: r.den } +} + +// Both projects count time in the same ticks (254 016 000 000 per second). +const _: () = assert!(filmcraft_time::TICKS_PER_SECOND == effectcraft_time::TICKS_PER_SECOND); + +pub(crate) fn tick_in(t: filmcraft_time::Tick) -> effectcraft_time::Tick { + effectcraft_time::Tick(t.0) +} + +pub(crate) fn tick_out(t: effectcraft_time::Tick) -> filmcraft_time::Tick { + filmcraft_time::Tick(t.0) +} + +/// A pixel aspect ratio as a small fraction (1.0 → 1:1, 0.9 → 9:10, 1.2121 → 40:33…). +pub(crate) fn par_fraction(par: f64) -> (u32, u32) { + if !par.is_finite() || par <= 0.0 || (par - 1.0).abs() < 1e-6 { + return (1, 1); + } + let mut best = (1u32, 1u32); + let mut err = f64::MAX; + for den in 1..=200u32 { + let num = (par * den as f64).round() as u32; + if num == 0 { + continue; + } + let e = (num as f64 / den as f64 - par).abs(); + if e < err - 1e-9 { + err = e; + best = (num, den); + } + if e < 1e-6 { + break; + } + } + best +} diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs new file mode 100644 index 00000000..530ed455 --- /dev/null +++ b/crates/interchange/src/tests.rs @@ -0,0 +1,474 @@ +//! Import of documents written in code with FilmCraft's writers, and export → re-import. + +use std::collections::HashMap; +use std::sync::Arc; + +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_project as fp; + +use crate::*; + +const W: u32 = 640; +const H: u32 = 360; + +fn secs(s: f64) -> filmcraft_time::Tick { + filmcraft_time::Tick::from_seconds_f64(s) +} + +fn t(s: f64) -> Tick { + Tick::from_seconds_f64(s) +} + +fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft_media::MediaInfo { + filmcraft_media::MediaInfo { + name: String::new(), + kind, + duration: secs(20.0), + video: video.then(|| filmcraft_media::VideoStreamInfo { + width: W, + height: H, + frame_rate: filmcraft_time::FrameRate { num: 25, den: 1 }, + par: (1, 1), + codec: "png".into(), + pixel_format: String::new(), + color: Default::default(), + has_alpha: false, + bitrate: None, + }), + audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + container: String::new(), + start_timecode: None, + file_size: None, + } +} + +fn media(p: &mut fp::Project, name: &str, path: &str, kind: filmcraft_media::MediaKind, video: bool, audio: bool, bin: Option) -> fp::ItemId { + let clip = fp::MediaClip { + media: fp::MediaRef::File { path: path.into() }, + info: info(kind, video, audio), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + p.add_item(name, fp::Label::Iris, fp::ItemKind::Media(clip), bin) +} + +fn rate() -> filmcraft_time::FrameRate { + filmcraft_time::FrameRate { num: 25, den: 1 } +} + +fn item(p: &mut fp::Project, it: fp::ItemId, kind: fp::TrackKind, start: f64, src: f64, dur: f64) -> fp::TrackItem { + p.make_track_item(it, kind, secs(start), filmcraft_time::TimeRange::new(secs(src), secs(dur)), rate()).unwrap() +} + +/// A Premiere-like edit: two clips with a cross dissolve on V1, a speed-changed, scaled and +/// faded clip on V2, a nested sequence on V3, and an audio clip at -6 dB on A1. +fn edit() -> (fp::Project, fp::ItemId) { + let mut p = fp::Project::new("Edit"); + let bin = p.add_bin("Footage", None); + let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, Some(bin)); + let b = media(&mut p, "b.png", "/media/b.mov", filmcraft_media::MediaKind::Movie, true, false, Some(bin)); + let s = media(&mut p, "tone.wav", "/media/tone.wav", filmcraft_media::MediaKind::AudioOnly, false, true, Some(bin)); + let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() }; + let nested = p.new_sequence("Nested", settings.clone(), 1, 0, None); + let n_clip = item(&mut p, a, fp::TrackKind::Video, 0.0, 0.0, 2.0); + p.sequence_mut(nested).unwrap().video_tracks[0].items.push(n_clip); + let main = p.new_sequence("Main", settings, 3, 1, None); + + let ca = item(&mut p, a, fp::TrackKind::Video, 0.0, 0.0, 2.0); + let cb = item(&mut p, b, fp::TrackKind::Video, 2.0, 1.0, 2.0); + let tr = fp::Transition { + id: fp::TransitionId(p.alloc_id()), + effect: fp::find_effect("cross_dissolve").unwrap().instance(), + start: secs(1.6), + duration: secs(0.8), + from: Some(ca.id), + to: Some(cb.id), + align: fp::TransitionAlign::CenterAtCut, + reverse: false, + }; + let mut cc = item(&mut p, b, fp::TrackKind::Video, 1.0, 2.0, 2.0); + cc.speed = 2.0; + let m = cc.effect_mut("motion").unwrap(); + m.params.insert("scale".into(), fp::Param::new(fp::ParamValue::Float(50.0))); + m.params.insert("rotation".into(), fp::Param::new(fp::ParamValue::Float(30.0))); + let mut pos = fp::Param::new(fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 100.0, y: 100.0 })); + pos.keyframes = vec![ + fp::Keyframe::new(secs(0.0), fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 100.0, y: 100.0 })), + fp::Keyframe::new(secs(1.0), fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 300.0, y: 200.0 })), + ]; + m.params.insert("position".into(), pos); + cc.effect_mut("opacity").unwrap().params.insert("opacity".into(), fp::Param::new(fp::ParamValue::Float(50.0))); + let cn = item(&mut p, nested, fp::TrackKind::Video, 4.0, 0.0, 2.0); + let mut cs = item(&mut p, s, fp::TrackKind::Audio, 0.0, 0.0, 3.0); + cs.effect_mut("volume").unwrap().params.insert("level".into(), fp::Param::new(fp::ParamValue::Float(-6.0))); + let seq = p.sequence_mut(main).unwrap(); + seq.video_tracks[0].items = vec![ca, cb]; + seq.video_tracks[0].transitions = vec![tr]; + seq.video_tracks[1].items = vec![cc]; + seq.video_tracks[2].items = vec![cn]; + seq.audio_tracks[0].items = vec![cs]; + (p, main) +} + +fn probe(path: &str) -> Option { + let audio = path.ends_with(".wav"); + Some(Footage { + path: path.into(), + kind: if audio { FootageKind::Audio } else { FootageKind::Video }, + width: if audio { 0 } else { W }, + height: if audio { 0 } else { H }, + frame_rate: FrameRate::new(25, 1), + duration: t(20.0), + has_video: !audio, + has_audio: audio, + ..Default::default() + }) +} + +fn import_doc(bytes: &[u8], name: &str, probe_fn: &mut dyn FnMut(&str) -> Option) -> (Project, ImportResult) { + let mut p = Project::default(); + let r = import(&mut p, bytes, None, &ImportOptions { name: name.into(), ..Default::default() }, probe_fn).unwrap(); + (p, r) +} + +fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer { + c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}: {:?}", c.layers.iter().map(|l| &l.name).collect::>())) +} + +fn near(a: Tick, b: Tick) -> bool { + (a.0 - b.0).abs() <= Tick::from_seconds_f64(0.001).0 +} + +fn check_structure(p: &Project, r: &ImportResult, motion: bool) { + let main = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp"); + let c = p.comp(main).unwrap(); + assert_eq!((c.width, c.height), (W, H)); + assert_eq!(c.frame_rate, FrameRate::new(25, 1)); + assert!(near(c.duration, t(6.0)), "{:?}", c.duration.seconds()); + // V3 (nested) on top, then V2, then V1's later clip, then the earlier one; audio last. + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + let pos = |n: &str| names.iter().position(|x| x.starts_with(n)).unwrap_or_else(|| panic!("{n} in {names:?}")); + assert!(pos("Nested") < pos("b.png"), "{names:?}"); + let nested = layer(c, "Nested"); + assert!(matches!(nested.source, LayerSource::Comp { .. })); + assert!(near(nested.in_point, t(4.0)) && near(nested.out_point, t(6.0))); + // The faster clip: 200 % speed → 50 % stretch, source 2 s at its in point. + let fast = c.layers.iter().find(|l| (l.stretch - 50.0).abs() < 1e-6).expect("stretched layer"); + assert!(near(fast.in_point, t(1.0)) && near(fast.out_point, t(3.0))); + assert!(near(fast.layer_time(t(1.0)), t(2.0)), "{}", fast.layer_time(t(1.0)).seconds()); + // V1: b above a, overlapping over the dissolve; b fades in. + let v1: Vec<&Layer> = + c.layers.iter().filter(|l| (l.stretch - 100.0).abs() < 1e-6 && matches!(l.source, LayerSource::Footage { .. }) && l.switches.video).collect(); + assert_eq!(v1.len(), 2, "{names:?}"); + let (lb, la) = (v1[0], v1[1]); + assert!(near(la.in_point, t(0.0)) && near(la.out_point, t(2.4)), "{:?}", la.out_point.seconds()); + assert!(near(lb.in_point, t(1.6)) && near(lb.out_point, t(4.0)), "{:?}", lb.in_point.seconds()); + assert!(near(lb.layer_time(t(2.0)), t(1.0))); + let op = lb.transform().unwrap().get("opacity").unwrap(); + assert_eq!(op.keys.len(), 2); + assert!(op.value_at(lb.layer_time(t(1.6))).as_f64() < 1e-6); + assert!((op.value_at(lb.layer_time(t(2.0))).as_f64() - 50.0).abs() < 1.0); + assert!((op.value_at(lb.layer_time(t(3.0))).as_f64() - 100.0).abs() < 1e-6); + // Audio: an audio-only layer at -6 dB. + let au = c.layers.iter().find(|l| !l.switches.video && l.switches.audio).expect("audio layer"); + assert!(near(au.out_point, t(3.0))); + if motion { + let lv = au.props.prop("audio/levels").unwrap().value.as_vec2(); + assert!((lv[0] + 6.0).abs() < 0.01, "{lv:?}"); + let tr = fast.transform().unwrap(); + assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [50.0, 50.0]); + assert!((tr.get("rotation").unwrap().value.as_f64() - 30.0).abs() < 1e-6); + assert!((tr.get("opacity").unwrap().value.as_f64() - 50.0).abs() < 1e-6); + let pk = &tr.get("position").unwrap().keys; + assert_eq!(pk.len(), 2); + // Clip time 1 s = comp 2 s = layer time 4 s at 200 % speed. + assert!(near(pk[1].time, fast.layer_time(t(2.0)))); + assert_eq!(pk[1].value.as_vec3()[..2], [300.0, 200.0]); + } + // Everything lands in the document's folder. + assert!(p.item(r.folder).unwrap().is_folder()); + assert!(p.items.values().filter(|i| i.id != r.folder).all(|i| i.parent.is_some())); +} + +/// A Premiere-style xmeml export with a bin, a master clip, Basic Motion and Opacity keyframes. +const PREMIERE_XML: &str = r#" + + + + Promo + + + Shots + + + Day 1 + + + wide.mov + 250 + 25FALSE + + + + + + + + Promo Cut + 50 + 25FALSE + + + + + + + +"#; + +#[test] +fn premiere_xml_bins_motion_and_opacity() { + let mut paths = vec![]; + let (p, r) = import_doc(PREMIERE_XML.as_bytes(), "Promo.xml", &mut |path| { + paths.push(path.to_string()); + probe(path) + }); + assert_eq!(paths, vec!["/media/wide shot.mov".to_string()]); + let shots = p.items.values().find(|i| i.name == "Shots").unwrap(); + assert_eq!(shots.parent, Some(r.folder)); + let day = p.items.values().find(|i| i.name == "Day 1").unwrap(); + assert_eq!(day.parent, Some(shots.id)); + assert!(p.items.values().any(|i| i.name == "wide.mov" && i.parent == Some(day.id))); + let c = p.comp(r.comps[0]).unwrap(); + assert_eq!(p.item(r.comps[0]).unwrap().name, "Promo Cut"); + assert!(near(c.duration, t(2.0))); + let l = &c.layers[0]; + assert!(near(l.layer_time(Tick::ZERO), t(1.0)), "source in 1 s"); + let tr = l.transform().unwrap(); + assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [150.0, 150.0]); + // Center 0.25 of the width right of the frame centre. + assert_eq!(tr.get("position").unwrap().value.as_vec3()[..2], [320.0 + 160.0, 180.0]); + let op = tr.get("opacity").unwrap(); + assert_eq!(op.keys.len(), 2); + assert!(near(op.keys[1].time, l.layer_time(t(1.0)))); + assert!((op.value_at(l.layer_time(t(0.5))).as_f64() - 50.0).abs() < 1e-6); +} + +#[test] +fn imports_fcp7_xml_fcpxml_and_otio() { + let (doc, main) = edit(); + for (fmt, motion) in [(fc::Format::Fcp7Xml, true), (fc::Format::Fcpxml, false), (fc::Format::Otio, false)] { + let (bytes, _) = fc::export(&doc, main, fmt, &Default::default()).unwrap(); + let name = format!("Main.{}", fmt.extension()); + assert_eq!(TimelineFormat::detect(&bytes, Some(&name)).map(|f| f.fc()), Some(fmt)); + let (p, r) = import_doc(&bytes, &name, &mut probe); + assert!(r.missing.is_empty(), "{fmt:?}: {:?}", r.missing); + check_structure(&p, &r, motion); + } +} + +#[test] +fn missing_media_become_placeholders() { + let (doc, main) = edit(); + let (bytes, _) = fc::export(&doc, main, fc::Format::Fcp7Xml, &Default::default()).unwrap(); + let (p, r) = import_doc(&bytes, "Main.xml", &mut |_| None); + assert_eq!(r.missing.len(), 3, "{:?}", r.missing); + let a = p.items.values().find(|i| i.name == "a.png").unwrap(); + match &a.kind { + ItemKind::Footage(f) => assert!(f.missing && f.width == W && f.height == H && f.has_video), + _ => panic!(), + } + check_structure(&p, &r, true); +} + +#[test] +fn edl_imports_one_track() { + let (doc, main) = edit(); + let (bytes, _) = fc::export(&doc, main, fc::Format::Edl, &Default::default()).unwrap(); + let mut p = Project::default(); + let r = import(&mut p, &bytes, None, &ImportOptions { name: "Main.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() }, &mut probe).unwrap(); + assert_eq!(r.format, Some(TimelineFormat::Edl)); + let c = p.comp(r.comps[0]).unwrap(); + assert!(c.layers.len() >= 2); +} + +#[test] +fn rejects_unknown_documents() { + let mut p = Project::default(); + assert!(matches!(import(&mut p, b"hello", None, &ImportOptions::default(), &mut probe), Err(Error::UnknownFormat))); +} + +// ---------------------------------------------------------------- export + +fn footage_item(p: &mut Project, path: &str) -> ItemId { + let f = probe(path).unwrap(); + p.add_item(path.rsplit('/').next().unwrap(), effectcraft_color::Label::Aqua, None, ItemKind::Footage(f)) +} + +fn comp_item(p: &mut Project, name: &str, c: Comp) -> ItemId { + p.add_item(name, effectcraft_color::Label::Sandstone, None, ItemKind::Comp(Arc::new(c))) +} + +/// A comp with a stretched, keyframed footage layer, a text layer, a precomp and an audio layer. +fn ae_project() -> (Project, ItemId) { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let s = footage_item(&mut p, "/media/tone.wav"); + let mut inner = Comp::new(W, H, FrameRate::new(25, 1), t(4.0)); + let li = effectcraft_project::build::layer(&mut p, &inner, "inner a", LayerSource::Footage { item: a }, (W, H), None); + inner.layers.push(li); + let inner_id = comp_item(&mut p, "Inner", inner); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(5.0)); + let mut la = effectcraft_project::build::layer(&mut p, &c, "clip a", LayerSource::Footage { item: a }, (W, H), None); + la.stretch = 200.0; // half speed + la.start_time = t(-1.0); // layer time 1 s at comp 3 s… source 1 s at comp 1 s + la.in_point = t(1.0); + la.out_point = t(4.0); + { + let tr = la.transform_mut().unwrap(); + let pos = tr.get_mut("position").unwrap(); + pos.keys = vec![Keyframe::new(t(1.0), Value::Vec3([100.0, 120.0, 0.0])), Keyframe::new(t(2.0), Value::Vec3([500.0, 240.0, 0.0])).hold()]; + tr.get_mut("scale").unwrap().value = Value::Vec3([80.0, 80.0, 100.0]); + tr.get_mut("rotation").unwrap().value = Value::Scalar(-15.0); + tr.get_mut("opacity").unwrap().value = Value::Scalar(75.0); + } + let mut lt = effectcraft_project::build::layer(&mut p, &c, "Title", LayerSource::Text, (W, H), None); + lt.in_point = t(0.6); + lt.out_point = t(2.6); + let mut lp = effectcraft_project::build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner_id }, (W, H), None); + lp.start_time = t(2.0); + lp.in_point = t(2.0); + lp.out_point = t(5.0); + let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None); + ls.out_point = t(3.0); + ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]); + c.layers = vec![lt, lp, la, ls]; + let cid = comp_item(&mut p, "Main", c); + (p, cid) +} + +#[test] +fn plan_prerenders_rendered_only_layers() { + let (p, cid) = ae_project(); + let opts = ExportOptions::default(); + let plan = plan_prerender(&p, cid, &opts); + let c = p.comp(cid).unwrap(); + assert_eq!(plan, vec![(cid, layer(c, "Title").id)]); + assert_eq!(prerender_reason(&p, cid, layer(c, "Title").id, &opts).as_deref(), Some("text layer")); + let all = plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::All, ..Default::default() }); + assert_eq!(all.len(), 3, "audio-only layers are never rendered"); + assert!(plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }).is_empty()); +} + +#[test] +fn export_then_reimport_keeps_the_timeline() { + let (p, cid) = ae_project(); + let c = p.comp(cid).unwrap(); + let title = layer(c, "Title").id; + let mut pre = HashMap::new(); + pre.insert((cid, title), Prerendered { path: "/render/Title.mov".into(), width: W, height: H, frame_rate: FrameRate::new(25, 1), duration: t(2.0) }); + for format in [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio] { + let out = export(&p, cid, &ExportOptions { format, ..Default::default() }, &pre).unwrap(); + assert_eq!(out.prerendered, 1); + assert_eq!(out.sequences, 2); + assert_eq!(out.video_tracks, 4, "3 in Main + 1 in Inner"); + assert_eq!(out.audio_tracks, 1); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert!(text.contains("Title.mov"), "{format:?}"); + let (q, r) = import_doc(&out.bytes, &format!("Main.{}", format.extension()), &mut probe); + let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main"); + let m = q.comp(main).unwrap(); + assert_eq!(m.layers.len(), 4, "{format:?}: {:?}", m.layers.iter().map(|l| &l.name).collect::>()); + // Stacking order kept: Title (pre-rendered) on top, then the precomp, then clip a. + assert_eq!(m.layers[0].name, "Title"); + assert!(near(m.layers[0].in_point, t(0.6)) && near(m.layers[0].out_point, t(2.6))); + assert!(matches!(m.layers[1].source, LayerSource::Comp { .. }), "{format:?}"); + assert!(near(m.layers[1].in_point, t(2.0)) && near(m.layers[1].layer_time(t(2.0)), Tick::ZERO)); + let la = &m.layers[2]; + assert!((la.stretch - 200.0).abs() < 1e-6, "{format:?} stretch {}", la.stretch); + assert!(near(la.in_point, t(1.0)) && near(la.out_point, t(4.0))); + let orig = layer(c, "clip a"); + assert!(near(la.layer_time(t(1.0)), orig.layer_time(t(1.0))), "{format:?} {}", la.layer_time(t(1.0)).seconds()); + assert!(!m.layers[3].switches.video && m.layers[3].switches.audio); + if format == TimelineFormat::Fcp7Xml { + let tr = la.transform().unwrap(); + assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [80.0, 80.0]); + assert!((tr.get("rotation").unwrap().value.as_f64() + 15.0).abs() < 1e-6); + assert!((tr.get("opacity").unwrap().value.as_f64() - 75.0).abs() < 1e-6); + let pk = &tr.get("position").unwrap().keys; + assert_eq!(pk.len(), 2); + for (k, o) in pk.iter().zip(&tr_orig(orig).keys) { + assert!(near(la.comp_time(k.time), orig.comp_time(o.time)), "{} vs {}", k.time.seconds(), o.time.seconds()); + assert_eq!(k.value.as_vec3()[..2], o.value.as_vec3()[..2]); + } + let lv = m.layers[3].props.prop("audio/levels").unwrap().value.as_vec2(); + assert!((lv[0] + 3.0).abs() < 0.01, "{lv:?}"); + } + } +} + +fn tr_orig(l: &Layer) -> &effectcraft_project::Property { + l.transform().unwrap().get("position").unwrap() +} + +#[test] +fn export_without_prerender_leaves_text_out() { + let (p, cid) = ae_project(); + let out = export(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }, &HashMap::new()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("Title")), "{:?}", out.warnings); + assert_eq!(out.prerendered, 0); + assert_eq!(out.video_tracks, 3); +} + +#[test] +fn formats_parse() { + assert_eq!(TimelineFormat::parse("premiere"), Some(TimelineFormat::Fcp7Xml)); + assert_eq!(TimelineFormat::parse(".fcpxml"), Some(TimelineFormat::Fcpxml)); + assert_eq!(TimelineFormat::from_path("/a/b.otio"), Some(TimelineFormat::Otio)); + assert_eq!(TimelineFormat::Fcp7Xml.extension(), "xml"); + assert_eq!(par_fraction(0.9), (9, 10)); + assert_eq!(par_fraction(1.0), (1, 1)); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 681aee1e..e7c6a38c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -47,6 +47,7 @@ const LAYERS: &[(&str, u8)] = &[ ("lottie", 3), ("format", 3), ("plugin", 3), + ("interchange", 3), ("engine", 4), ("script", 4), ("host", 4),