From d6b86a14b1dbfaffeb351f4becd9e5fe1027ca2b Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 9 Jul 2026 13:57:46 -0400 Subject: [PATCH] Save: skip re-encoding unchanged raster keyframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_beam re-encoded every resident raster keyframe to PNG (+ proxy) on every save, even untouched frames — the dominant per-save cost for painting/animation projects (the code noted this as deferred "Phase 3"). The infrastructure to do it incrementally already exists: kf.dirty means "current pixels not yet in the container" (set on any edit, cleared on a successful save, per main.rs), and it's preserved in the document clone the save worker receives. Gate the encode on it: a clean keyframe already stored keeps its full + proxy rows untouched; only dirty (or not-yet-stored) frames are re-encoded. Media blobs were already incremental; this closes the raster gap. No new data-loss risk: the mid-save-edit race (edit between the document clone and save completion) is pre-existing and identical to the old full-write path. --- .../lightningbeam-core/src/file_io.rs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 98129ee..d9c31a3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -399,16 +399,29 @@ 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.) + // Incremental: only (re)encode a keyframe whose pixels changed since the last save. + // `kf.dirty` means "current pixels are not yet in the container" (set on any edit, + // cleared on a successful save — see main.rs); a clean frame already stored is kept + // in place, skipping the PNG re-encode of every resident frame on every save. // 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_skipped = 0usize; 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() { + // Clean + already stored → keep the existing full + proxy rows untouched. + if !kf.dirty && txn.media_exists(kf.id)? { + live_media.insert(kf.id); + let proxy_id = raster_proxy_media_id(kf.id); + if txn.media_exists(proxy_id)? { + live_media.insert(proxy_id); + } + raster_skipped += 1; + continue; + } let img = crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height); match crate::brush_engine::encode_png(&img) { @@ -608,9 +621,10 @@ pub fn save_beam( } eprintln!( - "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media, {} orphans removed, in {:.2}ms", + "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media ({} unchanged frames skipped), {} orphans removed, in {:.2}ms", audio_pool_entries.len(), raster_count, + raster_skipped, removed, fn_start.elapsed().as_secs_f64() * 1000.0 );