M13.26: Premiere Pro interop commands (file.importTimeline, file.exportTimeline with ProRes 4444 pre-renders), File menu entries, export dialog, docs and pixel round-trip tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-04 00:25:43 -04:00
1 parent fe80862eeb
commit 955422819c
16 files changed
+662 -4

No files matched your search

Generated
+3
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,6 +1341,7 @@ dependencies = [
"effectcraft-script",
"effectcraft-svg",
"effectcraft-time",
"image",
"rayon",
"serde_json",
]
+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)"))),
}
}
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 / Timeline Interchange...",
["File", "Import"],
None,
"{path (.xml Final Cut Pro XML from Premiere Pro or .fcpxml .otio .edl), format?: auto|xml|fcpxml|otio|edl, 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), format?: xml|fcpxml|otio|edl, prerender?: none|unsupported|all, precomps?: nest|prerender}",
has_comp,
export_timeline
),
cmd!("file.timelineFormats", "Timeline Interchange Formats", [], None, "{}", always, timeline_formats),
]
}
+2
View File
@@ -924,6 +924,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(), 4);
}
#[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}");
}
+4 -3
View File
@@ -108,13 +108,14 @@ enum Plan {
}
/// Why a layer can't be a plain clip (None = it can), regardless of the mode.
fn unsupported(project: &Project, comp: &Comp, l: &Layer) -> Option<String> {
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,
@@ -184,7 +185,7 @@ fn plan(project: &Project, comp: &Comp, l: &Layer, opts: &ExportOptions) -> Plan
if l.switches.adjustment {
return Plan::Skip("adjustment layer (its effects apply to the layers below)".into());
}
let why = unsupported(project, comp, l);
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) {
@@ -443,7 +444,7 @@ impl Exporter<'_> {
},
Plan::Native | Plan::Nest => {
if self.opts.prerender == PrerenderMode::None
&& let Some(w) = unsupported(self.p, &comp, l)
&& let Some(w) = unsupported(self.p, &comp, l, self.opts.format)
{
self.warn(format!("layer \"{}\": {w} not exported", l.name));
}
+3
View File
@@ -36,10 +36,13 @@ 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};
+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"])),
"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"])),
+108 -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,41 @@ 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")),
],
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 +641,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 +703,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 +789,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
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` or `edl`; `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 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`) |
@@ -427,6 +428,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 and CMX 3600 EDL.
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 and EDL are the other formats.
AAF and OMF import arrive with the FilmCraft revision that adds them to `filmcraft-interchange`
(the pinned revision predates them).
## 7. Performance of everyday operations
`effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N]` measures the
+11
View File
@@ -373,6 +373,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 and EDL (`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; `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), AAF/OMF import (needs the
FilmCraft revision that adds them), Premiere effects other than Motion/Opacity/Volume, speed
ramps, titles/graphics, and Dynamic Link.
## Highest-value gaps, in order
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).