mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
6fc39eb133
commit
948f06fbf2
9 files changed
+145
-9
No files matched your search
@@ -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::<u32>().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;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()}))
|
||||
|
||||
@@ -42,21 +42,35 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> {
|
||||
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<Value> {
|
||||
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();
|
||||
|
||||
@@ -77,7 +77,7 @@ fn close_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()}))
|
||||
}
|
||||
|
||||
@@ -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("<?xml"));
|
||||
s.execute("file.saveAs", json!({"path": tmp("Xml.ecprojx")})).unwrap();
|
||||
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
|
||||
let inc = r["path"].as_str().unwrap().to_string();
|
||||
assert!(inc.ends_with("Xml 2.ecprojx"), "{inc}");
|
||||
assert!(std::fs::read_to_string(&inc).unwrap().starts_with("<?xml"));
|
||||
let r = s.execute("file.open", json!({"path": inc})).unwrap();
|
||||
assert_eq!(r["savedBy"], json!(effectcraft_project::APP_VERSION));
|
||||
// A project from a newer version opens with a warning.
|
||||
let newer = tmp("Newer.ecproj");
|
||||
std::fs::write(&newer, saved.replacen(effectcraft_project::APP_VERSION, "999.0.0", 1)).unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute("file.open", json!({"path": newer})).unwrap();
|
||||
assert_eq!(r["savedBy"], json!("999.0.0"));
|
||||
let warned = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("newer than this version")));
|
||||
assert!(warned);
|
||||
// A value a project file can't hold: Save fails and the file on disk is left as it was.
|
||||
s.execute("file.open", json!({"path": path})).unwrap();
|
||||
let cid = s.state.active_comp.unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.project)
|
||||
.comp_mut(cid)
|
||||
.unwrap()
|
||||
.layer_mut(effectcraft_project::LayerId(a))
|
||||
.unwrap()
|
||||
.props
|
||||
.prop_mut("transform/opacity")
|
||||
.unwrap()
|
||||
.value = KV::Scalar(f64::NAN);
|
||||
let e = s.execute("file.save", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't be saved"), "{e}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), saved);
|
||||
// Not a project: a clear message.
|
||||
let junk = tmp("Junk.ecproj");
|
||||
std::fs::write(&junk, "{ not json").unwrap();
|
||||
let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string();
|
||||
assert!(e.contains("is not a project EffectCraft can open"), "{e}");
|
||||
}
|
||||
@@ -26,6 +26,28 @@ pub use effectcraft_keyframe::{Keyframe, Value};
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 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 fn saved_by(text: &str) -> Option<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Meta {
|
||||
#[serde(rename = "savedBy", default)]
|
||||
saved_by: Option<String>,
|
||||
}
|
||||
serde_json::from_str::<Meta>(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<String, ProjectError> {
|
||||
#[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()
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user