From 948f06fbf2d2228e8325aca3561c010d357dca8e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:08:07 +0700 Subject: [PATCH] M3.14: project files name the version that wrote them, never save unreadable, keep their format - `Project::to_file_json` writes `savedBy` (the EffectCraft version) ahead of the project and checks that the text reads back before anything is written: JSON has no NaN or infinity, so such a value made a file that saved but could never be opened. Save now fails with a message and the file on disk is left as it was. - Opening a project saved by a newer EffectCraft warns that settings this version doesn't know are lost on saving; `file.open` reports `savedBy`. A file that isn't a project names itself in the error. - Increment and Save keeps the extension (`.ecprojx` stays XML); Save a Copy writes XML for `.ecprojx` / `.xml` paths, like Save As. - Docs: architecture (document model), parity. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 5 ++- crates/engine/src/commands/app_more.rs | 2 +- crates/engine/src/commands/file.rs | 24 +++++++++--- crates/engine/src/commands/file_more.rs | 2 +- crates/engine/src/tests_menu_cmds.rs | 49 +++++++++++++++++++++++++ crates/project/src/lib.rs | 39 ++++++++++++++++++++ crates/project/src/tests.rs | 16 ++++++++ docs/architecture.md | 8 ++++ docs/parity.md | 9 +++++ 9 files changed, 145 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index dd55130a..e1c56ef2 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -245,16 +245,17 @@ pub fn end(store: &dyn ConfigStore) { } /// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj` -/// → `Intro 3.ecproj` (skipping names that exist). +/// → `Intro 3.ecproj` (skipping names that exist; the extension, `.ecprojx` too, is kept). pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String { let p = Path::new(path); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let ext = p.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| EXT.to_string()); let (base, mut n) = match stem.rsplit_once(' ') { Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::().unwrap_or(1) + 1), _ => (stem.clone(), 2), }; loop { - let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string(); + let cand = p.with_file_name(format!("{base} {n}.{ext}")).to_string_lossy().to_string(); if !exists(&cand) { return cand; } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 414fae22..34780274 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; - let xml = crate::xml_project::to_xml(&s.project.to_json()).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?; s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved an XML copy to {path}")); Ok(json!({"path": path, "bytes": xml.len()})) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 28759286..b784a34d 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -42,21 +42,35 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; - let proj = Project::from_json(&text)?; + let proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); + // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. + let saved_by = effectcraft_project::saved_by(&text); + if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) { + s.toast(format!( + "This project was saved by EffectCraft {v}, newer than this version ({}). Settings this version doesn't know are lost if you save it.", + effectcraft_project::APP_VERSION + )); + } // Lazy open: footage is checked in the background (Progress panel), not before the // project shows. if s.check_footage_on_open { crate::footage_check::start(s, None, false)?; } - Ok(json!({"path": path})) + Ok(json!({"path": path, "savedBy": saved_by})) +} + +/// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a +/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project +/// wouldn't read back. +pub(crate) fn file_text(s: &Session, path: &str) -> Result { + let json = s.project.to_file_json()?; + if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } fn save_to(s: &mut Session, path: &str) -> Result { - let json = s.project.to_json(); - // A project opened from an XML copy saves as XML again. - let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json }; + let json = file_text(s, path)?; s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.path = Some(path.to_string()); s.mark_saved(); diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index e4d62cf1..b9e40242 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -77,7 +77,7 @@ fn close_project(s: &mut Session, _: &Value) -> Result { fn save_copy(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "missing `path`"))?; - let json = s.project.to_json(); + let json = super::file::file_text(s, path)?; s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; Ok(json!({"path": path, "bytes": json.len()})) } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 8e1e202e..85e12674 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -736,3 +736,52 @@ fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() { s.state.project_selection = vec![a, b]; assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err()); } + +#[test] +fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let path = tmp("Safe.ecproj"); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + assert_eq!(effectcraft_project::saved_by(&saved).as_deref(), Some(effectcraft_project::APP_VERSION)); + // Save a Copy and Increment and Save keep an XML project XML. + let copy = tmp("Copy.ecprojx"); + s.execute("file.saveCopy", json!({"path": copy})).unwrap(); + assert!(std::fs::read_to_string(©).unwrap().starts_with(" Option { + #[derive(Deserialize)] + struct Meta { + #[serde(rename = "savedBy", default)] + saved_by: Option, + } + serde_json::from_str::(text).ok()?.saved_by +} + +/// Whether version `a` (`major.minor.patch`, pre-release suffixes ignored) is newer than `b`. +pub fn is_newer_version(a: &str, b: &str) -> bool { + let parts = |v: &str| -> [u64; 3] { + let mut n = v.split(['-', '+']).next().unwrap_or("").split('.').map(|x| x.parse().unwrap_or(0)); + [n.next().unwrap_or(0), n.next().unwrap_or(0), n.next().unwrap_or(0)] + }; + parts(a) > parts(b) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ItemId(pub u64); @@ -1082,6 +1104,23 @@ impl Project { && self.render_templates == other.render_templates && self.render_prefs == other.render_prefs } + /// The project file's text: the project with the version that wrote it (`savedBy`), checked + /// to read back, so a save never writes a file that can't be opened (JSON has no NaN or + /// infinity: such a value would be written as `null`). + pub fn to_file_json(&self) -> Result { + #[derive(Serialize)] + struct File<'a> { + #[serde(rename = "savedBy")] + saved_by: &'a str, + #[serde(flatten)] + project: &'a Project, + } + let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, project: self }) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + Project::from_json(&text) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; + Ok(text) + } pub fn to_json(&self) -> String { serde_json::to_string_pretty(self).unwrap_or_default() } diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 9c200672..514a2fc4 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -104,3 +104,19 @@ fn same_content_ignores_unused_ids_and_sees_comp_edits() { r.settings.bit_depth = crate::BitDepth::Bpc16; assert!(!r.same_content(&p)); } + +#[test] +fn project_files_name_their_version_and_always_read_back() { + let (mut p, cid) = project_with_layer(); + let text = p.to_file_json().unwrap(); + assert_eq!(crate::saved_by(&text).as_deref(), Some(crate::APP_VERSION)); + assert!(text.trim_start().starts_with("{\n \"savedBy\""), "the version comes first"); + assert_eq!(Project::from_json(&text).unwrap(), p); + assert_eq!(crate::saved_by(&p.to_json()), None, "plain JSON (worker hand-offs) has none"); + // JSON can't hold NaN: saving refuses instead of writing a file that can't be opened. + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::NAN); + let e = p.to_file_json().unwrap_err().to_string(); + assert!(e.contains("can't be saved"), "{e}"); + assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9")); + assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1")); +} diff --git a/docs/architecture.md b/docs/architecture.md index 04c4d137..492c1a29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,14 @@ Everything animatable is a `Property` in the layer's `PropGroup` tree, addressed `transform/position`, `effects/#1/blurriness`, `masks/#1/feather`, or by `@uid`. Effects, masks, text animators and shape contents are groups in the same tree. +A project file (`.ecproj`) is the project as readable JSON, headed by `savedBy` (the EffectCraft +version that wrote it) and `schema`; `.ecprojx` is the same data as XML (File ▸ Save a Copy As +XML). Saving goes through `Project::to_file_json`, which checks that the text reads back before +anything is written (JSON can't hold NaN or infinity), and files are written atomically. Opening +a file that a newer EffectCraft saved shows a warning: fields this version doesn't know are +dropped if it saves the project. Save, Save As, Save a Copy and Increment and Save keep the +file's format (`.ecprojx` stays XML). + ## 4. Rendering `Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders diff --git a/docs/parity.md b/docs/parity.md index 2d1637f1..f3a26b9a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -383,6 +383,15 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M3.14 project files + +Project files name the version that wrote them (`savedBy`); opening one that a newer EffectCraft +saved warns that what this version doesn't know is lost on saving (`file.open` reports +`savedBy`). Saving checks that the file reads back first, so a value JSON can't hold (NaN) +fails the save with a message instead of writing a project that can never be opened (the file +on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`) +XML; a file that isn't a project says so by name. + ## Update: M3.13 menu bar Group Shapes (Ctrl+G) and Ungroup Shapes (Ctrl+Shift+G) moved to the Layer menu itself, where