mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Add a muted flag to Keyframe. Muted keys stay in the Timeline and Graph Editor (dimmed, selectable and movable) but evaluation skips them, so the active neighbours interpolate as if they were not there; auto-Bezier and eases use the active neighbours. When every key is muted the property falls back to its static value.\n\nkeys.setMuted {muted} mutes or unmutes the selected keys in one undo step. The Timeline context menu offers Mute/Unmute Keyframes and the Animation menu has Mute Keyframes. Muted keys have no Bezier handles and motion-path points are dimmed.\n\nLottie export omits muted keys (with a warning) because Lottie has no mute state; scripting keeps seeing them in numKeys/key().\n\nTests: keyframe evaluation (linear, auto-Bezier spatial, all muted), serialization and value replacement, engine command + undo, Lottie export.
702 lines
29 KiB
Rust
702 lines
29 KiB
Rust
//! 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<PrerenderMode> {
|
||
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<String>,
|
||
}
|
||
|
||
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<u8>,
|
||
pub warnings: Vec<String>,
|
||
/// 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<String> {
|
||
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 l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) {
|
||
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<String> {
|
||
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<ItemId>) {
|
||
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<ExportOutput> {
|
||
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<ItemId, fp::ItemId>,
|
||
/// Media items by (path or solid key).
|
||
media: HashMap<String, fp::ItemId>,
|
||
bins: HashMap<ItemId, fp::BinId>,
|
||
stack: Vec<ItemId>,
|
||
}
|
||
|
||
/// 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<Timing> {
|
||
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<String>) {
|
||
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<ItemId>) -> Option<fp::BinId> {
|
||
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<fp::ItemId> {
|
||
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<fp::Track> = vec![];
|
||
let mut audio: Vec<fp::Track> = 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<fp::Marker> = 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<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))?;
|
||
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 = !non_uniform_scale(p);
|
||
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
|
||
}
|
||
|
||
/// Whether a scale property is non-uniform at some point: active key values, or the static
|
||
/// value when every key is muted (muted keys are not exported, #1894).
|
||
fn non_uniform_scale(p: &Property) -> bool {
|
||
let non_uniform = |v: &Value| {
|
||
let a = v.as_vec3();
|
||
(a[0] - a[1]).abs() > 1e-9
|
||
};
|
||
if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) }
|
||
}
|
||
|
||
/// 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<fp::EffectInstance> {
|
||
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));
|
||
// XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so
|
||
// the exported animation matches what the renderer shows (#1894).
|
||
for k in p.keys.iter().filter(|k| !k.muted) {
|
||
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<VideoSpec>, 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 }
|
||
}
|