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);
|
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
|
||||||
kf.raw_pixels = self.buffer_after.clone();
|
kf.raw_pixels = self.buffer_after.clone();
|
||||||
kf.texture_dirty = true;
|
kf.texture_dirty = true;
|
||||||
|
kf.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ impl Action for RasterFillAction {
|
||||||
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
|
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
|
||||||
kf.raw_pixels = self.buffer_before.clone();
|
kf.raw_pixels = self.buffer_before.clone();
|
||||||
kf.texture_dirty = true;
|
kf.texture_dirty = true;
|
||||||
|
kf.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ impl Action for RasterStrokeAction {
|
||||||
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
||||||
kf.raw_pixels = self.buffer_after.clone();
|
kf.raw_pixels = self.buffer_after.clone();
|
||||||
kf.texture_dirty = true;
|
kf.texture_dirty = true;
|
||||||
|
kf.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ impl Action for RasterStrokeAction {
|
||||||
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
||||||
kf.raw_pixels = self.buffer_before.clone();
|
kf.raw_pixels = self.buffer_before.clone();
|
||||||
kf.texture_dirty = true;
|
kf.texture_dirty = true;
|
||||||
|
kf.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -386,8 +386,11 @@ pub fn save_beam(
|
||||||
// --- raster keyframes -> media rows (PNG), keyed by keyframe id ---
|
// --- raster keyframes -> media rows (PNG), keyed by keyframe id ---
|
||||||
// (Phase 0 writes all resident frames each save; a disk-dirty flag to skip
|
// (Phase 0 writes all resident frames each save; a disk-dirty flag to skip
|
||||||
// unchanged frames in place is deferred to Phase 3.)
|
// 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;
|
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 {
|
if let crate::layer::AnyLayer::Raster(rl) = layer {
|
||||||
for kf in &rl.keyframes {
|
for kf in &rl.keyframes {
|
||||||
if !kf.raw_pixels.is_empty() {
|
if !kf.raw_pixels.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,13 @@ pub struct RasterKeyframe {
|
||||||
/// set true on load and again when evicted. Never serialized.
|
/// set true on load and again when evicted. Never serialized.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub needs_fault_in: bool,
|
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 }
|
fn default_true() -> bool { true }
|
||||||
|
|
@ -147,6 +154,7 @@ impl RasterKeyframe {
|
||||||
raw_pixels: Vec::new(),
|
raw_pixels: Vec::new(),
|
||||||
texture_dirty: true,
|
texture_dirty: true,
|
||||||
needs_fault_in: false,
|
needs_fault_in: false,
|
||||||
|
dirty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1039,6 +1039,11 @@ struct EditorApp {
|
||||||
/// Keyframe ids whose page-in is currently running on a background thread
|
/// Keyframe ids whose page-in is currently running on a background thread
|
||||||
/// (prevents duplicate loads while the canvas keeps re-requesting them).
|
/// (prevents duplicate loads while the canvas keeps re-requesting them).
|
||||||
raster_loads_inflight: std::collections::HashSet<uuid::Uuid>,
|
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.)
|
/// Application configuration (recent files, etc.)
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
/// Remappable keyboard shortcut manager
|
/// Remappable keyboard shortcut manager
|
||||||
|
|
@ -1100,6 +1105,11 @@ enum ImportFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorApp {
|
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(
|
fn new(
|
||||||
cc: &eframe::CreationContext,
|
cc: &eframe::CreationContext,
|
||||||
layouts: Vec<LayoutDefinition>,
|
layouts: Vec<LayoutDefinition>,
|
||||||
|
|
@ -1308,6 +1318,7 @@ impl EditorApp {
|
||||||
raster_load_result_tx,
|
raster_load_result_tx,
|
||||||
raster_load_result_rx,
|
raster_load_result_rx,
|
||||||
raster_loads_inflight: Default::default(),
|
raster_loads_inflight: Default::default(),
|
||||||
|
raster_resident_lru: Default::default(),
|
||||||
keymap: KeymapManager::new(&config.keybindings),
|
keymap: KeymapManager::new(&config.keybindings),
|
||||||
config,
|
config,
|
||||||
file_command_tx,
|
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(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
||||||
let Some(kf) = rl.keyframe_at_mut(float.time) 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());
|
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
|
/// 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.raw_pixels = pixels;
|
||||||
kf.texture_dirty = true;
|
kf.texture_dirty = true;
|
||||||
any_applied = 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).
|
// Resolved (loaded, or genuinely absent/blank).
|
||||||
kf.needs_fault_in = false;
|
kf.needs_fault_in = false;
|
||||||
|
|
@ -5188,6 +5209,29 @@ impl eframe::App for EditorApp {
|
||||||
ctx.request_repaint();
|
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.
|
// 2) Dispatch background page-ins for newly-requested keyframes.
|
||||||
let wanted: Vec<uuid::Uuid> = {
|
let wanted: Vec<uuid::Uuid> = {
|
||||||
let mut s = self.raster_fault_requests.lock().unwrap();
|
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.
|
// raster paging store so future faults read the right file.
|
||||||
self.raster_store.set_path(self.current_file_path.clone());
|
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
|
// Add to recent files
|
||||||
self.config.add_recent_file(path.clone());
|
self.config.add_recent_file(path.clone());
|
||||||
update_recent_menu = true;
|
update_recent_menu = true;
|
||||||
|
|
|
||||||
|
|
@ -5821,6 +5821,9 @@ impl StagePane {
|
||||||
kf.raw_pixels[si..si + 4].fill(0);
|
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.
|
// Re-set selection (commit_raster_floating_now cleared it) and create float.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue