diff --git a/crates/engine/src/cmd/timeline.rs b/crates/engine/src/cmd/timeline.rs index 633e73c5a..ae57a3b88 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -5,14 +5,16 @@ //! The native `.prproj` format has no public specification and is not read or written. use std::collections::HashMap; +use std::sync::Arc; 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}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p}; +use crate::jobs::TaskCtl; +use crate::{EngineError, Exporter, Result, Session, cmd}; fn format_p(p: &Value, cmd: &str) -> Result> { match str_p(p, "format") { @@ -95,18 +97,34 @@ fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option { 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")?; +/// What a pre-render needs from the session, so it can run on a background thread. +struct PrerenderCx { + exporter: Option>, + footage: Arc, + expr: Option>, + accel: Option>, + nested_switches: bool, +} + +/// Render one layer to ProRes 4444 with alpha over its in–out span; `progress` as the exporter's. +fn prerender( + cx: &PrerenderCx, + project: &Project, + cid: ItemId, + lid: LayerId, + path: &str, + progress: &mut dyn FnMut(u64, u64) -> bool, +) -> std::result::Result { + let exporter = cx.exporter.as_ref().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 comp = 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 q = isolate(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); @@ -116,16 +134,16 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result: 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(), + footage: cx.footage.as_ref(), + expr: cx.expr.as_deref(), + accel: cx.accel.as_deref(), item: &item, path, storage: None, label: format!("Pre-render {}", l.name), - nested_switches: s.prefs.general.switches_affect_nested_comps, + nested_switches: cx.nested_switches, }; - let r = exporter.export(&job, &mut |_, _| true)?; + let r = exporter.export(&job, progress)?; Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start }) } @@ -157,33 +175,67 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result { 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 project = s.project.clone(); + let services = s.services.clone(); + let cx = PrerenderCx { + exporter: s.exporter.clone(), + footage: s.footage.clone(), + expr: s.expr.clone(), + accel: s.accel.clone(), + nested_switches: s.prefs.general.switches_affect_nested_comps, + }; + // The pre-renders can take minutes: with `background` (the export dialog) they run as a job + // in Window ▸ Progress instead of with the window waiting (#2269). + let wait = !b_p(p, "background").unwrap_or(false); + s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| { + let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?; + services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?; + result["path"] = json!(path); + result["toast"] = json!(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(Box::new(move |_: &mut Session| Ok(result))) + }) +} + +/// Pre-render what the timeline can't represent, then build the timeline: its bytes and the +/// command's result (without `path`). +fn export_timeline_work( + cx: &PrerenderCx, + project: &Project, + cid: ItemId, + opts: &ExportOptions, + dir: &std::path::Path, + stem: &str, + ctl: &TaskCtl, +) -> std::result::Result<(Vec, Value), String> { 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 plan = effectcraft_interchange::plan_prerender(project, cid, opts); + let n = plan.len() as u64; + for (i, (c, l)) in (0u64..).zip(plan) { + let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default(); + let layer_name = project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default(); + ctl.message(format!("Pre-render {layer_name}")); 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) { + // Progress over all the layers, each one's frames a share of it. + let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000); + match prerender(cx, project, c, l, &file, &mut progress) { Ok(r) => { rendered.push(r.path.clone()); pre.insert((c, l), r); } + Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()), 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 out = effectcraft_interchange::export(project, cid, opts, &pre).map_err(|e| e.to_string())?; 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(), + let result = json!({ + "format": opts.format.id(), "bytes": out.bytes.len(), "sequences": out.sequences, "videoTracks": out.video_tracks, @@ -191,7 +243,8 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result { "clips": out.clips, "prerendered": rendered, "warnings": warnings, - })) + }); + Ok((out.bytes, result)) } /// The formats, for agents and the export dialog. @@ -220,7 +273,7 @@ pub fn specs() -> Vec { "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}", + "{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, background?: bool (pre-render and write as a job, jobs.list / jobs.wait; returns {job})}", has_comp, export_timeline ), diff --git a/crates/engine/src/tests/timeline_interchange.rs b/crates/engine/src/tests/timeline_interchange.rs index 45be0f7a1..0ebfa2316 100644 --- a/crates/engine/src/tests/timeline_interchange.rs +++ b/crates/engine/src/tests/timeline_interchange.rs @@ -52,6 +52,14 @@ fn export_and_reimport_timeline_commands() { 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()); + // In the background (the export dialog, #2269): a job, then the same file and result. + let path = tmp("Background.xml"); + let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": path, "background": true})).unwrap(); + assert!(r["job"].is_string(), "{r}"); + s.wait_jobs(); + let done = s.job_log.last().unwrap(); + assert_eq!((done.status.as_str(), done.result["path"].as_str()), ("done", Some(path.as_str()))); + assert!(std::fs::read_to_string(&path).unwrap().contains(" bool { ) } // File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL). - "file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s), + "file.exportTimeline" if !has(p, &["path"]) => { + // Its pre-renders run as a job in Window ▸ Progress, not with the window waiting (#2269). + match base.as_object_mut() { + Some(m) => { + m.insert("background".into(), json!(true)); + } + None => base = json!({"background": true}), + } + export_timeline_form(s) + } "file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => ( "New Placeholder".into(), vec![ @@ -1117,8 +1126,11 @@ mod tests { 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_eq!(params["background"], true, "the dialog exports in the background"); + assert!(app.session.execute("file.exportTimeline", params).unwrap()["job"].is_string()); + app.session.wait_jobs(); + let done = app.session.job_log.last().unwrap(); + assert_eq!((done.status.as_str(), &done.result["path"]), ("done", &json!(path))); assert!(std::fs::read_to_string(&path).unwrap().contains("