//! 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). /// The deepest element / array nesting accepted in XML and OTIO documents. Real timelines nest /// a few levels per nested sequence; parsing several thousand levels overflowed the stack. pub const MAX_DEPTH: usize = 256; /// The deepest element nesting of an XML document (a quick scan that skips comments, CDATA, /// processing instructions, declarations and quoted attribute values). pub(crate) fn xml_depth(b: &[u8]) -> usize { let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); let skip_to = |from: usize, end: &[u8]| b.get(from..).and_then(|r| r.windows(end.len()).position(|w| w == end)).map_or(b.len(), |p| from + p + end.len()); while let Some(&c) = b.get(i) { if c != b'<' { i += 1; continue; } let rest = b.get(i..).unwrap_or_default(); if rest.starts_with(b""); } else if rest.starts_with(b""); } else if rest.starts_with(b""); } else if rest.starts_with(b""); } else if rest.starts_with(b""); } else { // A start tag: find its end outside quotes; `/>` closes it at once. let mut j = i + 1; let mut quote = None; while let Some(&c) = b.get(j) { match quote { Some(q) if c == q => quote = None, Some(_) => {} None if c == b'"' || c == b'\'' => quote = Some(c), None if c == b'>' => break, None => {} } j += 1; } if b.get(j.wrapping_sub(1)) != Some(&b'/') { depth += 1; max = max.max(depth); } i = j + 1; } } max } /// The deepest array / object nesting of a JSON document (strings skipped). pub(crate) fn json_depth(b: &[u8]) -> usize { let (mut depth, mut max, mut in_str, mut esc) = (0usize, 0usize, false, false); for &c in b { if in_str { if esc { esc = false; } else if c == b'\\' { esc = true; } else if c == b'"' { in_str = false; } continue; } match c { b'"' => in_str = true, b'[' | b'{' => { depth += 1; max = max.max(depth); } b']' | b'}' => depth = depth.saturating_sub(1), _ => {} } } max } 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)?, }; // The XML and JSON parsers recurse per nesting level: a small file nested thousands of levels // deep overflowed the stack and aborted the app. let depth = match format { TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml => xml_depth(bytes), TimelineFormat::Otio => json_depth(bytes), _ => 0, }; if depth > MAX_DEPTH { return Err(Error::TooDeep(MAX_DEPTH)); } 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 Some(tr) = l.transform_mut() else { return }; // 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(); }