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:
parent
2e07a88905
commit
39dc402ba3
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue