mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge branch 'worktree-agent-a9fea98fe9839069d'
This commit is contained in:
commit
d39c0e818c
21 files changed
+2759
-2
No files matched your search
Generated
+66
@@ -1219,6 +1219,7 @@ dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-interchange",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-lottie",
|
||||
"effectcraft-model",
|
||||
@@ -1330,6 +1331,7 @@ dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
"effectcraft-expr",
|
||||
"effectcraft-interchange",
|
||||
"effectcraft-media",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-plugin",
|
||||
@@ -1339,10 +1341,30 @@ dependencies = [
|
||||
"effectcraft-script",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"image",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-project",
|
||||
"effectcraft-time",
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.1.1"
|
||||
@@ -1991,6 +2013,11 @@ name = "filmcraft-bitstream"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.1.1"
|
||||
@@ -2052,6 +2079,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.1.1"
|
||||
@@ -2082,6 +2117,23 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.1.1"
|
||||
@@ -2142,6 +2194,20 @@ name = "filmcraft-opus"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-time",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.1.1"
|
||||
|
||||
+6
-1
@@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" }
|
||||
effectcraft-export = { path = "crates/export" }
|
||||
effectcraft-expr = { path = "crates/expr" }
|
||||
effectcraft-lottie = { path = "crates/lottie" }
|
||||
effectcraft-interchange = { path = "crates/interchange" }
|
||||
effectcraft-pdf = { path = "crates/pdf" }
|
||||
effectcraft-psd = { path = "crates/psd" }
|
||||
effectcraft-vp9enc = { path = "crates/vp9enc" }
|
||||
@@ -68,7 +69,7 @@ exr = "1.74"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
|
||||
# Only `crates/media` and `crates/export` may depend on these.
|
||||
# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these.
|
||||
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
@@ -85,6 +86,10 @@ filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a
|
||||
# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only)
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only)
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
|
||||
@@ -23,6 +23,7 @@ effectcraft-render = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
effectcraft-model = { workspace = true }
|
||||
effectcraft-lottie = { workspace = true }
|
||||
effectcraft-interchange = { workspace = true }
|
||||
effectcraft-psd = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
effectcraft-svg = { workspace = true }
|
||||
|
||||
@@ -61,6 +61,7 @@ mod text_anim;
|
||||
pub mod text_edit;
|
||||
mod three_d;
|
||||
pub mod time;
|
||||
mod timeline;
|
||||
mod track;
|
||||
mod view;
|
||||
pub mod viewer_cmds;
|
||||
@@ -168,6 +169,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(timeline::specs());
|
||||
v.extend(comp_more::specs());
|
||||
v.extend(frame_export::specs());
|
||||
v.extend(watch_folder::specs());
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Premiere Pro interop (`effectcraft-interchange`): File ▸ Import ▸ Adobe Premiere Pro Project /
|
||||
//! Timeline Interchange… and File ▸ Export ▸ Adobe Premiere Pro Project…, through the public
|
||||
//! interchange formats Premiere reads and writes. Export writes Final Cut Pro XML (`.xml`) by
|
||||
//! default, which Premiere Pro imports (File ▸ Import); FCPXML, OpenTimelineIO and EDL on request.
|
||||
//! The native `.prproj` format has no public specification and is not read or written.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use effectcraft_interchange::{ExportOptions, ImportOptions, PrecompMode, PrerenderMode, Prerendered, TimelineFormat};
|
||||
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
|
||||
match str_p(p, "format") {
|
||||
None | Some("") | Some("auto") => Ok(None),
|
||||
Some(f) => TimelineFormat::parse(f).map(Some).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (xml, fcpxml, otio, edl, aaf, omf)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_timeline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.importTimeline";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(CMD, "missing `path`"))?.to_string();
|
||||
if path.to_ascii_lowercase().ends_with(".prproj") {
|
||||
return Err(bad(
|
||||
CMD,
|
||||
"native Premiere Pro projects (.prproj) are not supported: in Premiere Pro use File ▸ Export ▸ Final Cut Pro XML… and import the .xml",
|
||||
));
|
||||
}
|
||||
let format = format_p(p, CMD)?;
|
||||
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let fp = std::path::Path::new(&path);
|
||||
let opts = ImportOptions {
|
||||
base_dir: fp.parent().map(|d| d.to_string_lossy().to_string()).filter(|d| !d.is_empty()),
|
||||
name: fp.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into()),
|
||||
edl_frame_rate: f_p(p, "edlFrameRate"),
|
||||
};
|
||||
let importer = s.importer.clone();
|
||||
let services = s.services.clone();
|
||||
let mut probe = move |m: &str| -> Option<effectcraft_project::Footage> {
|
||||
if !services.exists(m) {
|
||||
return None;
|
||||
}
|
||||
importer.as_ref()?.probe(m).ok()
|
||||
};
|
||||
let res = s.edit("Import Timeline", None, |proj, st| {
|
||||
let r = effectcraft_interchange::import(proj, &bytes, format, &opts, &mut probe).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
st.project_selection = r.comps.clone();
|
||||
Ok(r)
|
||||
})?;
|
||||
if let Some(c) = res.comps.first() {
|
||||
s.open_comp(*c);
|
||||
}
|
||||
let n = res.missing.len();
|
||||
s.toast(match n {
|
||||
0 => format!("Imported {}", opts.name),
|
||||
_ => format!("Imported {} ({n} missing file{})", opts.name, if n == 1 { "" } else { "s" }),
|
||||
});
|
||||
Ok(json!({
|
||||
"format": res.format.map(|f| f.id()),
|
||||
"folder": res.folder.0,
|
||||
"comps": res.comps.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
"allComps": res.all_comps.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
"items": res.items.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
"missing": res.missing,
|
||||
"warnings": res.warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
/// File-name-safe text.
|
||||
fn safe(s: &str) -> String {
|
||||
let t: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
|
||||
if t.is_empty() { "layer".into() } else { t }
|
||||
}
|
||||
|
||||
/// A copy of the project where comp `cid` shows layer `lid` alone (cameras and lights kept, so 3D
|
||||
/// layers render as they look in the comp).
|
||||
fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option<Project> {
|
||||
let mut q = project.clone();
|
||||
let c = q.comp_mut(cid)?;
|
||||
for l in &mut c.layers {
|
||||
if l.id == lid {
|
||||
l.switches.solo = false;
|
||||
l.switches.video = true;
|
||||
} else if !matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }) {
|
||||
l.switches.video = false;
|
||||
l.switches.audio = false;
|
||||
l.switches.solo = false;
|
||||
}
|
||||
}
|
||||
Some(q)
|
||||
}
|
||||
|
||||
/// Render one layer to ProRes 4444 with alpha over its in–out span.
|
||||
fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result::Result<Prerendered, String> {
|
||||
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
|
||||
if !exporter.formats().contains(&OutputFormat::ProRes) {
|
||||
return Err("ProRes export is not available in this build".into());
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or("no composition")?;
|
||||
let l = comp.layer(lid).ok_or("no layer")?;
|
||||
let fd = comp.frame_duration();
|
||||
let start = comp.frame_rate.snap(l.in_point.max(effectcraft_time::Tick::ZERO));
|
||||
let end = l.out_point.min(comp.duration).max(start + fd);
|
||||
let q = isolate(&s.project, cid, lid).ok_or("no composition")?;
|
||||
let mut item = RenderQueueItem::new(0, cid);
|
||||
item.settings.time_span = TimeSpan::Custom { start, end };
|
||||
item.output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
item.output.channels = Channels::Rgba;
|
||||
item.output.prores_profile = ProResProfile::P4444;
|
||||
item.output.audio = AudioOutput::Off;
|
||||
item.output.output = path.to_string();
|
||||
let job = crate::ExportJob {
|
||||
project: &q,
|
||||
footage: s.footage.as_ref(),
|
||||
expr: s.expr.as_deref(),
|
||||
accel: s.accel.as_deref(),
|
||||
item: &item,
|
||||
path,
|
||||
storage: None,
|
||||
label: format!("Pre-render {}", l.name),
|
||||
nested_switches: s.prefs.general.switches_affect_nested_comps,
|
||||
};
|
||||
let r = exporter.export(&job, &mut |_, _| true)?;
|
||||
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
|
||||
}
|
||||
|
||||
fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.exportTimeline";
|
||||
let mut path = str_p(p, "path").ok_or_else(|| bad(CMD, "missing `path`"))?.to_string();
|
||||
let format = format_p(p, CMD)?.or_else(|| TimelineFormat::from_path(&path)).unwrap_or(TimelineFormat::Fcp7Xml);
|
||||
if TimelineFormat::from_path(&path).is_none() || path.to_ascii_lowercase().ends_with(".prproj") {
|
||||
if let Some(stem) = path.strip_suffix(".prproj").map(str::to_string) {
|
||||
path = stem;
|
||||
}
|
||||
path = format!("{path}.{}", format.extension());
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let prerender_mode = match str_p(p, "prerender") {
|
||||
None => match p.get("prerender").and_then(Value::as_bool) {
|
||||
Some(false) => PrerenderMode::None,
|
||||
_ => PrerenderMode::Unsupported,
|
||||
},
|
||||
Some(m) => PrerenderMode::parse(m).ok_or_else(|| bad(CMD, "prerender: none|unsupported|all"))?,
|
||||
};
|
||||
let precomps = match str_p(p, "precomps") {
|
||||
None | Some("nest") => PrecompMode::Nest,
|
||||
Some("prerender") => PrecompMode::Prerender,
|
||||
Some(_) => return Err(bad(CMD, "precomps: nest|prerender")),
|
||||
};
|
||||
let fp = std::path::Path::new(&path);
|
||||
let dir = fp.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into());
|
||||
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
|
||||
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
|
||||
// Pre-render what the timeline can't represent.
|
||||
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
|
||||
let mut rendered = vec![];
|
||||
let mut errors = vec![];
|
||||
for (c, l) in effectcraft_interchange::plan_prerender(&s.project, cid, &opts) {
|
||||
let comp_name = s.project.item(c).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let layer_name = s.project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let file = dir.join(format!("{stem}_{}_{}_{}.mov", safe(&comp_name), safe(&layer_name), l.0)).to_string_lossy().to_string();
|
||||
match prerender(s, c, l, &file) {
|
||||
Ok(r) => {
|
||||
rendered.push(r.path.clone());
|
||||
pre.insert((c, l), r);
|
||||
}
|
||||
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
|
||||
}
|
||||
}
|
||||
let out = effectcraft_interchange::export(&s.project, cid, &opts, &pre).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &out.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
let mut warnings = out.warnings.clone();
|
||||
warnings.extend(errors.iter().map(|e| format!("pre-render failed: {e}")));
|
||||
s.toast(match format {
|
||||
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
|
||||
_ => format!("Exported {path} ({})", format.label()),
|
||||
});
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"format": format.id(),
|
||||
"bytes": out.bytes.len(),
|
||||
"sequences": out.sequences,
|
||||
"videoTracks": out.video_tracks,
|
||||
"audioTracks": out.audio_tracks,
|
||||
"clips": out.clips,
|
||||
"prerendered": rendered,
|
||||
"warnings": warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The formats, for agents and the export dialog.
|
||||
fn timeline_formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(
|
||||
TimelineFormat::ALL
|
||||
.iter()
|
||||
.map(|f| json!({"id": f.id(), "label": f.label(), "extension": f.extension(), "import": true, "export": true}))
|
||||
.collect::<Vec<_>>()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.importTimeline",
|
||||
"Adobe Premiere Pro Project...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{path (.xml Final Cut Pro XML from Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: auto|xml|fcpxml|otio|edl|aaf|omf, edlFrameRate?: number}",
|
||||
always,
|
||||
import_timeline
|
||||
),
|
||||
cmd!(
|
||||
"file.exportTimeline",
|
||||
"Adobe Premiere Pro Project...",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender}",
|
||||
has_comp,
|
||||
export_timeline
|
||||
),
|
||||
cmd!("file.timelineFormats", "Timeline Interchange Formats", [], None, "{}", always, timeline_formats),
|
||||
]
|
||||
}
|
||||
@@ -928,6 +928,8 @@ mod tests_text_edit;
|
||||
#[cfg(test)]
|
||||
mod tests_timeline;
|
||||
#[cfg(test)]
|
||||
mod tests_timeline_interchange;
|
||||
#[cfg(test)]
|
||||
mod tests_track;
|
||||
#[cfg(test)]
|
||||
mod tests_vector_import;
|
||||
|
||||
@@ -505,6 +505,7 @@ File
|
||||
Import
|
||||
File... | file.import
|
||||
Multiple Files... | file.importMultiple
|
||||
Adobe Premiere Pro Project... | file.importTimeline
|
||||
Placeholder... | file.importPlaceholder
|
||||
Solid... | file.importSolid
|
||||
Lottie... | file.importLottie
|
||||
@@ -516,6 +517,7 @@ File
|
||||
Clear Recent Footage | file.clearRecentFootage
|
||||
Export
|
||||
Add to Render Queue | renderQueue.add
|
||||
Adobe Premiere Pro Project... | file.exportTimeline
|
||||
Lottie JSON... | file.exportLottie
|
||||
Essential Graphics Template... | essential.exportTemplate
|
||||
---
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::project::{ItemId, ItemKind, LayerSource};
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-timeline-tests-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_reimport_timeline_commands() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 25, "duration": 2})).unwrap();
|
||||
let full = s.execute("layer.newSolid", json!({"color": "#ff8000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": full, "path": "transform/opacity", "value": 40})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#0080ff", "width": 50, "height": 50})).unwrap();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
let main = s.active_comp_id().unwrap().0;
|
||||
for (ext, format) in [("xml", "xml"), ("fcpxml", "fcpxml"), ("otio", "otio")] {
|
||||
let path = tmp(&format!("Main.{ext}"));
|
||||
// No exporter in a bare session: the small solid can't be pre-rendered and is left out.
|
||||
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": path})).unwrap();
|
||||
assert_eq!(r["format"], format);
|
||||
// Only Final Cut Pro XML carries colour mattes; the other formats would pre-render solids.
|
||||
assert_eq!(r["videoTracks"], if format == "xml" { 1 } else { 0 }, "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("pre-render")), "{r}");
|
||||
let r = s.execute_checked("file.importTimeline", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["format"], format);
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.active_comp_id(), Some(cid));
|
||||
let c = s.project.comp(cid).unwrap();
|
||||
assert_eq!((c.width, c.height), (320, 180));
|
||||
if format != "xml" {
|
||||
assert!(c.layers.is_empty());
|
||||
continue;
|
||||
}
|
||||
assert_eq!(c.layers.len(), 1);
|
||||
let l = &c.layers[0];
|
||||
let LayerSource::Solid { item } = l.source else { panic!("{:?}", l.source) };
|
||||
let ItemKind::Solid(sol) = &s.project.item(item).unwrap().kind else { panic!() };
|
||||
assert!((sol.color[0] - 1.0).abs() < 0.01 && (sol.color[2]).abs() < 0.01, "{:?}", sol.color);
|
||||
assert!((l.transform().unwrap().get("opacity").unwrap().value.as_f64() - 40.0).abs() < 1e-6);
|
||||
let folder = ItemId(r["folder"].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(folder).unwrap().name, format!("Main.{ext}"));
|
||||
}
|
||||
// A path without an extension gets the format's.
|
||||
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": tmp("NoExt"), "format": "otio", "prerender": "none"})).unwrap();
|
||||
assert!(r["path"].as_str().unwrap().ends_with("NoExt.otio"));
|
||||
assert!(s.execute_checked("file.exportTimeline", json!({"path": tmp("x.xml"), "prerender": "sometimes"})).is_err());
|
||||
assert!(s.execute_checked("file.importTimeline", json!({"path": "/nope/a.prproj"})).is_err());
|
||||
let formats = s.execute("file.timelineFormats", json!({})).unwrap();
|
||||
assert_eq!(formats.as_array().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premiere_menu_entries_keep_after_effects_labels() {
|
||||
let entries = crate::menus::entries();
|
||||
let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
|
||||
assert_eq!(find("file.exportTimeline"), Some(("File > Export".into(), "Adobe Premiere Pro Project...".into())));
|
||||
assert_eq!(find("file.importTimeline"), Some(("File > Import".into(), "Adobe Premiere Pro Project...".into())));
|
||||
}
|
||||
@@ -29,4 +29,6 @@ effectcraft-svg = { workspace = true }
|
||||
effectcraft-pdf = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-interchange = { workspace = true }
|
||||
image = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Premiere Pro interop through timeline interchange, with real media: documents written in code
|
||||
//! with FilmCraft's writers are imported (`file.importTimeline`) and rendered; compositions are
|
||||
//! exported (`file.exportTimeline`, with a ProRes 4444 pre-render) and re-imported, and both
|
||||
//! render the same pixels.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_interchange::{fc, fc_media, fc_project as fp, fc_time};
|
||||
use effectcraft_project::{ItemId, LayerSource};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 36;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-timeline-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn png(name: &str, rgb: [u8; 3]) -> String {
|
||||
let path = tmp(name);
|
||||
image::RgbImage::from_pixel(W, H, image::Rgb(rgb)).save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[(y * img.width + x) as usize]
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool {
|
||||
(0..3).all(|c| (a[c] - b[c]).abs() <= tol)
|
||||
}
|
||||
|
||||
fn rate() -> fc_time::FrameRate {
|
||||
fc_time::FrameRate { num: 25, den: 1 }
|
||||
}
|
||||
|
||||
fn secs(s: f64) -> fc_time::Tick {
|
||||
fc_time::Tick::from_seconds_f64(s)
|
||||
}
|
||||
|
||||
fn still(p: &mut fp::Project, path: &str) -> fp::ItemId {
|
||||
let info = fc_media::MediaInfo {
|
||||
name: String::new(),
|
||||
kind: fc_media::MediaKind::Still,
|
||||
duration: secs(10.0),
|
||||
video: Some(fc_media::VideoStreamInfo {
|
||||
width: W,
|
||||
height: H,
|
||||
frame_rate: rate(),
|
||||
par: (1, 1),
|
||||
codec: "png".into(),
|
||||
pixel_format: String::new(),
|
||||
color: Default::default(),
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
}),
|
||||
audio: None,
|
||||
container: String::new(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
};
|
||||
let clip = fp::MediaClip {
|
||||
media: fp::MediaRef::File { path: path.into() },
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
};
|
||||
let name = path.rsplit('/').next().unwrap().to_string();
|
||||
p.add_item(&name, fp::Label::Iris, fp::ItemKind::Media(clip), None)
|
||||
}
|
||||
|
||||
/// V1: red for 2 s; V2: blue at 50 % opacity and 50 % scale; a cross dissolve from red to green
|
||||
/// at the 2 s cut on V1.
|
||||
fn document(fmt: fc::Format) -> Vec<u8> {
|
||||
let (red, green, blue) = (png("red.png", [255, 0, 0]), png("green.png", [0, 255, 0]), png("blue.png", [0, 0, 255]));
|
||||
let mut p = fp::Project::new("Interop");
|
||||
let (r, g, b) = (still(&mut p, &red), still(&mut p, &green), still(&mut p, &blue));
|
||||
let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() };
|
||||
let seq = p.new_sequence("Interop", settings, 2, 0, None);
|
||||
let range = |s, d| fc_time::TimeRange::new(secs(s), secs(d));
|
||||
let cr = p.make_track_item(r, fp::TrackKind::Video, secs(0.0), range(0.0, 2.0), rate()).unwrap();
|
||||
let cg = p.make_track_item(g, fp::TrackKind::Video, secs(2.0), range(0.0, 2.0), rate()).unwrap();
|
||||
let mut cb = p.make_track_item(b, fp::TrackKind::Video, secs(0.0), range(0.0, 1.6), rate()).unwrap();
|
||||
cb.effect_mut("motion").unwrap().params.insert("scale".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
|
||||
cb.effect_mut("opacity").unwrap().params.insert("opacity".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
|
||||
let tr = fp::Transition {
|
||||
id: fp::TransitionId(p.alloc_id()),
|
||||
effect: fp::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: secs(1.6),
|
||||
duration: secs(0.8),
|
||||
from: Some(cr.id),
|
||||
to: Some(cg.id),
|
||||
align: fp::TransitionAlign::CenterAtCut,
|
||||
reverse: false,
|
||||
};
|
||||
let s = p.sequence_mut(seq).unwrap();
|
||||
s.video_tracks[0].items = vec![cr, cg];
|
||||
s.video_tracks[0].transitions = vec![tr];
|
||||
s.video_tracks[1].items = vec![cb];
|
||||
fc::export(&p, seq, fmt, &Default::default()).unwrap().0
|
||||
}
|
||||
|
||||
fn render(s: &effectcraft_engine::Session, cid: ItemId, secs: f64) -> Image {
|
||||
s.render(cid, Tick::from_seconds_f64(secs), RenderOpts::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_timelines_render_like_premiere() {
|
||||
for (fmt, motion) in [(fc::Format::Fcp7Xml, true), (fc::Format::Otio, false)] {
|
||||
let path = tmp(&format!("interop.{}", fmt.extension()));
|
||||
std::fs::write(&path, document(fmt)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute("file.importTimeline", json!({"path": path})).unwrap();
|
||||
assert!(r["missing"].as_array().unwrap().is_empty(), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let img = render(&s, cid, 1.0);
|
||||
// Corner: red (V1). Centre: blue at 50 % over red (FCP7 XML carries Motion/Opacity).
|
||||
assert!(close(px(&img, 1, 1), [1.0, 0.0, 0.0], 0.02), "{fmt:?} corner {:?}", px(&img, 1, 1));
|
||||
if motion {
|
||||
assert!(close(px(&img, W / 2, H / 2), [0.5, 0.0, 0.5], 0.06), "{fmt:?} centre {:?}", px(&img, W / 2, H / 2));
|
||||
}
|
||||
// Mid-dissolve: half red, half green.
|
||||
let mid = render(&s, cid, 2.0);
|
||||
assert!(close(px(&mid, 1, 1), [0.5, 0.5, 0.0], 0.08), "{fmt:?} dissolve {:?}", px(&mid, 1, 1));
|
||||
// After: green.
|
||||
assert!(close(px(&render(&s, cid, 3.0), 1, 1), [0.0, 1.0, 0.0], 0.02));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_prerenders_and_reimports_the_same_pixels() {
|
||||
let red = png("x_red.png", [255, 0, 0]);
|
||||
let mut s = effectcraft_host::session();
|
||||
s.execute("comp.new", json!({"name": "Spot", "width": W, "height": H, "frameRate": 25, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let item = s.execute("file.import", json!({"paths": [red]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
let lr = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": lr, "path": "transform/opacity", "value": 60})).unwrap();
|
||||
// A small solid: a rendered-only layer (Premiere's colour mattes fill the frame).
|
||||
s.execute("layer.newSolid", json!({"color": "#00ffff", "width": 16, "height": 12})).unwrap();
|
||||
let out = tmp("Spot.xml");
|
||||
let r = s.execute("file.exportTimeline", json!({"path": out})).unwrap();
|
||||
assert_eq!(r["format"], "xml");
|
||||
let pre = r["prerendered"].as_array().unwrap();
|
||||
assert_eq!(pre.len(), 1, "{r}");
|
||||
assert!(pre[0].as_str().unwrap().ends_with(".mov") && std::path::Path::new(pre[0].as_str().unwrap()).exists());
|
||||
let text = std::fs::read_to_string(&out).unwrap();
|
||||
assert!(text.contains("<xmeml") && text.contains(".mov"));
|
||||
|
||||
let r2 = s.execute("file.importTimeline", json!({"path": out})).unwrap();
|
||||
assert!(r2["missing"].as_array().unwrap().is_empty(), "{r2}");
|
||||
let back = ItemId(r2["comps"][0].as_u64().unwrap());
|
||||
let c = s.project.comp(back).unwrap();
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
assert!(c.layers.iter().all(|l| matches!(l.source, LayerSource::Footage { .. })));
|
||||
for t in [0.0, 1.0] {
|
||||
let (a, b) = (render(&s, cid, t), render(&s, back, t));
|
||||
for (x, y) in [(1, 1), (W / 2, H / 2), (W - 2, H - 2), (W / 2 - 9, H / 2)] {
|
||||
let (pa, pb) = (px(&a, x, y), px(&b, x, y));
|
||||
assert!(close(pb, [pa[0], pa[1], pa[2]], 0.03), "t={t} ({x},{y}) {pa:?} vs {pb:?}");
|
||||
}
|
||||
}
|
||||
// Native .prproj is refused with a pointer to the XML route.
|
||||
let e = s.execute("file.importTimeline", json!({"path": "/x/a.prproj"})).unwrap_err().to_string();
|
||||
assert!(e.contains("Final Cut Pro XML"), "{e}");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "effectcraft-interchange"
|
||||
description = "Premiere Pro interop through timeline interchange: Final Cut Pro 7 XML (xmeml), FCPXML, OpenTimelineIO and CMX 3600 EDL sequences to and from compositions (via filmcraft-interchange)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-color = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
filmcraft-interchange = { workspace = true }
|
||||
filmcraft-project = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
filmcraft-geom = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,690 @@
|
||||
//! 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 let Some(s) = l.transform().and_then(|t| t.get("scale"))
|
||||
&& std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
})
|
||||
{
|
||||
return Some("non-uniform scale".into());
|
||||
}
|
||||
if l.preserve_transparency {
|
||||
return Some("preserve underlying transparency".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn plan(project: &Project, comp: &Comp, l: &Layer, opts: &ExportOptions) -> Plan {
|
||||
if l.switches.guide {
|
||||
return Plan::Skip(String::new());
|
||||
}
|
||||
match &l.source {
|
||||
LayerSource::Camera | LayerSource::Light { .. } | LayerSource::Null => return Plan::Skip(String::new()),
|
||||
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.kind == FootageKind::Audio || !f.has_video => return Plan::AudioOnly,
|
||||
Some(ItemKind::Footage(f)) if f.kind == FootageKind::Data => return Plan::Skip("data footage has no picture".into()),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
if l.switches.adjustment {
|
||||
return Plan::Skip("adjustment layer (its effects apply to the layers below)".into());
|
||||
}
|
||||
let why = unsupported(project, comp, l, opts.format);
|
||||
match opts.prerender {
|
||||
PrerenderMode::All => Plan::Render("pre-render all layers".into()),
|
||||
PrerenderMode::Unsupported => match (&l.source, why) {
|
||||
(_, Some(w)) => Plan::Render(w),
|
||||
(LayerSource::Comp { .. }, None) if opts.precomps == PrecompMode::Prerender => Plan::Render("precomp".into()),
|
||||
(LayerSource::Comp { .. }, None) => Plan::Nest,
|
||||
_ => Plan::Native,
|
||||
},
|
||||
PrerenderMode::None => match (&l.source, why) {
|
||||
(LayerSource::Text | LayerSource::Shape | LayerSource::Model { .. } | LayerSource::Primitive { .. }, Some(w)) => Plan::Skip(w),
|
||||
(LayerSource::Solid { .. }, Some(w)) => Plan::Skip(w),
|
||||
(LayerSource::Comp { .. }, _) => Plan::Nest,
|
||||
_ => Plan::Native,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `layer` would be pre-rendered under `opts` (None when it exports as a clip or is left out).
|
||||
pub fn prerender_reason(project: &Project, comp: ItemId, layer: LayerId, opts: &ExportOptions) -> Option<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);
|
||||
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).
|
||||
fn sequence(&mut self, cid: ItemId) -> fp::ItemId {
|
||||
if let Some(s) = self.seqs.get(&cid) {
|
||||
return *s;
|
||||
}
|
||||
let comp = self.p.comp_arc(cid).expect("comp");
|
||||
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);
|
||||
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate);
|
||||
ti.source_in = filmcraft_time::Tick::ZERO;
|
||||
ti.speed = 1.0;
|
||||
ti.reverse = false;
|
||||
self.out.prerendered += 1;
|
||||
Some(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) => Some(self.sequence(*item)),
|
||||
_ => None,
|
||||
};
|
||||
item.map(|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())));
|
||||
}
|
||||
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) => {
|
||||
Some(self.sequence(*item))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = sound
|
||||
&& !matches!(pl, Plan::Skip(_))
|
||||
{
|
||||
let 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();
|
||||
sid
|
||||
}
|
||||
|
||||
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> 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)).expect("item exists");
|
||||
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;
|
||||
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 mut m = ti.effect("motion").cloned().unwrap_or_else(|| fp::find_effect("motion").expect("motion").instance());
|
||||
if let Some(p) = tr.get("position") {
|
||||
let def = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
|
||||
if p.is_animated() || differs(&p.value, def) {
|
||||
m.params.insert(
|
||||
"position".into(),
|
||||
param_out(p, &to_clip, |v| {
|
||||
let a = v.as_vec3();
|
||||
center(a[0], a[1])
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(p) = tr.get("anchor") {
|
||||
let def = [sw as f64 / 2.0, sh as f64 / 2.0];
|
||||
if p.is_animated() || differs(&p.value, def) {
|
||||
m.params.insert(
|
||||
"anchor".into(),
|
||||
param_out(p, &to_clip, |v| {
|
||||
let a = v.as_vec3();
|
||||
center(a[0], a[1])
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(p) = tr.get("scale") {
|
||||
let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() < 1e-9
|
||||
});
|
||||
m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1])));
|
||||
if !uniform {
|
||||
m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0])));
|
||||
m.params.insert("uniform_scale".into(), fp::Param::new(fp::ParamValue::Bool(false)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = tr.get("rotation") {
|
||||
m.params.insert("rotation".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64())));
|
||||
}
|
||||
set_effect(ti, m);
|
||||
if let Some(p) = tr.get("opacity") {
|
||||
let mut o = ti.effect("opacity").cloned().unwrap_or_else(|| fp::find_effect("opacity").expect("opacity").instance());
|
||||
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 mut v = ti.effect("volume").cloned().unwrap_or_else(|| fp::find_effect("volume").expect("volume").instance());
|
||||
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
|
||||
}
|
||||
|
||||
fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
|
||||
match ti.effects.iter_mut().find(|x| x.effect == e.effect) {
|
||||
Some(x) => *x = e,
|
||||
None => ti.effects.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A property → a FilmCraft parameter (layer-time keys → clip-time keys).
|
||||
fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param {
|
||||
let mut out = fp::Param::new(conv(&p.value));
|
||||
for k in &p.keys {
|
||||
let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value));
|
||||
fk.interp = match k.out_interp {
|
||||
Interp::Linear => fp::Interpolation::Linear,
|
||||
Interp::Hold => fp::Interpolation::Hold,
|
||||
Interp::Bezier => fp::Interpolation::Bezier,
|
||||
};
|
||||
out.keyframes.push(fk);
|
||||
}
|
||||
out.keyframes.sort_by_key(|k| k.time);
|
||||
if let Some(k) = out.keyframes.first() {
|
||||
out.value = k.value.clone();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
type VideoSpec = (u32, u32, FrameRate, f64, String, bool);
|
||||
|
||||
fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option<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 }
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
//! 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).
|
||||
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)?,
|
||||
};
|
||||
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 tr = l.transform_mut().expect("transform");
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Premiere Pro interop through the public timeline interchange formats Premiere itself reads and
|
||||
//! writes (After Effects' File ▸ Import / Export ▸ Adobe Premiere Pro Project…):
|
||||
//!
|
||||
//! - **Final Cut Pro 7 XML** (`xmeml`, `.xml`): Premiere's File ▸ Export ▸ Final Cut Pro XML and
|
||||
//! File ▸ Import; the default for export;
|
||||
//! - **FCPXML** 1.9–1.11 (`.fcpxml`);
|
||||
//! - **OpenTimelineIO** (`.otio`);
|
||||
//! - **CMX 3600 EDL** (`.edl`);
|
||||
//! - **AAF** (`.aaf`) and **OMF** (`.omf`) (embedded audio is not extracted).
|
||||
//!
|
||||
//! Parsing and writing is FilmCraft's `filmcraft-interchange` (our first-party sibling project);
|
||||
//! this crate maps its timeline model to compositions and back:
|
||||
//!
|
||||
//! - [`import`]: sequences → compositions (size, rate, duration, pixel aspect, start timecode,
|
||||
//! markers); video tracks → layers stacked by track (top track = top layer); clips → footage
|
||||
//! layers with in/out/start, speed and reverse (time stretch), frame holds (time remapping),
|
||||
//! Motion (position/scale/rotation/anchor point) and Opacity with keyframes; cross dissolves and
|
||||
//! dips → overlapping layers with opacity keyframes; nested sequences → precomps; audio tracks →
|
||||
//! audio-only layers with Audio Levels; bins → Project panel folders; missing media →
|
||||
//! placeholders.
|
||||
//! - [`export`]: a composition → one sequence with one track per layer (bottom layer = V1),
|
||||
//! footage clips with their timing, Motion and Opacity; precomps as nested sequences; solids as
|
||||
//! colour mattes; layers only EffectCraft can draw (text, shapes, effects, masks, 3D…) are
|
||||
//! either left out or pre-rendered by the caller (ProRes 4444 with alpha, see
|
||||
//! [`plan_prerender`]) and referenced as media.
|
||||
//!
|
||||
//! The native `.prproj` format has no public specification and is not read or written.
|
||||
//!
|
||||
//! This crate is L3 and does no file I/O: media files are probed through a callback, pre-rendered
|
||||
//! media come in as paths, and documents are bytes.
|
||||
|
||||
mod export;
|
||||
mod import;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use export::{ExportOptions, ExportOutput, PrecompMode, PrerenderMode, Prerendered, export, plan_prerender, prerender_reason};
|
||||
pub use import::{ImportOptions, ImportResult, import};
|
||||
|
||||
pub use filmcraft_geom as fc_geom;
|
||||
/// FilmCraft's interchange crate (format readers/writers) and timeline model, re-exported so
|
||||
/// tests and tools can build documents in code.
|
||||
pub use filmcraft_interchange as fc;
|
||||
pub use filmcraft_media as fc_media;
|
||||
pub use filmcraft_project as fc_project;
|
||||
pub use filmcraft_time as fc_time;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A timeline interchange format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TimelineFormat {
|
||||
/// Final Cut Pro 7 XML (`xmeml`), the format Premiere Pro imports and exports.
|
||||
Fcp7Xml,
|
||||
/// FCPXML 1.9–1.11.
|
||||
Fcpxml,
|
||||
/// OpenTimelineIO JSON.
|
||||
Otio,
|
||||
/// CMX 3600 edit decision list.
|
||||
Edl,
|
||||
/// Advanced Authoring Format (AAF Edit Protocol), as Premiere Pro exports for audio post.
|
||||
Aaf,
|
||||
/// OMF Interchange 2.0.
|
||||
Omf,
|
||||
}
|
||||
|
||||
impl TimelineFormat {
|
||||
pub const ALL: [TimelineFormat; 6] =
|
||||
[TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio, TimelineFormat::Edl, TimelineFormat::Aaf, TimelineFormat::Omf];
|
||||
|
||||
/// Id used by commands (`xml`, `fcpxml`, `otio`, `edl`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimelineFormat::Fcp7Xml => "xml",
|
||||
TimelineFormat::Fcpxml => "fcpxml",
|
||||
TimelineFormat::Otio => "otio",
|
||||
TimelineFormat::Edl => "edl",
|
||||
TimelineFormat::Aaf => "aaf",
|
||||
TimelineFormat::Omf => "omf",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimelineFormat::Fcp7Xml => "Final Cut Pro XML (Premiere Pro)",
|
||||
TimelineFormat::Fcpxml => "FCPXML",
|
||||
TimelineFormat::Otio => "OpenTimelineIO",
|
||||
TimelineFormat::Edl => "CMX 3600 EDL",
|
||||
TimelineFormat::Aaf => "AAF",
|
||||
TimelineFormat::Omf => "OMF",
|
||||
}
|
||||
}
|
||||
|
||||
/// File extension (without the dot).
|
||||
pub fn extension(self) -> &'static str {
|
||||
self.fc().extension()
|
||||
}
|
||||
|
||||
/// Parse a command parameter: a format id, a name or an extension.
|
||||
pub fn parse(s: &str) -> Option<TimelineFormat> {
|
||||
match s.trim().trim_start_matches('.').to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"xml" | "fcp7" | "fcp7xml" | "xmeml" | "finalcutproxml" | "premiere" | "premierepro" | "prproj" => Some(TimelineFormat::Fcp7Xml),
|
||||
"fcpxml" | "fcpxmld" | "fcpx" => Some(TimelineFormat::Fcpxml),
|
||||
"otio" | "opentimelineio" => Some(TimelineFormat::Otio),
|
||||
"edl" | "cmx3600" | "cmx" => Some(TimelineFormat::Edl),
|
||||
"aaf" => Some(TimelineFormat::Aaf),
|
||||
"omf" | "omfi" => Some(TimelineFormat::Omf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format of a file from its extension.
|
||||
pub fn from_path(path: &str) -> Option<TimelineFormat> {
|
||||
let ext = path.rsplit_once('.').map(|(_, e)| e)?;
|
||||
if ext.contains('/') || ext.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
filmcraft_interchange::Format::from_extension(ext).map(TimelineFormat::from_fc)
|
||||
}
|
||||
|
||||
/// Sniff a document (bytes first, the extension as a hint for ambiguous `.xml`).
|
||||
pub fn detect(bytes: &[u8], path: Option<&str>) -> Option<TimelineFormat> {
|
||||
let ext = path.and_then(|p| p.rsplit_once('.')).map(|(_, e)| e);
|
||||
filmcraft_interchange::detect(bytes, ext).map(TimelineFormat::from_fc)
|
||||
}
|
||||
|
||||
pub(crate) fn fc(self) -> filmcraft_interchange::Format {
|
||||
match self {
|
||||
TimelineFormat::Fcp7Xml => filmcraft_interchange::Format::Fcp7Xml,
|
||||
TimelineFormat::Fcpxml => filmcraft_interchange::Format::Fcpxml,
|
||||
TimelineFormat::Otio => filmcraft_interchange::Format::Otio,
|
||||
TimelineFormat::Edl => filmcraft_interchange::Format::Edl,
|
||||
TimelineFormat::Aaf => filmcraft_interchange::Format::Aaf,
|
||||
TimelineFormat::Omf => filmcraft_interchange::Format::Omf,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_fc(f: filmcraft_interchange::Format) -> TimelineFormat {
|
||||
match f {
|
||||
filmcraft_interchange::Format::Fcp7Xml => TimelineFormat::Fcp7Xml,
|
||||
filmcraft_interchange::Format::Fcpxml => TimelineFormat::Fcpxml,
|
||||
filmcraft_interchange::Format::Otio => TimelineFormat::Otio,
|
||||
filmcraft_interchange::Format::Edl => TimelineFormat::Edl,
|
||||
filmcraft_interchange::Format::Aaf => TimelineFormat::Aaf,
|
||||
filmcraft_interchange::Format::Omf => TimelineFormat::Omf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Interchange(#[from] filmcraft_interchange::Error),
|
||||
#[error("not a timeline document (expected Final Cut Pro XML, FCPXML, OpenTimelineIO or EDL)")]
|
||||
UnknownFormat,
|
||||
#[error("no composition {0}")]
|
||||
NoComp(u64),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
// ---------------------------------------------------------------- shared conversions
|
||||
|
||||
pub(crate) fn rate_in(r: filmcraft_time::FrameRate) -> effectcraft_time::FrameRate {
|
||||
if r.num <= 0 || r.den <= 0 { effectcraft_time::FrameRate::new(30, 1) } else { effectcraft_time::FrameRate::new(r.num, r.den) }
|
||||
}
|
||||
|
||||
pub(crate) fn rate_out(r: effectcraft_time::FrameRate) -> filmcraft_time::FrameRate {
|
||||
filmcraft_time::FrameRate { num: r.num, den: r.den }
|
||||
}
|
||||
|
||||
// Both projects count time in the same ticks (254 016 000 000 per second).
|
||||
const _: () = assert!(filmcraft_time::TICKS_PER_SECOND == effectcraft_time::TICKS_PER_SECOND);
|
||||
|
||||
pub(crate) fn tick_in(t: filmcraft_time::Tick) -> effectcraft_time::Tick {
|
||||
effectcraft_time::Tick(t.0)
|
||||
}
|
||||
|
||||
pub(crate) fn tick_out(t: effectcraft_time::Tick) -> filmcraft_time::Tick {
|
||||
filmcraft_time::Tick(t.0)
|
||||
}
|
||||
|
||||
/// A pixel aspect ratio as a small fraction (1.0 → 1:1, 0.9 → 9:10, 1.2121 → 40:33…).
|
||||
pub(crate) fn par_fraction(par: f64) -> (u32, u32) {
|
||||
if !par.is_finite() || par <= 0.0 || (par - 1.0).abs() < 1e-6 {
|
||||
return (1, 1);
|
||||
}
|
||||
let mut best = (1u32, 1u32);
|
||||
let mut err = f64::MAX;
|
||||
for den in 1..=200u32 {
|
||||
let num = (par * den as f64).round() as u32;
|
||||
if num == 0 {
|
||||
continue;
|
||||
}
|
||||
let e = (num as f64 / den as f64 - par).abs();
|
||||
if e < err - 1e-9 {
|
||||
err = e;
|
||||
best = (num, den);
|
||||
}
|
||||
if e < 1e-6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//! Import of documents written in code with FilmCraft's writers, and export → re-import.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_project as fp;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const W: u32 = 640;
|
||||
const H: u32 = 360;
|
||||
|
||||
fn secs(s: f64) -> filmcraft_time::Tick {
|
||||
filmcraft_time::Tick::from_seconds_f64(s)
|
||||
}
|
||||
|
||||
fn t(s: f64) -> Tick {
|
||||
Tick::from_seconds_f64(s)
|
||||
}
|
||||
|
||||
fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft_media::MediaInfo {
|
||||
filmcraft_media::MediaInfo {
|
||||
name: String::new(),
|
||||
kind,
|
||||
duration: secs(20.0),
|
||||
video: video.then(|| filmcraft_media::VideoStreamInfo {
|
||||
width: W,
|
||||
height: H,
|
||||
frame_rate: filmcraft_time::FrameRate { num: 25, den: 1 },
|
||||
par: (1, 1),
|
||||
codec: "png".into(),
|
||||
pixel_format: String::new(),
|
||||
color: Default::default(),
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
}),
|
||||
audio: audio.then(|| filmcraft_media::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(p: &mut fp::Project, name: &str, path: &str, kind: filmcraft_media::MediaKind, video: bool, audio: bool, bin: Option<fp::BinId>) -> fp::ItemId {
|
||||
let clip = fp::MediaClip {
|
||||
media: fp::MediaRef::File { path: path.into() },
|
||||
info: info(kind, video, audio),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
};
|
||||
p.add_item(name, fp::Label::Iris, fp::ItemKind::Media(clip), bin)
|
||||
}
|
||||
|
||||
fn rate() -> filmcraft_time::FrameRate {
|
||||
filmcraft_time::FrameRate { num: 25, den: 1 }
|
||||
}
|
||||
|
||||
fn item(p: &mut fp::Project, it: fp::ItemId, kind: fp::TrackKind, start: f64, src: f64, dur: f64) -> fp::TrackItem {
|
||||
p.make_track_item(it, kind, secs(start), filmcraft_time::TimeRange::new(secs(src), secs(dur)), rate()).unwrap()
|
||||
}
|
||||
|
||||
/// A Premiere-like edit: two clips with a cross dissolve on V1, a speed-changed, scaled and
|
||||
/// faded clip on V2, a nested sequence on V3, and an audio clip at -6 dB on A1.
|
||||
fn edit() -> (fp::Project, fp::ItemId) {
|
||||
let mut p = fp::Project::new("Edit");
|
||||
let bin = p.add_bin("Footage", None);
|
||||
let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, Some(bin));
|
||||
let b = media(&mut p, "b.png", "/media/b.mov", filmcraft_media::MediaKind::Movie, true, false, Some(bin));
|
||||
let s = media(&mut p, "tone.wav", "/media/tone.wav", filmcraft_media::MediaKind::AudioOnly, false, true, Some(bin));
|
||||
let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() };
|
||||
let nested = p.new_sequence("Nested", settings.clone(), 1, 0, None);
|
||||
let n_clip = item(&mut p, a, fp::TrackKind::Video, 0.0, 0.0, 2.0);
|
||||
p.sequence_mut(nested).unwrap().video_tracks[0].items.push(n_clip);
|
||||
let main = p.new_sequence("Main", settings, 3, 1, None);
|
||||
|
||||
let ca = item(&mut p, a, fp::TrackKind::Video, 0.0, 0.0, 2.0);
|
||||
let cb = item(&mut p, b, fp::TrackKind::Video, 2.0, 1.0, 2.0);
|
||||
let tr = fp::Transition {
|
||||
id: fp::TransitionId(p.alloc_id()),
|
||||
effect: fp::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: secs(1.6),
|
||||
duration: secs(0.8),
|
||||
from: Some(ca.id),
|
||||
to: Some(cb.id),
|
||||
align: fp::TransitionAlign::CenterAtCut,
|
||||
reverse: false,
|
||||
};
|
||||
let mut cc = item(&mut p, b, fp::TrackKind::Video, 1.0, 2.0, 2.0);
|
||||
cc.speed = 2.0;
|
||||
let m = cc.effect_mut("motion").unwrap();
|
||||
m.params.insert("scale".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
|
||||
m.params.insert("rotation".into(), fp::Param::new(fp::ParamValue::Float(30.0)));
|
||||
let mut pos = fp::Param::new(fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 100.0, y: 100.0 }));
|
||||
pos.keyframes = vec![
|
||||
fp::Keyframe::new(secs(0.0), fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 100.0, y: 100.0 })),
|
||||
fp::Keyframe::new(secs(1.0), fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 300.0, y: 200.0 })),
|
||||
];
|
||||
m.params.insert("position".into(), pos);
|
||||
cc.effect_mut("opacity").unwrap().params.insert("opacity".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
|
||||
let cn = item(&mut p, nested, fp::TrackKind::Video, 4.0, 0.0, 2.0);
|
||||
let mut cs = item(&mut p, s, fp::TrackKind::Audio, 0.0, 0.0, 3.0);
|
||||
cs.effect_mut("volume").unwrap().params.insert("level".into(), fp::Param::new(fp::ParamValue::Float(-6.0)));
|
||||
let seq = p.sequence_mut(main).unwrap();
|
||||
seq.video_tracks[0].items = vec![ca, cb];
|
||||
seq.video_tracks[0].transitions = vec![tr];
|
||||
seq.video_tracks[1].items = vec![cc];
|
||||
seq.video_tracks[2].items = vec![cn];
|
||||
seq.audio_tracks[0].items = vec![cs];
|
||||
(p, main)
|
||||
}
|
||||
|
||||
fn probe(path: &str) -> Option<Footage> {
|
||||
let audio = path.ends_with(".wav");
|
||||
Some(Footage {
|
||||
path: path.into(),
|
||||
kind: if audio { FootageKind::Audio } else { FootageKind::Video },
|
||||
width: if audio { 0 } else { W },
|
||||
height: if audio { 0 } else { H },
|
||||
frame_rate: FrameRate::new(25, 1),
|
||||
duration: t(20.0),
|
||||
has_video: !audio,
|
||||
has_audio: audio,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn import_doc(bytes: &[u8], name: &str, probe_fn: &mut dyn FnMut(&str) -> Option<Footage>) -> (Project, ImportResult) {
|
||||
let mut p = Project::default();
|
||||
let r = import(&mut p, bytes, None, &ImportOptions { name: name.into(), ..Default::default() }, probe_fn).unwrap();
|
||||
(p, r)
|
||||
}
|
||||
|
||||
fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
|
||||
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}: {:?}", c.layers.iter().map(|l| &l.name).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn near(a: Tick, b: Tick) -> bool {
|
||||
(a.0 - b.0).abs() <= Tick::from_seconds_f64(0.001).0
|
||||
}
|
||||
|
||||
fn check_structure(p: &Project, r: &ImportResult, motion: bool) {
|
||||
let main = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp");
|
||||
let c = p.comp(main).unwrap();
|
||||
assert_eq!((c.width, c.height), (W, H));
|
||||
assert_eq!(c.frame_rate, FrameRate::new(25, 1));
|
||||
assert!(near(c.duration, t(6.0)), "{:?}", c.duration.seconds());
|
||||
// V3 (nested) on top, then V2, then V1's later clip, then the earlier one; audio last.
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
let pos = |n: &str| names.iter().position(|x| x.starts_with(n)).unwrap_or_else(|| panic!("{n} in {names:?}"));
|
||||
assert!(pos("Nested") < pos("b.png"), "{names:?}");
|
||||
let nested = layer(c, "Nested");
|
||||
assert!(matches!(nested.source, LayerSource::Comp { .. }));
|
||||
assert!(near(nested.in_point, t(4.0)) && near(nested.out_point, t(6.0)));
|
||||
// The faster clip: 200 % speed → 50 % stretch, source 2 s at its in point.
|
||||
let fast = c.layers.iter().find(|l| (l.stretch - 50.0).abs() < 1e-6).expect("stretched layer");
|
||||
assert!(near(fast.in_point, t(1.0)) && near(fast.out_point, t(3.0)));
|
||||
assert!(near(fast.layer_time(t(1.0)), t(2.0)), "{}", fast.layer_time(t(1.0)).seconds());
|
||||
// V1: b above a, overlapping over the dissolve; b fades in.
|
||||
let v1: Vec<&Layer> =
|
||||
c.layers.iter().filter(|l| (l.stretch - 100.0).abs() < 1e-6 && matches!(l.source, LayerSource::Footage { .. }) && l.switches.video).collect();
|
||||
assert_eq!(v1.len(), 2, "{names:?}");
|
||||
let (lb, la) = (v1[0], v1[1]);
|
||||
assert!(near(la.in_point, t(0.0)) && near(la.out_point, t(2.4)), "{:?}", la.out_point.seconds());
|
||||
assert!(near(lb.in_point, t(1.6)) && near(lb.out_point, t(4.0)), "{:?}", lb.in_point.seconds());
|
||||
assert!(near(lb.layer_time(t(2.0)), t(1.0)));
|
||||
let op = lb.transform().unwrap().get("opacity").unwrap();
|
||||
assert_eq!(op.keys.len(), 2);
|
||||
assert!(op.value_at(lb.layer_time(t(1.6))).as_f64() < 1e-6);
|
||||
assert!((op.value_at(lb.layer_time(t(2.0))).as_f64() - 50.0).abs() < 1.0);
|
||||
assert!((op.value_at(lb.layer_time(t(3.0))).as_f64() - 100.0).abs() < 1e-6);
|
||||
// Audio: an audio-only layer at -6 dB.
|
||||
let au = c.layers.iter().find(|l| !l.switches.video && l.switches.audio).expect("audio layer");
|
||||
assert!(near(au.out_point, t(3.0)));
|
||||
if motion {
|
||||
let lv = au.props.prop("audio/levels").unwrap().value.as_vec2();
|
||||
assert!((lv[0] + 6.0).abs() < 0.01, "{lv:?}");
|
||||
let tr = fast.transform().unwrap();
|
||||
assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [50.0, 50.0]);
|
||||
assert!((tr.get("rotation").unwrap().value.as_f64() - 30.0).abs() < 1e-6);
|
||||
assert!((tr.get("opacity").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
|
||||
let pk = &tr.get("position").unwrap().keys;
|
||||
assert_eq!(pk.len(), 2);
|
||||
// Clip time 1 s = comp 2 s = layer time 4 s at 200 % speed.
|
||||
assert!(near(pk[1].time, fast.layer_time(t(2.0))));
|
||||
assert_eq!(pk[1].value.as_vec3()[..2], [300.0, 200.0]);
|
||||
}
|
||||
// Everything lands in the document's folder.
|
||||
assert!(p.item(r.folder).unwrap().is_folder());
|
||||
assert!(p.items.values().filter(|i| i.id != r.folder).all(|i| i.parent.is_some()));
|
||||
}
|
||||
|
||||
/// A Premiere-style xmeml export with a bin, a master clip, Basic Motion and Opacity keyframes.
|
||||
const PREMIERE_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4">
|
||||
<project>
|
||||
<name>Promo</name>
|
||||
<children>
|
||||
<bin>
|
||||
<name>Shots</name>
|
||||
<children>
|
||||
<bin>
|
||||
<name>Day 1</name>
|
||||
<children>
|
||||
<clip id="masterclip-1">
|
||||
<name>wide.mov</name>
|
||||
<duration>250</duration>
|
||||
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<media><video><track><clipitem id="clipitem-m1"><name>wide.mov</name>
|
||||
<file id="file-1">
|
||||
<name>wide.mov</name>
|
||||
<pathurl>file://localhost/media/wide%20shot.mov</pathurl>
|
||||
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<duration>250</duration>
|
||||
<media><video><samplecharacteristics><width>640</width><height>360</height></samplecharacteristics></video></media>
|
||||
</file>
|
||||
</clipitem></track></video></media>
|
||||
</clip>
|
||||
</children>
|
||||
</bin>
|
||||
</children>
|
||||
</bin>
|
||||
<sequence id="sequence-1">
|
||||
<name>Promo Cut</name>
|
||||
<duration>50</duration>
|
||||
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<media>
|
||||
<video>
|
||||
<format><samplecharacteristics><rate><timebase>25</timebase></rate><width>640</width><height>360</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>
|
||||
<track>
|
||||
<clipitem id="clipitem-1">
|
||||
<masterclipid>masterclip-1</masterclipid>
|
||||
<name>wide.mov</name>
|
||||
<enabled>TRUE</enabled>
|
||||
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<start>0</start><end>50</end><in>25</in><out>75</out>
|
||||
<file id="file-1"/>
|
||||
<filter>
|
||||
<effect>
|
||||
<name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>scale</parameterid><name>Scale</name><value>150</value></parameter>
|
||||
<parameter><parameterid>center</parameterid><name>Center</name><value><horiz>0.25</horiz><vert>0</vert></value></parameter>
|
||||
</effect>
|
||||
</filter>
|
||||
<filter>
|
||||
<effect>
|
||||
<name>Opacity</name><effectid>opacity</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>opacity</parameterid><name>Opacity</name>
|
||||
<keyframe><when>0</when><value>0</value></keyframe>
|
||||
<keyframe><when>25</when><value>100</value></keyframe>
|
||||
</parameter>
|
||||
</effect>
|
||||
</filter>
|
||||
</clipitem>
|
||||
</track>
|
||||
</video>
|
||||
</media>
|
||||
</sequence>
|
||||
</children>
|
||||
</project>
|
||||
</xmeml>
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn premiere_xml_bins_motion_and_opacity() {
|
||||
let mut paths = vec![];
|
||||
let (p, r) = import_doc(PREMIERE_XML.as_bytes(), "Promo.xml", &mut |path| {
|
||||
paths.push(path.to_string());
|
||||
probe(path)
|
||||
});
|
||||
assert_eq!(paths, vec!["/media/wide shot.mov".to_string()]);
|
||||
let shots = p.items.values().find(|i| i.name == "Shots").unwrap();
|
||||
assert_eq!(shots.parent, Some(r.folder));
|
||||
let day = p.items.values().find(|i| i.name == "Day 1").unwrap();
|
||||
assert_eq!(day.parent, Some(shots.id));
|
||||
assert!(p.items.values().any(|i| i.name == "wide.mov" && i.parent == Some(day.id)));
|
||||
let c = p.comp(r.comps[0]).unwrap();
|
||||
assert_eq!(p.item(r.comps[0]).unwrap().name, "Promo Cut");
|
||||
assert!(near(c.duration, t(2.0)));
|
||||
let l = &c.layers[0];
|
||||
assert!(near(l.layer_time(Tick::ZERO), t(1.0)), "source in 1 s");
|
||||
let tr = l.transform().unwrap();
|
||||
assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [150.0, 150.0]);
|
||||
// Center 0.25 of the width right of the frame centre.
|
||||
assert_eq!(tr.get("position").unwrap().value.as_vec3()[..2], [320.0 + 160.0, 180.0]);
|
||||
let op = tr.get("opacity").unwrap();
|
||||
assert_eq!(op.keys.len(), 2);
|
||||
assert!(near(op.keys[1].time, l.layer_time(t(1.0))));
|
||||
assert!((op.value_at(l.layer_time(t(0.5))).as_f64() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_fcp7_xml_fcpxml_and_otio() {
|
||||
let (doc, main) = edit();
|
||||
for (fmt, motion) in [(fc::Format::Fcp7Xml, true), (fc::Format::Fcpxml, false), (fc::Format::Otio, false)] {
|
||||
let (bytes, _) = fc::export(&doc, main, fmt, &Default::default()).unwrap();
|
||||
let name = format!("Main.{}", fmt.extension());
|
||||
assert_eq!(TimelineFormat::detect(&bytes, Some(&name)).map(|f| f.fc()), Some(fmt));
|
||||
let (p, r) = import_doc(&bytes, &name, &mut probe);
|
||||
assert!(r.missing.is_empty(), "{fmt:?}: {:?}", r.missing);
|
||||
check_structure(&p, &r, motion);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_aaf_and_omf() {
|
||||
let (doc, main) = edit();
|
||||
for fmt in [fc::Format::Aaf, fc::Format::Omf] {
|
||||
let (bytes, _) = fc::export(&doc, main, fmt, &Default::default()).unwrap();
|
||||
let name = format!("Main.{}", fmt.extension());
|
||||
assert_eq!(TimelineFormat::detect(&bytes, Some(&name)).map(|f| f.fc()), Some(fmt));
|
||||
let (p, r) = import_doc(&bytes, &name, &mut probe);
|
||||
let c = p.comp(r.comps[0]).unwrap();
|
||||
if fmt == fc::Format::Aaf {
|
||||
assert_eq!((c.width, c.height), (W, H));
|
||||
}
|
||||
// Audio: an audio-only layer for the A1 clip (OMF carries the audio tracks only).
|
||||
let au = c.layers.iter().find(|l| !l.switches.video && l.switches.audio).expect("audio layer");
|
||||
assert!(near(au.in_point, Tick::ZERO) && near(au.out_point, t(3.0)));
|
||||
if fmt == fc::Format::Aaf {
|
||||
// The dissolve's clips overlap over the transition, the incoming one on top.
|
||||
let a = layer(c, "a.png");
|
||||
assert!(near(a.out_point, t(2.4)), "{}", a.out_point.seconds());
|
||||
let b = c.layers.iter().find(|l| near(l.in_point, t(1.6))).expect("incoming clip");
|
||||
assert!(c.index_of(b.id) < c.index_of(a.id));
|
||||
assert_eq!(b.transform().unwrap().get("opacity").unwrap().keys.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_become_placeholders() {
|
||||
let (doc, main) = edit();
|
||||
let (bytes, _) = fc::export(&doc, main, fc::Format::Fcp7Xml, &Default::default()).unwrap();
|
||||
let (p, r) = import_doc(&bytes, "Main.xml", &mut |_| None);
|
||||
assert_eq!(r.missing.len(), 3, "{:?}", r.missing);
|
||||
let a = p.items.values().find(|i| i.name == "a.png").unwrap();
|
||||
match &a.kind {
|
||||
ItemKind::Footage(f) => assert!(f.missing && f.width == W && f.height == H && f.has_video),
|
||||
_ => panic!(),
|
||||
}
|
||||
check_structure(&p, &r, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edl_imports_one_track() {
|
||||
let (doc, main) = edit();
|
||||
let (bytes, _) = fc::export(&doc, main, fc::Format::Edl, &Default::default()).unwrap();
|
||||
let mut p = Project::default();
|
||||
let r = import(&mut p, &bytes, None, &ImportOptions { name: "Main.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() }, &mut probe).unwrap();
|
||||
assert_eq!(r.format, Some(TimelineFormat::Edl));
|
||||
let c = p.comp(r.comps[0]).unwrap();
|
||||
assert!(c.layers.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_documents() {
|
||||
let mut p = Project::default();
|
||||
assert!(matches!(import(&mut p, b"hello", None, &ImportOptions::default(), &mut probe), Err(Error::UnknownFormat)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- export
|
||||
|
||||
fn footage_item(p: &mut Project, path: &str) -> ItemId {
|
||||
let f = probe(path).unwrap();
|
||||
p.add_item(path.rsplit('/').next().unwrap(), effectcraft_color::Label::Aqua, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
fn comp_item(p: &mut Project, name: &str, c: Comp) -> ItemId {
|
||||
p.add_item(name, effectcraft_color::Label::Sandstone, None, ItemKind::Comp(Arc::new(c)))
|
||||
}
|
||||
|
||||
/// A comp with a stretched, keyframed footage layer, a text layer, a precomp and an audio layer.
|
||||
fn ae_project() -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let s = footage_item(&mut p, "/media/tone.wav");
|
||||
let mut inner = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
|
||||
let li = effectcraft_project::build::layer(&mut p, &inner, "inner a", LayerSource::Footage { item: a }, (W, H), None);
|
||||
inner.layers.push(li);
|
||||
let inner_id = comp_item(&mut p, "Inner", inner);
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(5.0));
|
||||
let mut la = effectcraft_project::build::layer(&mut p, &c, "clip a", LayerSource::Footage { item: a }, (W, H), None);
|
||||
la.stretch = 200.0; // half speed
|
||||
la.start_time = t(-1.0); // layer time 1 s at comp 3 s… source 1 s at comp 1 s
|
||||
la.in_point = t(1.0);
|
||||
la.out_point = t(4.0);
|
||||
{
|
||||
let tr = la.transform_mut().unwrap();
|
||||
let pos = tr.get_mut("position").unwrap();
|
||||
pos.keys = vec![Keyframe::new(t(1.0), Value::Vec3([100.0, 120.0, 0.0])), Keyframe::new(t(2.0), Value::Vec3([500.0, 240.0, 0.0])).hold()];
|
||||
tr.get_mut("scale").unwrap().value = Value::Vec3([80.0, 80.0, 100.0]);
|
||||
tr.get_mut("rotation").unwrap().value = Value::Scalar(-15.0);
|
||||
tr.get_mut("opacity").unwrap().value = Value::Scalar(75.0);
|
||||
}
|
||||
let mut lt = effectcraft_project::build::layer(&mut p, &c, "Title", LayerSource::Text, (W, H), None);
|
||||
lt.in_point = t(0.6);
|
||||
lt.out_point = t(2.6);
|
||||
let mut lp = effectcraft_project::build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner_id }, (W, H), None);
|
||||
lp.start_time = t(2.0);
|
||||
lp.in_point = t(2.0);
|
||||
lp.out_point = t(5.0);
|
||||
let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None);
|
||||
ls.out_point = t(3.0);
|
||||
ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]);
|
||||
c.layers = vec![lt, lp, la, ls];
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_prerenders_rendered_only_layers() {
|
||||
let (p, cid) = ae_project();
|
||||
let opts = ExportOptions::default();
|
||||
let plan = plan_prerender(&p, cid, &opts);
|
||||
let c = p.comp(cid).unwrap();
|
||||
assert_eq!(plan, vec![(cid, layer(c, "Title").id)]);
|
||||
assert_eq!(prerender_reason(&p, cid, layer(c, "Title").id, &opts).as_deref(), Some("text layer"));
|
||||
let all = plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::All, ..Default::default() });
|
||||
assert_eq!(all.len(), 3, "audio-only layers are never rendered");
|
||||
assert!(plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_then_reimport_keeps_the_timeline() {
|
||||
let (p, cid) = ae_project();
|
||||
let c = p.comp(cid).unwrap();
|
||||
let title = layer(c, "Title").id;
|
||||
let mut pre = HashMap::new();
|
||||
pre.insert((cid, title), Prerendered { path: "/render/Title.mov".into(), width: W, height: H, frame_rate: FrameRate::new(25, 1), duration: t(2.0) });
|
||||
for format in [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio] {
|
||||
let out = export(&p, cid, &ExportOptions { format, ..Default::default() }, &pre).unwrap();
|
||||
assert_eq!(out.prerendered, 1);
|
||||
assert_eq!(out.sequences, 2);
|
||||
assert_eq!(out.video_tracks, 4, "3 in Main + 1 in Inner");
|
||||
assert_eq!(out.audio_tracks, 1);
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
assert!(text.contains("Title.mov"), "{format:?}");
|
||||
let (q, r) = import_doc(&out.bytes, &format!("Main.{}", format.extension()), &mut probe);
|
||||
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
|
||||
let m = q.comp(main).unwrap();
|
||||
assert_eq!(m.layers.len(), 4, "{format:?}: {:?}", m.layers.iter().map(|l| &l.name).collect::<Vec<_>>());
|
||||
// Stacking order kept: Title (pre-rendered) on top, then the precomp, then clip a.
|
||||
assert_eq!(m.layers[0].name, "Title");
|
||||
assert!(near(m.layers[0].in_point, t(0.6)) && near(m.layers[0].out_point, t(2.6)));
|
||||
assert!(matches!(m.layers[1].source, LayerSource::Comp { .. }), "{format:?}");
|
||||
assert!(near(m.layers[1].in_point, t(2.0)) && near(m.layers[1].layer_time(t(2.0)), Tick::ZERO));
|
||||
let la = &m.layers[2];
|
||||
assert!((la.stretch - 200.0).abs() < 1e-6, "{format:?} stretch {}", la.stretch);
|
||||
assert!(near(la.in_point, t(1.0)) && near(la.out_point, t(4.0)));
|
||||
let orig = layer(c, "clip a");
|
||||
assert!(near(la.layer_time(t(1.0)), orig.layer_time(t(1.0))), "{format:?} {}", la.layer_time(t(1.0)).seconds());
|
||||
assert!(!m.layers[3].switches.video && m.layers[3].switches.audio);
|
||||
if format == TimelineFormat::Fcp7Xml {
|
||||
let tr = la.transform().unwrap();
|
||||
assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [80.0, 80.0]);
|
||||
assert!((tr.get("rotation").unwrap().value.as_f64() + 15.0).abs() < 1e-6);
|
||||
assert!((tr.get("opacity").unwrap().value.as_f64() - 75.0).abs() < 1e-6);
|
||||
let pk = &tr.get("position").unwrap().keys;
|
||||
assert_eq!(pk.len(), 2);
|
||||
for (k, o) in pk.iter().zip(&tr_orig(orig).keys) {
|
||||
assert!(near(la.comp_time(k.time), orig.comp_time(o.time)), "{} vs {}", k.time.seconds(), o.time.seconds());
|
||||
assert_eq!(k.value.as_vec3()[..2], o.value.as_vec3()[..2]);
|
||||
}
|
||||
let lv = m.layers[3].props.prop("audio/levels").unwrap().value.as_vec2();
|
||||
assert!((lv[0] + 3.0).abs() < 0.01, "{lv:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
|
||||
l.transform().unwrap().get("position").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_prerender_leaves_text_out() {
|
||||
let (p, cid) = ae_project();
|
||||
let out = export(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }, &HashMap::new()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w.contains("Title")), "{:?}", out.warnings);
|
||||
assert_eq!(out.prerendered, 0);
|
||||
assert_eq!(out.video_tracks, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_parse() {
|
||||
assert_eq!(TimelineFormat::parse("premiere"), Some(TimelineFormat::Fcp7Xml));
|
||||
assert_eq!(TimelineFormat::parse(".fcpxml"), Some(TimelineFormat::Fcpxml));
|
||||
assert_eq!(TimelineFormat::from_path("/a/b.otio"), Some(TimelineFormat::Otio));
|
||||
assert_eq!(TimelineFormat::Fcp7Xml.extension(), "xml");
|
||||
assert_eq!(par_fraction(0.9), (9, 10));
|
||||
assert_eq!(par_fraction(1.0), (1, 1));
|
||||
}
|
||||
@@ -929,6 +929,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
||||
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
|
||||
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
||||
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
|
||||
|
||||
@@ -22,6 +22,10 @@ pub enum FieldKind {
|
||||
value: String,
|
||||
exts: Vec<String>,
|
||||
},
|
||||
/// A file to write, with a Browse… button (the host's save dialog).
|
||||
SavePath(String),
|
||||
/// Explanatory text (no parameter).
|
||||
Note(String),
|
||||
Bool(bool),
|
||||
/// Options as (label, value); `sel` is the chosen index.
|
||||
Choice {
|
||||
@@ -48,6 +52,12 @@ impl Field {
|
||||
pub fn path(key: &str, label: &str, value: &str, exts: &[&str]) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Path { value: value.into(), exts: exts.iter().map(|e| e.to_string()).collect() } }
|
||||
}
|
||||
pub fn save_path(key: &str, label: &str, value: &str) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::SavePath(value.into()) }
|
||||
}
|
||||
pub fn note(key: &str, text: &str) -> Field {
|
||||
Field { key: key.into(), label: String::new(), kind: FieldKind::Note(text.into()) }
|
||||
}
|
||||
pub fn bool(key: &str, label: &str, value: bool) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Bool(value) }
|
||||
}
|
||||
@@ -75,7 +85,8 @@ impl Form {
|
||||
for f in &self.fields {
|
||||
let v = match &f.kind {
|
||||
FieldKind::Number { value, .. } => json!(value),
|
||||
FieldKind::Text(s) | FieldKind::Path { value: s, .. } => json!(s),
|
||||
FieldKind::Text(s) | FieldKind::Path { value: s, .. } | FieldKind::SavePath(s) => json!(s),
|
||||
FieldKind::Note(_) => continue,
|
||||
FieldKind::Bool(b) => json!(b),
|
||||
FieldKind::Choice { options, sel } => options.get(*sel).map(|o| o.1.clone()).unwrap_or(Value::Null),
|
||||
};
|
||||
@@ -499,6 +510,43 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
],
|
||||
)
|
||||
}
|
||||
// File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
|
||||
"file.exportTimeline" if !has(p, &["path"]) => {
|
||||
let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
|
||||
let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
|
||||
(
|
||||
"Export Adobe Premiere Pro Project".into(),
|
||||
vec![
|
||||
Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
|
||||
Field::save_path("path", "File", &path),
|
||||
Field::choice(
|
||||
"format",
|
||||
"Format",
|
||||
&[
|
||||
("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
|
||||
("FCPXML (.fcpxml)", json!("fcpxml")),
|
||||
("OpenTimelineIO (.otio)", json!("otio")),
|
||||
("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
|
||||
("AAF (.aaf)", json!("aaf")),
|
||||
("OMF (.omf, audio)", json!("omf")),
|
||||
],
|
||||
0,
|
||||
),
|
||||
Field::choice(
|
||||
"prerender",
|
||||
"Pre-render (ProRes 4444)",
|
||||
&[
|
||||
("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
|
||||
("All layers", json!("all")),
|
||||
("None (leave them out)", json!("none")),
|
||||
],
|
||||
0,
|
||||
),
|
||||
Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
|
||||
],
|
||||
)
|
||||
}
|
||||
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
|
||||
"New Placeholder".into(),
|
||||
vec![
|
||||
@@ -595,6 +643,19 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
})
|
||||
.inner
|
||||
}
|
||||
FieldKind::SavePath(value) => {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect;
|
||||
let b = ui.button("Browse…");
|
||||
regs.push((format!("form.field.{}.browse", fl.key), b.rect, "Browse…".into()));
|
||||
if b.clicked() {
|
||||
browse = Some(i);
|
||||
}
|
||||
r
|
||||
})
|
||||
.inner
|
||||
}
|
||||
FieldKind::Note(text) => ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap()).rect,
|
||||
FieldKind::Bool(b) => ui.checkbox(b, "").rect,
|
||||
FieldKind::Choice { options, sel } => {
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
@@ -644,6 +705,19 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
None => app.ui.status = "no file dialog available: type the path".into(),
|
||||
}
|
||||
}
|
||||
if let Some(i) = browse
|
||||
&& let Some(FieldKind::SavePath(value)) = f.fields.get_mut(i).map(|f| &mut f.kind)
|
||||
{
|
||||
let default = std::path::Path::new(value.as_str()).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
match app.hooks.pick_save.as_ref() {
|
||||
Some(pick) => {
|
||||
if let Some(path) = pick(&default) {
|
||||
*value = path;
|
||||
}
|
||||
}
|
||||
None => app.ui.status = "no file dialog available: type the path".into(),
|
||||
}
|
||||
}
|
||||
let enter = !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
app.dialog_state.form = f.clone();
|
||||
if ok || enter {
|
||||
@@ -717,4 +791,39 @@ mod tests {
|
||||
};
|
||||
assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"}));
|
||||
}
|
||||
|
||||
/// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro
|
||||
/// XML; its fields register automation ids and OK runs `file.exportTimeline`.
|
||||
#[test]
|
||||
fn premiere_export_dialog() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens);
|
||||
app.session.execute("comp.new", json!({"name": "Spot", "width": 64, "height": 36, "frameRate": 25, "duration": 1})).unwrap();
|
||||
app.session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
crate::menus::invoke(&mut app, &ctx, "file.exportTimeline", json!({})).unwrap();
|
||||
assert!(matches!(app.dialog, Some(Dialog::Form)));
|
||||
let f = &app.dialog_state.form;
|
||||
assert_eq!(f.command, "file.exportTimeline");
|
||||
assert!(matches!(&f.fields[0].kind, FieldKind::Note(t) if t.contains("Final Cut Pro XML (.xml)")));
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-ui-premiere-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Spot.xml").to_string_lossy().to_string();
|
||||
app.dialog_state.form.fields[1].kind = FieldKind::SavePath(path.clone());
|
||||
ctx.run_ui(Default::default(), |ui| {
|
||||
app.auto.begin_frame();
|
||||
crate::panels::dialogs::show(&mut app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
for id in ["form.field.path", "form.field.path.browse", "form.field.format", "form.field.prerender", "form.field.precomps", "form.ok"] {
|
||||
assert!(app.auto.find(id).is_some(), "{id}");
|
||||
}
|
||||
let params = app.dialog_state.form.params();
|
||||
assert!(params.get("note").is_none());
|
||||
assert_eq!(params["format"], "xml");
|
||||
let r = app.session.execute("file.exportTimeline", params).unwrap();
|
||||
assert_eq!(r["path"], path.as_str());
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
|
||||
}
|
||||
}
|
||||
@@ -397,6 +397,23 @@ cannot express (most effects, cameras and lights, layer styles, audio, video foo
|
||||
agent can check what a player will not show. A `.lottie` path writes a dotLottie archive.
|
||||
`file.importLottie` returns `{comp, items, warnings}` and opens the new composition.
|
||||
|
||||
Premiere Pro interop goes through timeline interchange (MCP: the `execute_command` tool; control channel: `engine.execute`
|
||||
with the same ids):
|
||||
|
||||
```sh
|
||||
effectcraft-cli exec file.importTimeline '{"path":"edit.xml"}' main.ecproj --save # FCP7 XML / .fcpxml / .otio / .edl / .aaf / .omf
|
||||
effectcraft-cli exec file.exportTimeline '{"comp":"Main","path":"Main.xml"}' main.ecproj --json
|
||||
effectcraft-cli exec file.exportTimeline '{"comp":"Main","path":"Main.otio","prerender":"none"}' main.ecproj
|
||||
```
|
||||
|
||||
`file.importTimeline` returns `{format, folder, comps, allComps, items, missing, warnings}`
|
||||
(`missing` lists media imported as placeholders). `file.exportTimeline` writes Final Cut Pro XML
|
||||
(`.xml`, which Premiere Pro opens with File ▸ Import) unless `format` or the extension says
|
||||
`fcpxml`, `otio`, `edl`, `aaf` or `omf`; `prerender` (`unsupported` by default, `all`, `none`) controls which
|
||||
layers are rendered to ProRes 4444 movies next to the document. It returns `{path, format,
|
||||
sequences, videoTracks, audioTracks, clips, prerendered, warnings}`. Native `.prproj` files are
|
||||
not read or written.
|
||||
|
||||
`<comp>` is an id or name, or `-` for the active comp. `<value>` is JSON (`50`, `[960,540]`,
|
||||
`"#ff0000"`) or a bare string.
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
||||
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
|
||||
| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF |
|
||||
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
|
||||
| L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) |
|
||||
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
|
||||
| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) |
|
||||
| L4 | `engine` | `Session`: project, branching undo history, editor state, the command registry and menus, the ScriptUI window model (`scriptui`) |
|
||||
@@ -455,6 +456,43 @@ applied on the UI thread.
|
||||
The shortcut dispatcher and the menus read the active preset. See
|
||||
[preferences.md](preferences.md).
|
||||
|
||||
## 6a. Premiere Pro interop (timeline interchange)
|
||||
|
||||
After Effects' File ▸ Import / Export ▸ Adobe Premiere Pro Project… go through the public
|
||||
interchange formats Premiere Pro itself reads and writes, not the native `.prproj` file (it has no
|
||||
public specification; supporting it awaits a decision). `crates/interchange` (L3, no file I/O)
|
||||
maps FilmCraft's timeline model (`filmcraft-project`, parsed and written by `filmcraft-interchange`,
|
||||
pinned at the same git revision as the other `filmcraft-*` crates; pure Rust, builds for wasm32)
|
||||
to compositions and back:
|
||||
|
||||
- **Import** (`file.importTimeline {path, format?, edlFrameRate?}`): Final Cut Pro 7 XML (`xmeml`,
|
||||
Premiere's File ▸ Export ▸ Final Cut Pro XML), FCPXML 1.9–1.11, OpenTimelineIO, CMX 3600 EDL, AAF and OMF.
|
||||
Sequences become comps (size, rate, duration, pixel aspect, start timecode, markers); video
|
||||
tracks become layers stacked by track (top track = top layer, later clips of a track above
|
||||
earlier ones); clips become footage layers with their in/out/start, speed and reverse (time
|
||||
stretch) and frame holds (Time Remap); Motion (position, scale / scale width, rotation, anchor
|
||||
point, Scale to Frame Size) and Opacity (blend mode) with keyframes, keyframe times converted
|
||||
from clip time to layer time; cross dissolves and dips become overlapping layers with opacity
|
||||
keyframes; nested sequences become precomps; audio tracks become audio-only layers with Audio
|
||||
Levels (clip volume + gain + track volume, crossfades as level keyframes); colour mattes and
|
||||
adjustment layers become solids; bins become Project panel folders under a folder named after
|
||||
the document; media that cannot be found become placeholders with the document's metadata
|
||||
(`missing` in the result). Effects, effect masks, titles/graphics and speed ramps are reported
|
||||
in `warnings`.
|
||||
- **Export** (`file.exportTimeline {comp?, path, format?, prerender?, precomps?}`): one sequence
|
||||
per comp, one track per layer (bottom layer = V1), footage clips with their timing, Motion,
|
||||
Opacity and audio levels (linked audio clips for layers with sound), precomps as nested
|
||||
sequences, full-frame solids as colour mattes. Layers a timeline cannot represent (text, shapes,
|
||||
3D, effects, masks, track mattes, parenting, expressions, animated time remapping, non-uniform
|
||||
scale, blend modes Premiere lacks) are pre-rendered to ProRes 4444 with alpha next to the
|
||||
document (`prerender: unsupported`, the default; `all` renders every visual layer; `none` leaves
|
||||
them out with a warning) and referenced as media. The menu keeps After Effects' label "Adobe
|
||||
Premiere Pro Project…", but the file written is Final Cut Pro XML (`.xml`), which Premiere Pro
|
||||
imports with File ▸ Import; the dialog says so. FCPXML, OTIO, EDL, AAF and OMF are the other formats.
|
||||
|
||||
AAF and OMF (FilmCraft's structured-storage and Bento readers/writers) import and export too;
|
||||
media embedded in them is not extracted, and the AAF writer keeps neither speed nor nesting.
|
||||
|
||||
## 7. Performance of everyday operations
|
||||
|
||||
`effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N]` measures the
|
||||
|
||||
@@ -411,6 +411,17 @@ in Settings (`customRgb`) and simulated like the built-in profiles, with Preserv
|
||||
`form.field.<key>`, `form.ok` and `form.cancel` automation ids. Not yet: LUT-based (`A2B0`)
|
||||
ICC profiles.
|
||||
|
||||
### M13.26: Premiere Pro interop via timeline interchange
|
||||
|
||||
| Feature | After Effects | EffectCraft | Status |
|
||||
|---|---|---|---|
|
||||
| File ▸ Import ▸ Adobe Premiere Pro Project… | reads `.prproj` | reads Final Cut Pro XML (Premiere's File ▸ Export ▸ Final Cut Pro XML), FCPXML, OTIO, EDL, AAF and OMF (`file.importTimeline`): comps, layers by track, timing/speed/reverse/holds, Motion and Opacity keyframes, dissolves as opacity keys, nested sequences as precomps, audio layers with levels, bins as folders, missing media as placeholders | ≈ 85% |
|
||||
| File ▸ Export ▸ Adobe Premiere Pro Project… | writes `.prproj` | writes Final Cut Pro XML `.xml` (also FCPXML, OTIO, EDL, AAF, OMF; `file.exportTimeline`): clips per layer with timing, Motion, Opacity and levels; precomps nested; rendered-only layers pre-rendered to ProRes 4444 with alpha | ≈ 85% |
|
||||
|
||||
Not yet: native `.prproj` (no public specification; awaits a decision), media embedded in AAF/OMF
|
||||
(not extracted), Premiere effects other than Motion/Opacity/Volume, speed
|
||||
ramps, titles/graphics, and Dynamic Link.
|
||||
|
||||
## Update: M13.23 GPU effects, part B (EFF-5)
|
||||
|
||||
40 more effects run on the GPU (236 in all, with M13.22's 30), each checked against the CPU oracle (≤ 1/255 at
|
||||
|
||||
@@ -47,6 +47,7 @@ const LAYERS: &[(&str, u8)] = &[
|
||||
("lottie", 3),
|
||||
("format", 3),
|
||||
("plugin", 3),
|
||||
("interchange", 3),
|
||||
("engine", 4),
|
||||
("script", 4),
|
||||
("host", 4),
|
||||
|
||||
Reference in new issue
Block a user