//! 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, format: TimelineFormat) -> 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"), Some(ItemKind::Solid(_)) if format != TimelineFormat::Fcp7Xml => Some("solid (the format has no colour mattes)"), _ => 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, opts.format); 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).ok_or(Error::NoComp(comp.0))?; 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); None if `cid` is not a composition. fn sequence(&mut self, cid: ItemId) -> Option { if let Some(s) = self.seqs.get(&cid) { return Some(*s); } let comp = self.p.comp_arc(cid)?; 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); self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate).map(|mut ti| { ti.source_in = filmcraft_time::Tick::ZERO; ti.speed = 1.0; ti.reverse = false; self.out.prerendered += 1; 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.opts.format) { 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) => self.sequence(*item), _ => None, }; item.and_then(|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()))); } Some(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) => { self.sequence(*item) } _ => None, }; if let Some(item) = sound && !matches!(pl, Plan::Skip(_)) && let Some(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(); Some(sid) } fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> Option { 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))?; 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; Some(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 Some(mut m) = effect_or_new(ti, "motion") else { return }; 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 Some(mut o) = effect_or_new(ti, "opacity") { 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 Some(mut v) = effect_or_new(ti, "volume") else { return }; 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 } /// The clip's `id` effect, or a new instance of it (None if the registry has no such effect). fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option { ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance())) } 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 } }