diff --git a/Cargo.lock b/Cargo.lock index 27fbbbc09c..ed5486be6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,8 +93,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.17.1", "static_assertions", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -650,7 +650,7 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -1021,7 +1021,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2372,8 +2372,8 @@ dependencies = [ "read-fonts", "roxmltree 0.21.1", "smallvec", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "yeslogic-fontconfig-sys", ] @@ -2572,7 +2572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.5", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2774,7 +2774,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.21", - "windows", + "windows 0.62.2", ] [[package]] @@ -2998,7 +2998,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -3355,15 +3355,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -3411,7 +3402,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3761,7 +3752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3899,6 +3890,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + [[package]] name = "maybe-owned" version = "0.3.4" @@ -4151,6 +4148,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -4563,6 +4569,16 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4825,7 +4841,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5064,6 +5080,7 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", + "photocraft-geom", "photocraft-io", "photocraft-raster", "photocraft-raw", @@ -5355,11 +5372,14 @@ dependencies = [ name = "photocraft-raster" version = "0.20.1" dependencies = [ + "crc32fast", + "lz4_flex", "photocraft-color", "photocraft-geom", "proptest", "rayon", "serde", + "sysinfo", ] [[package]] @@ -7359,6 +7379,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -8630,9 +8664,9 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows", - "windows-core", - "windows-result", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] @@ -8698,16 +8732,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -8716,7 +8772,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -8727,9 +8796,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -8738,9 +8818,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -8765,20 +8845,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -8787,9 +8883,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -8798,7 +8903,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -8807,7 +8921,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8834,7 +8948,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8853,13 +8967,22 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -8985,8 +9108,8 @@ dependencies = [ "strum", "tar", "thiserror 2.0.21", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "windows-implement", "xml", ] @@ -9159,7 +9282,7 @@ dependencies = [ "thiserror 2.0.21", "url", "widestring", - "windows", + "windows 0.62.2", "xcb", "zbus", ] diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index bda29cb2ea..60a3b488e4 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -1023,6 +1023,31 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } } +/// Conservative transient allocation estimate for filter admission. Output tiles remain in +/// the paged store; whole-image kernels must admit their float input before reading it. +pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: usize) -> u64 { + if area.is_empty() { + return 0; + } + #[cfg(not(target_arch = "wasm32"))] + let threads = rayon::current_num_threads().max(1) as u64; + #[cfg(target_arch = "wasm32")] + let threads = 1u64; + let tile = auto_tile(params, bounds).max(1) as u64; + let floats = (channels as u64).saturating_mul(4); + // Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area. + let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3); + match params.halo_for(bounds) { + Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)), + Halo::Radius(r) => { + let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2)); + base.saturating_add( + side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads), + ) + } + } +} + /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). /// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index a86fc52966..1e0c1dd149 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -53,9 +53,10 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec { let tc = TileCoord::containing(x, y); let x1 = (tc.rect().x1).min(area.x1); let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default(); - let src = s.tile(tc).and_then(|t| { + let guard = s.tile(tc).map(|t| t.bytes()); + let src = guard.as_deref().and_then(|bytes| { let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize; - t.bytes().get(base..base + dst.len()) + bytes.get(base..base + dst.len()) }); match src { Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]), @@ -115,7 +116,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn( let tr = tc.rect().intersect(&area); let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize); let mut tile = Arc::clone(blank); - let data = Arc::make_mut(&mut tile).bytes_mut(); + let mut data = Arc::make_mut(&mut tile).bytes_mut(); let (mut lo, mut hi) = (if tr == tc.rect() { u8::MAX } else { 0 }, 0u8); for y in tr.y0..tr.y1 { let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize; @@ -123,6 +124,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn( row((y - area.y0) as usize * w + lx, dst); (lo, hi) = dst.iter().fold((lo, hi), |(lo, hi), b| (lo.min(*b), hi.max(*b))); } + drop(data); (hi > 0).then(|| (*tc, if lo == u8::MAX { Arc::clone(&full) } else { tile })) }); s.put_tiles(built.into_iter().flatten()); diff --git a/crates/algo/src/selection/combine_mask.rs b/crates/algo/src/selection/combine_mask.rs index bbe4c65738..5c45ed8ff0 100644 --- a/crates/algo/src/selection/combine_mask.rs +++ b/crates/algo/src/selection/combine_mask.rs @@ -69,8 +69,10 @@ fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: b for y in cut.y0..cut.y1 { let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize; let len = cut.width() as usize; - let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue }; - let src = previous.and_then(|t| t.bytes().get(offset..offset + len)); + let mut data = tile.bytes_mut(); + let Some(dst) = data.get_mut(offset..offset + len) else { continue }; + let previous_bytes = previous.map(|t| t.bytes()); + let src = previous_bytes.as_ref().and_then(|t| t.get(offset..offset + len)); let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize)); for (i, d) in dst.iter_mut().enumerate() { let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0); @@ -86,7 +88,7 @@ fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: b let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b))); let tile = if lo == out_default && hi == out_default { None - } else if previous.is_some_and(|p| p.bytes() == tile.bytes()) { + } else if previous.is_some_and(|p| *p.bytes() == *tile.bytes()) { previous.cloned() } else if lo == u8::MAX { Some(Arc::clone(&full)) diff --git a/crates/algo/src/selection/inverse.rs b/crates/algo/src/selection/inverse.rs index e92cc918d2..8616ac3111 100644 --- a/crates/algo/src/selection/inverse.rs +++ b/crates/algo/src/selection/inverse.rs @@ -53,14 +53,14 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { let cell = cache.get(&(Arc::as_ptr(input) as usize))?; let tile = cell.get_or_init(|| { if format == PixelFormat::GRAY8 { - if input.bytes() == full.bytes() { + if *input.bytes() == *full.bytes() { return None; } - if input.bytes() == blank.bytes() { + if *input.bytes() == *blank.bytes() { return Some(Arc::clone(&full)); } let mut tile = Tile::clone(input); - for b in tile.bytes_mut() { + for b in tile.bytes_mut().iter_mut() { *b = 255 - *b; } return Some(Arc::new(tile)); @@ -78,7 +78,8 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize); for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize { let start = y * TILE_SIZE as usize + x0; - if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) { + let mut data = tile.bytes_mut(); + if let Some(row) = data.get_mut(start..start + x1 - x0) { for (i, b) in (start..).zip(row) { *b = input.map_or(default, |t| value(t, i)); } @@ -91,9 +92,9 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { } fn canonical(tile: Tile, blank: &Arc, full: &Arc) -> Option> { - if tile.bytes() == blank.bytes() { + if *tile.bytes() == *blank.bytes() { None - } else if tile.bytes() == full.bytes() { + } else if *tile.bytes() == *full.bytes() { Some(Arc::clone(full)) } else { Some(Arc::new(tile)) diff --git a/crates/algo/src/selection/lasso.rs b/crates/algo/src/selection/lasso.rs index 2471a379b5..a13e4de1df 100644 --- a/crates/algo/src/selection/lasso.rs +++ b/crates/algo/src/selection/lasso.rs @@ -202,7 +202,8 @@ pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option { } for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::().0) { let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize; - let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue }; + let mut data = tile.bytes_mut(); + let Some(dst) = data.get_mut(base..base + r.width() as usize) else { continue }; for (d, v) in dst.iter_mut().zip(row) { *d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index c4830ae134..7afd788fdc 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -217,19 +217,25 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3 } } let mut tile = Tile::clone(&blank); - let data = tile.bytes_mut(); + let mut data = tile.bytes_mut(); + let same_tile = ta.zip(tb).is_some_and(|(left, right)| Arc::ptr_eq(left, right)); + let read_a = ta.map(|tile| tile.bytes()); + let read_b = tb.filter(|_| !same_tile).map(|tile| tile.bytes()); + let bytes_a = read_a.as_deref(); + let bytes_b = if same_tile { bytes_a } else { read_b.as_deref() }; let (mut lo, mut hi) = (if tr == rect { u8::MAX } else { 0 }, 0u8); let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize); for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize { let (s, e) = (ly * TS + x0, ly * TS + x1); - let src_a = ta.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_a[..x1 - x0]); - let src_b = tb.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_b[..x1 - x0]); + let src_a = bytes_a.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_a[..x1 - x0]); + let src_b = bytes_b.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_b[..x1 - x0]); let Some(dst) = data.get_mut(s..e) else { continue }; for (d, (x, y)) in dst.iter_mut().zip(src_a.iter().zip(src_b)) { *d = pair(*x, *y); (lo, hi) = (lo.min(*d), hi.max(*d)); } } + drop(data); (hi > 0).then(|| (tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) })) }); out.put_tiles(built.into_iter().flatten()); @@ -258,15 +264,17 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) let tr = rect.intersect(&area); let tb = b.tile(tc)?; let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched)); - let data = tile.bytes_mut(); + let mut data = tile.bytes_mut(); + let source = tb.bytes(); let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize); for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize { let (s, e) = (ly * TS + x0, ly * TS + x1); - let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue }; + let (Some(dst), Some(src)) = (data.get_mut(s..e), source.get(s..e)) else { continue }; for (d, y) in dst.iter_mut().zip(src) { *d = pair(*d, *y); } } + drop((data, source)); let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); Some(( tc, diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 57b7428330..8a2e6a272d 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -4,6 +4,18 @@ mod ext; mod mosaic; mod selection_mix; +#[test] +fn relight_admission_tracks_the_document_dependent_working_set() { + let params = FilterParams::Relight { angle: 0.0, elevation: 45.0, intensity: 50.0, ambient: 50.0, warmth: 0.0, softness: 100.0 }; + let small = Rect::new(0, 0, 128, 128); + let large = Rect::new(0, 0, 86400, 43200); + assert!(working_bytes(¶ms, small, small, 4) < working_bytes(¶ms, small, large, 4)); + assert_eq!(working_bytes(¶ms, Rect::EMPTY, large, 4), 0); + // A tiny image must not reserve the 256 MiB group budget three times over. + let tiny = Rect::new(0, 0, 24, 18); + assert!(working_bytes(¶ms, tiny, tiny, 4) < 64 << 20, "{}", working_bytes(¶ms, tiny, tiny, 4)); +} + fn fmt(s: SampleType) -> PixelFormat { PixelFormat::new(ColorMode::Rgb, s, true) } diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index ba073af2ff..e72b38f760 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -29,6 +29,7 @@ cap-std = "4" clap = { workspace = true } [dev-dependencies] +photocraft-geom = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index a74af99241..fddd15ca96 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -58,14 +58,20 @@ pub fn previews(doc: &Document) -> SaveOptions { } /// [`previews`], plus the full-size composite when `opts` asks for it and it can be rendered -/// ([`ExportOptions::pcraft_full_composite`]). -fn native_options(doc: &Document, opts: &ExportOptions) -> SaveOptions { +/// ([`ExportOptions::pcraft_full_composite`]). Fails when a scratch tile of `doc` could not be +/// read while they were rendered: the previews may then hold placeholder pixels, and the +/// bundle's own scratch checks only cover reads made after it starts. +fn native_options(doc: &Document, opts: &ExportOptions) -> Result { + doc.scratch_health().map_err(AutomationError::Io)?; + let generation = photocraft_raster::spill::read_error_generation(); let mut out = previews(doc); if opts.pcraft_full_composite { out.full_composite = photocraft_io::pcraft_full_composite(doc); out.full_composite_icc = out.full_composite.as_ref().map(|_| photocraft_io::pcraft_full_composite_icc(doc)); } - out + photocraft_raster::spill::check_since(generation).map_err(AutomationError::Io)?; + doc.scratch_health().map_err(AutomationError::Io)?; + Ok(out) } /// Encode the name the saved native bundle will report on reopening. Save As updates @@ -97,7 +103,7 @@ pub fn save( let named = named_for_output(doc, &path.to_string_lossy()); let mut local = PcraftWriter::new(); let w = writer.unwrap_or(&mut local); - w.save_path(&named, path, &native_options(&named, opts))?; + w.save_path(&named, path, &native_options(&named, opts)?)?; return Ok(Vec::new()); } let r = photocraft_io::export(doc, &ext, opts)?; @@ -115,7 +121,7 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt .ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?; if ext == photocraft_format::EXTENSION { let named = named_for_output(doc, name); - return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts))?, Vec::new())); + return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?, Vec::new())); } let result = photocraft_io::export(doc, &ext, opts)?; Ok((result.bytes, result.warnings)) diff --git a/crates/automation/tests/scratch_save.rs b/crates/automation/tests/scratch_save.rs new file mode 100644 index 0000000000..e61c607e8f --- /dev/null +++ b/crates/automation/tests/scratch_save.rs @@ -0,0 +1,55 @@ +//! A scripted `.pcraft` save refuses to write previews or a full-size composite rendered from +//! scratch tiles that could not be read. Its own test binary: the scratch store is per process. +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_automation::{AutomationError, files}; +use photocraft_doc::{ColorMode, Document, Layer, PixelFormat, SampleType, Size}; +use photocraft_geom::TileCoord; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn native_save_refuses_previews_rendered_from_unreadable_scratch() { + let dir = std::env::temp_dir().join(format!("photocraft-automation-scratch-save-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Scratch", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + for i in 0..2 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, byte) in bytes.iter_mut().enumerate() { + *byte = (j as u8).wrapping_mul(29).wrapping_add((i * 7) as u8 + 1); + } + } + doc.layers.push(layer); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + + let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() }; + // Refused while rendering the previews, before the bundle is assembled. + let err = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + let path = dir.join("Scratch.pcraft"); + let err = files::save(&doc, &path, None, &on, None).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + assert!(!path.exists()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for (_, tile) in doc.layers[0].surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + assert!(spill::check_integrity().is_ok()); + let (bytes, _) = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().layers, doc.layers); + drop((doc, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index 985a400e08..d6a3a88ef1 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -34,6 +34,12 @@ fn cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP })) } +pub(crate) fn purge() { + let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner()); + cache.entries.clear(); + cache.sweep_at = MIN_SWEEP; +} + /// `Surface::content_bounds`, cached per tile. pub fn content_bounds(s: &Surface) -> Rect { if s.tile_count() == 0 { @@ -54,8 +60,11 @@ pub fn content_bounds(s: &Surface) -> Rect { let b = match hit { Some(b) => b, None => { + let generation = photocraft_raster::spill::read_error_generation(); let b = t.content_bounds(&dp); - c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + if photocraft_raster::spill::check_since(generation).is_ok() && t.scratch_health().is_ok() { + c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + } b } }; diff --git a/crates/compose/src/fx_cache.rs b/crates/compose/src/fx_cache.rs index 43e524d37f..b24aabc29b 100644 --- a/crates/compose/src/fx_cache.rs +++ b/crates/compose/src/fx_cache.rs @@ -122,6 +122,21 @@ pub fn purge_effect_cache() -> usize { freed } +/// Discard derived pixels and their source pins once after each new scratch read failure. +/// This lets closing the affected document release its failed tiles instead of keeping them +/// in a cache. Caches never admit results built from unreadable tiles, so healthy documents' +/// caches are not purged again on every call while one document stays unreadable. +pub fn discard_unreadable_pixel_caches() { + static SEEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let generation = photocraft_raster::spill::read_error_generation(); + if SEEN.swap(generation, std::sync::atomic::Ordering::AcqRel) != generation && photocraft_raster::spill::any_failed() { + purge_effect_cache(); + super::bounds::purge(); + super::masks::purge(); + super::shape_split::purge(); + } +} + fn fx_cache() -> &'static std::sync::Mutex { static C: std::sync::OnceLock> = std::sync::OnceLock::new(); C.get_or_init(|| std::sync::Mutex::new(FxCache { map: Default::default(), order: Default::default(), bytes: 0 })) @@ -218,13 +233,22 @@ pub(crate) fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc } fn build_fx_slot_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx, slot: &FxSlot) -> std::sync::Arc { + let generation = photocraft_raster::spill::read_error_generation(); let shape: Vec = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns); let mut pin = Vec::new(); - layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t))); + let mut readable = true; + layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| { + readable &= t.scratch_health().is_ok(); + pin.push(std::sync::Arc::downgrade(t)); + }); // The maps, plus what each reserved tile address keeps allocated. let bytes = maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::() + 2 * std::mem::size_of::())); let maps = std::sync::Arc::new(maps); + if !readable || photocraft_raster::spill::check_since(generation).is_err() { + // Built from a scratch placeholder: use it for this frame, never cache it. + return maps; + } if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() { // Counted exactly once, by the build that filled the slot, and only while the // slot is still cached (eviction may have dropped it meanwhile). diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 25dc55984a..9039a2c621 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -68,7 +68,9 @@ pub use extents::{ }; #[cfg(test)] use extents::{mask_k, shapeless_stroke_bounds, trim_to_painted}; -pub use fx_cache::{DEFAULT_FX_CACHE_BUDGET, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget}; +pub use fx_cache::{ + DEFAULT_FX_CACHE_BUDGET, discard_unreadable_pixel_caches, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget, +}; #[cfg(test)] use fx_cache::{effect_maps, effect_region, fx_region_bytes}; pub use reduced::{PROXY_THUMBNAIL_PIXELS, render_reduced, render_reduced_damage, render_reduced_rect, thumbnail, thumbnail_buffer, thumbnail_size}; diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index 851c9bc509..faf45a19d0 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -81,6 +81,12 @@ fn cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(Cache::default())) } +pub(crate) fn purge() { + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + c.clear(); + c.in_flight.clear(); +} + /// Entries kept (least recently used dropped first). const CAPACITY: usize = 64; @@ -116,7 +122,11 @@ fn lookup(c: &Mutex, k: u64) -> CachedMask { fn finish_mask(c: &Mutex, k: u64, slot: &Arc>, generation: u64, build: impl FnOnce() -> (Surface, Option)) -> Surface { slot.get_or_init(|| { // Never hold the global cache mutex while rasterizing or feathering. + let read_generation = photocraft_raster::spill::read_error_generation(); let (surface, pixel_input) = build(); + if photocraft_raster::spill::check_since(read_generation).is_err() || pixel_input.as_ref().is_some_and(|p| p.scratch_health().is_err()) { + return surface; + } let bytes = entry_bytes(&surface, pixel_input.as_ref()); let mut c = c.lock().unwrap_or_else(|e| e.into_inner()); if c.in_flight.get(&k).and_then(Weak::upgrade).is_some_and(|active| Arc::ptr_eq(&active, slot)) { @@ -135,7 +145,7 @@ fn cached_mask(c: &Mutex, k: u64, build: impl FnOnce() -> (Surface, Optio } fn surface_bytes(surface: &Surface) -> usize { - surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.bytes().len())) + surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.len())) } fn entry_bytes(surface: &Surface, pixel_input: Option<&Surface>) -> usize { diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index c6e76fb0b2..53875d5922 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -21,6 +21,10 @@ fn cache() -> &'static Mutex<(HashMap, u64)> { C.get_or_init(|| Mutex::new((HashMap::new(), 0))) } +pub(crate) fn purge() { + cache().lock().unwrap_or_else(|e| e.into_inner()).0.clear(); +} + const CAPACITY: usize = 32; /// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces, /// so 32 of them at 24 MP could hold several GB outside every memory budget. @@ -32,7 +36,7 @@ const BUDGET: usize = 8 << 20; /// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's). fn entry_bytes(parts: &(Surface, Surface)) -> usize { - [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::()).sum() + [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.len()).sum::()).sum() } /// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may @@ -49,6 +53,7 @@ pub fn purge_cache() -> usize { /// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is /// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects). pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> { + let generation = photocraft_raster::spill::read_error_generation(); sh.stroke.as_ref()?; let mut h = std::collections::hash_map::DefaultHasher::new(); (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); @@ -78,6 +83,9 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac if let Some(cache) = &sh.cache { fit_stroke(&parts.0, &mut parts.1, cache, canvas); } + if photocraft_raster::spill::check_since(generation).is_err() || sh.cache.as_ref().is_some_and(|c| c.scratch_health().is_err()) { + return Some(parts); + } let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); // Least recently used first, until the new entry fits both the count and the byte budget. let new_bytes = entry_bytes(&parts); diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs index 02bc4a4111..35ddd5b4cc 100644 --- a/crates/compose/src/uniform.rs +++ b/crates/compose/src/uniform.rs @@ -85,7 +85,8 @@ impl<'a> UniformAreas<'a> { Some(v) => v, None => { let bpp = s.default_bytes().len().max(1); - let mut px = t.bytes().chunks_exact(bpp); + let bytes = t.bytes(); + let mut px = bytes.chunks_exact(bpp); let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from); self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone()); v diff --git a/crates/compose/tests/scratch_retry.rs b/crates/compose/tests/scratch_retry.rs new file mode 100644 index 0000000000..70fdc41225 --- /dev/null +++ b/crates/compose/tests/scratch_retry.rs @@ -0,0 +1,49 @@ +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Effect, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn failed_effect_build_is_not_reused_after_original_pixels_recover() { + let dir = std::env::temp_dir().join(format!("photocraft-compose-scratch-retry-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Shadow", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut background = Layer::raster("Background", PixelFormat::RGBA8); + background.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 64, 64), &[1.0; 4]); + let mut foreground = Layer::raster("Shadow", PixelFormat::RGBA8); + foreground.surface_mut().unwrap().fill_rect(Rect::new(16, 16, 48, 48), &[1.0, 0.0, 0.0, 1.0]); + foreground.effects.items.push(Effect::default_drop_shadow()); + doc.layers.extend([background, foreground]); + let expected = photocraft_compose::flatten(&doc).px.clone(); + // Keep the known-good bounds, but force a new effect-map build from the damaged source. + photocraft_compose::purge_effect_cache(); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + drop(photocraft_compose::flatten(&doc)); + assert!(spill::check_integrity().is_err()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for layer in &doc.layers { + for (_, tile) in layer.surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + } + assert!(spill::check_integrity().is_ok()); + assert_eq!(photocraft_compose::flatten(&doc).px, expected); + photocraft_compose::purge_effect_cache(); + drop((doc, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ab13697e73..915ddca7da 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1064,6 +1064,58 @@ impl Document { max } + /// Every pixel surface the document holds: layer pixels and caches, masks, smart filter + /// masks, video frames (including the originals), channels, the quick mask, patterns and + /// the selection. History snapshots and saves cover exactly these tiles. + pub fn for_each_surface(&self, mut visit: impl FnMut(&Surface)) { + for (_, _, layer) in self.walk() { + if let Some(surface) = layer.surface() { + visit(surface); + } + if let Some(mask) = &layer.mask { + visit(&mask.surface); + } + if let Some(cache) = &layer.fill_cache { + visit(&cache.surface); + } + if let LayerContent::Smart(smart) = &layer.content + && let Some(mask) = &smart.filter_mask + { + visit(&mask.surface); + } + if let Some(video) = &layer.video { + for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { + visit(frame); + } + } + } + for channel in self.channels.iter().chain(&self.quick_mask) { + visit(&channel.surface); + } + for pattern in &self.patterns { + visit(&pattern.surface); + } + if let Some(selection) = &self.selection { + visit(selection); + } + } + + /// `Err` if any of this document's tiles is unreadable on the scratch disk. Edits, saves + /// and exports of this document check it; other documents are unaffected. Free when no + /// tile in the process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !photocraft_raster::spill::any_failed() { + return Ok(()); + } + let mut health = Ok(()); + self.for_each_surface(|surface| { + if health.is_ok() { + health = surface.scratch_health(); + } + }); + health + } + /// Depth-first walk yielding `(path, depth, layer)` bottom-to-top. pub fn walk(&self) -> Vec<(LayerPath, usize, &Layer)> { fn rec<'a>(layers: &'a [Layer], prefix: &mut LayerPath, out: &mut Vec<(LayerPath, usize, &'a Layer)>) { diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index 7b766ed084..5fb2d7d0f2 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -659,24 +659,29 @@ pub fn convert_surface(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transf .collect(); let tile_px = (photocraft_geom::TILE_SIZE * photocraft_geom::TILE_SIZE) as usize; let dst_len = tile_px * to.bytes_per_pixel(); - let mut staging = vec![0u8; dst_len * distinct.len()]; - let jobs: Vec<(&[u8], &mut [u8])> = distinct.iter().map(|t| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect(); let kind = match sf.sample { SampleType::U8 => SampleKind::U8, SampleType::U16 => SampleKind::U16, SampleType::F32 => SampleKind::F32, }; - t.convert_bytes_many(kind, jobs, ss, ds, true); - let mut converted: Vec>> = vec![None; distinct.len()]; - for (c, i) in tiles { - if let Some(Some(tile)) = converted.get(i) { - out.put_tiles([(c, tile.clone())]); - continue; + let mut converted = Vec::with_capacity(distinct.len()); + for batch in distinct.chunks(32) { + let mut staging = vec![0u8; dst_len * batch.len()]; + { + let sources: Vec<_> = batch.iter().map(|tile| tile.bytes()).collect(); + let jobs: Vec<(&[u8], &mut [u8])> = sources.iter().map(|bytes| &**bytes).zip(staging.chunks_mut(dst_len.max(1))).collect(); + t.convert_bytes_many(kind, jobs, ss, ds, true); } - let Some(bytes) = staging.chunks(dst_len.max(1)).nth(i) else { continue }; - out.tile_mut(c).bytes_mut().copy_from_slice(bytes); - if let Some(slot) = converted.get_mut(i) { - *slot = out.tile(c).cloned(); + for bytes in staging.chunks(dst_len.max(1)) { + let coord = photocraft_geom::TileCoord::new(0, 0); + let mut surface = Surface::new(to); + surface.tile_mut(coord).bytes_mut().copy_from_slice(bytes); + converted.push(surface.tile(coord).cloned()); + } + } + for (coord, index) in tiles { + if let Some(Some(tile)) = converted.get(index) { + out.put_tiles([(coord, tile.clone())]); } } out @@ -847,7 +852,7 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { let (mut px, mut o) = ([0.0f32; 2], [0.0f32; 4]); for i in 0..n { for (k, v) in px.iter_mut().enumerate().take(ss) { - *v = photocraft_color::read_sample(src, sf.sample, i * ss + k); + *v = photocraft_color::read_sample(&src, sf.sample, i * ss + k); } ink(&px[..ss], &mut o[..ds]); for (k, v) in o.iter().enumerate().take(ds) { @@ -858,7 +863,7 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { }) .collect(); for ((c, _), bytes) in tiles.iter().zip(inked) { - let tile = out.tile_mut(*c).bytes_mut(); + let mut tile = out.tile_mut(*c).bytes_mut(); if tile.len() == bytes.len() { tile.copy_from_slice(&bytes); } diff --git a/crates/engine/src/cmd/color_cmds/profile_dither.rs b/crates/engine/src/cmd/color_cmds/profile_dither.rs index 8f99f49dbe..4bc5854ac0 100644 --- a/crates/engine/src/cmd/color_cmds/profile_dither.rs +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -122,7 +122,7 @@ fn dither_tile(s: &Surface, out: &Surface, t: &Transform, bounds: Rect, c: TileC for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() { let x = x0 + (index % TILE_SIZE as usize) as i32; let y = y0 + (index / TILE_SIZE as usize) as i32; - let sp = match source { + let sp = match &source { Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?, None => default, }; diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index aee1ecc7e0..a74edb818b 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -14,7 +14,7 @@ fn history_bytes(s: &Session, all: bool) -> usize { } fn clip_bytes(s: &Session) -> usize { - s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.bytes().len()).sum()) + s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.len()).sum()) } pub(super) fn purge(s: &mut Session, what: &str) -> Result { diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index 7da8abccad..e523b4b5e6 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -440,7 +440,7 @@ fn visible_bounds(s: &photocraft_raster::Surface, canvas: Rect) -> Rect { } out }; - tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b)) + tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), &t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b)) } // ---------- layers ---------- diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 75529715ad..88bbafe878 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -415,7 +415,7 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf .try_clone() .map_err(|e| EngineError::Other(format!("not enough memory while blending a pixel tile ({e}); the document was not changed")))?, }; - blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); + blend_tile(&mut tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, std::sync::Arc::new(tile))); Ok(()) })?; @@ -426,7 +426,8 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf /// The selection's coverage over one tile (its channel 0). pub(crate) struct Mask<'a> { - bytes: &'a [u8], + bytes: Option>, + default: &'a [u8], fmt: photocraft_color::PixelFormat, origin: Rect, } @@ -434,13 +435,14 @@ pub(crate) struct Mask<'a> { impl<'a> Mask<'a> { /// `sel` over the tile at `tc` (its default pixel where it has no tile there). pub(crate) fn of(sel: &'a Surface, tc: photocraft_geom::TileCoord) -> Self { - Mask { bytes: sel.tile(tc).map_or(sel.default_bytes(), |t| t.bytes()), fmt: sel.format(), origin: tc.rect() } + Mask { bytes: sel.tile(tc).map(|t| t.bytes()), default: sel.default_bytes(), fmt: sel.format(), origin: tc.rect() } } pub(crate) fn at(&self, x: i32, y: i32) -> f32 { + let bytes = self.bytes.as_deref().unwrap_or(self.default); let bpp = self.fmt.bytes_per_pixel(); // A default-pixel mask is one pixel long: every position reads it. - let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; - self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) + let i = if bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; + bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) } /// Fully selected over `r`? pub(crate) fn full(&self, r: Rect) -> bool { diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index 19ac191900..bb93f233cb 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -174,9 +174,10 @@ fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha { return Ok(Some((tc, t.clone()))); } - let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes()); + let src_guard = src_tile.map(|t| t.bytes()); + let src_bytes: &[u8] = src_guard.as_deref().unwrap_or(base.default_bytes()); let mut out = piece.try_default_tile().map_err(oom)?; - let out_bytes = out.bytes_mut(); + let mut out_bytes = out.bytes_mut(); let mut px = [0.0f32; 8]; let mut has_content = false; for y in tr.y0..tr.y1 { @@ -224,6 +225,7 @@ fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha has_content |= non_zero; } } + drop(out_bytes); Ok(has_content.then(|| (tc, Arc::new(out)))) }) .collect::>>()?; diff --git a/crates/engine/src/cmd/history_cmds.rs b/crates/engine/src/cmd/history_cmds.rs index ba639876a3..d7b3fc8207 100644 --- a/crates/engine/src/cmd/history_cmds.rs +++ b/crates/engine/src/cmd/history_cmds.rs @@ -509,7 +509,7 @@ mod tests { } fn tile_bytes(doc: &Document) -> usize { - doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).sum() + doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.len())).sum() } /// A Merged Layers snapshot is a flattened copy only it holds (#4530): it counts in the diff --git a/crates/engine/src/cmd/lens_cmds.rs b/crates/engine/src/cmd/lens_cmds.rs index b0061cf89f..1192281744 100644 --- a/crates/engine/src/cmd/lens_cmds.rs +++ b/crates/engine/src/cmd/lens_cmds.rs @@ -283,6 +283,8 @@ fn raw_enabled(s: &Session) -> std::result::Result<(), String> { /// Runs `f` on the target layer (pixel layer edited in place inside the selection; smart object /// gets a smart filter with `stored` params). fn run_filter(s: &mut Session, cmd: &str, label: &str, p: &Value, stored: Value, float_bg: bool, f: &dyn Fn(&Surface, Rect) -> Surface) -> Result { + let state = s.active().ok_or(EngineError::NoDocument)?; + let _working = photocraft_raster::memory::try_reserve_operation(state.doc.size.area().saturating_mul(64)).map_err(EngineError::Other)?; let id = target(s, p)?; let colors = crate::color_channels::target(s, p)?.is_some(); let mut stored = stored; diff --git a/crates/engine/src/cmd/smart_cmds/stack_cache.rs b/crates/engine/src/cmd/smart_cmds/stack_cache.rs index 2822f01027..372d30eaab 100644 --- a/crates/engine/src/cmd/smart_cmds/stack_cache.rs +++ b/crates/engine/src/cmd/smart_cmds/stack_cache.rs @@ -95,5 +95,5 @@ pub fn purge() -> usize { pub(super) fn surface_bytes(s: &Surface) -> usize { // Tiles shared with the document (or another stage) are charged anyway: the cache is what // keeps them alive once the document moves on. - s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.bytes().len())) + s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.len())) } diff --git a/crates/engine/src/cmd/timeline_cmds.rs b/crates/engine/src/cmd/timeline_cmds.rs index 7893ac8d8f..9b6e0623cc 100644 --- a/crates/engine/src/cmd/timeline_cmds.rs +++ b/crates/engine/src/cmd/timeline_cmds.rs @@ -31,6 +31,7 @@ fn has_timeline(s: &Session) -> std::result::Result<(), String> { /// Mutate the document timeline without a history step (navigation / metadata). fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let generation = photocraft_raster::spill::read_error_generation(); let mut doc = (*st.doc).clone(); crate::video_cmds::store(&mut doc); f(&mut doc.timeline); @@ -38,6 +39,8 @@ fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Resu t.clamp(); } crate::video_cmds::sync(&mut doc); + // Storing and syncing video frames bypasses edit_with: apply the same scratch guard. + photocraft_raster::spill::check_since(generation).and_then(|()| doc.scratch_health()).map_err(EngineError::Other)?; st.doc = Arc::new(doc); st.revision += 1; info(s) diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index 9c29f9e3e0..c322da8e26 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -641,6 +641,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0), }; if all_opaque { + drop(tb); return Some((tc, top_tile)); } let all_transparent = match fmt.sample { @@ -658,6 +659,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) > 0.0 || px.iter().all(|&b| b == 0)), }; if clean_zero { + drop(tb); return Some((tc, top_tile)); } } diff --git a/crates/engine/src/color_channels.rs b/crates/engine/src/color_channels.rs index c0ec66fc6c..c19c7e389c 100644 --- a/crates/engine/src/color_channels.rs +++ b/crates/engine/src/color_channels.rs @@ -163,6 +163,7 @@ fn restrict_surface_impl(before: &Surface, after: &mut Surface, colors: ColorCha let mut merged = before.clone(); for coord in coords { let source = after.tile(coord); + let source_bytes = source.map(|t| t.bytes()); let dest = merged.try_tile_mut(coord).map_err(|e| EngineError::Other(e.to_string()))?; let rect = coord.rect(); let width = rect.width() as usize; @@ -174,7 +175,8 @@ fn restrict_surface_impl(before: &Surface, after: &mut Surface, colors: ColorCha continue; } let offset = i.saturating_mul(warped.bytes_per_pixel()); - let input = source.and_then(|t| t.bytes().get(offset..offset.saturating_add(warped.bytes_per_pixel()))).unwrap_or(after.default_bytes()); + let input = + source_bytes.as_deref().and_then(|t| t.get(offset..offset.saturating_add(warped.bytes_per_pixel()))).unwrap_or(after.default_bytes()); for channel in colors.indices() { let offset = channel.saturating_mul(bytes); let range = offset..offset.saturating_add(bytes); @@ -229,9 +231,10 @@ fn restrict_coords(before: &Surface, after: &mut Surface, colors: ColorChannels, } for c in coords { let old = before.tile(c); + let old_bytes = old.map(|t| t.bytes()); let tile = after.try_tile_mut(c).map_err(|e| EngineError::Other(e.to_string()))?; for (i, pixel) in tile.bytes_mut().chunks_exact_mut(stride).enumerate() { - let original = old.and_then(|t| t.bytes().get(i * stride..(i + 1) * stride)).unwrap_or(before.default_bytes()); + let original = old_bytes.as_deref().and_then(|t| t.get(i * stride..(i + 1) * stride)).unwrap_or(before.default_bytes()); for (ch, (out, src)) in pixel.chunks_exact_mut(bytes).zip(original.chunks_exact(bytes)).enumerate() { if !colors.contains(ch) { out.copy_from_slice(src); @@ -329,6 +332,7 @@ pub(crate) fn scatter_selected( } .map_err(|e| EngineError::Other(e.to_string()))?; let src = plane.tile(coord); + let src_bytes = src.map(|t| t.bytes()); let default = plane.default_bytes(); let stride = fmt.bytes_per_pixel(); let size = photocraft_geom::TILE_SIZE as usize; @@ -339,7 +343,7 @@ pub(crate) fn scatter_selected( continue; } let offset = i * fmt.sample.bytes(); - let bytes = src.and_then(|t| t.bytes().get(offset..offset + fmt.sample.bytes())).unwrap_or(default); + let bytes = src_bytes.as_deref().and_then(|t| t.get(offset..offset + fmt.sample.bytes())).unwrap_or(default); let v = read_sample(bytes, fmt.sample, 0); let v = if fmt.mode == ColorMode::Cmyk { 1.0 - v } else { v }; write_sample(pixel, fmt.sample, component, v); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 2bcccb7a5c..9e73d10811 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -259,6 +259,8 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result &label, move |doc, _, ctx| { let filter = |surf: &photocraft_raster::Surface, fp: &FilterParams, area, bounds, sel: Option<&photocraft_raster::Surface>, extent| { + let _working = photocraft_raster::memory::try_reserve_operation(algo::working_bytes(fp, area, bounds, surf.format().channels())) + .map_err(EngineError::Other)?; ctx.stage(0.0, 1.0, &msg, |ctl| algo::apply_in_with(surf, fp, area, bounds, sel, extent, ctl)).ok_or(EngineError::Cancelled) }; let selection = doc.selection.clone(); diff --git a/crates/engine/src/filters/tests.rs b/crates/engine/src/filters/tests.rs index d88123df52..4d21d2dfdf 100644 --- a/crates/engine/src/filters/tests.rs +++ b/crates/engine/src/filters/tests.rs @@ -79,6 +79,28 @@ const ALL: [&str; 24] = [ "filter.other.offset", ]; +#[test] +fn enormous_global_filter_is_refused_before_allocating_and_preserves_document() { + let mut s = Session::new(); + // A sparse document exercises admission without allocating a giant background first. + s.add_document( + photocraft_doc::Document::new("large", photocraft_doc::Size::new(300000, 300000), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8), + None, + ); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("one pixel", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let err = s.execute("filter.distort.twirl", json!({})).unwrap_err().to_string(); + assert!(err.contains("working memory"), "{err}"); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().doc, before); +} + #[test] fn every_filter_command_runs_changes_pixels_and_undoes() { for id in ALL { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5ebe45599c..7c64b74fa5 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -377,6 +377,18 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + // The job reads the active document it captured; only that document's pixels gate it. + let source = s.active().map(|d| d.doc.clone()); + let work = move |ctx: &JobCtx| -> Result { + photocraft_compose::discard_unreadable_pixel_caches(); + let health = || source.as_deref().map_or(Ok(()), photocraft_doc::Document::scratch_health).map_err(EngineError::Other); + health()?; + let generation = photocraft_raster::spill::read_error_generation(); + let value = work(ctx)?; + photocraft_raster::spill::check_since(generation).map_err(EngineError::Other)?; + health()?; + Ok(value) + }; let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); let _authorization = AuthorizationScope::enter(authorization); // The job's document: stored paths it follows are resolved against it on any thread. @@ -813,6 +825,7 @@ impl Session { // The offer belongs to this one dispatch. Early errors drop it locally, including // authorization, validation, floating-selection and admission failures. let live_commit = self.live_commit.take(); + self.sync_scratch_state(); let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; // Params are named fields: anything but an object (or null, meaning none) is a caller // mistake, not "use every default". diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b49675176e..1b370ace46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,6 +37,7 @@ pub mod prefs; pub mod preset_store; pub mod presets; pub mod proof_sim; +mod scratch; pub mod select_mask_edits; pub mod shell_commands; pub mod shortcut_sets; @@ -282,6 +283,11 @@ pub struct Session { /// document, source read-only)`. The shell takes them with [`Session::take_held_open`]. held_opens: Vec<(u64, Document, bool)>, next_held_open: u64, + /// Shared native memory supervision, owned and stopped by the last session using it. + scratch_runtime: Option, + /// [`photocraft_raster::spill::epoch`] when History's byte limits were last applied: a + /// scratch disk that pauses or resumes changes the limit ([`Session::sync_scratch_state`]). + scratch_epoch: u64, /// The document before the latest [`Session::edit`] of the running command, which /// [`Session::hint_damage`] checks a hint against. Set by `edit`, taken by `hint_damage`, /// dropped when the command ends. Debug and test builds only. diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index f95a3feb0d..3f2274cef6 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -479,6 +479,7 @@ fn edit_tiles( None => {} } let Some(src) = current.map(|t| &**t).or(blank.as_ref()) else { return Ok(None) }; + let source = src.try_bytes().map_err(|e| crate::EngineError::Other(format!("could not read pixels while {purpose}: {e}")))?; let mut out: Option = None; let (mut px, mut enc) = ([0.0f32; 8], [0u8; 32]); let (Some(px), Some(enc)) = (px.get_mut(..n), enc.get_mut(..bpp)) else { return Ok(None) }; @@ -486,7 +487,7 @@ fn edit_tiles( let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize; for x in tr.x0..tr.x1 { let i = (row + (x - tc.tx * TILE_SIZE) as usize) * bpp; - let Some(old) = src.bytes().get(i..i + bpp) else { continue }; + let Some(old) = source.get(i..i + bpp) else { continue }; for (c, v) in px.iter_mut().enumerate() { *v = photocraft_color::read_sample(old, fmt.sample, c); } @@ -498,10 +499,15 @@ fn edit_tiles( continue; } if out.is_none() { - out = Some(src.try_clone().map_err(oom)?); + let mut tile = surf.try_default_tile().map_err(oom)?; + tile.bytes_mut().copy_from_slice(&source); + out = Some(tile); } - if let Some(dst) = out.as_mut().and_then(|t| t.bytes_mut().get_mut(i..i + bpp)) { - dst.copy_from_slice(enc); + if let Some(tile) = out.as_mut() { + let mut bytes = tile.bytes_mut(); + if let Some(dst) = bytes.get_mut(i..i + bpp) { + dst.copy_from_slice(enc); + } } } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 786295d1a7..a463418ef6 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -583,6 +583,9 @@ pub struct Performance { /// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the /// oldest states are dropped beyond it), and the result of one transform. pub memory_usage_mb: u32, + /// Percent of physical RAM for the complete process; None preserves the legacy MB limit. + #[serde(default)] + pub memory_usage_percent: Option, /// Undo steps kept per document (History panel states). pub history_states: u32, /// Cache Levels, 1–8: the highest cache level the Histogram panel counts a large document at @@ -643,6 +646,25 @@ impl Performance { pub fn history_budget_bytes(&self) -> usize { (self.effective_memory_usage_mb() as usize).saturating_mul(1 << 20) } + + /// The byte limit History enforces now: none (only History States) while the scratch disk + /// is spilling, since tiles only History holds go to disk instead; otherwise + /// [`Self::history_budget_bytes`]. + pub fn history_cap_bytes(&self) -> usize { + if photocraft_raster::spill::enabled() { 0 } else { self.history_budget_bytes() } + } + + /// Pixel bytes kept in memory before tiles move to the scratch disk: half of Memory Usage + /// (the rest is for the GPU canvas, caches and the app). `PHOTOCRAFT_TILE_BUDGET_MB` + /// overrides it (tests, stress runs). + pub fn tile_budget_bytes(&self) -> usize { + let bytes = std::env::var("PHOTOCRAFT_TILE_BUDGET_MB") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|mb| mb.saturating_mul(1 << 20)) + .unwrap_or_else(|| u64::from(self.effective_memory_usage_mb()).saturating_mul(1 << 20) / 2); + usize::try_from(bytes).unwrap_or(usize::MAX) + } } impl Default for Performance { @@ -650,6 +672,7 @@ impl Default for Performance { fn default() -> Self { Self { memory_usage_mb: DEFAULT_MEMORY_USAGE_MB, + memory_usage_percent: Some(70), history_states: 50, cache_levels: 4, cache_tile_size: 8192, @@ -679,11 +702,15 @@ pub struct ScratchDisk { pub struct ScratchDisks { /// Disks for spilling tiles once the memory budget is reached (in priority order). pub disks: Vec, + /// Largest the scratch file may grow, in MB; 0 = no limit. Reaching it pauses spilling + /// like a full disk (History's byte limit applies again). At least max(1 GB, 5 % of the + /// volume) is always left free, whatever this is set to. + pub size_limit_mb: u32, } impl Default for ScratchDisks { fn default() -> Self { - Self { disks: vec![ScratchDisk { path: "(system temp)".into(), enabled: true }] } + Self { disks: vec![ScratchDisk { path: "(system temp)".into(), enabled: true }], size_limit_mb: 0 } } } @@ -1330,8 +1357,11 @@ impl Session { /// linearity, the transform and layer-effect cache budgets and the text engine's /// missing-glyph protection. pub fn apply_prefs(&mut self) { + self.scratch_runtime.get_or_insert_with(crate::scratch::Runtime::default); + crate::scratch::configure(self.prefs()); + self.scratch_epoch = photocraft_raster::spill::epoch(); let n = self.prefs().performance.history_states.max(1) as usize; - let bytes = self.prefs().performance.history_budget_bytes(); + let bytes = self.prefs().performance.history_cap_bytes(); let budget = self.prefs().performance.effect_cache_mb as usize; let non_linear = self.prefs().history_options.allow_non_linear_history; for st in &mut self.docs { @@ -1347,6 +1377,25 @@ impl Session { crate::plugin_cmds::sync_prefs(self); } + /// Re-apply History's byte limit after the scratch disk paused (a write, create or + /// free-space failure) or resumed since it was last applied: while spilling is paused, + /// History again bounds the pixel memory it holds, as with no scratch disk. Runs before + /// every command; a single atomic load when nothing changed. + pub(crate) fn sync_scratch_state(&mut self) { + let epoch = photocraft_raster::spill::epoch(); + if self.scratch_epoch == epoch { + return; + } + self.scratch_epoch = epoch; + let bytes = self.prefs().performance.history_cap_bytes(); + for st in &mut self.docs { + if st.history.max_bytes != bytes { + st.history.max_bytes = bytes; + st.trim_history(); + } + } + } + /// Everything persisted as one JSON document: the preferences plus `colorSettings`. pub fn prefs_to_json(&self) -> String { serde_json::to_string_pretty(&self.prefs_value()).unwrap_or_default() diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index 37d0fdf022..e474f30dbe 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -40,7 +40,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.enableFloatingDocumentWindowDocking", "performance.effectCacheMb", "performance.legacyCompositing", - "scratchDisks.disks", "unitsAndRulers.columnWidth", "unitsAndRulers.gutter", "plugIns.showExtensionPanels", @@ -119,6 +118,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), "performance.memoryUsageMb" => (256.0, 1_048_576.0), "performance.gpuMemoryMb" => (0.0, 1_048_576.0), + "performance.memoryUsagePercent" => (1.0, 95.0), "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), @@ -231,7 +231,7 @@ pub(super) fn check_value(path: &str, v: &Value) -> std::result::Result<(), Stri seen.push(theme); } } - if path == "performance.renderingMode" && v.is_null() { + if matches!(path, "performance.renderingMode" | "performance.memoryUsagePercent") && v.is_null() { return Ok(()); // Legacy policy, resolved from useGpu and gpuBackend. } if let Some(c) = choices(path) { diff --git a/crates/engine/src/scratch.rs b/crates/engine/src/scratch.rs new file mode 100644 index 0000000000..6e21d9c1d7 --- /dev/null +++ b/crates/engine/src/scratch.rs @@ -0,0 +1,146 @@ +//! Native tile storage configuration, kept out of the preference data model. + +use crate::prefs::Preferences; + +/// A web session has no filesystem or memory-supervision thread. +#[cfg(target_arch = "wasm32")] +#[derive(Default)] +pub(crate) struct Runtime; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use native::Runtime; + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + // Weak ownership: the process-wide cache cannot keep a session's worker alive. + pub(crate) struct Runtime { + _worker: Arc, + } + + impl Default for Runtime { + fn default() -> Self { + static SHARED: OnceLock>> = OnceLock::new(); + let mut shared = SHARED.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let worker = shared.upgrade().unwrap_or_else(|| { + let worker = Arc::new(Worker::start()); + *shared = Arc::downgrade(&worker); + worker + }); + Self { _worker: worker } + } + } + + struct Worker { + stop: Arc<(Mutex, Condvar)>, + thread: Option>, + } + + impl Worker { + fn start() -> Self { + let stop = Arc::new((Mutex::new(false), Condvar::new())); + let flag = stop.clone(); + let thread = std::thread::Builder::new() + .name("pc-memory-residency".into()) + .spawn(move || { + let _maintenance = photocraft_raster::spill::background_maintenance(); + let mut measured = Instant::now(); + loop { + let (lock, wake) = &*flag; + let stopped = lock.lock().unwrap_or_else(PoisonError::into_inner); + let (stopped, _) = wake.wait_timeout_while(stopped, Duration::from_millis(100), |stop| !*stop).unwrap_or_else(PoisonError::into_inner); + if *stopped { + break; + } + drop(stopped); + if measured.elapsed() >= Duration::from_secs(2) { + photocraft_raster::memory::refresh(); + measured = Instant::now(); + } + photocraft_raster::spill::maintain_budget(); + } + }) + .ok(); + // If spawning fails, raster's synchronous fallback still enforces the budget. + Self { stop, thread } + } + } + + impl Drop for Worker { + fn drop(&mut self) { + let (lock, wake) = &*self.stop; + *lock.lock().unwrap_or_else(PoisonError::into_inner) = true; + wake.notify_all(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn workers_stop_join_and_can_restart() { + for _ in 0..3 { + let worker = Worker::start(); + assert!(worker.thread.is_some()); + let stop = worker.stop.clone(); + let began = Instant::now(); + drop(worker); + assert!(*stop.0.lock().unwrap()); + assert!(began.elapsed() < Duration::from_secs(2)); + } + } + + #[test] + fn sessions_share_supervision_without_static_ownership() { + let a = Runtime::default(); + let b = Runtime::default(); + assert!(Arc::ptr_eq(&a._worker, &b._worker)); + let weak = Arc::downgrade(&a._worker); + drop((a, b)); + // Other engine tests can hold the shared runtime concurrently; a private worker + // proves the same ownership contract without depending on their timing. + let worker = Arc::new(Worker::start()); + let private = Arc::downgrade(&worker); + drop(worker); + assert!(private.upgrade().is_none()); + drop(weak); + } + } +} + +pub(crate) fn configure(prefs: &Preferences) { + let budget = photocraft_raster::memory::configure(photocraft_raster::memory::Policy { + percent: prefs.performance.memory_usage_percent, + // The web build's cap applies here too (effective_memory_usage_mb). + absolute_bytes: u64::from(prefs.performance.effective_memory_usage_mb()).saturating_mul(1 << 20), + }); + #[cfg(not(target_arch = "wasm32"))] + { + let dir = prefs + .scratch_disks + .disks + .iter() + .find(|d| d.enabled && !d.path.trim().is_empty()) + .map(|d| if d.path.trim() == "(system temp)" { std::env::temp_dir() } else { std::path::PathBuf::from(d.path.trim()) }); + // An unusable or full directory pauses eviction without dropping resident pixels and + // is retried with backoff; History's byte limit applies meanwhile (sync_scratch_state). + // The reason is exposed in ui.inspect and the application's status bar. + let tiles = if std::env::var_os("PHOTOCRAFT_TILE_BUDGET_MB").is_some() { + prefs.performance.tile_budget_bytes() + } else { + usize::try_from(budget.tile_bytes).unwrap_or(usize::MAX) + }; + let limit = prefs.scratch_disks.size_limit_mb; + photocraft_raster::spill::set_size_cap((limit > 0).then(|| u64::from(limit).saturating_mul(1 << 20))); + let _ = photocraft_raster::spill::configure(dir.as_deref(), tiles); + } + #[cfg(target_arch = "wasm32")] + let _ = (prefs, budget); +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 2e74736334..8b23a7110b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -133,7 +133,7 @@ impl Session { self.open_document_order.push(doc.id); let mut st = DocState::new(doc, path); st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize; - st.history.max_bytes = self.prefs.get().performance.history_budget_bytes(); + st.history.max_bytes = self.prefs.get().performance.history_cap_bytes(); st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history; self.docs.push(st); let i = self.docs.len() - 1; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 1a57be0981..d7916fe548 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -227,15 +227,21 @@ impl Session { // through this entry; the requesting session's authority must reach those renders. let _authorization = crate::jobs::render_authorization_scope(self.authorize); let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); + photocraft_compose::discard_unreadable_pixel_caches(); + let scratch_generation = photocraft_raster::spill::read_error_generation(); let restrict = self.color_restrict; let filter_mask_layer = self.filter_mask_layer; let sampler_map = self.sampler_map.take(); let symmetry_map = self.symmetry_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let before = st.doc.clone(); + // Only this document's pixels gate its edits: an unreadable tile elsewhere (another + // open document) does not block it. + before.scratch_health().map_err(EngineError::Other)?; // Selecting layers is not a step, so the state this edit leaves behind targets what was // selected just before it (#1356): undoing a stroke keeps the painted layer active. let prior = st.layer_target(); + let previous_active_layer = st.active_layer; let mut doc = (*before).clone(); let mut active = st.active_layer; let r = f(&mut doc, &mut active)?; @@ -245,6 +251,11 @@ impl Session { if let Some(id) = filter_mask_layer { smart_cmds::refresh_after_filter_mask_edit(&mut doc, id)?; } + if let Err(error) = photocraft_raster::spill::check_since(scratch_generation).and_then(|()| before.scratch_health()).and_then(|()| doc.scratch_health()) + { + st.active_layer = previous_active_layer; + return Err(EngineError::Other(error)); + } st.doc = Arc::new(doc); st.active_layer = active; fix_selection(st); diff --git a/crates/engine/tests/convert_profile_dither.rs b/crates/engine/tests/convert_profile_dither.rs index 6a78b83956..a8c92e2e5b 100644 --- a/crates/engine/tests/convert_profile_dither.rs +++ b/crates/engine/tests/convert_profile_dither.rs @@ -104,7 +104,7 @@ fn shared_tiles_and_distant_nondefault_pixels_use_actual_coordinates() { let a = converted(document(size, source, SampleType::U8), params(json!(true))); let b = converted(document(size, independent, SampleType::U8), params(json!(true))); assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); - assert_ne!(pixels(&a).tile(TileCoord::new(0, 0)).unwrap().bytes(), pixels(&a).tile(TileCoord::new(1, 0)).unwrap().bytes()); + assert_ne!(pixels(&a).tile(TileCoord::new(0, 0)).unwrap().bytes().to_vec(), pixels(&a).tile(TileCoord::new(1, 0)).unwrap().bytes().to_vec()); for (x, y) in [(-512, 0), (256_000_003, 256_000_007)] { let px = pixels(&a).pixel(x, y); assert!([55.0, 56.0].contains(&(px[0] * 255.0).round())); diff --git a/crates/engine/tests/memory_prefs.rs b/crates/engine/tests/memory_prefs.rs new file mode 100644 index 0000000000..a21affa91d --- /dev/null +++ b/crates/engine/tests/memory_prefs.rs @@ -0,0 +1,27 @@ +use photocraft_engine::prefs::{Performance, Preferences}; +use serde_json::json; + +#[test] +fn old_preferences_keep_their_absolute_limit() { + let p: Performance = serde_json::from_value(json!({"memoryUsageMb": 2048})).unwrap(); + assert_eq!(p.memory_usage_percent, None); + assert_eq!(p.memory_usage_mb, 2048); + assert_eq!(Performance::default().memory_usage_percent, Some(70)); +} + +#[test] +fn memory_preferences_validate_and_can_restore_absolute_mode() { + let mut p = Preferences::default(); + assert!(p.set("performance.memoryUsagePercent", json!(96)).is_err()); + assert!(p.set("performance.memoryUsagePercent", json!(0)).is_err()); + assert!(p.set("performance.gpuMemoryMb", json!(-1)).is_err()); + p.set("performance.memoryUsagePercent", json!(75)).unwrap(); + assert_eq!(p.performance.memory_usage_percent, Some(75)); + p.set("performance.memoryUsagePercent", json!(null)).unwrap(); + assert_eq!(p.performance.memory_usage_percent, None); + p.set("performance.gpuMemoryMb", json!(512)).unwrap(); + assert_eq!(p.performance.gpu_memory_mb, 512); + // Main's GPU budget: 0 selects the automatic limit. + p.set("performance.gpuMemoryMb", json!(0)).unwrap(); + assert_eq!(p.performance.gpu_memory_mb, 0); +} diff --git a/crates/engine/tests/scratch_disk.rs b/crates/engine/tests/scratch_disk.rs new file mode 100644 index 0000000000..55a361133e --- /dev/null +++ b/crates/engine/tests/scratch_disk.rs @@ -0,0 +1,119 @@ +//! Disk-backed current pixels and history: lossless undo, and failed reads never commit. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::{Rect, Size, TileCoord}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::Arc; + +#[test] +fn scratch_pixels_history_and_failed_save_are_transactional() { + let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-{}", std::process::id())); + let mut session = Session::new(); + session.edit_prefs(|p| { + p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }]; + p.performance.memory_usage_mb = 1; + }); + assert!(spill::enabled()); + assert_eq!(session.prefs().performance.history_cap_bytes(), 0); + let mut doc = Document::new("Scratch fixture", Size::new(2048, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + let id = layer.id; + for i in 0..8 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, b) in bytes.iter_mut().enumerate() { + *b = (j as u32).wrapping_mul(17).wrapping_add(i as u32 * 43) as u8; + } + } + doc.layers.push(layer); + session.add_document(doc, None); + session.select_layer(id).unwrap(); + let before = session.active().unwrap().doc.clone(); + session + .edit("Paint", |d, _| { + d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + spill::settle(); + assert!(session.undo()); + assert_eq!(*session.active().unwrap().doc, *before); + assert!(session.redo()); + assert_eq!(session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + spill::settle(); + + // Force all tiles to disk, including the tile the failed edit will touch. + spill::configure(Some(&dir), 1).unwrap(); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + // Damage only this test's scratch file; retain its exact contents to restore the backing. + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + let retained = session.active().unwrap().doc.clone(); + let revision = session.active().unwrap().revision; + let failed = session.edit("Failed paint", |d, active| { + let discarded = Layer::raster("Discarded", PixelFormat::RGBA8); + *active = Some(discarded.id); + d.layers.push(discarded); + d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(1800, 0, 4, 4), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }); + assert!(failed.is_err()); + assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained)); + assert_eq!(session.active().unwrap().revision, revision); + assert_eq!(session.active().unwrap().active_layer, Some(id)); + let mut writer = photocraft_format::PcraftWriter::new(); + assert!(writer.save_zip(&retained, &Default::default()).is_err()); + assert!(photocraft_io::export(&retained, "png", &Default::default()).is_err()); + // No failure or recovery happens during a retry, so only the document's own health + // refuses it. + let generation = spill::read_error_generation(); + assert!(session.edit("Retry paint", |_, _| Ok(())).is_err()); + assert_eq!(spill::read_error_generation(), generation, "an already-failed tile does not change the generation"); + assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained)); + + // Another document is unaffected: it edits, saves and exports while this one is unreadable. + assert!(spill::check_integrity().is_err(), "the process-wide status still reports the failure"); + let mut other = Document::new("Healthy", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut pixels = Layer::raster("Other", PixelFormat::RGBA8); + let other_id = pixels.id; + pixels.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 512, 256), &[0.0, 0.0, 1.0, 1.0]); + other.layers.push(pixels); + session.add_document(other, None); + session.select_layer(other_id).unwrap(); + session + .edit("Paint other", |d, _| { + d.layer_mut(other_id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 8, 8), &[1.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + spill::settle(); + let healthy = session.active().unwrap().doc.clone(); + assert!(healthy.scratch_health().is_ok()); + let (saved, _) = photocraft_format::PcraftWriter::new().save_zip(&healthy, &Default::default()).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&saved).unwrap().layers, healthy.layers); + assert!(photocraft_io::export(&healthy, "png", &Default::default()).is_ok()); + assert!(retained.scratch_health().is_err()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for d in [&before, &retained] { + for (_, tile) in d.layer(id).unwrap().surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + } + assert!(spill::check_integrity().is_ok()); + let (bytes, _) = writer.save_zip(&retained, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded.layers, retained.layers); + drop((session, before, retained, healthy, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/tests/scratch_pause.rs b/crates/engine/tests/scratch_pause.rs new file mode 100644 index 0000000000..3b694f2da2 --- /dev/null +++ b/crates/engine/tests/scratch_pause.rs @@ -0,0 +1,60 @@ +//! A scratch disk that fills up pauses spilling; History's byte limit applies again until it +//! resumes, so undo states cannot pile up in memory unbounded meanwhile. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::{Size, TileCoord}; +use photocraft_raster::spill; +use serde_json::json; + +#[test] +fn paused_scratch_disk_restores_the_history_limit() { + let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-pause-{}", std::process::id())); + let mut session = Session::new(); + session.edit_prefs(|p| { + p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }]; + p.performance.memory_usage_mb = 64; + }); + assert!(spill::enabled()); + assert_eq!(spill::stats().size_cap_bytes, None, "0 MB = no scratch size limit"); + // Preferences › Scratch Disks › Scratch Size Limit becomes the scratch file's cap. + session.edit_prefs(|p| p.scratch_disks.size_limit_mb = 4096); + assert_eq!(spill::stats().size_cap_bytes, Some(4096 << 20)); + session.edit_prefs(|p| p.scratch_disks.size_limit_mb = 0); + assert_eq!(spill::stats().size_cap_bytes, None); + let mut doc = Document::new("Pause fixture", Size::new(1024, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + let id = layer.id; + for i in 0..4 { + for (j, b) in layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut().iter_mut().enumerate() { + *b = (j as u32).wrapping_mul(31).wrapping_add(i as u32 * 7) as u8; + } + } + doc.layers.push(layer); + session.add_document(doc, None); + session.select_layer(id).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, 0, "spilling: only History States bound History"); + + // The disk "fills up": a size limit below what eviction needs pauses spilling. + spill::set_size_cap(Some(1)); + spill::configure(Some(&dir), 1).unwrap_or_default(); + spill::settle(); + assert!(spill::paused() && !spill::enabled(), "{:?}", spill::stats()); + let limit = session.prefs().performance.history_budget_bytes(); + assert!(limit > 0); + session.execute("edit.fill", json!({"color": "#204060"})).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, limit, "a paused disk must restore History's byte limit"); + assert!(session.active().unwrap().memory_bytes() <= limit); + + // Room again: spilling resumes and the next command lifts the limit again. + spill::set_size_cap(None); + assert!(spill::enabled() && !spill::paused(), "{:?}", spill::stats()); + session.execute("edit.fill", json!({"color": "#406080"})).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, 0); + assert!(session.undo()); + assert!(session.undo()); + + drop(session); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index fbe121c153..8ea8c5cc3e 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -367,7 +367,7 @@ impl Loader<'_> { if let Some(tile) = self.tiles.get(&f.sample).and_then(|m| m.get(&t.hash)) { // One hash names one byte string, so a cached tile of another length means // the same hash was referenced from formats of different pixel sizes. - if tile.bytes().len() != len { + if tile.len() != len { return Err(FormatError::corrupt(format!("tile {} is referenced with different sizes", t.hash))); } s.put_tiles([(c, tile.clone())]); diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index eeb082954c..309151d52d 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -44,27 +44,34 @@ struct SourceReader { } impl SourceReader { - fn all(&self) -> &[u8] { + fn len(&self) -> usize { match &self.source { - Source::Tile(t) => t.bytes(), - Source::Blob(b) => b.as_slice(), - Source::Owned(v) => v.as_slice(), + Source::Tile(t) => t.len(), + Source::Blob(b) => b.len(), + Source::Owned(v) => v.len(), } } +} - fn len(&self) -> usize { - self.all().len() +/// Copies the source bytes from `pos` into `buf`, returning how many were copied. +fn read_from(all: &[u8], pos: usize, buf: &mut [u8]) -> usize { + let rest = all.get(pos..).unwrap_or(&[]); + let n = rest.len().min(buf.len()); + if let (Some(dst), Some(src)) = (buf.get_mut(..n), rest.get(..n)) { + dst.copy_from_slice(src); } + n } impl Read for SourceReader { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let all = self.all(); - let rest = all.get(self.pos..).unwrap_or(&[]); - let n = rest.len().min(buf.len()); - if let (Some(dst), Some(src)) = (buf.get_mut(..n), rest.get(..n)) { - dst.copy_from_slice(src); - } + // A tile's bytes are borrowed only for this call, so its scratch-disk backing may + // page out between reads (a later read reloads it). + let n = match &self.source { + Source::Tile(t) => read_from(&t.bytes(), self.pos, buf), + Source::Blob(b) => read_from(b, self.pos, buf), + Source::Owned(v) => read_from(v, self.pos, buf), + }; self.pos += n; Ok(n) } @@ -105,7 +112,7 @@ impl Compressor { fn object(&mut self, obj: &Object) -> Vec { let source = match obj { Object::Tile(tile, _) if cfg!(target_endian = "little") => Source::Tile(Arc::clone(tile)), - Object::Tile(tile, sample) => Source::Owned(tile_le(tile, *sample).into_owned()), + Object::Tile(tile, sample) => Source::Owned(tile_le(tile, *sample).to_vec()), Object::Blob(blob) => Source::Blob(Arc::clone(blob)), }; self.run(source) @@ -505,13 +512,32 @@ impl Sink for Collect<'_> { } } -fn tile_le(t: &Tile, sample: SampleType) -> std::borrow::Cow<'_, [u8]> { +/// A tile's bytes in little-endian order: the tile itself (kept in memory while this lives), or +/// a byte-swapped copy on big-endian machines. +enum LeBytes<'a> { + Native(photocraft_raster::TileBytes<'a>), + Swapped(Vec), + Borrowed(&'a [u8]), +} + +impl std::ops::Deref for LeBytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + match self { + LeBytes::Native(b) => b, + LeBytes::Swapped(b) => b, + LeBytes::Borrowed(b) => b, + } + } +} + +fn tile_le(t: &Tile, sample: SampleType) -> LeBytes<'_> { if cfg!(target_endian = "big") { let mut b = t.bytes().to_vec(); swap_to_le(&mut b, sample); - std::borrow::Cow::Owned(b) + LeBytes::Swapped(b) } else { - std::borrow::Cow::Borrowed(t.bytes()) + LeBytes::Native(t.bytes()) } } @@ -621,6 +647,14 @@ struct Prepared { stats: SaveStats, } +/// Refuse to write pixels of `doc` that are unreadable now, or that may have been read as a +/// placeholder because some tile failed or recovered since `generation` (the save's start). +/// Other documents' scratch failures do not block this save. +fn scratch_ok(doc: &Document, generation: u64) -> Result<()> { + doc.scratch_health().map_err(FormatError::corrupt)?; + photocraft_raster::spill::check_since(generation).map_err(FormatError::corrupt) +} + impl Prepared { /// Takes the full-size composite out of the manifest when `previews` does not hold it. fn match_previews(&mut self, previews: &[(&str, Vec)]) -> Result<()> { @@ -644,10 +678,10 @@ impl Object { } /// The bytes this object stores (tiles little-endian). - fn content(&self) -> std::borrow::Cow<'_, [u8]> { + fn content(&self) -> LeBytes<'_> { match self { Object::Tile(tile, sample) => tile_le(tile, *sample), - Object::Blob(blob) => std::borrow::Cow::Borrowed(blob.as_slice()), + Object::Blob(blob) => LeBytes::Borrowed(blob.as_slice()), } } } @@ -1028,6 +1062,19 @@ impl PcraftWriter { } fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { + doc.scratch_health().map_err(FormatError::corrupt)?; + let generation = photocraft_raster::spill::read_error_generation(); + let prepared = self.prepare_inner(doc, opts); + if let Err(error) = photocraft_raster::spill::check_since(generation) { + // Failed reads may have hashed display placeholders. Never reuse those hashes + // when the original backing becomes readable and the same writer retries. + self.hash_cache.clear(); + return Err(FormatError::corrupt(error)); + } + prepared + } + + fn prepare_inner(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); self.blob_hash_cache.retain(|_, (w, _)| w.strong_count() > 0); convert::check_nesting(&doc.layers)?; @@ -1047,6 +1094,7 @@ impl PcraftWriter { for (h, b) in c.blobs { objects.insert(blob_path(&h).ok_or_else(|| invalid(&h))?, Object::Blob(b)); } + doc.scratch_health().map_err(FormatError::corrupt)?; Ok(Prepared { manifest, model, full_composite, objects, stats }) } @@ -1058,6 +1106,7 @@ impl PcraftWriter { /// [`Self::save_zip`] with `limit` (bytes, entries) in place of [`ZIP_LIMIT`]: an archive that /// the full-size composite would take past either is saved without that composite. fn save_zip_within(&mut self, doc: &Document, opts: &SaveOptions, limit: (u64, usize)) -> Result<(Vec, SaveStats)> { + let generation = photocraft_raster::spill::read_error_generation(); // Another thread encodes the previews while the document is collected and its new // objects are compressed on all cores (zstd dominates a full save). let (previews, prepared) = beside( @@ -1141,6 +1190,7 @@ impl PcraftWriter { next.insert(key.unwrap_or_else(|| path.clone()), data); } } + scratch_ok(doc, generation)?; self.compressed = next; Ok((z.finish()?, stats)) } @@ -1155,6 +1205,7 @@ impl PcraftWriter { /// The same save path with an injectable publication barrier, for failure/GC ordering tests. fn save_dir_with(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions, fs: &dyn crate::atomic::AtomicFs) -> Result { + let scratch_generation = photocraft_raster::spill::read_error_generation(); let canonical_dir = bundle_root(dir)?; let dir = canonical_dir.as_path(); // Held until the end of the save: listing, writing, publishing and cleanup (#4957). @@ -1215,7 +1266,9 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed(compressor.get_or_insert_with(Compressor::new)), fs)?; + let compressed = obj.compressed(compressor.get_or_insert_with(Compressor::new)); + scratch_ok(doc, scratch_generation)?; + write_bundle_file(dir, &object_path, &compressed, fs)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -1251,6 +1304,7 @@ impl PcraftWriter { confirm_flushed(&folder, &folder)?; } } + scratch_ok(doc, scratch_generation)?; write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest, fs)?; // Omitted previews belong to the previous manifest until the new one commits. // After commit, a locked leftover is only housekeeping, just like unused objects. diff --git a/crates/format/tests/scratch_retry.rs b/crates/format/tests/scratch_retry.rs new file mode 100644 index 0000000000..3d8a32f540 --- /dev/null +++ b/crates/format/tests/scratch_retry.rs @@ -0,0 +1,48 @@ +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Size, TileCoord}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn retry_after_failed_hashing_never_reuses_placeholder_hashes() { + let dir = std::env::temp_dir().join(format!("photocraft-format-scratch-retry-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Retry", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + for i in 0..2 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, byte) in bytes.iter_mut().enumerate() { + *byte = (j as u8).wrapping_mul(17).wrapping_add((i * 43) as u8); + } + } + doc.layers.push(layer); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + + let mut writer = photocraft_format::PcraftWriter::new(); + // This save discovers the failure itself, after entering the hash collection stage. + assert!(writer.save_zip(&doc, &Default::default()).is_err()); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for (_, tile) in doc.layers[0].surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + assert!(spill::check_integrity().is_ok()); + let (saved, _) = writer.save_zip(&doc, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&saved).unwrap(); + assert_eq!(loaded.layers, doc.layers); + drop((doc, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/gpu/src/convert.rs b/crates/gpu/src/convert.rs index 19ad194765..1c04f03e28 100644 --- a/crates/gpu/src/convert.rs +++ b/crates/gpu/src/convert.rs @@ -160,13 +160,13 @@ pub(crate) fn colour_managed(fmt: &PixelFormat, kind: TexKind) -> bool { /// Tile pixels in the texture's format. `tile = None` gives the default pixel everywhere. pub(crate) fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: TileCoord) -> Vec { if let (Some(t), TexKind::Rgba8Direct | TexKind::R8Direct) = (tile, kind) { - let mut out = scratch::take_cap(t.bytes().len()); - out.extend_from_slice(t.bytes()); + let mut out = scratch::take_cap(t.len()); + out.extend_from_slice(&t.bytes()); return out; } let fmt = surface.format(); if let (Some(t), TexKind::Rgba16F) = (tile, kind) - && let Some(out) = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())) + && let Some(out) = rgb_gray16_to_f16(&fmt, &t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, &t.bytes())) { return out; } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 281c67421e..397dcb0124 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -303,7 +303,7 @@ fn sixteen_and_thirty_two_bit_fast_paths_match_the_general_one() { let data: Vec = (0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) as f32 / u32::MAX as f32 * hdr - (hdr - 1.0) / 4.0).collect(); s.write_region(r, &data); let t = s.tile(TileCoord::new(0, 0)).expect("written").clone(); - let fast = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())).expect("fast path"); + let fast = rgb_gray16_to_f16(&fmt, &t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, &t.bytes())).expect("fast path"); let raw = s.read_region(r); let general: Vec = raw.chunks_exact(fmt.channels()).flat_map(|px| photocraft_raster::to_rgba(&fmt, px).map(|x| f32_to_f16(x).to_le_bytes())).flatten().collect(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 1a1e3b41e1..c3bdcefeb8 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -393,11 +393,16 @@ pub fn import_for_open(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result /// [`import_for_open`] with cancellation and progress, shared with [`import_with_options_and_interrupt`]. pub fn import_for_open_with(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { + photocraft_compose::discard_unreadable_pixel_caches(); + let scratch_generation = photocraft_raster::spill::read_error_generation(); ctl.check().map_err(|_| IoError::Cancelled)?; let mut damaged = false; let result = import_stages(name, bytes, ctl, opts, &mut damaged)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); + // The imported document's own tiles, and anything that failed or recovered meanwhile. + photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; + result.document.scratch_health().map_err(IoError::Unsupported)?; Ok((result, damaged)) } @@ -604,7 +609,12 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; + photocraft_compose::discard_unreadable_pixel_caches(); + doc.scratch_health().map_err(IoError::Unsupported)?; + let scratch_generation = photocraft_raster::spill::read_error_generation(); let r = export_stages(doc, name_or_ext, opts, ctl)?; + photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; + doc.scratch_health().map_err(IoError::Unsupported)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok(r) diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index 50243cc8d8..64da14929e 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -11,40 +11,10 @@ pub(crate) struct Tiles { bytes: u128, } -pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { - for (_, _, layer) in doc.walk() { - if let Some(surface) = layer.surface() { - visit(surface); - } - if let Some(mask) = &layer.mask { - visit(&mask.surface); - } - if let Some(cache) = &layer.fill_cache { - visit(&cache.surface); - } - if let LayerContent::Smart(smart) = &layer.content - && let Some(mask) = &smart.filter_mask - { - visit(&mask.surface); - } - if let Some(video) = &layer.video { - // Undo retains the whole clip, not only the displayed frame. Visit aliases too: - // Tiles tracks every owner and counts their shared allocations only once. - for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { - visit(frame); - } - } - } - for channel in doc.channels.iter().chain(&doc.quick_mask) { - visit(&channel.surface); - } - // A pattern fill or layer style copies its pattern into the document. - for pattern in &doc.patterns { - visit(&pattern.surface); - } - if let Some(selection) = &doc.selection { - visit(selection); - } +pub(crate) fn surfaces(doc: &Document, visit: impl FnMut(&Surface)) { + // Undo retains the whole clip, not only the displayed frame. Video aliases are visited + // too: Tiles tracks every owner and counts their shared allocations only once. + doc.for_each_surface(visit); } /// Every shared allocation `doc` holds that history can retain, as `visit(address, length)`: @@ -53,7 +23,7 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { pub(crate) fn blocks(doc: &Document, mut visit: impl FnMut(usize, usize)) { surfaces(doc, |surface| { for (_, tile) in surface.tiles() { - visit(Arc::as_ptr(tile) as usize, tile.bytes().len()); + visit(Arc::as_ptr(tile) as usize, tile.len()); } }); for (_, _, layer) in doc.walk() { diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index 52e3b85826..9e4ddc2266 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -156,7 +156,7 @@ impl Plugin { let results: Vec> = chunk.iter().map(run).collect(); for r in results { for (c, t) in r?.tiles() { - out.tile_mut(*c).bytes_mut().copy_from_slice(t.bytes()); + out.tile_mut(*c).bytes_mut().copy_from_slice(&t.bytes()); } } } @@ -227,7 +227,7 @@ fn encode_band(surface: &Surface, band: Rect, data: &[f32]) -> Surface { let mut s = Surface::with_default(surface.format(), &surface.default_pixel()); for c in band.tiles() { if let Some(t) = surface.tile(c) { - s.tile_mut(c).bytes_mut().copy_from_slice(t.bytes()); + s.tile_mut(c).bytes_mut().copy_from_slice(&t.bytes()); } else { s.tile_mut(c); } diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 41b188a94f..e7f5af6420 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -11,10 +11,14 @@ workspace = true photocraft-geom = { workspace = true } photocraft-color = { workspace = true } serde = { workspace = true } +crc32fast = "1" +lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode"] } -# Native only: converting a region's tiles is independent per tile, so it runs on all cores. +# Native only: converting a region's tiles is independent per tile, so it runs on all cores; +# sysinfo reads the physical RAM size for the memory limit and the scratch volume's free space. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } +sysinfo = { version = "0.37", default-features = false, features = ["system", "disk"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/examples/scratch_bench.rs b/crates/raster/examples/scratch_bench.rs new file mode 100644 index 0000000000..c7d04a56c4 --- /dev/null +++ b/crates/raster/examples/scratch_bench.rs @@ -0,0 +1,52 @@ +//! 24 MP release benchmark: identical pixel access with RAM-only and disk-backed tiles. +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_raster::{Surface, spill}; +use std::time::Instant; + +fn run(dir: Option<&std::path::Path>) -> Result<(), String> { + spill::configure(dir, 16 << 20)?; + let start = Instant::now(); + let area = Rect::from_xywh(0, 0, 6000, 4000); + let mut surface = Surface::new(PixelFormat::RGBA8); + let mut random = 0x1234abcd_u32; + for tc in area.tiles() { + let mut bytes = surface.tile_mut(tc).bytes_mut(); + for byte in bytes.iter_mut() { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + *byte = random as u8; + } + } + spill::settle(); + let build_ms = start.elapsed().as_secs_f64() * 1000.0; + let resident = spill::stats().resident_bytes; + let disk = spill::stats().scratch_bytes; + let read = Instant::now(); + let mut checksum = 0u64; + // Rows exercise the same locks and conversions the CPU renderer uses. + let mut row = vec![[0; 4]; 6000]; + for y in 0..4000 { + surface.read_rgba8_into(Rect::from_xywh(0, y, 6000, 1), &mut row); + for pixel in &row { + checksum = checksum.wrapping_add(u32::from_le_bytes(*pixel) as u64); + } + } + spill::check_integrity()?; + println!( + "mode={} build_ms={build_ms:.2} read_ms={:.2} resident_bytes={resident} scratch_bytes={disk} checksum={checksum} tile_side={TILE_SIZE}", + if dir.is_some() { "scratch" } else { "ram" }, + read.elapsed().as_secs_f64() * 1000.0 + ); + drop(surface); + spill::configure(None, 0)?; + Ok(()) +} + +fn main() -> Result<(), String> { + run(None)?; + let dir = std::env::temp_dir().join(format!("photocraft-24mp-{}", std::process::id())); + run(Some(&dir))?; + std::fs::remove_dir(&dir).map_err(|e| e.to_string()) +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index d949445bcc..994c02c57c 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -13,14 +13,16 @@ pub mod scratch; use std::collections::BTreeMap; use std::fmt; -use std::sync::Arc; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, RwLockReadGuard, RwLockWriteGuard}; use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, read_samples, write_sample}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; /// A checked surface allocation could not be represented or reserved. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum AllocationError { + ScratchRead(String), SizeOverflow, NotEnoughMemory { bytes: usize, @@ -35,6 +37,7 @@ pub enum AllocationError { impl fmt::Display for AllocationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::ScratchRead(error) => write!(f, "scratch tile could not be read: {error}"), Self::SizeOverflow => f.write_str("requested surface allocation is too large"), Self::NotEnoughMemory { bytes } => write!(f, "not enough memory to allocate {} bytes", bytes), Self::LengthMismatch { expected, got } => write!(f, "{got} bytes of pixels given for a region that takes {expected}"), @@ -43,6 +46,9 @@ impl fmt::Display for AllocationError { } impl std::error::Error for AllocationError {} +pub mod memory; +mod scratch_file; +pub mod spill; /// One shared tile per pixel value (and tile size), for [`Surface::share_uniform_tiles`]. #[derive(Debug, Default)] @@ -63,23 +69,12 @@ impl TileInterner { } /// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels. -#[derive(Debug, PartialEq, Eq)] +/// +/// The bytes may live on the scratch disk ([`spill`]); [`Tile::bytes`] and [`Tile::bytes_mut`] +/// bring them back and keep them in memory while the returned guard lives. Take one guard per +/// tile, not per pixel, in hot loops. pub struct Tile { - data: Box<[u8]>, -} - -impl Clone for Tile { - fn clone(&self) -> Self { - let mut data = scratch::take_exact(self.data.len(), 0u8); - data.copy_from_slice(&self.data); - Tile { data: data.into_boxed_slice() } - } -} - -impl Drop for Tile { - fn drop(&mut self) { - scratch::give(std::mem::take(&mut self.data).into_vec()); - } + cell: Arc, } impl Tile { @@ -92,15 +87,16 @@ impl Tile { px.copy_from_slice(pixel); } } - Ok(Tile { data: data.into_boxed_slice() }) + Ok(Tile::from_vec(data)) } /// Clone this tile's pixel buffer with a fallible reservation. pub fn try_clone(&self) -> Result { - let bytes = self.data.len(); + let source = self.try_bytes().map_err(AllocationError::ScratchRead)?; + let bytes = source.len(); let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?; - data.copy_from_slice(&self.data); - Ok(Tile { data: data.into_boxed_slice() }) + data.copy_from_slice(&source); + Ok(Tile::from_vec(data)) } fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { @@ -117,15 +113,38 @@ impl Tile { out.copy_from_slice(&row); } } - Tile { data: data.into_boxed_slice() } + Tile::from_vec(data) } - pub fn bytes(&self) -> &[u8] { - &self.data + fn from_vec(data: Vec) -> Self { + Tile { cell: spill::Cell::new(data) } } - pub fn bytes_mut(&mut self) -> &mut [u8] { - &mut self.data + /// The tile's bytes (read back from the scratch disk if needed). + pub fn bytes(&self) -> TileBytes<'_> { + let guard = self.cell.read(); + let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; + TileBytes(guard, placeholder) + } + pub fn bytes_mut(&mut self) -> TileBytesMut<'_> { + let guard = self.cell.write(); + let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; + TileBytesMut(guard, placeholder) + } + /// `Err` while this tile's scratch copy is unreadable (its bytes read as a placeholder). + /// Free when no tile in the process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + self.cell.read_error().map_or(Ok(()), Err) + } + /// Fallible pixel access for callers that can propagate a scratch read failure. + pub fn try_bytes(&self) -> Result, String> { + let bytes = self.bytes(); + match self.cell.read_error() { + Some(error) => Err(error), + None => Ok(bytes), + } } - /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels that differ from the encoded /// pixel `dp` (one pixel of the tile's format), or `None` when every pixel equals it (or `dp` /// is empty). @@ -141,7 +160,7 @@ impl Tile { // last differing pixels. let blank = dp.repeat(ts); let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() { + for (y, row) in self.bytes().chunks_exact(ts * bpp).enumerate() { let Some(first) = first_difference(row, &blank) else { continue }; let Some(last) = last_difference(row, &blank) else { continue }; x0 = x0.min(first / bpp); @@ -151,6 +170,67 @@ impl Tile { } (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) } + /// Byte length, without touching the pixels (they may be on disk). + pub fn len(&self) -> usize { + self.cell.len() + } + pub fn is_empty(&self) -> bool { + self.cell.len() == 0 + } +} + +impl Clone for Tile { + fn clone(&self) -> Self { + match self.try_bytes() { + Ok(bytes) => { + let mut data = scratch::take_exact(bytes.len(), 0u8); + data.copy_from_slice(&bytes); + Tile::from_vec(data) + } + // Preserve the original backing; the transaction will reject this poisoned tile. + Err(_) => Tile { cell: self.cell.clone() }, + } + } +} + +impl PartialEq for Tile { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.cell, &other.cell) || (self.len() == other.len() && *self.bytes() == *other.bytes()) + } +} + +impl Eq for Tile {} + +impl std::fmt::Debug for Tile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tile").field("len", &self.len()).finish_non_exhaustive() + } +} + +/// Read guard over a tile's bytes; derefs to `[u8]`. +pub struct TileBytes<'a>(RwLockReadGuard<'a, Option>>, Vec); + +impl Deref for TileBytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0.as_deref().unwrap_or(&self.1) + } +} + +/// Write guard over a tile's bytes; derefs to `[u8]`. +pub struct TileBytesMut<'a>(RwLockWriteGuard<'a, Option>>, Vec); + +impl Deref for TileBytesMut<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0.as_deref().unwrap_or(&self.1) + } +} + +impl DerefMut for TileBytesMut<'_> { + fn deref_mut(&mut self) -> &mut [u8] { + self.0.as_deref_mut().unwrap_or(&mut self.1) + } } /// Bytes compared at once while looking for a difference in a tile row. @@ -215,12 +295,15 @@ impl UniformTiles { if let Some(k) = self.of.get(&key) { return *k; } - let mut px = t.data.chunks_exact(bpp); - let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.data.get(..bpp) == Some(p0)) { - Some(k) => k, - None => { - self.canonical.push(t.clone()); - self.canonical.len() - 1 + let bytes = t.try_bytes().ok()?; + let mut px = bytes.chunks_exact(bpp); + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| { + match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } } }); self.of.insert(key, k); @@ -240,12 +323,13 @@ fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: u // Convert contiguous channel samples a row at a time. Dispatching on depth here removes the // per-pixel channel loop and lets the compiler vectorise conversion. let span = tr.width() as usize * n; + let mut target = t.bytes_mut(); for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; let src = &data[o..o + span]; let bytes = sample.bytes(); - let dst = &mut t.data[base * bytes..(base + span) * bytes]; + let dst = &mut target[base * bytes..(base + span) * bytes]; match sample { SampleType::U8 => { for (d, v) in dst.iter_mut().zip(src) { @@ -290,12 +374,13 @@ fn copy_tile(tc: TileCoord, t: &mut Arc, r: Rect, bytes: &[u8], w: usize, *t = Arc::new(t.try_clone()?); } let t = Arc::get_mut(t).ok_or(AllocationError::SizeOverflow)?; + let mut target = t.bytes_mut(); let tr = tc.rect().intersect(&r); let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - let (Some(d), Some(s)) = (t.data.get_mut(dst..dst + span), bytes.get(src..src + span)) else { return Err(AllocationError::SizeOverflow) }; + let (Some(d), Some(s)) = (target.get_mut(dst..dst + span), bytes.get(src..src + span)) else { return Err(AllocationError::SizeOverflow) }; d.copy_from_slice(s); } Ok(()) @@ -314,17 +399,17 @@ fn copy_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, bytes: &[u8], w: us tiles.iter_mut().try_for_each(|(tc, t)| copy_tile(*tc, t, r, bytes, w, bpp)) } -fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { +fn decode_tile_row_rgba(fmt: &PixelFormat, t: &[u8], base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { let n = fmt.channels(); match (fmt.mode, fmt.sample, fmt.alpha) { (ColorMode::Rgb, SampleType::U8, true) => { - let src = &t.data[base..base + dst.len() * 4]; + let src = &t[base..base + dst.len() * 4]; for (d, s) in dst.iter_mut().zip(src.as_chunks::<4>().0) { *d = [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, s[3] as f32 / 255.0]; } } (ColorMode::Grayscale, SampleType::U8, true) => { - let src = &t.data[base..base + dst.len() * 2]; + let src = &t[base..base + dst.len() * 2]; for (d, s) in dst.iter_mut().zip(src.as_chunks::<2>().0) { let g = s[0] as f32 / 255.0; *d = [g, g, g, s[1] as f32 / 255.0]; @@ -333,7 +418,7 @@ fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f3 (ColorMode::Cmyk, SampleType::U8, _) => { // Identical ink bytes share the exact ICC result; alpha is independent. let mut previous: Option<([u8; 4], [f32; 3])> = None; - let src = &t.data[base..base + dst.len() * n]; + let src = &t[base..base + dst.len() * n]; for (d, s) in dst.iter_mut().zip(src.chunks_exact(n)) { let ink = [s[0], s[1], s[2], s[3]]; let rgb = match previous { @@ -352,7 +437,7 @@ fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f3 // RGB and grey take their channels as `to_rgba` does, other models go // through it. row.resize(dst.len() * n, 0.0); - read_samples(&t.data, fmt.sample, base, row); + read_samples(t, fmt.sample, base, row); let a = |s: &[f32], i: usize| if fmt.alpha { s[i] } else { 1.0 }; let pixels = dst.iter_mut().zip(row.chunks_exact(n)); match fmt.mode { @@ -371,10 +456,30 @@ impl Surface { let len = (r.width() as usize).checked_mul(r.height() as usize).and_then(|n| n.checked_mul(self.channels())).ok_or(AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(AllocationError::SizeOverflow)?; let mut out = scratch::try_take_exact(len, 0.0f32).ok_or(AllocationError::NotEnoughMemory { bytes })?; + let generation = spill::read_error_generation(); self.read_region_into(r, &mut out); + spill::check_since(generation).map_err(AllocationError::ScratchRead)?; + self.region_health(r).map_err(AllocationError::ScratchRead)?; Ok(out) } + /// `Err` if any stored tile of this surface is unreadable. Free when no tile in the + /// process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + self.tiles.values().try_for_each(|t| t.scratch_health()) + } + + /// [`Surface::scratch_health`] for the tiles overlapping `r` only. + pub fn region_health(&self, r: Rect) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + r.tiles().filter_map(|c| self.tiles.get(&c)).try_for_each(|t| t.scratch_health()) + } + /// Reads a region as encoded interleaved bytes with fallible allocation. pub fn try_to_interleaved(&self, r: Rect) -> Result, AllocationError> { let bpp = self.format.bytes_per_pixel(); @@ -391,7 +496,7 @@ impl Surface { match self.tiles.get(&tc) { Some(t) => { let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + out[dst..dst + span].copy_from_slice(&t.try_bytes().map_err(AllocationError::ScratchRead)?[src..src + span]); } None => { for px in out[dst..dst + span].chunks_exact_mut(bpp) { @@ -455,6 +560,9 @@ impl Surface { /// Fallible counterpart to [`Surface::tile_mut`]. Pixel storage is reserved before changing /// the surface, and a shared tile is copied only after its replacement has been allocated. pub fn try_tile_mut(&mut self, c: TileCoord) -> Result<&mut Tile, AllocationError> { + if let Some(tile) = self.tiles.get(&c) { + drop(tile.try_bytes().map_err(AllocationError::ScratchRead)?); + } if !self.tiles.contains_key(&c) { let tile = Arc::new(Tile::try_filled(&self.format, &self.default_pixel)?); self.tiles.insert(c, tile); @@ -488,18 +596,41 @@ impl Surface { for tc in r.tiles() { let tr = tc.rect().intersect(&r); let t = self.try_tile_mut(tc)?; + let mut target = t.bytes_mut(); let span = tr.width() as usize * n; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * r.width() as usize + (tr.x0 - r.x0) as usize) * n; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; for (i, v) in data[src..src + span].iter().enumerate() { - write_sample(&mut t.data, sample, dst + i, *v); + write_sample(&mut target, sample, dst + i, *v); } } } Ok(()) } + /// Tile rects that differ between `self` and `other`: tiles present in only one, or holding + /// a different (copied-on-write) tile. Compares pointers, never pixels, so it is cheap and + /// never reads spilled tiles back. `None` when the format or default pixel differ (every + /// pixel may read differently). + pub fn changed_rect(&self, other: &Surface) -> Option { + if self.format != other.format || self.default_pixel != other.default_pixel { + return None; + } + let mut r = Rect::EMPTY; + for (c, t) in &self.tiles { + if other.tiles.get(c).is_none_or(|o| !Arc::ptr_eq(t, o)) { + r = r.union(&c.rect()); + } + } + for c in other.tiles.keys() { + if !self.tiles.contains_key(c) { + r = r.union(&c.rect()); + } + } + Some(r) + } + /// Coarse bounds: union of allocated tile rects. pub fn tile_bounds(&self) -> Rect { self.tiles.keys().fold(Rect::EMPTY, |r, c| r.union(&c.rect())) @@ -540,14 +671,17 @@ impl Surface { let bpp = self.format.bytes_per_pixel(); let mut saved = 0; for t in self.tiles.values_mut() { - let mut px = t.data.chunks_exact(bpp); + let Ok(bytes) = t.try_bytes() else { continue }; + let mut px = bytes.chunks_exact(bpp); let Some(p0) = px.next() else { continue }; if !px.all(|p| p == p0) { continue; } - let shared = interner.tiles.entry(t.data.len()).or_default().entry(p0.to_vec()).or_insert_with(|| t.clone()); + let p0 = p0.to_vec(); + drop(bytes); + let shared = interner.tiles.entry(t.len()).or_default().entry(p0).or_insert_with(|| t.clone()); if !Arc::ptr_eq(shared, t) { - saved += t.data.len(); + saved += t.len(); *t = shared.clone(); } } @@ -558,7 +692,7 @@ impl Surface { pub fn prune(&mut self) { let dp = self.default_pixel.as_ref(); let bpp = dp.len(); - self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != dp)); + self.tiles.retain(|_, t| t.bytes().chunks_exact(bpp).any(|px| px != dp)); } #[inline] @@ -581,8 +715,9 @@ impl Surface { let n = self.channels(); match self.tiles.get(&c) { Some(t) => { + let data = t.bytes(); for (i, o) in out.iter_mut().enumerate().take(n) { - *o = read_sample(&t.data, self.format.sample, base + i); + *o = read_sample(&data, self.format.sample, base + i); } } None => { @@ -597,9 +732,9 @@ impl Surface { let (c, base) = self.locate(x, y); let n = self.channels(); let sample = self.format.sample; - let t = self.tile_mut(c); + let mut t = self.tile_mut(c).bytes_mut(); for (i, v) in px.iter().enumerate().take(n) { - write_sample(&mut t.data, sample, base + i, *v); + write_sample(&mut t, sample, base + i, *v); } } @@ -608,7 +743,7 @@ impl Surface { pub fn sample_channel(&self, x: i32, y: i32, c: usize) -> f32 { let (tc, base) = self.locate(x, y); match self.tiles.get(&tc) { - Some(t) => read_sample(&t.data, self.format.sample, base + c), + Some(t) => read_sample(&t.bytes(), self.format.sample, base + c), None => read_sample(&self.default_pixel, self.format.sample, c), } } @@ -638,9 +773,10 @@ impl Surface { match self.tiles.get(&TileCoord { tx: tx as i32, ty }) { None => out[i..end].fill(dp), Some(t) => { + let data = t.bytes(); for (k, o) in out[i..end].iter_mut().enumerate() { let lx = (x + k as i64 * step - tx * i64::from(TILE_SIZE)) as usize; - *o = read_sample(&t.data, self.format.sample, (ly * TILE_SIZE as usize + lx) * n + c); + *o = read_sample(&data, self.format.sample, (ly * TILE_SIZE as usize + lx) * n + c); } } } @@ -668,10 +804,11 @@ impl Surface { let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); - if tile.is_none() && zero { + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + if guard.is_none() && zero { continue; } + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; @@ -679,7 +816,7 @@ impl Surface { match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - read_samples(&t.data, self.format.sample, base, dst); + read_samples(t, self.format.sample, base, dst); } None if n == 1 => dst.fill(dp[0]), None => { @@ -707,7 +844,8 @@ impl Surface { let mut row = Vec::new(); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = ((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize; let dst = &mut out[o..o + tr.width() as usize]; @@ -722,7 +860,7 @@ impl Surface { } /// Reads `r` as straight 8-bit RGBA into `out` (`w*h` entries). RGBA8 surfaces copy tile bytes - /// directly; other formats convert through [`Surface::read_rgba_into`] one tile row at a time. + /// directly; other formats convert while holding the same tile read guard. pub fn read_rgba8_into(&self, r: Rect, out: &mut [[u8; 4]]) { let w = r.width() as usize; debug_assert_eq!(out.len(), w * r.height() as usize); @@ -733,10 +871,11 @@ impl Surface { let mut heap = Vec::new(); let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; - let mut row = Vec::new(); + let mut px = [0.0f32; 8]; for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = ((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize; let dst = &mut out[o..o + tr.width() as usize]; @@ -745,14 +884,19 @@ impl Surface { Some(t) if rgba8 => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * 4; let len = dst.len(); - for (d, s) in dst.iter_mut().zip(t.data[base..base + len * 4].as_chunks::<4>().0) { + for (d, s) in dst.iter_mut().zip(t[base..base + len * 4].as_chunks::<4>().0) { *d = [s[0], s[1], s[2], s[3]]; } } - Some(_) => { - row.resize(dst.len(), [0.0f32; 4]); - self.read_rgba_into(Rect::new(tr.x0, y, tr.x1, y + 1), &mut row); - for (d, p) in dst.iter_mut().zip(&row) { + Some(t) => { + // A second read lock could deadlock behind an eviction writer. + let n = fmt.channels(); + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; + for (i, d) in dst.iter_mut().enumerate() { + for (c, v) in px.iter_mut().enumerate().take(n) { + *v = read_sample(t, fmt.sample, base + i * n + c); + } + let p = to_rgba(&fmt, &px[..n]); *d = [q(p[0]), q(p[1]), q(p[2]), q(p[3])]; } } @@ -885,12 +1029,12 @@ impl Surface { self.tiles.insert(tc, t.clone()); continue; } - let t = self.tile_mut(tc); + let mut t = self.tile_mut(tc).bytes_mut(); for y in tr.y0..tr.y1 { let ly = (y - tc.ty * TILE_SIZE) as usize; let lx0 = (tr.x0 - tc.tx * TILE_SIZE) as usize; let lx1 = (tr.x1 - tc.tx * TILE_SIZE) as usize; - let row = &mut t.data[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; + let row = &mut t[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; for p in row.chunks_exact_mut(bpp) { p.copy_from_slice(&enc); } @@ -912,12 +1056,12 @@ impl Surface { assert_eq!(bytes.len(), w * r.height() as usize * bpp, "interleaved length mismatch"); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let t = self.tile_mut(tc); + let mut t = self.tile_mut(tc).bytes_mut(); let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - t.data[dst..dst + span].copy_from_slice(&bytes[src..src + span]); + t[dst..dst + span].copy_from_slice(&bytes[src..src + span]); } } } @@ -951,10 +1095,10 @@ impl Surface { let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let dst = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; - match self.tiles.get(&tc) { + match self.tiles.get(&tc).map(|t| t.bytes()) { Some(t) => { let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + out[dst..dst + span].copy_from_slice(&t[src..src + span]); } None => { for p in out[dst..dst + span].chunks_exact_mut(bpp) { @@ -1021,7 +1165,8 @@ impl Surface { && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); if covered && let Some(shared) = uniform.window(self, sr) { // A tile of only the default pixel is left out, as `prune` would. - return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared)); + let keep = shared.bytes().get(..bpp) != Some(&*self.default_pixel); + return keep.then_some((dc, shared)); } // The identities of the source tiles under a covered window (the offset is the same // for every target of this call). @@ -1032,7 +1177,7 @@ impl Surface { return hit.clone().map(|t| (dc, t)); } - let mut tile = Tile { data: scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8).into_boxed_slice() }; + let mut data = scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8); let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); let row_len = TILE_SIZE as usize * bpp; // Per stored row: copy the source span that falls inside the content in as few @@ -1040,7 +1185,7 @@ impl Surface { // row — no whole-tile pre-fill, which rewrote nearly every byte twice. for local_y in 0..i64::from(TILE_SIZE) { let y = ty0 + local_y; - let Some(row) = tile.data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; + let Some(row) = data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); // The row's target columns whose SOURCE lies inside the content are copied // (the copy loop crosses source tiles as needed); the rest stays default — @@ -1066,7 +1211,7 @@ impl Surface { let mut x = cx0.max(tx0); let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); // Rows run over the same one or two source tiles: cache the last lookup. - let mut cached: Option<(i64, i64, &std::sync::Arc)> = None; + let mut cached: Option<(i64, i64, TileBytes<'_>)> = None; while x < x_end { // The source document position this target column reads, and the source // tile it lives in (the shift crosses source tiles as x runs). @@ -1075,16 +1220,9 @@ impl Surface { // A missing source tile inside the content holds the default pixel, not // zeros: fill the span from the default row instead of skipping it // (echelon's white-mask-with-sparse-tiles case). - let st = match cached.take() { - Some((ctx, cty, t)) if ctx == tx && cty == ty => { - cached = Some((ctx, cty, t)); - t - } - _ => match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { - Some(t) => { - cached = Some((tx, ty, t)); - t - } + if !matches!(&cached, Some((ctx, cty, _)) if *ctx == tx && *cty == ty) { + match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { + Some(t) => cached = Some((tx, ty, t.bytes())), None => { let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); if !fill_row.is_empty() { @@ -1097,13 +1235,14 @@ impl Surface { x = span_end; continue; } - }, - }; + } + } + let Some((_, _, st)) = &cached else { break }; let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); let span = (span_end - x) as usize * bpp; let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; let dst = (x - tx0) as usize * bpp; - if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.data.get(src..src + span)) { + if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.get(src..src + span)) { d.copy_from_slice(sr); } x = span_end; @@ -1111,7 +1250,12 @@ impl Surface { } // A target that got only default pixels isn't stored, as `prune` would drop it. Checked // after the copy, a row at a time: a tile with content stops at its first such row. - let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile)); + let finished = if data.chunks(default_row.len()).any(|row| row != default_row) { + Some(Arc::new(Tile::from_vec(data))) + } else { + scratch::give(data); + None + }; // Workers that missed the memo at the same time each built a copy: all of them take // the one stored first, so the targets share it however many threads raced. let finished = match memo_key { @@ -1156,13 +1300,15 @@ impl Surface { continue; } let tile = self.tile_mut(c); + let mut bytes = tile.bytes_mut(); for i in 0..px_count { for k in 0..color { let at = i * n + k; - let v = read_sample(&tile.data, format.sample, at); - write_sample(&mut tile.data, format.sample, at, f(v)); + let v = read_sample(&bytes, format.sample, at); + write_sample(&mut bytes, format.sample, at, f(v)); } } + drop(bytes); if let Some(mapped) = self.tiles.get(&c) { done.insert(key, mapped.clone()); } @@ -1181,17 +1327,19 @@ impl Surface { out.tiles.insert(*c, converted.clone()); continue; } - let dst = out.tile_mut(*c); + let source = t.bytes(); + let mut dst = out.tile_mut(*c).bytes_mut(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { for (k, v) in src.iter_mut().enumerate() { - *v = read_sample(&t.data, self.format.sample, i * from_n + k); + *v = read_sample(&source, self.format.sample, i * from_n + k); } let px = convert_pixel(&self.format, &to, &src); for (k, v) in px.iter().enumerate() { - write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + write_sample(&mut dst, to.sample, i * to.channels() + k, *v); } } + drop(dst); if let Some(converted) = out.tiles.get(c) { done.insert(Arc::as_ptr(t), converted.clone()); } @@ -1213,16 +1361,19 @@ impl Surface { continue; } let dst = out.try_tile_mut(*c)?; + let source_bytes = t.try_bytes().map_err(AllocationError::ScratchRead)?; + let mut dest_bytes = dst.bytes_mut(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { for (k, v) in src.iter_mut().enumerate() { - *v = read_sample(&t.data, self.format.sample, i * from_n + k); + *v = read_sample(&source_bytes, self.format.sample, i * from_n + k); } let px = convert_pixel(&self.format, &to, &src); for (k, v) in px.iter().enumerate() { - write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + write_sample(&mut dest_bytes, to.sample, i * to.channels() + k, *v); } } + drop(dest_bytes); if let Some(converted) = out.tiles.get(c) { done.insert(Arc::as_ptr(t), converted.clone()); } diff --git a/crates/raster/src/memory.rs b/crates/raster/src/memory.rs new file mode 100644 index 0000000000..5ad3df50d0 --- /dev/null +++ b/crates/raster/src/memory.rs @@ -0,0 +1,229 @@ +//! Process-wide memory admission and adaptive tile residency. Native measurements are safe +//! `sysinfo` calls; web clients retain their explicit MB limit without filesystem assumptions. + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; + +const MIB: u64 = 1 << 20; + +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub total_bytes: u64, + pub available_bytes: u64, + pub process_resident_bytes: u64, + /// Address-space/virtual usage; this is not a portable measure of committed memory. + pub process_virtual_bytes: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct Policy { + /// None keeps the pre-streaming absolute MB setting. + pub percent: Option, + pub absolute_bytes: u64, +} + +impl Default for Policy { + fn default() -> Self { + Self { percent: None, absolute_bytes: 8 << 30 } + } +} + +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Budgets { + pub process_ceiling_bytes: u64, + pub effective_process_bytes: u64, + pub tile_bytes: u64, + pub transient_bytes: u64, +} + +/// Other applications and untiled buffers count against the same ceiling. Keep both system +/// headroom and a working reserve; virtual address-space size is deliberately not treated as RSS. +pub fn budgets(policy: Policy, sample: Snapshot, resident_tiles: u64) -> Budgets { + let ceiling = match (policy.percent, sample.total_bytes) { + (Some(p), total) if total > 0 => total / 100 * u64::from(p.clamp(1, 95)), + _ => policy.absolute_bytes, + } + .max(MIB); + let headroom = (sample.total_bytes / 20).max(256 * MIB); + let effective = if sample.total_bytes == 0 { + ceiling + } else { + ceiling.min(sample.process_resident_bytes.saturating_add(sample.available_bytes).saturating_sub(headroom)) + } + .max(MIB); + let transient = (effective / 8).clamp(MIB, 2 << 30); + let other = sample.process_resident_bytes.saturating_sub(resident_tiles); + let tiles = effective.saturating_sub(other).saturating_sub(transient).max(MIB); + Budgets { process_ceiling_bytes: ceiling, effective_process_bytes: effective, tile_bytes: tiles, transient_bytes: transient } +} + +static POLICY: Mutex = Mutex::new(Policy { percent: None, absolute_bytes: 8 << 30 }); +static CURRENT: Mutex<(Snapshot, Budgets)> = Mutex::new(( + Snapshot { total_bytes: 0, available_bytes: 0, process_resident_bytes: 0, process_virtual_bytes: 0 }, + Budgets { process_ceiling_bytes: 8 << 30, effective_process_bytes: 8 << 30, tile_bytes: 7 << 30, transient_bytes: 1 << 30 }, +)); +static RESERVED: AtomicU64 = AtomicU64::new(0); +static CONFIGURATION: AtomicU64 = AtomicU64::new(0); + +pub fn configuration_generation() -> u64 { + CONFIGURATION.load(Ordering::Relaxed) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn measure() -> Snapshot { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + static SYSTEM: OnceLock> = OnceLock::new(); + let mut s = SYSTEM.get_or_init(|| Mutex::new(System::new())).lock().unwrap_or_else(PoisonError::into_inner); + s.refresh_memory(); + let pid = Pid::from_u32(std::process::id()); + s.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), true, ProcessRefreshKind::nothing().with_memory()); + Snapshot { + total_bytes: s.total_memory(), + available_bytes: s.available_memory(), + process_resident_bytes: s.process(pid).map_or(0, |p| p.memory()), + process_virtual_bytes: s.process(pid).map_or(0, |p| p.virtual_memory()), + } +} + +#[cfg(target_arch = "wasm32")] +pub fn measure() -> Snapshot { + Snapshot::default() +} + +pub fn refresh() -> Budgets { + let policy = *POLICY.lock().unwrap_or_else(PoisonError::into_inner); + let sample = measure(); + let b = budgets(policy, sample, crate::spill::stats().resident_bytes as u64); + *CURRENT.lock().unwrap_or_else(PoisonError::into_inner) = (sample, b); + // The test override remains fixed even when process pressure changes. + let tiles = std::env::var("PHOTOCRAFT_TILE_BUDGET_MB").ok().and_then(|s| s.parse::().ok()).map_or(b.tile_bytes, |mb| mb.saturating_mul(MIB)); + crate::spill::set_budget(usize::try_from(tiles).unwrap_or(usize::MAX)); + b +} + +pub fn configure(policy: Policy) -> Budgets { + *POLICY.lock().unwrap_or_else(PoisonError::into_inner) = policy; + CONFIGURATION.fetch_add(1, Ordering::Relaxed); + refresh() +} + +pub fn stats() -> (Snapshot, Budgets, u64) { + let (s, b) = *CURRENT.lock().unwrap_or_else(PoisonError::into_inner); + (s, b, RESERVED.load(Ordering::Relaxed)) +} + +/// A bounded temporary working set. Callers retain the guard until the allocation is released. +pub struct Reservation(u64); + +pub fn try_reserve(bytes: u64) -> Result { + let (_, b, _) = stats(); + RESERVED + .try_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= b.transient_bytes)) + .map_err(|_| "not enough working memory; reduce the operation or wait for other jobs".to_string())?; + Ok(Reservation(bytes)) +} + +/// Admit a legacy operation that needs a whole buffer, including concurrent working sets and +/// the current process RSS. Streaming workers keep the smaller [`try_reserve`] allowance. +/// This deliberately refuses work before an allocation rather than relying on OS paging. +pub fn try_reserve_operation(bytes: u64) -> Result { + let b = refresh(); + let (sample, _, _) = stats(); + // Resident tiles are evictable when spilling is on, so only the untiled part of RSS is + // charged. The transient allowance is always admissible: it is what the budget reserves + // for working sets, so normal-sized operations are never refused by a high RSS sample. + let evictable = if crate::spill::enabled() { crate::spill::stats().resident_bytes as u64 } else { 0 }; + let untiled = sample.process_resident_bytes.saturating_sub(evictable); + let available = if sample.total_bytes == 0 { b.transient_bytes } else { b.effective_process_bytes.saturating_sub(untiled).max(b.transient_bytes) }; + let refused = || "not enough working memory for this operation; reduce its region or use a streaming format".to_string(); + if bytes > available { + return Err(refused()); + } + // It fits on its own: wait (bounded) for concurrent operations to return their working + // sets instead of failing an operation that only overlapped another one. + #[cfg(not(target_arch = "wasm32"))] + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if RESERVED.try_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= available)).is_ok() { + return Ok(Reservation(bytes)); + } + #[cfg(target_arch = "wasm32")] + return Err(refused()); + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::Instant::now(); + if now >= deadline { + return Err(refused()); + } + let guard = RELEASED.0.lock().unwrap_or_else(PoisonError::into_inner); + // Re-checked after the wake-up; a missed notification costs at most this slice. + let slice = deadline.saturating_duration_since(now).min(std::time::Duration::from_millis(50)); + drop(RELEASED.1.wait_timeout(guard, slice).unwrap_or_else(PoisonError::into_inner)); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +static RELEASED: (Mutex<()>, std::sync::Condvar) = (Mutex::new(()), std::sync::Condvar::new()); + +impl Drop for Reservation { + fn drop(&mut self) { + RESERVED.fetch_sub(self.0, Ordering::AcqRel); + #[cfg(not(target_arch = "wasm32"))] + { + let _guard = RELEASED.0.lock().unwrap_or_else(PoisonError::into_inner); + RELEASED.1.notify_all(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ceiling_includes_non_tile_memory_and_other_apps() { + let s = Snapshot { total_bytes: 64 << 30, available_bytes: 10 << 30, process_resident_bytes: 20 << 30, process_virtual_bytes: 100 << 30 }; + let b = budgets(Policy { percent: Some(90), absolute_bytes: 8 << 30 }, s, 15 << 30); + assert!(b.process_ceiling_bytes > 57 << 30); + assert!(b.effective_process_bytes < 30 << 30); + assert!(b.tile_bytes + (5 << 30) + b.transient_bytes <= b.effective_process_bytes); + } + #[test] + fn old_absolute_setting_and_missing_measurements_work() { + let b = budgets(Policy { percent: None, absolute_bytes: 512 * MIB }, Snapshot::default(), 0); + assert_eq!(b.process_ceiling_bytes, 512 * MIB); + assert_eq!(b.tile_bytes + b.transient_bytes, b.effective_process_bytes); + } + #[test] + fn hostile_percent_and_pressure_do_not_overflow() { + let b = budgets(Policy { percent: Some(255), absolute_bytes: u64::MAX }, Snapshot { total_bytes: u64::MAX, ..Snapshot::default() }, u64::MAX); + assert!(b.process_ceiling_bytes < u64::MAX); + assert_eq!(b.tile_bytes, MIB); + } + #[test] + fn an_operation_that_fits_alone_waits_for_a_concurrent_one() { + let available = refresh().transient_bytes; + let first = try_reserve_operation(available / 2 + 1).unwrap(); + let releaser = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + drop(first); + }); + let second = try_reserve_operation(available / 2 + 1); + releaser.join().unwrap(); + assert!(second.is_ok(), "overlapping work is serialized, not refused"); + assert!(try_reserve_operation(u64::MAX).is_err(), "work that can never fit is refused at once"); + } + #[test] + fn reservations_are_bounded_and_returned() { + let before = stats().2; + let r = try_reserve(1024).unwrap(); + assert_eq!(stats().2, before + 1024); + assert!(try_reserve(u64::MAX).is_err()); + drop(r); + assert_eq!(stats().2, before); + } +} diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs new file mode 100644 index 0000000000..bcb46c3d0f --- /dev/null +++ b/crates/raster/src/scratch_file.rs @@ -0,0 +1,493 @@ +//! The scratch file: where cold tiles go when pixel memory is over budget. +//! +//! - One file per configured directory, never visible after the process ends. Unix creates it +//! with private permissions and unlinks it right after opening, so the space is returned when +//! the last handle closes, even on a crash or `exit` without destructors. Windows opens it +//! delete-on-close. Files of dead processes (an older build, or a crash between creating and +//! unlinking) are reaped when the directory is next configured. +//! - Space is handed out in 4 KiB-aligned extents from a free list (best fit, coalescing), else +//! appended at the end. +//! - All I/O is positioned: no shared cursor, no lock held during I/O. The allocator lock only +//! guards the free list. +//! - Unix reads and writes with `pread`/`pwrite` on the one handle (no reopen by path, which +//! is impossible once unlinked). Windows reads through one handle per thread (the file opened +//! again by path): it serialises I/O on one synchronous handle, so parallel readers sharing it +//! would run one at a time. +//! +//! Ported from Fotox's `fx-tiles` scratch file (same author), minus its `unsafe` `ReOpenFile`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +const ALIGN: u64 = 4096; + +/// A region of the scratch file holding one compressed tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Extent { + pub offset: u64, + /// Exact length of the data. + pub len: u32, + /// Allocated length (multiple of 4 KiB, ≥ `len`). + pub alloc: u64, + /// CRC-32 of the stored bytes, checked on every read. + pub crc32: u32, +} + +/// Free-list allocator. Pure bookkeeping, no I/O. +#[derive(Debug, Default)] +pub(crate) struct Allocator { + by_offset: BTreeMap, + by_size: BTreeSet<(u64, u64)>, + end: u64, +} + +impl Allocator { + /// Bytes currently allocated (end of file minus free space). + pub fn used(&self) -> u64 { + self.end - self.by_offset.values().sum::() + } + + pub fn alloc(&mut self, len: u32) -> Extent { + let size = u64::from(len).div_ceil(ALIGN).max(1) * ALIGN; + if let Some(&(free_size, offset)) = self.by_size.range((size, 0)..).next() { + self.remove_free(offset, free_size); + if free_size > size { + self.insert_free(offset + size, free_size - size); + } + return Extent { offset, len, alloc: size, crc32: 0 }; + } + let offset = self.end; + self.end += size; + Extent { offset, len, alloc: size, crc32: 0 } + } + + pub fn free(&mut self, extent: Extent) { + let mut offset = extent.offset; + let mut size = extent.alloc; + if let Some((&prev_off, &prev_size)) = self.by_offset.range(..offset).next_back() + && prev_off + prev_size == offset + { + self.remove_free(prev_off, prev_size); + offset = prev_off; + size += prev_size; + } + if let Some(&next_size) = self.by_offset.get(&(offset + size)) { + self.remove_free(offset + size, next_size); + size += next_size; + } + if offset + size == self.end { + // Free space at the end of the file just shrinks the used range. + self.end = offset; + } else { + self.insert_free(offset, size); + } + } + + fn insert_free(&mut self, offset: u64, size: u64) { + self.by_offset.insert(offset, size); + self.by_size.insert((size, offset)); + } + + fn remove_free(&mut self, offset: u64, size: u64) { + self.by_offset.remove(&offset); + self.by_size.remove(&(size, offset)); + } +} + +pub(crate) struct ScratchFile { + main: File, + #[cfg(windows)] + readers: OnceLock>, + path: PathBuf, + /// The directory entry still exists (Unix unlink failed): remove it on drop. + #[cfg(not(windows))] + linked: bool, + allocator: Mutex, +} + +impl std::fmt::Debug for ScratchFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScratchFile").field("path", &self.path).finish() + } +} + +impl ScratchFile { + pub fn create(dir: &Path) -> std::io::Result { + std::fs::create_dir_all(dir)?; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + // A reused process id must not collide with an abandoned file from an older launch. + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128); + let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000; + options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE); + } + let main = options.open(&path)?; + #[cfg(unix)] + reap_abandoned(dir, &main); + // Unix: the open handle keeps the inode; nothing is left behind at any exit. A + // filesystem that refuses the unlink keeps the name until drop or the next reap. + #[cfg(not(windows))] + let linked = std::fs::remove_file(&path).is_err(); + Ok(Self { + main, + #[cfg(windows)] + readers: OnceLock::new(), + path, + #[cfg(not(windows))] + linked, + allocator: Mutex::new(Allocator::default()), + }) + } + + /// Another handle to the open file (the Unix one has no name), for fault-injection tests. + pub(crate) fn duplicate_handle(&self) -> std::io::Result { + self.main.try_clone() + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Reserve space and write `data` there. + pub fn write(&self, data: &[u8]) -> std::io::Result { + let len = u32::try_from(data.len()).map_err(|_| std::io::Error::other("block larger than 4 GiB"))?; + let mut extent = self.lock().alloc(len); + if let Err(e) = write_all_at(&self.main, data, extent.offset) { + self.free(extent); + return Err(e); + } + extent.crc32 = crc32fast::hash(data); + Ok(extent) + } + + /// Read an extent into `buf` (resized to it) and check its CRC. + pub fn read_into(&self, extent: Extent, buf: &mut Vec) -> std::io::Result<()> { + buf.resize(extent.len as usize, 0); + read_exact_at(self.reader(), buf, extent.offset)?; + if crc32fast::hash(buf) != extent.crc32 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("scratch CRC mismatch at offset {}", extent.offset))); + } + Ok(()) + } + + pub fn free(&self, extent: Extent) { + let mut allocator = self.lock(); + allocator.free(extent); + if allocator.end == 0 { + // Release the physical disk space after the last document/history tile closes. + // Keep the allocator locked so a new writer cannot reserve an extent before + // this truncation; failures retain excess space, never live tile bytes. + let _ = self.main.set_len(0); + } + } + + pub fn used(&self) -> u64 { + self.lock().used() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Allocator> { + self.allocator.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Positioned reads on one handle run in parallel on Unix. + #[cfg(not(windows))] + fn reader(&self) -> &File { + &self.main + } + + /// A handle for reading on the calling thread. + #[cfg(windows)] + fn reader(&self) -> &File { + let extra = self.readers.get_or_init(|| { + // The same file opened again by path: std shares read, write and delete, so this + // works while `main` (opened delete-on-close) is alive. A failure leaves fewer + // handles, never an error. + let count = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 16) - 1; + (0..count).map_while(|_| std::fs::OpenOptions::new().read(true).open(&self.path).ok()).collect() + }); + match thread_slot() % (extra.len() + 1) { + 0 => &self.main, + i => extra.get(i - 1).unwrap_or(&self.main), + } + } +} + +/// Accept only the names we generate, never other temporary files or directories. +#[cfg(any(unix, test))] +fn scratch_pid(name: &str) -> Option { + let (pid, nonce) = name.strip_prefix("photocraft-")?.strip_suffix(".scratch")?.split_once('-')?; + if pid.is_empty() || nonce.is_empty() || !pid.bytes().chain(nonce.bytes()).all(|b| b.is_ascii_digit()) { + return None; + } + let pid = pid.parse::().ok()?; + (pid > 0 && nonce.parse::().is_ok()).then_some(pid) +} + +#[cfg(unix)] +fn reap_abandoned(dir: &Path, own: &File) { + use std::os::unix::fs::MetadataExt; + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + let Ok(metadata) = own.metadata() else { return }; + let mut system = System::new(); + system.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::nothing()); + // Fail closed when process enumeration isn't available. Live files are never removed. + if system.process(Pid::from_u32(std::process::id())).is_none() { + return; + } + reap_with(dir, metadata.uid(), |pid| system.process(Pid::from_u32(pid)).is_some()); +} + +#[cfg(unix)] +fn reap_with(dir: &Path, uid: u32, live: impl Fn(u32) -> bool) { + use std::os::unix::fs::MetadataExt; + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(scratch_pid) else { continue }; + if pid == std::process::id() || live(pid) { + continue; + } + let path = entry.path(); + let Ok(meta) = std::fs::symlink_metadata(&path) else { continue }; + // Shared /tmp: do not remove other users' files or follow symlinks. + if meta.file_type().is_file() && meta.uid() == uid { + let _ = std::fs::remove_file(path); + } + } +} + +impl Drop for ScratchFile { + fn drop(&mut self) { + // Windows deletes on close and Unix unlinked at creation; only a refused unlink remains. + #[cfg(not(windows))] + if self.linked { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// A stable small number per thread, handed out in turn, so the worker threads of a pool land +/// on different handles. +#[cfg(windows)] +fn thread_slot() -> usize { + static NEXT: AtomicUsize = AtomicUsize::new(0); + thread_local! { + static SLOT: usize = NEXT.fetch_add(1, Ordering::Relaxed); + } + SLOT.with(|slot| *slot) +} + +#[cfg(unix)] +fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> { + std::os::unix::fs::FileExt::write_all_at(file, data, offset) +} + +#[cfg(unix)] +fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(file, buf, offset) +} + +#[cfg(windows)] +fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !data.is_empty() { + let n = file.seek_write(data, offset)?; + if n == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + data = data.get(n..).unwrap_or_default(); + offset += n as u64; + } + Ok(()) +} + +#[cfg(windows)] +fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !buf.is_empty() { + let n = file.seek_read(buf, offset)?; + if n == 0 { + return Err(std::io::ErrorKind::UnexpectedEof.into()); + } + buf = buf.get_mut(n..).unwrap_or_default(); + offset += n as u64; + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(not(any(unix, windows)))] +fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleanup_names_are_strict() { + assert_eq!(scratch_pid("photocraft-123-987.scratch"), Some(123)); + for name in [ + "photocraft-0-1.scratch", + "photocraft-1-.scratch", + "photocraft-1-../notes.scratch", + "photocraft-1-2.scratch.bak", + "other-1-2.scratch", + "photocraft-4294967296-2.scratch", + ] { + assert_eq!(scratch_pid(name), None, "{name}"); + } + } + + #[cfg(any(unix, windows))] + #[test] + fn abrupt_exit_does_not_leave_permanent_scratch_files() { + const CHILD_DIR: &str = "PHOTOCRAFT_SCRATCH_EXIT_TEST_DIR"; + if let Some(dir) = std::env::var_os(CHILD_DIR) { + let file = ScratchFile::create(Path::new(&dir)).unwrap(); + file.write(b"unreleased pixels").unwrap(); + // No Rust destructors run: this exercises the lifetime of the static manager. + std::process::exit(0); + } + let dir = std::env::temp_dir().join(format!("pc-abrupt-exit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "scratch_file::tests::abrupt_exit_does_not_leave_permanent_scratch_files", "--nocapture"]) + .env(CHILD_DIR, &dir) + .status() + .unwrap(); + assert!(result.success()); + // Windows: delete-on-close. Unix: unlinked right after opening. Neither needs Drop. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no scratch file outlives an exit without destructors"); + let next = ScratchFile::create(&dir).unwrap(); + // Unix unlinks the open file; Windows keeps the entry until the delete-on-close handle closes. + let live_entries = if cfg!(unix) { 0 } else { 1 }; + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), live_entries, "a live scratch file is unlinked on Unix only"); + next.write(b"still usable").unwrap(); + drop(next); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "closing the last scratch file leaves the directory empty"); + std::fs::remove_dir(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn unix_files_are_private_and_reaping_preserves_live_and_unrelated_files() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let dir = std::env::temp_dir().join(format!("pc-reap-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let own = ScratchFile::create(&dir).unwrap(); + let meta = own.main.metadata().unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + let stale = dir.join("photocraft-123-1.scratch"); + let live = dir.join("photocraft-124-1.scratch"); + let unrelated = dir.join("notes.scratch"); + let link = dir.join("photocraft-125-1.scratch"); + for p in [&stale, &live, &unrelated] { + std::fs::write(p, b"test").unwrap(); + } + std::os::unix::fs::symlink(&unrelated, &link).unwrap(); + reap_with(&dir, meta.uid().wrapping_add(1), |_| false); + assert!(stale.exists(), "another owner's files remain"); + reap_with(&dir, meta.uid(), |pid| pid == 124); + assert!(!stale.exists()); + assert!(live.exists() && unrelated.exists() && link.exists()); + assert!(!own.path.exists() && !own.linked, "the open scratch file is already unlinked"); + drop(own); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn parallel_positioned_reads_share_one_unlinked_file() { + let dir = std::env::temp_dir().join(format!("pc-scratch-parallel-{}", std::process::id())); + let scratch = ScratchFile::create(&dir).unwrap(); + let extents: Vec<_> = (0..32u8).map(|i| scratch.write(&vec![i; 5000 + usize::from(i)]).unwrap()).collect(); + std::thread::scope(|s| { + for t in 0..8 { + let (scratch, extents) = (&scratch, &extents); + s.spawn(move || { + let mut buf = Vec::new(); + for (i, e) in extents.iter().enumerate().skip(t) { + scratch.read_into(*e, &mut buf).unwrap(); + assert_eq!(buf, vec![i as u8; 5000 + i]); + } + }); + } + }); + drop(scratch); + std::fs::remove_dir(dir).unwrap(); + } + + #[test] + fn alloc_reuses_and_coalesces() { + let mut a = Allocator::default(); + let x = a.alloc(100); + let y = a.alloc(5000); + let z = a.alloc(4096); + assert_eq!((x.offset, y.offset, z.offset), (0, 4096, 12288)); + assert_eq!(a.used(), 16384); + a.free(x); + a.free(y); // coalesces with x: one free extent of 12 KiB at 0 + let w = a.alloc(12000); + assert_eq!(w.offset, 0, "coalesced hole is reused"); + a.free(z); // tail extent: the file end shrinks + assert_eq!(a.used(), 12288); + a.free(w); + assert_eq!(a.used(), 0); + assert_eq!(a.end, 0, "everything freed collapses the file"); + } + + #[test] + fn best_fit_splits_larger_holes() { + let mut a = Allocator::default(); + let big = a.alloc(40000); + let _tail = a.alloc(1); + a.free(big); + assert_eq!(a.alloc(10).offset, 0); + assert_eq!(a.alloc(10).offset, 4096, "remainder of the split hole is used next"); + } + + #[test] + fn file_roundtrip_and_crc() { + let dir = std::env::temp_dir().join("photocraft-scratch-test"); + let scratch = ScratchFile::create(&dir).unwrap(); + let a = scratch.write(b"hello tiles").unwrap(); + let b = scratch.write(&[7u8; 9000]).unwrap(); + let mut buf = Vec::new(); + scratch.read_into(a, &mut buf).unwrap(); + assert_eq!(buf, b"hello tiles"); + scratch.read_into(b, &mut buf).unwrap(); + assert_eq!(buf, vec![7u8; 9000]); + let bad = Extent { crc32: a.crc32 ^ 1, ..a }; + assert!(scratch.read_into(bad, &mut buf).is_err(), "a CRC mismatch is an error, not garbage"); + scratch.free(a); + scratch.free(b); + assert_eq!(scratch.main.metadata().unwrap().len(), 0, "closing the last tile releases physical disk space"); + let path = scratch.path().to_path_buf(); + drop(scratch); + assert!(!path.exists(), "scratch file is removed on drop"); + } +} diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs new file mode 100644 index 0000000000..26e06785b7 --- /dev/null +++ b/crates/raster/src/spill.rs @@ -0,0 +1,810 @@ +//! Scratch disk: tile pixels beyond a memory budget move to a file and come back on access. +//! +//! Every [`Tile`](crate::Tile) keeps its bytes in a [`Cell`]: resident (`Some`) or on disk +//! (`None`, with its [`Extent`] in the scratch file). Readers take the cell's read lock, so a +//! tile in use is never evicted; a spilled tile is read back (LZ4 + CRC-checked) by whoever +//! touches it first. +//! +//! - **Accounting.** [`stats`] counts the bytes of every live tile that is resident, whether or +//! not a scratch disk is configured. +//! - **Eviction** is the clock ("second chance") algorithm over a registry of weak cell +//! pointers: a tile touched since the hand last passed keeps its place once. A background +//! thread evicts down to 90 % of the budget; a thread that pushes memory past 125 % helps. +//! - **Clean tiles are free to drop.** A tile written once and not changed since keeps its +//! extent, so evicting it again only frees memory; changing it frees the extent. +//! - **Off by default.** Until [`configure`] sets a directory and a budget nothing is evicted +//! (tests, the web build). +//! +//! Failures never panic: a scratch write or file creation that fails, or a volume running low +//! on free space, keeps the tile in memory and pauses spilling (reported in +//! [`SpillStats::last_error`]); it is retried with exponential backoff and resumes on its own. +//! [`epoch`] tells owners of memory caps (history) to re-apply them meanwhile. A failed read retains its disk extent and +//! poisons the tile: edits and saves check the health of the document they publish, plus +//! [`check_since`] for failures that came and went during the operation. The +//! infallible display API uses a temporary placeholder, never replacement pixel storage. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; +use std::time::{Duration, Instant}; + +use crate::scratch_file::{Extent, ScratchFile}; + +/// One tile's bytes and where they live. +pub(crate) struct Cell { + data: RwLock>>, + /// Byte length of the tile (fixed for its lifetime). + len: usize, + error: Mutex>, + /// A copy on disk that matches `data` (kept while the tile is unchanged). + slot: Mutex, Extent)>>, + /// Touched since the clock hand last passed. + referenced: AtomicBool, +} + +impl Cell { + pub(crate) fn new(bytes: Vec) -> Arc { + let len = bytes.len(); + let cell = Arc::new(Cell { data: RwLock::new(Some(bytes)), len, error: Mutex::new(None), slot: Mutex::new(None), referenced: AtomicBool::new(true) }); + M.live.fetch_add(1, Ordering::Relaxed); + M.resident.fetch_add(len, Ordering::Relaxed); + M.register(&cell); + maybe_evict(); + cell + } + + pub(crate) fn len(&self) -> usize { + self.len + } + + pub(crate) fn read_error(&self) -> Option { + self.error.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + fn set_error(&self, error: Option) { + let mut old = self.error.lock().unwrap_or_else(PoisonError::into_inner); + match (old.is_some(), error.is_some()) { + (false, true) => { + M.failed.fetch_add(1, Ordering::Release); + M.read_errors.fetch_add(1, Ordering::Release); + } + (true, false) => { + // Invalidate computations that started while pixels were unreadable too. + // Publish this before reporting the tile healthy to commit guards. + M.read_errors.fetch_add(1, Ordering::Release); + M.failed.fetch_sub(1, Ordering::Release); + } + _ => {} + } + *old = error; + } + + #[inline] + fn touch(&self) { + if !self.referenced.load(Ordering::Relaxed) { + self.referenced.store(true, Ordering::Relaxed); + } + } + + /// Read access; reads the tile back from the scratch disk if it was evicted. + pub(crate) fn read(&self) -> RwLockReadGuard<'_, Option>> { + let mut loaded_pixels = false; + loop { + self.touch(); + let g = self.data.read().unwrap_or_else(PoisonError::into_inner); + if g.is_some() { + if loaded_pixels { + // Pin the tile before eviction: even a budget smaller than one tile + // must not repeatedly evict the pixels this reader just loaded. + maybe_evict(); + } + return g; + } + drop(g); + let mut w = self.data.write().unwrap_or_else(PoisonError::into_inner); + let loaded = self.load(&mut w).is_ok(); + drop(w); + if !loaded { + return self.data.read().unwrap_or_else(PoisonError::into_inner); + } + loaded_pixels = true; + } + } + + /// Write access: resident, and any disk copy is released (it no longer matches). + pub(crate) fn write(&self) -> RwLockWriteGuard<'_, Option>> { + self.touch(); + let mut w = self.data.write().unwrap_or_else(PoisonError::into_inner); + let loaded = w.is_none(); + if self.load(&mut w).is_err() { + // Keep the only original copy. The write guard's temporary placeholder is + // discarded, and the transaction's integrity check refuses to commit it. + return w; + } + if let Some((file, extent)) = self.slot.lock().unwrap_or_else(PoisonError::into_inner).take() { + file.free(extent); + } + if loaded { + // Evicting here could only pick other tiles (this one is locked), so it is safe, and + // a long run of writes to spilled tiles must not outgrow the budget. + maybe_evict(); + } + w + } + + /// Fill `w` from the scratch disk if it is not resident (`w` is this cell's write guard). + fn load(&self, w: &mut Option>) -> Result<(), String> { + if w.is_some() { + return Ok(()); + } + let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner).clone(); + let bytes = match slot { + Some((file, extent)) => { + let mut packed = Vec::new(); + match file.read_into(extent, &mut packed).map_err(|e| e.to_string()).and_then(|()| unpack(&packed, self.len)) { + Ok(b) => b, + Err(e) => { + let error = format!("scratch read failed ({e}); editing and saving are blocked until the tile is readable"); + self.set_error(Some(error.clone())); + M.error(error.clone()); + return Err(error); + } + } + } + None => { + let error = "a spilled tile has no scratch copy; editing and saving are blocked".to_string(); + self.set_error(Some(error.clone())); + M.error(error.clone()); + return Err(error); + } + }; + *w = Some(bytes); + self.set_error(None); + M.resident.fetch_add(self.len, Ordering::Relaxed); + M.reloads.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + /// Move this tile to disk if nobody is using it. Returns the bytes freed. + fn evict(&self, file: &Arc) -> Result { + let Ok(mut w) = self.data.try_write() else { return Ok(0) }; + let Some(bytes) = w.as_ref() else { return Ok(0) }; + let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner); + if slot.is_none() { + let packed = lz4_flex::block::compress(bytes); + let extent = file.write(&packed).map_err(|e| format!("scratch write to {} failed: {e}", file.path().display()))?; + M.written.fetch_add(packed.len() as u64, Ordering::Relaxed); + *slot = Some((file.clone(), extent)); + } + drop(slot); + *w = None; + M.resident.fetch_sub(self.len, Ordering::Relaxed); + M.evictions.fetch_add(1, Ordering::Relaxed); + Ok(self.len) + } +} + +impl Drop for Cell { + fn drop(&mut self) { + if self.error.get_mut().unwrap_or_else(PoisonError::into_inner).is_some() { + M.read_errors.fetch_add(1, Ordering::Release); + M.failed.fetch_sub(1, Ordering::Release); + } + if let Some(bytes) = self.data.get_mut().unwrap_or_else(PoisonError::into_inner).take() { + M.resident.fetch_sub(self.len, Ordering::Relaxed); + // The next tile of this size reuses the buffer, as main's pool expects. + crate::scratch::give(bytes); + } + if let Some((file, extent)) = self.slot.get_mut().unwrap_or_else(PoisonError::into_inner).take() { + file.free(extent); + } + M.live.fetch_sub(1, Ordering::Relaxed); + } +} + +fn unpack(packed: &[u8], len: usize) -> Result, String> { + let mut bytes = crate::scratch::take_exact(len, 0u8); + match lz4_flex::block::decompress_into(packed, &mut bytes) { + Ok(n) if n == len => Ok(bytes), + Ok(n) => { + crate::scratch::give(bytes); + Err(format!("tile decoded to {n} bytes, expected {len}")) + } + Err(e) => { + crate::scratch::give(bytes); + Err(e.to_string()) + } + } +} + +/// Process-wide state. Plain statics: everything here is const-constructible. +struct Manager { + failed: AtomicUsize, + read_errors: AtomicU64, + resident: AtomicUsize, + live: AtomicUsize, + /// Pixel bytes to keep resident; 0 = spilling off or paused. + budget: AtomicUsize, + /// Spilling is paused after a write, create or free-space failure ([`Config::paused`]). + paused: AtomicBool, + /// Changes whenever spilling turns on or off, so owners of memory caps re-apply them. + epoch: AtomicU64, + /// Scratch bytes allowed before the volume would run low or the size cap is reached. + space_limit: AtomicU64, + /// Re-read the volume's free space once the scratch file grows past this. + next_space_check: AtomicU64, + /// A sweep wrote successfully since the last resume: reset the retry backoff. + recovering: AtomicBool, + registry: Mutex, + config: Mutex, + evictions: AtomicU64, + reloads: AtomicU64, + written: AtomicU64, +} + +struct Registry { + cells: Vec>, + hand: usize, +} + +struct Config { + dir: Option, + /// Opened at configuration time; startup also reaps abandoned Unix files. + file: Option>, + last_error: Option, + /// The budget [`configure`] / [`set_budget`] asked for; restored when a pause ends. + budget: usize, + paused: Option, + /// Delay before the next retry; doubles per failed retry up to [`RETRY_MAX`]. + backoff: Duration, + /// Explicit scratch size limit ([`set_size_cap`]); the free-space reserve always applies. + size_cap: Option, +} + +struct Pause { + message: String, + retry_at: Instant, +} + +const RETRY_FIRST: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(60); +/// Re-check free space after this much scratch growth. +const SPACE_CHECK_EVERY: u64 = 64 << 20; +/// Leave at least max(1 GiB, 5 % of the volume) free for the system and other programs. +const RESERVE_MIN: u64 = 1 << 30; + +static M: Manager = Manager { + failed: AtomicUsize::new(0), + read_errors: AtomicU64::new(0), + resident: AtomicUsize::new(0), + live: AtomicUsize::new(0), + budget: AtomicUsize::new(0), + paused: AtomicBool::new(false), + epoch: AtomicU64::new(0), + space_limit: AtomicU64::new(u64::MAX), + next_space_check: AtomicU64::new(0), + recovering: AtomicBool::new(false), + registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }), + config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None }), + evictions: AtomicU64::new(0), + reloads: AtomicU64::new(0), + written: AtomicU64::new(0), +}; + +impl Manager { + fn register(&self, cell: &Arc) { + let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); + r.cells.push(Arc::downgrade(cell)); + // Drop the pointers of freed tiles once they outnumber the live ones. + if r.cells.len() > self.live.load(Ordering::Relaxed).saturating_mul(2).saturating_add(4096) { + r.cells.retain(|w| w.strong_count() > 0); + r.hand = 0; + } + } + + fn config(&self) -> std::sync::MutexGuard<'_, Config> { + self.config.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn error(&self, msg: String) { + log_error(&msg); + self.config().last_error = Some(msg); + } + + /// Set the active budget, bumping [`epoch`] when spilling turns on or off. + fn set_active(&self, budget: usize) { + let old = self.budget.swap(budget, Ordering::AcqRel); + if (old > 0) != (budget > 0) { + self.epoch.fetch_add(1, Ordering::AcqRel); + } + } + + /// Pause spilling after a failure: every tile stays in memory (owners re-apply their + /// memory caps through [`epoch`]) and a retry is scheduled with exponential backoff. + fn pause(&self, c: &mut Config, reason: &str) { + let wait = c.backoff; + c.backoff = c.backoff.saturating_mul(2).min(RETRY_MAX); + let message = format!("{reason}; spilling is paused (tiles stay in memory), retrying in {} s", wait.as_secs()); + log_error(&message); + c.last_error = Some(message.clone()); + c.paused = Some(Pause { message, retry_at: Instant::now().checked_add(wait).unwrap_or_else(Instant::now) }); + self.recovering.store(false, Ordering::Relaxed); + self.paused.store(true, Ordering::Release); + self.set_active(0); + } + + /// End a pause: the directory is usable and has room again. + fn resume(&self, c: &mut Config) { + if let Some(p) = c.paused.take() + && c.last_error.as_deref() == Some(p.message.as_str()) + { + c.last_error = None; + } + self.paused.store(false, Ordering::Release); + self.recovering.store(true, Ordering::Relaxed); + self.set_active(c.budget); + } + + /// Open the scratch file and check its volume's room; pause (and say why) if either fails. + /// The caller holds the config lock. Returns whether spilling can go ahead. + fn prepare(&self, c: &mut Config) -> bool { + let Some(dir) = c.dir.clone() else { return false }; + if c.file.is_none() { + match std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir)) { + Ok(f) => c.file = Some(Arc::new(f)), + Err(e) => { + self.pause(c, &format!("could not create a scratch file in {}: {e}", dir.display())); + return false; + } + } + } + let used = c.file.as_ref().map_or(0, |f| f.used()); + refresh_space(c, &dir, used); + if used >= self.space_limit.load(Ordering::Relaxed) { + let msg = no_room(&dir, c.size_cap); + self.pause(c, &msg); + return false; + } + true + } + + /// Retry a paused scratch disk once its backoff has elapsed. Cheap when not paused. + fn retry(&self) { + if !self.paused.load(Ordering::Acquire) { + return; + } + let mut c = match self.config.try_lock() { + Ok(c) => c, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + // Someone else is configuring or retrying. + Err(std::sync::TryLockError::WouldBlock) => return, + }; + let due = c.paused.as_ref().is_some_and(|p| Instant::now() >= p.retry_at); + if !due { + return; + } + if c.dir.is_none() || c.budget == 0 { + c.paused = None; + self.paused.store(false, Ordering::Release); + return; + } + if self.prepare(&mut c) { + self.resume(&mut c); + } + } + + /// The scratch file, if spilling is on and the volume still has room. + fn file(&self) -> Option> { + let mut c = self.config(); + if self.budget.load(Ordering::Relaxed) == 0 { + return None; + } + if c.file.is_none() && !self.prepare(&mut c) { + return None; + } + c.file.clone() + } + + /// Whether the scratch file may grow further; re-reads free space every + /// [`SPACE_CHECK_EVERY`] bytes and pauses spilling when the volume runs low. + fn has_room(&self, file: &ScratchFile) -> bool { + let used = file.used(); + if used < self.next_space_check.load(Ordering::Relaxed) && used < self.space_limit.load(Ordering::Relaxed) { + return true; + } + let mut c = self.config(); + let Some(dir) = c.dir.clone() else { return false }; + refresh_space(&c, &dir, used); + if used < self.space_limit.load(Ordering::Relaxed) { + return true; + } + if c.paused.is_none() { + let msg = no_room(&dir, c.size_cap); + self.pause(&mut c, &msg); + } + false + } + + /// Evict until resident memory is at most `target` or `max_scan` cells were looked at. + /// Returns the bytes freed. + fn sweep(&self, target: usize, max_scan: usize) -> usize { + let Some(file) = self.file() else { return 0 }; + let mut freed = 0; + let mut scanned = 0; + while self.resident.load(Ordering::Relaxed) > target && scanned < max_scan { + // Pick victims under the registry lock (cheap), evict them after releasing it. + let mut victims = Vec::with_capacity(64); + { + let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); + let mut i = 0; + while victims.len() < 64 && i < 4096 && !r.cells.is_empty() { + if r.hand >= r.cells.len() { + r.hand = 0; + } + let h = r.hand; + i += 1; + match r.cells.get(h).and_then(Weak::upgrade) { + None => { + r.cells.swap_remove(h); + continue; + } + Some(cell) => { + r.hand += 1; + if cell.referenced.swap(false, Ordering::Relaxed) { + continue; + } + if cell.data.try_read().is_ok_and(|d| d.is_some()) { + victims.push(cell); + } + } + } + } + scanned += i; + if i == 0 { + break; + } + } + for cell in victims { + if !self.has_room(&file) { + return freed; + } + match cell.evict(&file) { + Ok(n) => freed += n, + Err(e) => { + // Disk full or gone: keep tiles in memory until a retry succeeds. + let mut c = self.config(); + if c.paused.is_none() { + self.pause(&mut c, &e); + } + return freed; + } + } + } + } + if freed > 0 && self.recovering.swap(false, Ordering::Relaxed) { + self.config().backoff = RETRY_FIRST; + } + freed + } +} + +/// Free space on the volume holding `dir`, minus the reserve, sets how far the file may grow. +fn refresh_space(c: &Config, dir: &Path, used: u64) { + let room = free_space(dir).map_or(u64::MAX, |(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20)))); + let limit = c.size_cap.map_or(room, |cap| cap.min(room)); + M.space_limit.store(limit, Ordering::Relaxed); + M.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed); +} + +fn no_room(dir: &Path, cap: Option) -> String { + match cap { + Some(cap) => format!("scratch disk {} reached its size limit ({} MB) or is low on free space", dir.display(), cap >> 20), + None => format!("scratch disk {} is low on free space", dir.display()), + } +} + +/// (available, total) bytes of the volume holding `dir`: the mount point that is its longest +/// prefix. `None` if unknown, which disables only the free-space check, not spilling. +#[cfg(not(target_arch = "wasm32"))] +fn free_space(dir: &Path) -> Option<(u64, u64)> { + let dir = std::fs::canonicalize(dir).ok()?; + let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()); + disks + .list() + .iter() + .filter(|d| dir.starts_with(d.mount_point())) + .max_by_key(|d| d.mount_point().as_os_str().len()) + .map(|d| (d.available_space(), d.total_space())) +} + +#[cfg(target_arch = "wasm32")] +fn free_space(_dir: &Path) -> Option<(u64, u64)> { + None +} + +/// Called after resident memory grew. +fn maybe_evict() { + let budget = M.budget.load(Ordering::Relaxed); + if budget == 0 { + M.retry(); + return; + } + let resident = M.resident.load(Ordering::Relaxed); + if resident <= budget { + return; + } + let worker = MAINTENANCE.load(Ordering::Acquire) > 0; + // Far over budget (or no worker thread): this thread helps. + if !worker || resident > budget.saturating_add(budget / 4) { + M.sweep(low_water(budget), 1 << 16); + } +} + +fn low_water(budget: usize) -> usize { + budget / 10 * 9 +} + +// The engine owns and joins the worker. Standalone raster consumers keep synchronous +// eviction; this crate never starts a background thread or retains its owner. +static MAINTENANCE: AtomicUsize = AtomicUsize::new(0); + +/// Register an externally owned residency worker. Keep this guard until that worker exits. +pub struct Maintenance; + +pub fn background_maintenance() -> Maintenance { + MAINTENANCE.fetch_add(1, Ordering::AcqRel); + Maintenance +} + +impl Drop for Maintenance { + fn drop(&mut self) { + MAINTENANCE.fetch_sub(1, Ordering::AcqRel); + } +} + +/// One bounded clock pass, plus a due retry of a paused scratch disk. The caller owns +/// scheduling and shutdown. +pub fn maintain_budget() -> usize { + M.retry(); + let budget = M.budget.load(Ordering::Relaxed); + if budget == 0 || M.resident.load(Ordering::Relaxed) <= budget { + return 0; + } + M.sweep(low_water(budget), 4096) +} + +/// Change the memory budget of a configured scratch disk (the memory limit moved). A paused +/// disk keeps its pause and resumes with this budget. +pub fn set_budget(budget_bytes: usize) { + let mut c = M.config(); + if c.dir.is_none() || c.budget == 0 { + return; + } + c.budget = budget_bytes.max(1); + if c.paused.is_none() { + M.set_active(c.budget); + } + drop(c); + maybe_evict(); +} + +/// Limit how large the scratch file may grow (`None`: only the free-space reserve applies). +/// Reaching the limit pauses spilling like a full disk; changing it retries a paused disk now. +pub fn set_size_cap(cap: Option) { + let mut c = M.config(); + if c.size_cap == cap { + return; + } + c.size_cap = cap; + M.next_space_check.store(0, Ordering::Relaxed); + if let Some(p) = &mut c.paused { + p.retry_at = Instant::now(); + c.backoff = RETRY_FIRST; + } + drop(c); + M.retry(); +} + +/// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk +/// stay readable either way. Changing the directory starts a new scratch file there; the old one +/// is deleted once its last tile is freed or changed. A directory that can't be used is kept and +/// retried with backoff (spilling stays paused meanwhile); the error is returned and reported. +pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> { + let mut c = M.config(); + let dir = dir.filter(|_| budget_bytes > 0).map(Path::to_path_buf); + if dir != c.dir { + c.file = None; + } + c.dir = dir.clone(); + c.budget = if dir.is_some() { budget_bytes } else { 0 }; + c.backoff = RETRY_FIRST; + c.paused = None; + c.last_error = None; + M.paused.store(false, Ordering::Release); + M.next_space_check.store(0, Ordering::Relaxed); + if dir.is_some() && !M.prepare(&mut c) { + return Err(c.last_error.clone().unwrap_or_default()); + } + M.set_active(c.budget); + drop(c); + maybe_evict(); + Ok(()) +} + +/// Changes whenever spilling turns on or off, including a pause after a disk error and the +/// resume after it. Owners of memory caps that depend on [`enabled`] re-apply them on a change. +pub fn epoch() -> u64 { + M.epoch.load(Ordering::Acquire) +} + +/// Whether spilling is paused after a scratch disk failure (it retries on its own). +pub fn paused() -> bool { + M.paused.load(Ordering::Acquire) +} + +/// Retry a paused scratch disk on the next maintenance pass instead of after its backoff. +#[doc(hidden)] +pub fn retry_now_for_tests() { + if let Some(p) = &mut M.config().paused { + p.retry_at = Instant::now(); + } +} + +/// Where the tile memory stands. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpillStats { + /// Pixel bytes of live tiles in memory. + pub resident_bytes: usize, + pub live_tiles: usize, + /// 0 = spilling off. + pub budget_bytes: usize, + pub dir: Option, + /// Bytes allocated in the current scratch file. + pub scratch_bytes: u64, + pub evictions: u64, + pub reloads: u64, + /// Compressed bytes written to scratch so far. + pub written_bytes: u64, + pub last_error: Option, + /// Spilling is paused after a scratch disk failure and will be retried. + pub paused: bool, + /// Scratch file size limit ([`set_size_cap`]); `None` = only the free-space reserve. + pub size_cap_bytes: Option, +} + +pub fn stats() -> SpillStats { + let c = M.config(); + SpillStats { + resident_bytes: M.resident.load(Ordering::Relaxed), + live_tiles: M.live.load(Ordering::Relaxed), + budget_bytes: M.budget.load(Ordering::Relaxed), + dir: c.dir.clone(), + scratch_bytes: c.file.as_ref().map_or(0, |f| f.used()), + evictions: M.evictions.load(Ordering::Relaxed), + reloads: M.reloads.load(Ordering::Relaxed), + written_bytes: M.written.load(Ordering::Relaxed), + last_error: c.last_error.clone(), + paused: c.paused.is_some(), + size_cap_bytes: c.size_cap, + } +} + +/// Last storage error, without scanning the scratch allocator for UI status updates. +pub fn last_error() -> Option { + M.config().last_error.clone() +} + +/// Whether spilling is on (tiles beyond the budget go to disk). +pub fn enabled() -> bool { + M.budget.load(Ordering::Relaxed) > 0 +} + +/// Whether any live tile in the process is currently unreadable (cheap; the fast path of +/// every per-document health check). +pub fn any_failed() -> bool { + M.failed.load(Ordering::Acquire) > 0 +} + +/// Process-wide status for the UI: some live tile, in any document, is unreadable. Commit +/// guards check only the pixels they publish ([`Tile::scratch_health`](crate::Tile::scratch_health) +/// and the document's health) so one document's failure does not block the others. +pub fn check_integrity() -> Result<(), String> { + if any_failed() { + Err(M.config().last_error.clone().unwrap_or_else(|| "scratch pixels are unreadable; editing and saving are blocked".into())) + } else { + Ok(()) + } +} + +/// Capture before an edit/import/save or derived cache computation. A tile that fails or +/// recovers while the operation runs might have fed it placeholder pixels and then been +/// dropped or repaired; its result must still never be committed as real pixels. Both failure +/// and recovery change this generation. Tiles that were already unreadable when the operation +/// began do not change it: callers also check the health of the pixels they read. +pub fn read_error_generation() -> u64 { + M.read_errors.load(Ordering::Acquire) +} + +/// Refuse a result if any tile failed or recovered since `generation` was captured. A retry +/// (capturing a new generation) succeeds once the transition is over. +pub fn check_since(generation: u64) -> Result<(), String> { + if generation == read_error_generation() { + Ok(()) + } else { + Err(last_error().unwrap_or_else(|| "scratch read failed during this operation; the result was discarded".into())) + } +} + +/// A second handle to the current scratch file, for tests that damage or restore its bytes. +/// The Unix file is unlinked, so it cannot be reopened by path. All scratch I/O is positioned, +/// so the shared file offset of a duplicated handle does not affect it. +#[doc(hidden)] +pub fn scratch_handle_for_tests() -> Option { + M.config().file.as_ref().and_then(|f| f.duplicate_handle().ok()) +} + +/// Evict down to the budget now, on this thread (tests, and before a measurement). +pub fn settle() { + let budget = M.budget.load(Ordering::Relaxed); + // Stops when nothing more can be evicted (every remaining tile is in use). + while budget > 0 && M.resident.load(Ordering::Relaxed) > budget { + if M.sweep(budget, usize::MAX) == 0 { + break; + } + } +} + +fn log_error(msg: &str) { + #[cfg(not(target_arch = "wasm32"))] + eprintln!("photocraft: {msg}"); + #[cfg(target_arch = "wasm32")] + let _ = msg; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_read_retains_backing_and_blocks_commit_until_repaired() { + let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id())); + let file = Arc::new(ScratchFile::create(&dir).unwrap()); + let mut tile = crate::Tile { cell: Cell::new(vec![73; 1024]) }; + let generation = read_error_generation(); + assert_eq!(tile.cell.evict(&file).unwrap(), 1024); + let good = tile.cell.slot.lock().unwrap().as_ref().unwrap().1; + tile.cell.slot.lock().unwrap().as_mut().unwrap().1.crc32 ^= 1; + + assert!(tile.try_bytes().is_err()); + let failed_generation = read_error_generation(); + assert!(tile.cell.data.read().unwrap().is_none(), "placeholder must never become stored pixels"); + assert!(check_integrity().is_err()); + assert!(tile.scratch_health().is_err(), "the tile reports its own failure"); + assert!(crate::Tile::from_vec(vec![1; 16]).scratch_health().is_ok(), "other tiles stay healthy"); + assert!(tile.try_bytes().is_err()); + assert!(check_since(failed_generation).is_ok(), "re-reading a tile that already failed is not a new transition"); + tile.bytes_mut()[0] = 9; + assert!(tile.cell.data.read().unwrap().is_none()); + assert!(tile.cell.slot.lock().unwrap().is_some(), "a failed mutation must preserve the original extent"); + let snapshot = tile.clone(); + assert!(Arc::ptr_eq(&tile.cell, &snapshot.cell)); + configure(None, 0).unwrap(); + assert!(check_integrity().is_err(), "changing preferences must not clear unreadable pixels"); + + tile.cell.slot.lock().unwrap().as_mut().unwrap().1 = good; + assert_eq!(&*tile.try_bytes().unwrap(), &[73; 1024]); + assert!(check_integrity().is_ok()); + assert!(tile.scratch_health().is_ok()); + assert!(check_since(generation).is_err(), "repairing or dropping a failed tile must not validate an earlier result"); + assert!(check_since(failed_generation).is_err(), "recovery must reject a computation begun during the failure"); + assert_eq!(tile.cell.evict(&file).unwrap(), 1024); + tile.cell.slot.lock().unwrap().as_mut().unwrap().1.crc32 ^= 1; + assert!(tile.try_bytes().is_err()); + let dropped_generation = read_error_generation(); + drop((snapshot, tile, file)); + assert!(check_integrity().is_ok()); + assert!(check_since(dropped_generation).is_err(), "dropping the failed tile must also reject its placeholder result"); + let _ = std::fs::remove_dir(&dir); + } +} diff --git a/crates/raster/src/tests.rs b/crates/raster/src/tests.rs index 0cba6f9343..472849c977 100644 --- a/crates/raster/src/tests.rs +++ b/crates/raster/src/tests.rs @@ -41,7 +41,7 @@ fn previous_read_region(surface: &Surface, r: Rect) -> Vec { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; for i in 0..n { - out[o + i] = read_sample(&t.data, surface.format.sample, base + i); + out[o + i] = read_sample(&t.bytes(), surface.format.sample, base + i); } } None => out[o..o + n].copy_from_slice(&dp), @@ -137,7 +137,7 @@ fn write_region_reference(surface: &mut Surface, r: Rect, data: &[f32]) { let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; for i in 0..n { - write_sample(&mut t.data, sample, base + i, data[o + i]); + write_sample(&mut t.bytes_mut(), sample, base + i, data[o + i]); } } } @@ -571,13 +571,13 @@ fn tile_content_bounds_match_a_per_pixel_scan() { for &s in &spots { data[s - s % bpp + byte] ^= 0x5a; } - let t = Tile { data: data.into_boxed_slice() }; + let t = Tile::from_vec(data); assert_eq!(t.content_bounds(&dp), per_pixel(&t, &dp), "bpp {bpp}, spots {spots:?}, byte {byte}"); } } let mut data = dp.repeat(TILE_SIZE as usize * TILE_SIZE as usize); data[row * 3 + 9 * bpp..row * 3 + 10 * bpp].iter_mut().for_each(|b| *b = !*b); - let t = Tile { data: data.into_boxed_slice() }; + let t = Tile::from_vec(data); assert_eq!(t.content_bounds(&dp), Some((9, 3, 10, 4)), "bpp {bpp}"); } } diff --git a/crates/raster/tests/scratch_recovery.rs b/crates/raster/tests/scratch_recovery.rs new file mode 100644 index 0000000000..fc4e91bfc7 --- /dev/null +++ b/crates/raster/tests/scratch_recovery.rs @@ -0,0 +1,117 @@ +//! Scratch disk failures pause spilling instead of turning it off for the session: tiles stay +//! in memory, the pause is retried with backoff and spilling resumes once the disk has room. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +fn check(s: &Surface, seed: u32, n: i32) { + for i in 0..n { + let t = s.tile(TileCoord { tx: i, ty: 0 }).unwrap(); + assert!(*t.bytes() == *noise(seed + i as u32, TILE_BYTES), "tile {i} of seed {seed} changed"); + } +} + +#[test] +fn full_or_missing_scratch_disk_pauses_and_recovers() { + let root = std::env::temp_dir().join(format!("photocraft-scratch-recovery-{}", std::process::id())); + let dir = root.join("scratch"); + let budget = 8 * TILE_BYTES; + spill::configure(Some(&dir), budget).unwrap(); + assert!(spill::enabled() && !spill::paused()); + + // 1. "Disk full": the scratch file reaches its size limit. Spilling pauses, every tile stays + // in memory and readable, and owners of memory caps see the change. + let cap = 16 * TILE_BYTES as u64; + spill::set_size_cap(Some(cap)); + let epoch = spill::epoch(); + let a = surface(1, 64); + spill::settle(); + let st = spill::stats(); + assert!(st.paused && !spill::enabled(), "{st:?}"); + assert_ne!(spill::epoch(), epoch, "pausing must tell owners to re-apply their caps"); + assert!(st.resident_bytes > budget, "tiles must stay in memory while paused: {st:?}"); + assert!(st.scratch_bytes < cap + 2 * TILE_BYTES as u64, "the file grew past its limit: {st:?}"); + assert!(st.last_error.as_deref().is_some_and(|e| e.contains("paused") && e.contains("retrying in 1 s")), "{st:?}"); + check(&a, 1, 64); + + // 2. A retry while the disk is still full pauses again, with a longer backoff; a budget + // change (the memory limit moved) does not end the pause. + spill::retry_now_for_tests(); + spill::maintain_budget(); + let st = spill::stats(); + assert!(st.paused && !spill::enabled(), "{st:?}"); + assert!(st.last_error.as_deref().is_some_and(|e| e.contains("retrying in 2 s")), "{st:?}"); + spill::set_budget(budget); + assert!(!spill::enabled(), "set_budget must not bypass the pause"); + + // 3. Room again: the next retry resumes spilling with the latest budget and clears the error. + let epoch = spill::epoch(); + spill::set_size_cap(None); + spill::maintain_budget(); + let st = spill::stats(); + assert!(!st.paused && spill::enabled() && st.budget_bytes == budget && st.last_error.is_none(), "{st:?}"); + assert_ne!(spill::epoch(), epoch); + spill::settle(); + assert!(spill::stats().resident_bytes <= budget, "{:?}", spill::stats()); + check(&a, 1, 64); + drop(a); + + // 4. "Disk gone": the directory can't be created (a file is in the way). Configuring reports + // it and pauses; the directory is kept and retried, and spilling resumes once it works. + let blocked = root.join("blocked"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(spill::configure(Some(&blocked), budget).is_err()); + let st = spill::stats(); + assert!(st.paused && !spill::enabled() && st.dir.as_deref() == Some(blocked.as_path()), "{st:?}"); + let b = surface(500, 32); + spill::settle(); + check(&b, 500, 32); + std::fs::remove_file(&blocked).unwrap(); + spill::retry_now_for_tests(); + spill::maintain_budget(); + assert!(spill::enabled() && !spill::paused(), "{:?}", spill::stats()); + spill::settle(); + assert!(spill::stats().resident_bytes <= budget); + check(&b, 500, 32); + + // 5. Cleanup: once spilling is off and the tiles are freed, no scratch file is left behind. + drop(b); + spill::configure(None, 0).unwrap(); + for d in [&dir, &blocked] { + let left: Vec<_> = std::fs::read_dir(d).map(|r| r.flatten().map(|e| e.path()).collect()).unwrap_or_default(); + assert!(left.is_empty(), "scratch files left in {}: {left:?}", d.display()); + } + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/raster/tests/scratch_translate.rs b/crates/raster/tests/scratch_translate.rs new file mode 100644 index 0000000000..f3e9741da7 --- /dev/null +++ b/crates/raster/tests/scratch_translate.rs @@ -0,0 +1,44 @@ +//! Upstream's shared fills and pixel translations must also work with paged tile bytes. + +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +#[test] +fn translations_and_shared_fills_preserve_spilled_pixels_at_every_depth() { + let dir = std::env::temp_dir().join(format!("photocraft-translate-spill-{}", std::process::id())); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat::RGBA8.with_sample(depth); + let tile_bytes = (TILE_SIZE * TILE_SIZE) as usize * format.bytes_per_pixel(); + spill::configure(Some(&dir), tile_bytes * 2).unwrap(); + let bounds = Rect::new(-17, -21, 281, 271); + let pixels: Vec = (0..bounds.size().area() as usize).flat_map(|i| [(i % 127) as f32 / 126.0, (i % 59) as f32 / 58.0, 0.75, 1.0]).collect(); + let mut original = Surface::new(format); + original.write_region(bounds, &pixels); + let expected = original.to_interleaved(bounds); + spill::settle(); + assert!(spill::stats().resident_bytes <= tile_bytes * 2); + let reloads = spill::stats().reloads; + for (dx, dy) in [(19, -31), (256, -512)] { + let mut moved = original.translated(dx, dy, original.content_bounds()); + assert_eq!(moved.to_interleaved(bounds.translate(dx, dy)), expected, "{depth:?} ({dx}, {dy})"); + moved.fill_rect(Rect::from_xywh(bounds.x0 + dx, bounds.y0 + dy, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + spill::settle(); + assert_eq!(original.to_interleaved(bounds), expected, "translation edits changed the source"); + } + assert!(spill::stats().reloads > reloads, "translation must read paged pixels"); + let mut fill = Surface::new(format); + fill.fill_rect(Rect::new(0, 0, 1024, 1024), &[0.5, 0.25, 0.75, 1.0]); + assert!(std::sync::Arc::ptr_eq(fill.tile(TileCoord::new(0, 0)).unwrap(), fill.tile(TileCoord::new(3, 3)).unwrap())); + let snapshot = fill.clone(); + let color = snapshot.pixel(10, 10); + spill::settle(); + fill.fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(snapshot.pixel(1, 1), color); + assert_eq!(fill.pixel(10, 10), color); + assert_eq!(fill.pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + spill::check_integrity().unwrap(); + } + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/raster/tests/spill.rs b/crates/raster/tests/spill.rs new file mode 100644 index 0000000000..078c1c4bf6 --- /dev/null +++ b/crates/raster/tests/spill.rs @@ -0,0 +1,149 @@ +//! Scratch disk: tiles over the budget go to disk and come back unchanged. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per tile and per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +/// A surface of `n × 1` tiles of noise; tile `i` holds `noise(seed + i)`. +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +fn check(s: &Surface, seed: u32, n: i32) { + for i in 0..n { + let t = s.tile(TileCoord { tx: i, ty: 0 }).unwrap(); + assert!(*t.bytes() == *noise(seed + i as u32, TILE_BYTES), "tile {i} of seed {seed} changed"); + } +} + +#[test] +fn tiles_spill_and_come_back() { + let dir = std::env::temp_dir().join(format!("photocraft-spill-test-{}", std::process::id())); + let budget = 8 * TILE_BYTES; + spill::configure(Some(&dir), budget).unwrap(); + assert!(spill::enabled()); + + // 1. Over budget: the oldest tiles go to disk, resident memory drops to the budget. + let a = surface(1, 32); + let b = surface(1000, 32); + spill::settle(); + let st = spill::stats(); + assert!(st.resident_bytes <= budget, "resident {} > budget {budget}", st.resident_bytes); + assert!(st.evictions >= 48, "only {} evictions", st.evictions); + assert!(st.scratch_bytes > 0 && st.last_error.is_none(), "{st:?}"); + + // 2. Every pixel reads back exactly (CRC-checked, LZ4 round trip), through every reader. + check(&a, 1, 32); + check(&b, 1000, 32); + assert!(spill::stats().reloads > 0); + let mut row = vec![[0u8; 4]; TILE_SIZE as usize]; + a.read_rgba8_into(Rect::from_xywh(5 * TILE_SIZE, 7, TILE_SIZE as u32, 1), &mut row); + let want = noise(6, TILE_BYTES); + let off = 7 * TILE_SIZE as usize * 4; + assert!(row.iter().flatten().eq(want[off..off + TILE_SIZE as usize * 4].iter())); + + // Upstream's fallible readers and clones must reload evicted storage too. + spill::settle(); + let rect = Rect::from_xywh(0, 0, TILE_SIZE as u32, TILE_SIZE as u32); + assert_eq!(a.try_to_interleaved(rect).unwrap(), noise(1, TILE_BYTES)); + assert_eq!(a.try_read_region(rect).unwrap(), a.read_region(rect)); + let copied = a.tile(TileCoord::new(0, 0)).unwrap().try_clone().unwrap(); + assert_eq!(&*copied.try_bytes().unwrap(), &noise(1, TILE_BYTES)); + drop(copied); + + // 3. Copy-on-write still works on spilled tiles: the clone changes, the original doesn't. + spill::settle(); + let mut c = a.clone(); + c.fill_rect(Rect::from_xywh(0, 0, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(c.pixel(3, 3), vec![1.0, 0.0, 0.0, 1.0]); + check(&a, 1, 32); + spill::settle(); + // The edit survives its own eviction (the stale disk copy was released, a new one written). + assert_eq!(c.pixel(3, 3), vec![1.0, 0.0, 0.0, 1.0]); + check(&a, 1, 32); + + // 4. Parallel readers of spilled tiles all see the right bytes. + spill::settle(); + std::thread::scope(|scope| { + for k in 0..8 { + let (a, b) = (&a, &b); + scope.spawn(move || { + for round in 0..4 { + let (s, seed) = if (k + round) % 2 == 0 { (a, 1) } else { (b, 1000) }; + check(s, seed, 32); + } + }); + } + }); + + // 5. Dropping everything frees memory and scratch space. + drop((a, b, c)); + let st = spill::stats(); + assert_eq!(st.scratch_bytes, 0, "scratch extents leaked: {st:?}"); + + // 6. Off: nothing more is evicted, and the budget no longer applies. + spill::configure(None, 0).unwrap(); + assert!(!spill::enabled()); + let before = spill::stats().evictions; + let d = surface(5000, 32); + assert_eq!(spill::stats().evictions, before); + check(&d, 5000, 32); + drop(d); + + // 7. Encoded bytes and COW edits survive eviction at every supported depth. + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat { mode: ColorMode::Rgb, sample, alpha: true }; + let len = (TILE_SIZE * TILE_SIZE) as usize * fmt.bytes_per_pixel(); + spill::configure(Some(&dir), len * 2).unwrap(); + let mut s = Surface::new(fmt); + for i in 0..12 { + s.tile_mut(TileCoord::new(i, 0)).bytes_mut().copy_from_slice(&noise(8000 + i as u32, len)); + } + let original = s.clone(); + spill::settle(); + assert!(spill::stats().resident_bytes <= len * 2); + s.fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + spill::settle(); + assert_eq!(s.pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + for i in 0..12 { + assert_eq!(&*original.tile(TileCoord::new(i, 0)).unwrap().bytes(), &noise(8000 + i as u32, len)); + } + let mut converted = [[0; 4]; 4]; + s.read_rgba8_into(Rect::from_xywh(0, 0, 4, 1), &mut converted); + assert_eq!(converted, [[255, 0, 0, 255]; 4]); + } + // A tiny budget must still allow a pinned reader to finish instead of evicting itself. + spill::configure(Some(&dir), 1).unwrap(); + let tiny = surface(9000, 2); + spill::settle(); + check(&tiny, 9000, 2); + drop(tiny); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 180cdfa8fc..35e6174199 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -199,7 +199,7 @@ fn main() { .walk() .iter() .flat_map(|(_, _, l)| l.surface().into_iter().chain(l.mask.as_ref().map(|m| &m.surface))) - .map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::()) + .map(|s| s.tiles().map(|(_, t)| t.len() as u64).sum::()) .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); let vram = g.video_memory(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3015f0214d..2ae20a041a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -271,6 +271,14 @@ impl eframe::App for PhotocraftApp { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + photocraft_compose::discard_unreadable_pixel_caches(); + if let Err(error) = photocraft_raster::spill::check_integrity() { + self.ui.status = error; + self.ui.status_error = true; + } else if let Some(error) = photocraft_raster::spill::last_error() { + self.ui.status = error; + self.ui.status_error = true; + } self.ui_frame(ui, frame); self.finish_type_reset_pass(ui.ctx()); } diff --git a/crates/ui-egui/src/control/inspect.rs b/crates/ui-egui/src/control/inspect.rs index 4e09d79a1b..53df5d373f 100644 --- a/crates/ui-egui/src/control/inspect.rs +++ b/crates/ui-egui/src/control/inspect.rs @@ -31,6 +31,8 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { dialogs.extend(crate::discard_ui::inspect(app)); json!({ "window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()}, + "scratch": photocraft_raster::spill::stats(), + "memory": photocraft_raster::memory::stats(), "tool": app.ui.tool, "toolOptions": app.ui.tool_options, "perspectiveCrop": app.ui.perspective_crop, diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0b09aae6e1..0c67a91ec6 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3424,6 +3424,7 @@ curves Smooth تنعيم This document's layers: {needed} of {budget} for layers طبقات هذا المستند: {needed} من {budget} للطبقات This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU تحتاج طبقات هذا المستند إلى {needed} لكنها تستوعب {budget}: تستخدم إعادة رسم المستند بالكامل وحدة المعالجة المركزية GPU Memory (MB, 0 = Automatic) ذاكرة GPU (ميغابايت، 0 = تلقائي) + Scratch Size Limit (MB, 0 = No Limit) حد حجم ملف التسويد (ميغابايت، 0 = بلا حد) {path}: the path is not valid Unicode; save under another name. {path}: المسار ليس Unicode صالحًا؛ احفظ باسم آخر. {name} was changed by another app after it was opened or saved here. تم تغيير {name} بواسطة تطبيق آخر بعد فتحه أو حفظه هنا. Replace it with this document, or cancel and use File › Save As to keep both. استبدله بهذا المستند، أو ألغِ واستخدم ملف › حفظ باسم للاحتفاظ بكليهما. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6b84a31cbf..8cd95ed30d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3907,6 +3907,7 @@ navigator Zoom Zvětšení Sent to {printer} Odesláno do {printer} set in Print Settings… nastavuje se v nastavení tisku… ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů + Memory usage percent Využití paměti (%) Show Favorite Fonts Zobrazit oblíbená písma No favorite fonts yet: click the star beside a font to add it. Zatím žádná oblíbená písma: přidejte písmo kliknutím na hvězdičku vedle něj. Favorite fonts Oblíbená písma @@ -4023,6 +4024,7 @@ navigator Zoom Zvětšení This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) + Scratch Size Limit (MB, 0 = No Limit) Limit velikosti odkládacího disku (MB, 0 = bez limitu) {path}: the path is not valid Unicode; save under another name. {path}: cesta není platný Unicode; uložte soubor pod jiným názvem. {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00cfb09d84..46e71ed2cf 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3361,6 +3361,7 @@ curves Smooth Udjævn This document's layers: {needed} of {budget} for layers Dette dokuments lag: {needed} af {budget} til lag This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokuments lag kræver {needed}, men kan rumme {budget}: gentegninger af hele dokumentet bruger CPU'en GPU Memory (MB, 0 = Automatic) GPU-hukommelse (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Størrelsesgrænse for arbejdsdisk (MB, 0 = ingen grænse) {path}: the path is not valid Unicode; save under another name. {path}: stien er ikke gyldig Unicode; gem under et andet navn. Replace Erstat {name} was changed by another app after it was opened or saved here. {name} blev ændret af et andet program, efter at den blev åbnet eller gemt her. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c68e8e098d..45fb326bea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3306,6 +3306,7 @@ curves Smooth Glätten Sent to {printer} An {printer} gesendet set in Print Settings… in den Druckeinstellungen festlegen… ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt + Memory usage percent Speichernutzung (%) Show Favorite Fonts Favorisierte Schriften anzeigen No favorite fonts yet: click the star beside a font to add it. Noch keine favorisierten Schriften: Klicken Sie auf den Stern neben einer Schrift, um sie hinzuzufügen. Favorite fonts Favorisierte Schriften @@ -3424,6 +3425,7 @@ curves Smooth Glätten This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) + Scratch Size Limit (MB, 0 = No Limit) Größenlimit für Arbeitsvolume (MB, 0 = kein Limit) {path}: the path is not valid Unicode; save under another name. {path}: Der Dateipfad enthält ungültige Zeichen. Speichere unter einem anderen Namen. {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e40e24e130..8f20dd6705 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3990,6 +3990,7 @@ curves Smooth Εξομάλυνση Sent to {printer} Στάλθηκε στον {printer} set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία + Memory usage percent Χρήση μνήμης (%) Show Favorite Fonts Εμφάνιση αγαπημένων γραμματοσειρών No favorite fonts yet: click the star beside a font to add it. Δεν υπάρχουν ακόμη αγαπημένες γραμματοσειρές: κάντε κλικ στο αστέρι δίπλα σε μια γραμματοσειρά για να την προσθέσετε. Favorite fonts Αγαπημένες γραμματοσειρές @@ -4106,6 +4107,7 @@ curves Smooth Εξομάλυνση This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) + Scratch Size Limit (MB, 0 = No Limit) Όριο μεγέθους δίσκου προσωρινής αποθήκευσης (MB, 0 = χωρίς όριο) {path}: the path is not valid Unicode; save under another name. {path}: η διαδρομή δεν είναι έγκυρο Unicode· αποθηκεύστε με άλλο όνομα. {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4b039176f4..6b365ce2be 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4203,6 +4203,7 @@ navigator Zoom Zoom Sent to {printer} Enviado a {printer} set in Print Settings… se establece en Ajustes de impresión… ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles + Memory usage percent Uso de memoria (%) Show Favorite Fonts Mostrar fuentes favoritas No favorite fonts yet: click the star beside a font to add it. Aún no hay fuentes favoritas: haz clic en la estrella junto a una fuente para añadirla. Favorite fonts Fuentes favoritas @@ -4318,6 +4319,7 @@ navigator Zoom Zoom This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) + Scratch Size Limit (MB, 0 = No Limit) Límite de tamaño del disco de memoria virtual (MB, 0 = sin límite) {path}: the path is not valid Unicode; save under another name. {path}: la ruta no es Unicode válido; guarde con otro nombre. {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1cacf9ffea..b08a224b27 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3362,6 +3362,7 @@ curves Smooth Tasoita This document's layers: {needed} of {budget} for layers Tämän asiakirjan tasot: {needed} / {budget} tasoille This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Tämän asiakirjan tasot tarvitsevat {needed}, mutta saavat käyttää {budget}: koko asiakirjan uudelleenpiirrot käyttävät suoritinta GPU Memory (MB, 0 = Automatic) GPU-muisti (Mt, 0 = automaattinen) + Scratch Size Limit (MB, 0 = No Limit) Työlevyn kokoraja (Mt, 0 = ei rajaa) {path}: the path is not valid Unicode; save under another name. {path}: polku ei ole kelvollista Unicodea; tallenna toisella nimellä. Replace Korvaa {name} was changed by another app after it was opened or saved here. Toinen ohjelma on muuttanut tiedostoa {name} sen jälkeen, kun se avattiin tai tallennettiin täällä. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c9e3bf5d3d..89bdec45d4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3297,6 +3297,7 @@ curves Smooth Lisser Sent to {printer} Envoyé à {printer} set in Print Settings… défini dans les paramètres d’impression… ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels + Memory usage percent Utilisation de la mémoire (%) Show Favorite Fonts Afficher les polices favorites No favorite fonts yet: click the star beside a font to add it. Aucune police favorite pour l’instant : cliquez sur l’étoile à côté d’une police pour l’ajouter. Favorite fonts Polices favorites @@ -3414,6 +3415,7 @@ curves Smooth Lisser This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) + Scratch Size Limit (MB, 0 = No Limit) Limite de taille du disque de travail (Mo, 0 = aucune limite) {path}: the path is not valid Unicode; save under another name. {path} : le chemin n’est pas un Unicode valide ; enregistrez sous un autre nom. Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 37e07e1690..c5a61baed8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4195,6 +4195,7 @@ cameraRaw Yellow Sárga Kept {name}: written by another application {name} megtartva: egy másik alkalmazás írta Shadow Tint Árnyék színezet Redrawing Újrarajzolás + Memory usage percent Memóriahasználat százaléka Show Favorite Fonts Kedvenc betűtípusok megjelenítése No favorite fonts yet: click the star beside a font to add it. Még nincsenek kedvenc betűtípusok: kattintson a betűtípus melletti csillagra a hozzáadáshoz. Favorite fonts Kedvenc betűtípusok @@ -4320,6 +4321,7 @@ cameraRaw Yellow Sárga This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) + Scratch Size Limit (MB, 0 = No Limit) Lapozólemez méretkorlátja (MB, 0 = nincs korlát) {path}: the path is not valid Unicode; save under another name. {path}: az elérési út nem érvényes Unicode-szöveg. Mentsd más néven. {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5d1a13dcc8..e5058af2c9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3302,6 +3302,7 @@ curves Smooth Haluskan Sent to {printer} Dikirim ke {printer} set in Print Settings… diatur di Pengaturan Cetak… ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel + Memory usage percent Pemakaian memori (%) Show Favorite Fonts Tampilkan Font Favorit No favorite fonts yet: click the star beside a font to add it. Belum ada font favorit: klik bintang di samping font untuk menambahkannya. Favorite fonts Font favorit @@ -3419,6 +3420,7 @@ curves Smooth Haluskan This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) + Scratch Size Limit (MB, 0 = No Limit) Batas ukuran disk scratch (MB, 0 = Tanpa batas) {path}: the path is not valid Unicode; save under another name. {path}: jalur bukan Unicode yang valid; simpan dengan nama lain. Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6c2d6c18cd..367c7267f5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4253,6 +4253,7 @@ navigator Zoom Zoom Sent to {printer} Inviato a {printer} set in Print Settings… impostato nelle impostazioni di stampa… ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel + Memory usage percent Uso memoria (%) Show Favorite Fonts Mostra font preferiti No favorite fonts yet: click the star beside a font to add it. Ancora nessun font preferito: fai clic sulla stella accanto a un font per aggiungerlo. Favorite fonts Font preferiti @@ -4529,6 +4530,7 @@ shape Appearance Aspetto This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) + Scratch Size Limit (MB, 0 = No Limit) Limite dimensioni disco di memoria virtuale (MB, 0 = nessun limite) {path}: the path is not valid Unicode; save under another name. {path}: il percorso non è Unicode valido; salva con un altro nome. {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0d3714c9e2..2eee5145a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3965,6 +3965,7 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです + Memory usage percent メモリ使用率 (%) Show Favorite Fonts お気に入りのフォントを表示 No favorite fonts yet: click the star beside a font to add it. お気に入りのフォントはまだありません。フォントの横の星をクリックして追加してください。 Favorite fonts お気に入りのフォント @@ -4080,6 +4081,7 @@ curves Smooth スムーズ This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) + Scratch Size Limit (MB, 0 = No Limit) 仮想記憶ディスクのサイズ上限(MB、0 = 上限なし) {path}: the path is not valid Unicode; save under another name. {path}: パスが有効な Unicode ではありません。別の名前で保存してください。 Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c52d0c31ca..43f7dea83e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3496,6 +3496,7 @@ curves Smooth 매끄럽게 Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + Memory usage percent 메모리 사용 비율(%) Show Favorite Fonts 즐겨찾는 글꼴 표시 No favorite fonts yet: click the star beside a font to add it. 아직 즐겨찾는 글꼴이 없습니다. 글꼴 옆의 별을 클릭하여 추가하세요. Favorite fonts 즐겨찾는 글꼴 @@ -3615,6 +3616,7 @@ curves Smooth 매끄럽게 This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) + Scratch Size Limit (MB, 0 = No Limit) 임시 디스크 크기 제한(MB, 0 = 제한 없음) {path}: the path is not valid Unicode; save under another name. {path}: 경로가 올바른 유니코드가 아닙니다. 다른 이름으로 저장하세요. Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 85f9a0441a..d7232004b6 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -3361,6 +3361,7 @@ curves Smooth Jevn ut This document's layers: {needed} of {budget} for layers Dette dokumentets lag: {needed} av {budget} for lag This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokumentets lag trenger {needed}, men kan holde {budget}: gjentegning av hele dokumentet bruker CPU-en GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Størrelsesgrense for arbeidsdisk (MB, 0 = ingen grense) {path}: the path is not valid Unicode; save under another name. {path}: banen er ikke gyldig Unicode. Lagre under et annet navn. Replace Erstatt {name} was changed by another app after it was opened or saved here. {name} ble endret av et annet program etter at den ble åpnet eller lagret her. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c80142366a..7ad2900a28 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4049,6 +4049,7 @@ curves Smooth Vloeiend maken Sent to {printer} Verzonden naar {printer} set in Print Settings… instellen in de afdrukinstellingen… ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels + Memory usage percent Geheugengebruik (%) Show Favorite Fonts Favoriete lettertypen tonen No favorite fonts yet: click the star beside a font to add it. Nog geen favoriete lettertypen: klik op de ster naast een lettertype om het toe te voegen. Favorite fonts Favoriete lettertypen @@ -4164,6 +4165,7 @@ curves Smooth Vloeiend maken This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) + Scratch Size Limit (MB, 0 = No Limit) Groottelimiet voor werkschijf (MB, 0 = geen limiet) {path}: the path is not valid Unicode; save under another name. {path}: het pad is geen geldige Unicode; sla op onder een andere naam. {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1b5c9e6878..fe13d8f177 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3906,6 +3906,7 @@ curves Smooth Wygładź Sent to {printer} Wysłano do {printer} set in Print Settings… ustawiane w ustawieniach druku… ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli + Memory usage percent Użycie pamięci (%) Show Favorite Fonts Pokaż ulubione czcionki No favorite fonts yet: click the star beside a font to add it. Brak ulubionych czcionek: kliknij gwiazdkę obok czcionki, aby ją dodać. Favorite fonts Ulubione czcionki @@ -4021,6 +4022,7 @@ curves Smooth Wygładź This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) + Scratch Size Limit (MB, 0 = No Limit) Limit rozmiaru dysku roboczego (MB, 0 = bez limitu) {path}: the path is not valid Unicode; save under another name. {path}: ścieżka nie jest prawidłowym Unicode; zapisz pod inną nazwą. Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7b50d472fe..f12a596d1a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4089,6 +4089,7 @@ layerLabel No Color Sem cor Sent to {printer} Enviado para {printer} set in Print Settings… definido nas configurações de impressão… ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels + Memory usage percent Uso de memória (%) Show Favorite Fonts Mostrar fontes favoritas No favorite fonts yet: click the star beside a font to add it. Ainda não há fontes favoritas: clique na estrela ao lado de uma fonte para adicioná-la. Favorite fonts Fontes favoritas @@ -4204,6 +4205,7 @@ layerLabel No Color Sem cor This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) + Scratch Size Limit (MB, 0 = No Limit) Limite de tamanho do disco de rascunho (MB, 0 = sem limite) {path}: the path is not valid Unicode; save under another name. {path}: o caminho não é Unicode válido; salve com outro nome. {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 487a6fc066..60f3929e5f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3283,6 +3283,7 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина + Memory usage percent Использование памяти (%) Show Favorite Fonts Показать избранные шрифты No favorite fonts yet: click the star beside a font to add it. Избранных шрифтов пока нет: нажмите на звёздочку рядом со шрифтом, чтобы добавить его. Favorite fonts Избранные шрифты @@ -3388,6 +3389,7 @@ curves Smooth Сгладить This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) + Scratch Size Limit (MB, 0 = No Limit) Предел размера рабочего диска (МБ, 0 = без ограничения) {path}: the path is not valid Unicode; save under another name. {path}: путь не является допустимым Unicode; сохраните под другим именем. Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 0152204d90..2ebc6c2e70 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4334,6 +4334,7 @@ shape Appearance Izgled Kept {name}: written by another application {name} je zadržan: upisala ga je druga aplikacija Shadow Tint Nijansa senki Redrawing Ponovno iscrtavanje + Memory usage percent Проценат употребе меморије Show Favorite Fonts Прикажи омиљене фонтове No favorite fonts yet: click the star beside a font to add it. Још нема омиљених фонтова: кликните на звездицу поред фонта да бисте га додали. Favorite fonts Омиљени фонтови @@ -4473,6 +4474,7 @@ shape Appearance Izgled This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) + Scratch Size Limit (MB, 0 = No Limit) Ograničenje veličine radnog diska (MB, 0 = bez ograničenja) {path}: the path is not valid Unicode; save under another name. {path}: putanja nije ispravan Unicode; sačuvajte pod drugim imenom. {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 64191012c4..b9c3721765 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3359,6 +3359,7 @@ curves Smooth Jämna ut This document's layers: {needed} of {budget} for layers Lagren i det här dokumentet: {needed} av {budget} för lager This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Lagren i det här dokumentet behöver {needed} men får använda {budget}: omritningar av hela dokumentet använder CPU:n GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Storleksgräns för arbetsdisk (MB, 0 = ingen gräns) {path}: the path is not valid Unicode; save under another name. {path}: sökvägen är inte giltig Unicode; spara under ett annat namn. Replace Ersätt {name} was changed by another app after it was opened or saved here. {name} har ändrats av en annan app efter att den öppnades eller sparades här. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a45309f6b2..007d756f89 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4002,6 +4002,7 @@ cameraData Lens Lens Kept {name}: written by another application {name} korundu: başka bir uygulama tarafından yazılmış Shadow Tint Gölge tonu Redrawing Yeniden çiziliyor + Memory usage percent Bellek kullanım yüzdesi Show Favorite Fonts Favori yazı tiplerini göster No favorite fonts yet: click the star beside a font to add it. Henüz favori yazı tipi yok: eklemek için bir yazı tipinin yanındaki yıldıza tıklayın. Favorite fonts Favori yazı tipleri @@ -4127,6 +4128,7 @@ cameraData Lens Lens This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) + Scratch Size Limit (MB, 0 = No Limit) Geçici disk boyut sınırı (MB, 0 = Sınırsız) {path}: the path is not valid Unicode; save under another name. {path}: yol geçerli bir Unicode değil; başka bir adla kaydedin. {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index eec0b45c31..68128b8584 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3992,6 +3992,7 @@ curves Smooth Згладити Sent to {printer} Надіслано на {printer} set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів + Memory usage percent Використання пам’яті (%) Show Favorite Fonts Показати вибрані шрифти No favorite fonts yet: click the star beside a font to add it. Вибраних шрифтів ще немає: натисніть зірочку біля шрифту, щоб додати його. Favorite fonts Вибрані шрифти @@ -4107,6 +4108,7 @@ curves Smooth Згладити This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) + Scratch Size Limit (MB, 0 = No Limit) Обмеження розміру робочого диска (МБ, 0 = без обмеження) {path}: the path is not valid Unicode; save under another name. {path}: шлях не є коректним Unicode; збережіть під іншою назвою. Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 768d1dd2ad..d868ab099a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3758,6 +3758,7 @@ curves Smooth Làm mượt Sent to {printer} Đã gửi tới {printer} set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel + Memory usage percent Tỷ lệ sử dụng bộ nhớ Show Favorite Fonts Hiển thị phông chữ yêu thích No favorite fonts yet: click the star beside a font to add it. Chưa có phông chữ yêu thích: nhấp vào ngôi sao bên cạnh phông chữ để thêm. Favorite fonts Phông chữ yêu thích @@ -3898,6 +3899,7 @@ curves Smooth Làm mượt This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) + Scratch Size Limit (MB, 0 = No Limit) Giới hạn dung lượng đĩa tạm (MB, 0 = Không giới hạn) {path}: the path is not valid Unicode; save under another name. {path}: đường dẫn không phải Unicode hợp lệ; hãy lưu với tên khác. Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2a203c08c1..d9b554acfa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2947,6 +2947,7 @@ curves Smooth 平滑 Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + Memory usage percent 内存使用比例 (%) Show Favorite Fonts 显示收藏的字体 No favorite fonts yet: click the star beside a font to add it. 还没有收藏的字体:点击字体旁边的星标即可添加。 Favorite fonts 收藏的字体 @@ -3069,6 +3070,7 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) + Scratch Size Limit (MB, 0 = No Limit) 暂存盘大小上限(MB,0 = 无限制) {path}: the path is not valid Unicode; save under another name. {path}:路径不是有效的 Unicode;请以其他名称存储。 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6452c838bd..9379418b9e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2908,6 +2908,7 @@ curves Smooth 平滑 Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + Memory usage percent 記憶體使用比例 (%) Show Favorite Fonts 顯示我的最愛字體 No favorite fonts yet: click the star beside a font to add it. 尚無我的最愛字體:按一下字體旁的星號即可加入。 Favorite fonts 我的最愛字體 @@ -3030,6 +3031,7 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) + Scratch Size Limit (MB, 0 = No Limit) 暫存磁碟大小上限(MB,0 = 無限制) {path}: the path is not valid Unicode; save under another name. {path}:路徑不是有效的 Unicode;請以其他名稱儲存。 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index 2b477a3c1e..2accc50371 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -74,6 +74,10 @@ pub(super) fn humanize(key: &str) -> String { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } // Performance: the canvas compositor's GPU memory, 0 for the automatic budget (L3). + // Scratch Disks: how large the scratch file may grow; the free-space reserve always applies. + if key == "sizeLimitMb" { + return "Scratch Size Limit (MB, 0 = No Limit)".into(); + } if key == "gpuMemoryMb" { return "GPU Memory (MB, 0 = Automatic)".into(); } @@ -374,7 +378,7 @@ pub(super) fn field_visible(section: &str, obj: &Map, k: &str, qu (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) - && obj.get(k).is_some_and(|v| !matches!(v, Value::Null | Value::Object(_))) + && obj.get(k).is_some_and(|v| (path == "performance.memoryUsagePercent" && v.is_null()) || !matches!(v, Value::Null | Value::Object(_))) } /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, @@ -413,6 +417,18 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke let v = obj.get(&k).cloned().unwrap_or(Value::Null); let human = humanize(&k); let label = tl!(&human).to_string(); + if path == "performance.memoryUsagePercent" { + ui.label(RichText::new(&label).color(t.text_dim)); + let mut enabled = v.is_number(); + let mut percent = v.as_u64().and_then(|p| u32::try_from(p).ok()).unwrap_or(70); + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, &mut enabled, ""); + ui.add_enabled(enabled, egui::DragValue::new(&mut percent).range(1..=95).suffix("%")); + }); + obj.insert(k, if enabled { json!(percent) } else { Value::Null }); + ui.end_row(); + continue; + } match &v { Value::Bool(b) => { ui.label(""); diff --git a/crates/ui-egui/src/prefs/prefs_ui/startup.rs b/crates/ui-egui/src/prefs/prefs_ui/startup.rs index e1789138ee..16b945b49e 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/startup.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/startup.rs @@ -40,6 +40,8 @@ pub fn load(app: &mut PhotocraftApp) { } } } + // Applies Memory Usage and the scratch disks even when the stored preferences were reset. + app.session.apply_prefs(); crate::dock::restore(app); crate::color_panel_ui::restore(app); crate::brush_picker::restore(app); diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 23f1f76e92..21994b7352 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -1444,8 +1444,8 @@ fn unimplemented_preferences_are_hidden_from_the_dialog() { let values = prefs::Preferences::default().to_json(); assert!(has_visible_fields(&values, "general")); assert!(has_visible_fields(&values, "fileHandling")); - // Every setting of this section is still unimplemented. - assert!(!has_visible_fields(&values, "scratchDisks"), "scratchDisks"); + assert!(has_visible_fields(&values, "scratchDisks")); + assert!(!prefs::is_hidden("scratchDisks.disks")); // Allow Agent Control and Control Port are read at launch (apps/photocraft main.rs). assert!(has_visible_fields(&values, "integrations")); assert!(!prefs::is_hidden("integrations.allowAgentControl")); diff --git a/docs/development.md b/docs/development.md index 3b15282761..49093fa23a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -155,6 +155,10 @@ The window itself draws with the same device (or, with the CPU canvas, one it wa ## Logs +Native pixel residency, RAM limits and scratch failure recovery are described in +[`scratch-disks.md`](scratch-disks.md). Control `ui.inspect` includes `scratch` counters and +the `memory` sample, effective allowances and current working reservations. + The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. A launch while another PhotoCraft is still running leaves that instance's log where it is and writes its own `photocraft-.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft , log file ` line at startup names the file in use). The file stops growing at 16 MiB. `--version` and command-line errors write no file. A desktop start that fails before any window exists logs the error and, when no terminal, automation client or headless session can report it, shows it in a dialog naming this file (#7643). By default PhotoCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,photocraft_gpu=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`photocraft*=debug`). The logger is `apps/photocraft/src/logging.rs`; the web build logs to the browser console instead. diff --git a/docs/roadmap.md b/docs/roadmap.md index 670083ccde..a47431dac2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -122,3 +122,5 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | 2026-10-10 | major | Progress docs restructured: honest assessment and dated measurements moved to `target-app-parity.md`, Affinity notes to `file-format-parity.md`; new Current focus and estimates; `parity.md` renamed `parity-checklist.md` | | 2026-10-08 | minor | Tools, Affinity import and performance measurements added | | 2026-10-05 | major | Honest parity assessment added | + +2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. diff --git a/docs/scorecard.md b/docs/scorecard.md index a4b9635f0f..c96a9cbe20 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 11 of 184 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 186 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -203,9 +203,9 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 11 of 184** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 186** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. +Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -276,7 +276,7 @@ Never-crash attribute on 30 of 30 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; save/import boundaries preserve existing state, but encoder/decoder internals still need a broader fallible-allocation audit | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | | REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md new file mode 100644 index 0000000000..93f314a426 --- /dev/null +++ b/docs/scratch-disks.md @@ -0,0 +1,60 @@ +# Scratch disks and memory residency + +Native PhotoCraft keeps pixel tiles in RAM and spills cold tiles to a configured scratch +directory. A read or write guard keeps a tile resident while a consumer uses its bytes. Undo +snapshots keep their copy-on-write tiles, including disk-backed tiles, until History States +retires them. Web builds retain their explicit memory limit (capped like Memory Usage) and do not use a +filesystem. + +New preferences use **70% of physical RAM** as the process ceiling. This is configurable from +1–95%; older preferences without `performance.memoryUsagePercent` retain their absolute +`performance.memoryUsageMb` limit. Other applications, non-tile process memory and bounded +working buffers reduce the live tile allowance. The separate `performance.gpuMemoryMb` +setting limits graphics resources. Memory virtual address-space usage is diagnostic data, +not a measurement of committed memory or a substitute for scratch storage. + +Enable a directory in Preferences → Scratch Disks. `(system temp)` selects the operating +system's temporary directory. Only the first enabled, nonempty directory is used. Changing +directories creates a new scratch file; old disk-backed tiles keep their old file alive. +The development override `PHOTOCRAFT_TILE_BUDGET_MB` fixes the tile allowance for stress tests. + +Scratch extents are LZ4 compressed, CRC-checked and reused after a tile is released. Closing +the last tile truncates its file. No scratch file outlives the process, including a crash or +an exit without Rust destructors: Windows opens it with delete-on-close, and Unix creates it +with mode `0600` and unlinks it immediately after opening, reading and writing through the +one open handle with positioned I/O (`pread`/`pwrite`). The space is returned when the last +handle closes. Configuring a directory also sweeps `photocraft--.scratch` files of +dead processes (older builds, or a crash between creating and unlinking). It preserves +live-process files, other users' files, symlinks and unrelated names; unavailable process +enumeration leaves files untouched. PID reuse conservatively retains a stale file, and a +unique launch nonce avoids a collision. A filesystem that refuses the unlink keeps the name +until the file is dropped or the next sweep. + +The engine owns a shared, stoppable residency supervisor and joins it when its last session +is dropped. `photocraft-raster` only exposes synchronous measurement and bounded eviction +operations; standalone callers work without a background thread. `sysinfo` is native-only +with its default features disabled and only the `system` (RAM) and `disk` (free space) +features enabled. + +An unavailable or full scratch disk never loses pixels or turns spilling off for the session. +A failed scratch write or file creation, or a volume about to run low (free space is checked +when configuring, every 64 MB of scratch growth and on each retry; spilling stops while less +than max(1 GiB, 5% of the volume) would stay free), pauses spilling: tiles stay in memory, +the status bar shows the reason, and History's Memory Usage byte limit applies again from the +next command, as without a scratch disk. The pause is retried after 1 s, doubling up to 60 s; +spilling resumes on its own once the directory is usable and has room, and changing +preferences retries at once. Preferences → Scratch Disks → Scratch Size Limit +(`scratchDisks.sizeLimitMb`, 0 = no limit; `spill::set_size_cap`) caps the scratch file: +reaching it pauses spilling the same way, and the free-space reserve applies regardless. +A failed read retains its backing extent and blocks editing, saving and exporting of the +document that holds the tile until it is readable again; other open documents keep working. +Each commit checks the health of its own document (before and after an edit, the document a +job captured, the imported result, the document being saved or exported) and refuses a result +if any tile failed or recovered while it ran; the retry succeeds. Derived-pixel caches never +admit results built from unreadable tiles and are purged once per new failure. Recovery can +retry the original bytes after the storage becomes available; zeros from a failed display +read must never become saved pixels. Limitation: a tile that was already unreadable and is +read from outside the edited document (for example a clipboard copy) is not detected. + +Scratch storage alone does not make every format, filter or renderer streamable. The native +PSD/EXR import and viewport streaming changes build on these guards and memory reservations. diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index 081af4461f..d5fc1fb5a7 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -35,7 +35,7 @@ id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" status = "partial" issue = 212 -note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; save/import boundaries preserve existing state, but encoder/decoder internals still need a broader fallible-allocation audit" +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." [[item]] id = "REL-212-2"