collective

Browse / github.com/storytold/effectcraft / patch

Project files: each save stores a poster frame of every composition

Project files: each save stores a poster frame of every composition Saving a project now stores a poster frame of each composition, so another app can show an `.ecproj` without EffectCraft's renderer or the project's footage. This changes every save: the file grows and saving takes longer (numbers below). ## What changes - Save, Save As, Increment and Save, Save a Copy and Save a Copy As XML write a `posters` member after the project. It maps each comp's item id to a base64 PNG of the comp at its poster time (Composition ▸ Set Poster Time), longest side at most 320 pixels, the size `essential.exportTemplate` already uses for a template's `poster.png`. The fr

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #2529, open)
Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537
Size
+176 −4 in 6 files
Review
pending
Shared
2026-10-28
Id
1165c84540160c51b78e49d4

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/app_more.rs crates/engine/src/cmd/file.rs crates/engine/src/tests.rs crates/engine/src/tests/project_posters.rs crates/project/src/item.rs docs/architecture.md

diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs--- a/crates/engine/src/cmd/app_more.rs+++ b/crates/engine/src/cmd/app_more.rs@@ -152,7 +152,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_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;+    let xml = crate::xml_project::to_xml(&super::file::file_json(s)?).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/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -1,5 +1,7 @@ //! File menu. +use std::collections::BTreeMap;+ use effectcraft_color::Label; use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project}; use serde_json::{Value, json};@@ -83,11 +85,39 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired})) } +/// The longest side of a poster frame in a project file, the size of the poster in an exported+/// template (`essential.exportTemplate`).+const POSTER_MAX_SIDE: u32 = 320;++/// Each composition's poster frame for the project file: a PNG of the comp at its `poster_time`,+/// longest side at most [`POSTER_MAX_SIDE`], in base64. A comp that uses missing footage, or whose+/// frame can't be encoded as PNG, is saved without one.+fn posters(s: &Session) -> BTreeMap<ItemId, String> {+    s.project+        .comps()+        .filter(|(id, _)| {+            !effectcraft_project::essential::dependencies(&s.project, **id)+                .iter()+                .any(|d| matches!(s.project.item(*d).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.missing))+        })+        .filter_map(|(id, c)| {+            let (w, h, rgba) = s.render_rgba8(*id, c.poster_time, POSTER_MAX_SIDE).ok()?;+            let png = super::comp_more::encode_png(&rgba, w, h).ok()?;+            Some((*id, effectcraft_track::roto::rle::base64_encode(&png)))+        })+        .collect()+}++/// The project file's JSON: the project, the open compositions and each comp's poster frame.+pub(crate) fn file_json(s: &Session) -> Result<String> {+    Ok(s.project.to_file_json_with_posters(&s.saved_view(), &posters(s))?)+}+ /// 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_with_view(&s.saved_view())?;+    let json = file_json(s)?;     if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -31,6 +31,7 @@ mod motion_blur_depth; mod paint; mod panels; mod project_items;+mod project_posters; mod proxy; mod quick_offset; mod replace_source;diff --git a/crates/engine/src/tests/project_posters.rs b/crates/engine/src/tests/project_posters.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/tests/project_posters.rs@@ -0,0 +1,129 @@+//! Project files store each composition's poster frame (`posters`): a PNG of the comp at its+//! poster time, longest side at most 320 pixels. Reading a project ignores it.++use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Project};+use effectcraft_time::Tick;+use serde_json::{Map, Value, json};++use crate::Session;++fn out_dir(name: &str) -> std::path::PathBuf {+    let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);+    let _ = std::fs::remove_dir_all(&d);+    std::fs::create_dir_all(&d).unwrap();+    d+}++/// "Wide" (640×360, blue): a red solid from 1 s on, poster time 1.5 s. "Tall" (90×200): a green+/// solid, poster time 0.+fn two_comps() -> (Session, ItemId, ItemId) {+    let mut s = Session::default();+    s.execute("comp.new", json!({"name": "Wide", "width": 640, "height": 360, "frameRate": 30, "duration": 3})).unwrap();+    let wide = s.active_comp_id().unwrap();+    s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000"})).unwrap();+    let p = std::sync::Arc::make_mut(&mut s.project);+    let c = p.comp_mut(wide).unwrap();+    c.background = [0.0, 0.0, 1.0];+    c.layers[0].in_point = Tick::from_seconds_f64(1.0);+    s.set_time(Tick::from_seconds_f64(1.5));+    s.execute("comp.setPosterTime", json!({})).unwrap();+    s.execute("comp.new", json!({"name": "Tall", "width": 90, "height": 200, "frameRate": 30, "duration": 3})).unwrap();+    let tall = s.active_comp_id().unwrap();+    s.execute("layer.newSolid", json!({"name": "Green", "color": "#00ff00"})).unwrap();+    (s, wide, tall)+}++fn posters(json: &str) -> Map<String, Value> {+    let v: Value = serde_json::from_str(json).unwrap();+    v.get("posters").and_then(Value::as_object).cloned().unwrap_or_default()+}++/// A stored poster decoded to (width, height, RGBA).+fn decode(posters: &Map<String, Value>, id: ItemId) -> (u32, u32, Vec<u8>) {+    let b64 = posters[&id.0.to_string()].as_str().unwrap();+    let png = effectcraft_track::roto::rle::base64_decode(b64).unwrap();+    assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));+    let img = image::load_from_memory(&png).unwrap().to_rgba8();+    (img.width(), img.height(), img.into_raw())+}++#[test]+fn a_save_stores_each_comps_poster_frame() {+    let dir = out_dir("project-posters");+    let (mut s, wide, tall) = two_comps();+    let path = dir.join("Posters.ecproj").to_string_lossy().to_string();+    s.execute("file.saveAs", json!({"path": path})).unwrap();+    let text = std::fs::read_to_string(&path).unwrap();+    let stored = posters(&text);+    assert_eq!(stored.len(), 2, "{:?}", stored.keys().collect::<Vec<_>>());+    // The longest side is 320 at most and a smaller comp keeps its size; the pixels are the comp's+    // render at its poster time, where the red solid has started.+    let (w, h, px) = decode(&stored, wide);+    assert_eq!((w, h), (320, 180));+    assert_eq!((w, h, px.clone()), s.render_rgba8(wide, Tick::from_seconds_f64(1.5), 320).unwrap());+    let mid = ((h / 2 * w + w / 2) * 4) as usize;+    assert_eq!(px[mid..mid + 4], [255, 0, 0, 255]);+    let (w, h, px) = decode(&stored, tall);+    assert_eq!((w, h), (90, 200));+    assert_eq!((w, h, px), s.render_rgba8(tall, Tick::ZERO, 320).unwrap());+    // The posters come after the project, and opening the file gives the same project.+    assert!(text.find("\"posters\"").unwrap() > text.find("\"next_id\"").unwrap());+    let mut t = Session::default();+    t.execute("file.open", json!({"path": path})).unwrap();+    assert_eq!(t.project.to_json(), s.project.to_json());+    // An XML copy carries them too.+    let xml_path = dir.join("Posters.ecprojx").to_string_lossy().to_string();+    s.execute("file.saveCopyAsXml", json!({"path": xml_path})).unwrap();+    let xml = std::fs::read_to_string(&xml_path).unwrap();+    assert_eq!(posters(&crate::xml_project::from_xml(&xml).unwrap()), stored);+}++#[test]+fn a_project_saved_without_posters_opens_and_saves_with_them() {+    let dir = out_dir("project-posters-old");+    let (s, wide, tall) = two_comps();+    let path = dir.join("Old.ecproj").to_string_lossy().to_string();+    std::fs::write(&path, s.project.to_file_json_with_view(&s.saved_view()).unwrap()).unwrap();+    let mut t = Session::default();+    t.execute("file.open", json!({"path": path})).unwrap();+    assert_eq!(t.project.to_json(), s.project.to_json());+    t.execute("file.save", json!({})).unwrap();+    let text = std::fs::read_to_string(&path).unwrap();+    let stored = posters(&text);+    assert!(stored.contains_key(&wide.0.to_string()) && stored.contains_key(&tall.0.to_string()), "{text:.200}");+    assert_eq!(Project::from_json(&text).unwrap(), *s.project);+}++#[test]+fn a_comp_with_missing_footage_saves_without_a_poster() {+    let dir = out_dir("project-posters-missing");+    let (mut s, wide, tall) = two_comps();+    let gone = std::sync::Arc::make_mut(&mut s.project).add_item(+        "gone.mov",+        Default::default(),+        None,+        ItemKind::Footage(Footage {+            path: "/offline/gone.mov".into(),+            kind: FootageKind::Video,+            width: 64,+            height: 64,+            has_video: true,+            duration: Tick::from_seconds_f64(3.0),+            missing: true,+            ..Default::default()+        }),+    );+    s.execute("layer.addItem", json!({"comp": tall.0, "item": gone.0, "time": 0.0})).unwrap();+    // A comp that nests it uses the missing footage too.+    s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 3})).unwrap();+    let outer = s.active_comp_id().unwrap();+    s.execute("layer.addItem", json!({"comp": outer.0, "item": tall.0, "time": 0.0})).unwrap();+    let path = dir.join("Missing.ecproj").to_string_lossy().to_string();+    s.execute("file.saveAs", json!({"path": path})).unwrap();+    let text = std::fs::read_to_string(&path).unwrap();+    let keys: Vec<String> = posters(&text).keys().cloned().collect();+    assert_eq!(keys, [wide.0.to_string()]);+    let mut t = Session::default();+    t.execute("file.open", json!({"path": path})).unwrap();+    assert_eq!(t.project.to_json(), s.project.to_json());+}diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs--- a/crates/project/src/item.rs+++ b/crates/project/src/item.rs@@ -256,6 +256,13 @@ impl Project {     }     /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]).     pub fn to_file_json_with_view(&self, view: &SavedView) -> Result<String, ProjectError> {+        self.to_file_json_with_posters(view, &BTreeMap::new())+    }+    /// [`Project::to_file_json_with_view`] that also stores a poster frame per composition:+    /// `posters`, written after the project, maps a comp's item id to a base64 PNG (RFC 4648 §4)+    /// of the comp at its `poster_time`, so other apps can show the project without rendering it.+    /// Reading a project ignores it, as versions without it do.+    pub fn to_file_json_with_posters(&self, view: &SavedView, posters: &BTreeMap<ItemId, String>) -> Result<String, ProjectError> {         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}))",@@ -270,8 +277,10 @@ impl Project {             view: &'a SavedView,             #[serde(flatten)]             project: &'a Project,+            #[serde(skip_serializing_if = "BTreeMap::is_empty")]+            posters: &'a BTreeMap<ItemId, String>,         }-        let file = File { saved_by: APP_VERSION, view, project: self };+        let file = File { saved_by: APP_VERSION, view, project: self, posters };         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 = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -100,7 +100,10 @@ dropped if it saves the project. Save, Save As, Save a Copy and Increment and Sa file's format (`.ecprojx` stays XML). Next to `savedBy` the file records the active comp and the open comp tabs (`activeComp`, `openComps`), which opening restores; they are editor state, not part of `Project`, so older files (and ids that no longer name a comp) open on the first comp and-older versions ignore them.+older versions ignore them. After the project, the file stores each comp's poster frame+(`posters`: the comp's item id to a base64 PNG of the comp at its poster time, longest side at+most 320 pixels), so other apps can show the project without rendering it. A comp that uses+missing footage is saved without one, and opening a project ignores them.  `schema` changes when a saved value changes meaning; a project from an older schema is converted when it is opened (`effectcraft_engine::upgrade_effects`, which also refreshes effect instances