effectcraft/crates/interchange/src/import.rs

699 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>,
/// 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<f64>,
}
/// What an import created.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ImportResult {
pub format: Option<TimelineFormat>,
/// The folder holding everything imported.
pub folder: ItemId,
/// Compositions of the document's top-level sequences, in document order.
pub comps: Vec<ItemId>,
/// Every composition created (nested sequences included).
pub all_comps: Vec<ItemId>,
/// Footage and solid items created.
pub items: Vec<ItemId>,
/// Media files that could not be found or read (imported as placeholders).
pub missing: Vec<String>,
/// Everything that could not be represented exactly.
pub warnings: Vec<String>,
}
/// 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"<!--") {
i = skip_to(i + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = skip_to(i + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = skip_to(i + 2, b"?>");
} else if rest.starts_with(b"<!") {
i = skip_to(i + 2, b">");
} else if rest.starts_with(b"</") {
depth = depth.saturating_sub(1);
i = skip_to(i + 2, 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<TimelineFormat>,
opts: &ImportOptions,
probe: &mut dyn FnMut(&str) -> Option<Footage>,
) -> Result<ImportResult> {
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<fp::ItemId, ItemId>,
/// Generator / adjustment solids by (source item, width, height).
solids: HashMap<(fp::ItemId, u32, u32), ItemId>,
solids_folder: Option<ItemId>,
/// Folder of each source item (from the bins).
bins: HashMap<fp::ItemId, ItemId>,
}
impl Importer<'_> {
fn warn(&mut self, m: impl Into<String>) {
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<Footage>) {
// 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<fp::ItemId> = 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>) -> 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<Layer> {
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<filmcraft_time::Tick> = 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<Value>,
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<Tick, Keyframe> = 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<Tick> = 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();
}