Save: skip re-encoding unchanged raster keyframes

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.
This commit is contained in:
Skyler Lehmkuhl 2026-07-09 13:57:46 -04:00
parent a2839f80b1
commit d6b86a14b1
1 changed files with 17 additions and 3 deletions

View File

@ -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
);