diff --git a/Cargo.lock b/Cargo.lock index 35c9cea1..3bbc5c0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6381de0b..629c9c21 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 2d6a40b1..c5b89927 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/timeline.rs b/crates/engine/src/commands/timeline.rs new file mode 100644 index 00000000..be4fdfeb --- /dev/null +++ b/crates/engine/src/commands/timeline.rs @@ -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> { + 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 { + 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 { + 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::>(), + "allComps": res.all_comps.iter().map(|c| c.0).collect::>(), + "items": res.items.iter().map(|c| c.0).collect::>(), + "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 { + 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 { + 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 { + 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 { + Ok(json!( + TimelineFormat::ALL + .iter() + .map(|f| json!({"id": f.id(), "label": f.label(), "extension": f.extension(), "import": true, "export": true})) + .collect::>() + )) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 94409c05..9e50b971 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 0cfb9227..2a197579 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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 --- diff --git a/crates/engine/src/tests_timeline_interchange.rs b/crates/engine/src/tests_timeline_interchange.rs new file mode 100644 index 00000000..2d3361b6 --- /dev/null +++ b/crates/engine/src/tests_timeline_interchange.rs @@ -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()))); +} diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 437a24c2..948b7829 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -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 } diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/timeline_interop.rs new file mode 100644 index 00000000..5123dc72 --- /dev/null +++ b/crates/host/tests/timeline_interop.rs @@ -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 { + 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(" Option { +fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat) -> Option { 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)); } diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs index 9632c468..57e802b4 100644 --- a/crates/interchange/src/lib.rs +++ b/crates/interchange/src/lib.rs @@ -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}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 15b05401..ba5afb8c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -929,6 +929,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("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"])), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 896e6613..0b2e3993 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -22,6 +22,10 @@ pub enum FieldKind { value: String, exts: Vec, }, + /// 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("` is an id or name, or `-` for the active comp. `` is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. diff --git a/docs/architecture.md b/docs/architecture.md index 3a9d5627..d5fe021d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/parity.md b/docs/parity.md index 718038d6..87e1da06 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -373,6 +373,17 @@ in Settings (`customRgb`) and simulated like the built-in profiles, with Preserv `form.field.`, `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).