diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs index 4e2fc41..c967590 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs @@ -44,6 +44,7 @@ impl Action for RasterFillAction { let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); kf.raw_pixels = self.buffer_after.clone(); kf.texture_dirty = true; + kf.dirty = true; Ok(()) } @@ -57,6 +58,7 @@ impl Action for RasterFillAction { let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); kf.raw_pixels = self.buffer_before.clone(); kf.texture_dirty = true; + kf.dirty = true; Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs index 3c63808..77a363d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs @@ -50,6 +50,7 @@ impl Action for RasterStrokeAction { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; kf.raw_pixels = self.buffer_after.clone(); kf.texture_dirty = true; + kf.dirty = true; Ok(()) } @@ -57,6 +58,7 @@ impl Action for RasterStrokeAction { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; kf.raw_pixels = self.buffer_before.clone(); kf.texture_dirty = true; + kf.dirty = true; Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 4f5d846..78d4fd2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -386,8 +386,11 @@ pub fn save_beam( // --- raster keyframes -> media rows (PNG), keyed by keyframe id --- // (Phase 0 writes all resident frames each save; a disk-dirty flag to skip // unchanged frames in place is deferred to Phase 3.) + // Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes + // are persisted too, and so `live_media` covers them — matching the load path, + // which arms `needs_fault_in` recursively. Top-level-only projects are unaffected. let mut raster_count = 0usize; - for layer in &document.root.children { + for layer in document.all_layers() { if let crate::layer::AnyLayer::Raster(rl) = layer { for kf in &rl.keyframes { if !kf.raw_pixels.is_empty() { diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index a8bf271..288c51b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -123,6 +123,13 @@ pub struct RasterKeyframe { /// set true on load and again when evicted. Never serialized. #[serde(skip)] pub needs_fault_in: bool, + /// Phase 3a eviction: set `true` whenever user editing mutates `raw_pixels` + /// (brush, fill, paint-bucket, floating-selection commit/lift, undo/redo of + /// those). A dirty keyframe's current pixels are NOT yet persisted in the + /// container, so it must NEVER be evicted (doing so would silently lose the + /// unsaved edit). Cleared on a successful save. Never serialized. + #[serde(skip)] + pub dirty: bool, } fn default_true() -> bool { true } @@ -147,6 +154,7 @@ impl RasterKeyframe { raw_pixels: Vec::new(), texture_dirty: true, needs_fault_in: false, + dirty: false, } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 5c14e63..6fdf5ca 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1039,6 +1039,11 @@ struct EditorApp { /// Keyframe ids whose page-in is currently running on a background thread /// (prevents duplicate loads while the canvas keeps re-requesting them). raster_loads_inflight: std::collections::HashSet, + /// Fault-in-recency LRU of resident raster keyframe ids. Most-recently + /// paged-in is at the back; eviction pops the front when over budget + /// (`RASTER_RESIDENT_MAX`). The currently-shown frame is always the most + /// recent fault-in, so it's never evicted. + raster_resident_lru: std::collections::VecDeque, /// Application configuration (recent files, etc.) config: AppConfig, /// Remappable keyboard shortcut manager @@ -1100,6 +1105,11 @@ enum ImportFilter { } impl EditorApp { + /// Maximum number of raster keyframes whose `raw_pixels` stay resident in + /// RAM. Older clean keyframes beyond this are evicted (re-page on revisit); + /// the most-recently-shown frame is always protected. + const RASTER_RESIDENT_MAX: usize = 12; + fn new( cc: &eframe::CreationContext, layouts: Vec, @@ -1308,6 +1318,7 @@ impl EditorApp { raster_load_result_tx, raster_load_result_rx, raster_loads_inflight: Default::default(), + raster_resident_lru: Default::default(), keymap: KeymapManager::new(&config.keybindings), config, file_command_tx, @@ -2323,6 +2334,11 @@ impl EditorApp { let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; kf.raw_pixels = std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()); + // Restoring the pre-lift snapshot rewrites raw_pixels in memory; that + // snapshot may itself contain un-persisted edits, and the lift had marked + // the kf dirty. Keep it pinned (dirty) so eviction can't lose it. + kf.dirty = true; + kf.texture_dirty = true; } /// Drop (discard) the floating selection keeping the hole punched in the @@ -5178,6 +5194,11 @@ impl eframe::App for EditorApp { kf.raw_pixels = pixels; kf.texture_dirty = true; any_applied = true; + // Record fault-in recency: most-recently paged-in is + // moved to the back so it's evicted last. The shown + // frame is always the most recent, so it's protected. + self.raster_resident_lru.retain(|id| *id != kf_id); + self.raster_resident_lru.push_back(kf_id); } // Resolved (loaded, or genuinely absent/blank). kf.needs_fault_in = false; @@ -5188,6 +5209,29 @@ impl eframe::App for EditorApp { ctx.request_repaint(); } + // 1b) Evict over-budget resident keyframes (fault-in-recency LRU). + // Drop the pixels of the oldest *clean* keyframes and re-arm their + // fault-in so they re-page on revisit. DIRTY keyframes are never + // evicted (their pixels aren't in the container yet — evicting would + // silently lose the unsaved edit), so we just unpin them from the LRU. + while self.raster_resident_lru.len() > Self::RASTER_RESIDENT_MAX { + let old = self.raster_resident_lru.pop_front().unwrap(); + let doc = self.action_executor.document_mut(); + let kf = doc.all_layers_mut().into_iter().find_map(|l| match l { + lightningbeam_core::layer::AnyLayer::Raster(rl) => { + rl.keyframes.iter_mut().find(|kf| kf.id == old) + } + _ => None, + }); + if let Some(kf) = kf { + if !kf.dirty && !kf.raw_pixels.is_empty() { + kf.raw_pixels = Vec::new(); + kf.needs_fault_in = true; + kf.texture_dirty = true; + } + } + } + // 2) Dispatch background page-ins for newly-requested keyframes. let wanted: Vec = { let mut s = self.raster_fault_requests.lock().unwrap(); @@ -5360,6 +5404,33 @@ impl eframe::App for EditorApp { // raster paging store so future faults read the right file. self.raster_store.set_path(self.current_file_path.clone()); + // All raster keyframes are now persisted in the + // container, so none are dirty — clearing the flag + // lets eviction reclaim their pixels again. + { + use lightningbeam_core::layer::AnyLayer; + let mut resident_ids = Vec::new(); + let doc = self.action_executor.document_mut(); + for layer in doc.all_layers_mut() { + if let AnyLayer::Raster(rl) = layer { + for kf in rl.keyframes.iter_mut() { + kf.dirty = false; + if !kf.raw_pixels.is_empty() { + resident_ids.push(kf.id); + } + } + } + } + // Frames edited this session were unpinned from the + // eviction LRU while dirty; now that they're clean and + // persisted, re-track them so the resident bound applies + // (the next fault-in trims the oldest, not the shown one). + for id in resident_ids { + self.raster_resident_lru.retain(|x| *x != id); + self.raster_resident_lru.push_back(id); + } + } + // Add to recent files self.config.add_recent_file(path.clone()); update_recent_menu = true; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 3e91dfd..a9d81ed 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5821,6 +5821,9 @@ impl StagePane { kf.raw_pixels[si..si + 4].fill(0); } } + // Punching the hole edits raw_pixels but is NOT committed through an + // action yet — mark dirty so eviction can't drop the un-persisted hole. + kf.dirty = true; } // Re-set selection (commit_raster_floating_now cleared it) and create float.