Files
effectcraft/crates/interchange/src/export.rs
T
Dylan M. Taylor 1db7b83e64 interchange: reuse a folder's cached bin on export
Exporter::bin returned None when the folder itself was already cached, so
every item after the first in a folder was exported at the project root.
Return the cached bin when the walk starts at a cached folder.
2026-11-03 11:20:47 +00:00

787 lines
32 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.
//! 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.styles_change_pixels() {
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.
///
/// Walks up the folder's parents without recursion: a damaged or hostile project can hold a
/// parent cycle (or a very long chain), which recursed until the stack ran out. A folder seen
/// twice in one walk ends it, and the cycle's topmost folder becomes a top-level bin.
fn bin(&mut self, folder: Option<ItemId>) -> Option<fp::BinId> {
let f = folder?;
// The uncached folders from `f` up, and the bin they hang from.
let mut chain: Vec<ItemId> = vec![];
let mut in_chain = std::collections::HashSet::new();
let mut parent = None;
let mut at = Some(f);
while let Some(id) = at {
if let Some(b) = self.bins.get(&id) {
parent = Some(*b);
break;
}
if !in_chain.insert(id) {
self.warn("a folder that contains itself was exported as a top-level bin");
break;
}
let Some(it) = self.p.item(id) else { break };
chain.push(id);
at = it.parent;
}
if chain.is_empty() {
// `f` is cached already (`parent` is its bin), or is not an item (`None`).
return parent;
}
for id in chain.into_iter().rev() {
let name = self.p.item(id).map(|it| it.name.clone()).unwrap_or_default();
let b = self.fp.add_bin(&name, parent);
self.bins.insert(id, b);
parent = Some(b);
}
parent
}
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);
}
}
/// Whether `c` or a comp nested in it (at any depth) has a layer of footage with audio.
///
/// A walk with a stack and a visited set, as in `Project::comp_contains`: recursion never ended
/// on a precomp cycle, and re-walked a comp shared by many layers once per path (exponential
/// for a chain of comps that each use the next one twice).
fn comp_has_audio(p: &Project, c: &Comp) -> bool {
let mut seen = std::collections::HashSet::new();
let mut stack = vec![c];
while let Some(c) = stack.pop() {
for l in &c.layers {
match &l.source {
LayerSource::Footage { item } => {
if matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio) {
return true;
}
}
LayerSource::Comp { item } => {
if seen.insert(*item)
&& let Some(nested) = p.comp(*item)
{
stack.push(nested);
}
}
_ => {}
}
}
}
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,
hdr: None,
}),
audio_streams: audio
.then(|| AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) })
.into_iter()
.collect(),
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 }
}
#[cfg(test)]
mod tests {
use super::*;
/// A folder's bin is the same one each time it is asked for (a cached folder once answered
/// `None`, so a folder's second item went to the project root); a non-item has no bin.
#[test]
fn folder_bins_are_reused_once_cached() {
let mut p = Project::default();
let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder);
let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder);
let (opts, pre) = (ExportOptions::default(), HashMap::new());
let mut ex = Exporter {
p: &p,
opts: &opts,
pre: &pre,
fp: fp::Project::new("x"),
out: ExportOutput::default(),
seqs: HashMap::new(),
media: HashMap::new(),
bins: HashMap::new(),
stack: vec![],
};
let bb = ex.bin(Some(b));
assert!(bb.is_some());
assert_eq!(ex.bin(Some(b)), bb);
let ba = ex.bin(Some(a));
assert!(ba.is_some() && ba != bb);
assert_eq!(ex.bin(Some(a)), ba);
assert_eq!(ex.bin(Some(ItemId(u64::MAX))), None);
assert_eq!(ex.bin(None), None);
}
}