From b1571abaaa624bddfa4c67b567942cad78c6de2f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:45:45 -0800 Subject: [PATCH 1/2] Project files: the file envelope comes from craft-ecproj-format effectcraft-project writes and checks the envelope of .ecproj files with craft-ecproj-format from craft-libs: File (savedBy, then the saved view and the project) with to_json and to_json_compact for Save Compact Project Files, check_schema and its message, SCHEMA_VERSION and saved_by, which stay re-exported under their names. Project, SavedView and the schema 1 conversion stay here. Saved files are byte-identical. saved_by now reads only a JSON object, so a project written as a JSON array no longer reports a savedBy. --- Cargo.lock | 11 +++++++++++ Cargo.toml | 1 + crates/project/Cargo.toml | 1 + crates/project/src/item.rs | 27 ++++++--------------------- crates/project/src/settings.rs | 11 ++--------- crates/project/src/tests.rs | 16 ++++++++++++++++ 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79498923d..097361d61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1094,6 +1094,16 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +[[package]] +name = "craft-ecproj-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=246e8f837347ac642d6852df89e1425630111d21#246e8f837347ac642d6852df89e1425630111d21" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1783,6 +1793,7 @@ dependencies = [ name = "effectcraft-project" version = "0.19.0" dependencies = [ + "craft-ecproj-format", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", diff --git a/Cargo.toml b/Cargo.toml index eae73bbca..6f8552723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/effectcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "246e8f837347ac642d6852df89e1425630111d21" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 72d40db34..db11fea11 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -14,6 +14,7 @@ effectcraft-time = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } +craft-ecproj-format = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index b2b64ed2c..1ef3d1f0a 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -272,26 +272,13 @@ impl Project { /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { - if self.schema > SCHEMA_VERSION { - return Err(ProjectError::Invalid(format!( - "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", - self.schema - ))); - } - #[derive(Serialize)] - struct File<'a> { - #[serde(rename = "savedBy")] - saved_by: &'a str, - #[serde(flatten)] - view: &'a SavedView, - #[serde(flatten)] - project: &'a Project, - } - let file = File { saved_by: APP_VERSION, view, project: self }; + craft_ecproj_format::check_schema(self.schema) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; + let file = craft_ecproj_format::File::new(APP_VERSION, view, self); file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } - .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = + if compact { file.to_json_compact() } else { file.to_json() }.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] 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})")))?; @@ -312,9 +299,7 @@ impl Project { /// hostile file still opens, within bounds, and the user can be told what changed. pub fn from_json_repaired(s: &str) -> Result<(Project, Vec), ProjectError> { let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?; - if p.schema > SCHEMA_VERSION { - return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema))); - } + craft_ecproj_format::check_schema(p.schema).map_err(|e| ProjectError::Invalid(e.to_string()))?; p.fix_next_id(); let mut notes = vec![]; for item in p.items.values_mut() { diff --git a/crates/project/src/settings.rs b/crates/project/src/settings.rs index 5d3d57204..aace78cdc 100644 --- a/crates/project/src/settings.rs +++ b/crates/project/src/settings.rs @@ -4,7 +4,7 @@ use super::{BTreeMap, ColorSpace, Deserialize, Label, Serialize, Tick}; /// The project file schema. 2: effects on layers without a source rectangle (shape, text) /// measure positions from the top-left of their comp-sized bounds (effect space), not from /// the layer's origin; projects from schema 1 are converted when opened (#227). -pub const SCHEMA_VERSION: u32 = 2; +pub use craft_ecproj_format::SCHEMA_VERSION; /// The first schema in which effect positions are in effect space (see [`SCHEMA_VERSION`]). pub const SCHEMA_EFFECT_SPACE: u32 = 2; @@ -13,14 +13,7 @@ pub const SCHEMA_EFFECT_SPACE: u32 = 2; pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// The EffectCraft version that wrote a project file (its `savedBy`), when it says. -pub fn saved_by(text: &str) -> Option { - #[derive(Deserialize)] - struct Meta { - #[serde(rename = "savedBy", default)] - saved_by: Option, - } - serde_json::from_str::(text).ok()?.saved_by -} +pub use craft_ecproj_format::saved_by; /// The compositions that were open when a project file was saved and their current times /// (`activeComp`, `openComps` and `compTimes` next to `savedBy`), so it reopens where it was left diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index afd3d3f03..a3e41adb5 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -193,6 +193,22 @@ fn saved_files_reopen_in_pretty_and_compact_form() { assert_eq!(Project::from_json(&p.to_json_compact()).unwrap(), p, "compact JSON"); } +/// A project file read back and saved again is the same text, indented and compact (Save Compact +/// Project Files), with its saved view. +#[test] +fn project_files_save_again_byte_for_byte() { + let (p, cid) = project_with_layer(); + let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; + for compact in [false, true] { + let text = p.to_file_json_with_view(&view, compact).unwrap(); + let prefix = if compact { "{\"savedBy\":\"" } else { "{\n \"savedBy\": \"" }; + assert!(text.starts_with(&format!("{prefix}{}\"", crate::APP_VERSION)), "compact {compact}: {text}"); + assert_eq!(text.contains('\n'), !compact); + let again = Project::from_json(&text).unwrap().to_file_json_with_view(&crate::saved_view(&text), compact).unwrap(); + assert_eq!(again, text, "compact {compact}"); + } +} + #[test] fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); From b133c0500f45329c73debbb01658525dbc7abe71 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 11:02:30 -0800 Subject: [PATCH 2/2] Project files: read savedBy through craft-ecproj-format's Head The craft-ecproj-format pin moves to storytold/craft-libs#409's new head, where Head::read is the one reader and saved_by() is removed. effectcraft-project's saved_by reads savedBy with Head::read, and from ReadError::TooNew for a project with a newer schema. saved_by now returns None for text that is not a JSON object with a u32 schema. EffectCraft calls it only on files Project::from_json opened, so opening and importing projects are unchanged. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/project/src/settings.rs | 11 +++++++++-- crates/project/src/tests.rs | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 097361d61..76fb9cf31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1097,7 +1097,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13af [[package]] name = "craft-ecproj-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=246e8f837347ac642d6852df89e1425630111d21#246e8f837347ac642d6852df89e1425630111d21" +source = "git+https://github.com/storytold/craft-libs?rev=d74959fb9caca1c5fd393b4351026885b5ba0cc8#d74959fb9caca1c5fd393b4351026885b5ba0cc8" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 6f8552723..c3f25e652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/effectcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "246e8f837347ac642d6852df89e1425630111d21" } +craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d74959fb9caca1c5fd393b4351026885b5ba0cc8" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal diff --git a/crates/project/src/settings.rs b/crates/project/src/settings.rs index aace78cdc..29353908f 100644 --- a/crates/project/src/settings.rs +++ b/crates/project/src/settings.rs @@ -12,8 +12,15 @@ pub const SCHEMA_EFFECT_SPACE: u32 = 2; /// The EffectCraft version, written into project files as `savedBy`. pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// The EffectCraft version that wrote a project file (its `savedBy`), when it says. -pub use craft_ecproj_format::saved_by; +/// The EffectCraft version that wrote a project file (its `savedBy`), when it says, also for a +/// project whose schema is newer than [`SCHEMA_VERSION`]. +pub fn saved_by(text: &str) -> Option { + match craft_ecproj_format::Head::read(text) { + Ok(head) => head.saved_by, + Err(craft_ecproj_format::ReadError::TooNew { saved_by, .. }) => saved_by, + Err(_) => None, + } +} /// The compositions that were open when a project file was saved and their current times /// (`activeComp`, `openComps` and `compTimes` next to `savedBy`), so it reopens where it was left diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index a3e41adb5..a47792b89 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -162,6 +162,8 @@ fn project_files_name_their_version_and_always_read_back() { 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"); + assert_eq!(crate::saved_by(r#"{"savedBy": "999.0.0", "schema": 3}"#).as_deref(), Some("999.0.0"), "a newer schema still names its version"); + assert_eq!(crate::saved_by(r#"[2, {}, {}, 1]"#), None, "the array form 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();