Merge branch 'worktree-agent-a9fea98fe9839069d'

This commit is contained in:
Brandon Thomas committed 2026-10-04 00:53:58 -04:00
commit d39c0e818c
21 files changed
+2759 -2

No files matched your search

Generated
+66
View File
@@ -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
View File
@@ -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"] }
+1
View File
@@ -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 }
+2
View File
@@ -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());
+228
View File
@@ -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),
]
}
+2
View File
@@ -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;
+2
View File
@@ -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())));
}
+2
View File
@@ -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 }
+174
View File
@@ -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}");
}
+25
View File
@@ -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 }
+690
View File
@@ -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 }
}
+611
View File
@@ -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();
}
+206
View File
@@ -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
}
+500
View File
@@ -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));
}
+1
View File
@@ -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"])),
+110 -1
View File
@@ -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"));
}
}
+17
View File
@@ -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.
+38
View File
@@ -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
+11
View File
@@ -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
+1
View File
@@ -47,6 +47,7 @@ const LAYERS: &[(&str, u8)] = &[
("lottie", 3),
("format", 3),
("plugin", 3),
("interchange", 3),
("engine", 4),
("script", 4),
("host", 4),