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.rs
new 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
