Phase 3b: bound resident raster pixels with an eviction LRU

Scrubbing a large paint project no longer accumulates every visited frame in RAM.
A fault-in-recency LRU keeps the most-recently-paged-in RASTER_RESIDENT_MAX (12)
keyframes resident and drops the pixels of older *clean* ones (re-arming their
fault-in so they re-page on revisit). The shown frame is always the most-recent
fault-in, so it's never evicted.

Data-loss safety: a new `dirty` flag marks any keyframe whose `raw_pixels` were
mutated by editing (stroke/fill/paint-bucket/floating-lift + their undo/redo) and
is NOT yet in the container. Dirty keyframes are NEVER evicted — they're only
unpinned from the LRU. The flag is cleared on a successful save, which also re-arms
the LRU for the now-clean resident frames so the bound still applies to frames
edited this session.

Also: the save loop now walks all layers (incl. nested) to match the load path's
recursive fault-in arming — evicted frames keep their existing container row
(media_exists), and nested raster keyframes are persisted + covered by live_media.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-19 17:56:37 -04:00
parent 2e07a88905
commit 39dc402ba3
6 changed files with 90 additions and 1 deletions

View File

@ -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(())
}

View File

@ -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(())
}

View File

@ -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() {

View File

@ -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,
}
}
}

View File

@ -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<uuid::Uuid>,
/// 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<uuid::Uuid>,
/// 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<LayoutDefinition>,
@ -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<uuid::Uuid> = {
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;

View File

@ -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.