From 0a1f9730c8553e5fceae7103256e3f33b42fecdb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:36:46 +1100 Subject: [PATCH] Add file.import replace mode for empty-project round-trips. Headless import merged into the current project, so a demo or leftover timeline mixed into export. replace:true starts from file.newProject with the same name, then imports, which is the documented empty-project-start workflow. --- crates/engine/src/commands.rs | 6 +++++- crates/engine/src/image_sequence_tests.rs | 18 ++++++++++++++++++ docs/architecture.md | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..20a21a64 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -913,7 +913,7 @@ fn build() -> Vec { Ok(json!({"items": ids.iter().map(|i| i.0).collect::>()})) } ), - cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?}"#, always, |s, p| { + cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?,"replace":bool?}"#, always, |s, p| { let bin = u64_p(p, "bin").map(filmcraft_project::BinId); let paths: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), @@ -922,6 +922,10 @@ fn build() -> Vec { if paths.is_empty() { return Err(bad("file.import", "need `paths`")); } + if bool_p(p, "replace").unwrap_or(false) { + let name = s.project.name.clone(); + s.execute("file.newProject", json!({ "name": name }))?; + } let mut ids = Vec::new(); let mut errors = Vec::new(); let mut sequences = Vec::new(); diff --git a/crates/engine/src/image_sequence_tests.rs b/crates/engine/src/image_sequence_tests.rs index ca36d9cc..8d028fe7 100644 --- a/crates/engine/src/image_sequence_tests.rs +++ b/crates/engine/src/image_sequence_tests.rs @@ -11,6 +11,24 @@ use serde_json::{Value, json}; use crate::Session; use crate::media_test_util::tmp_dir; +#[test] +fn import_replace_starts_from_an_empty_project() { + let dir = tmp_dir("import-replace"); + let wav = dir.join("tone.wav"); + std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap(); + let mut s = demo(); + let demo_items = s.project.items.len(); + assert!(demo_items > 1); + assert!(!s.state.open_sequences.is_empty()); + s.execute("file.import", json!({"paths": [wav.to_string_lossy()], "replace": true})).unwrap(); + assert!(s.state.open_sequences.is_empty()); + assert_eq!(s.project.items.len(), 1); + assert!(s.project.items.values().any(|it| it.name == "tone.wav")); + let mut merge = demo(); + merge.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap(); + assert!(merge.project.items.len() > demo_items); +} + fn demo() -> Session { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); diff --git a/docs/architecture.md b/docs/architecture.md index 9481641f..cdcc5ef3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -694,7 +694,9 @@ Video encoders implement `export::VideoEncoder`. Codec crates plug in with `regi Timelines can be exchanged as EDL, FCP7 XML, FCPXML, OTIO, AAF or OMF. `file.import` detects these formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is -written next to the document first), and `file.exportInterchange`, `file.exportEdl`, +written next to the document first). `file.import {replace: true}` starts from an empty project +with the same name (`file.newProject`) so a headless import→export round-trip contains only the +imported timeline. `file.exportInterchange`, `file.exportEdl`, `file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them. `file.exportInterchange {format}` takes `edl`, `xml` (the default), `fcpxml`, `otio`, `aaf` or `omf`; any other value is a parameter error and nothing is written. For AAF and