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
@@ -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()}))@@ -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) } } @@ -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;new file mode 100644@@ -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());+}@@ -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}")))?;@@ -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