mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 13:23:01 +00:00
Merge pull request #5330 from vistaero/codex/scratch-storage
Add guarded scratch storage and owned memory supervision
This commit is contained in:
Generated
+170
-47
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -53,9 +53,10 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
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());
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Tile>, full: &Arc<Tile>) -> Option<Arc<Tile>> {
|
||||
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))
|
||||
|
||||
@@ -202,7 +202,8 @@ pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option<Surface> {
|
||||
}
|
||||
for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::<TS>().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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<SaveOptions, AutomationError> {
|
||||
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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -34,6 +34,12 @@ fn cache() -> &'static Mutex<Cache> {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<FxCache> {
|
||||
static C: std::sync::OnceLock<std::sync::Mutex<FxCache>> = 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<effects::FxMaps> {
|
||||
let generation = photocraft_raster::spill::read_error_generation();
|
||||
let shape: Vec<f32> = 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::<photocraft_raster::Tile>() + 2 * std::mem::size_of::<usize>()));
|
||||
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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -81,6 +81,12 @@ fn cache() -> &'static Mutex<Cache> {
|
||||
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<Cache>, k: u64) -> CachedMask {
|
||||
fn finish_mask(c: &Mutex<Cache>, k: u64, slot: &Arc<OnceLock<Surface>>, generation: u64, build: impl FnOnce() -> (Surface, Option<Surface>)) -> 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<Cache>, 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 {
|
||||
|
||||
@@ -21,6 +21,10 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, 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::<usize>()).sum()
|
||||
[&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.len()).sum::<usize>()).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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)>) {
|
||||
|
||||
@@ -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<Option<std::sync::Arc<photocraft_raster::Tile>>> = 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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<photocraft_raster::TileBytes<'a>>,
|
||||
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 {
|
||||
|
||||
@@ -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::<Result<Vec<_>>>()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<LayerId> {
|
||||
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;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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<Timeline>)) -> Result<Value> {
|
||||
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<Timeline>)) -> 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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -259,6 +259,8 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
&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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -377,6 +377,18 @@ pub fn run<T: Send + 'static>(
|
||||
work: impl FnOnce(&JobCtx) -> Result<T> + Send + 'static,
|
||||
apply: impl FnOnce(&mut Session, T) -> Result<Value> + Send + 'static,
|
||||
) -> Result<Value> {
|
||||
// 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<T> {
|
||||
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".
|
||||
|
||||
@@ -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<scratch::Runtime>,
|
||||
/// [`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.
|
||||
|
||||
@@ -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<Tile> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8>,
|
||||
/// 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::<u64>().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<ScratchDisk>,
|
||||
/// 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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Worker>,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
static SHARED: OnceLock<Mutex<Weak<Worker>>> = 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<bool>, Condvar)>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())]);
|
||||
|
||||
+73
-19
@@ -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<usize> {
|
||||
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<u8> {
|
||||
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<u8>),
|
||||
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<u8>)]) -> 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<Prepared> {
|
||||
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<Prepared> {
|
||||
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<u8>, 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<SaveStats> {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Tile>>, kind: TexKind, c: TileCoord) -> Vec<u8> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ fn sixteen_and_thirty_two_bit_fast_paths_match_the_general_one() {
|
||||
let data: Vec<f32> = (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<u8> =
|
||||
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();
|
||||
|
||||
@@ -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<ExportResult, IoError> {
|
||||
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)
|
||||
|
||||
+5
-35
@@ -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() {
|
||||
|
||||
@@ -156,7 +156,7 @@ impl Plugin {
|
||||
let results: Vec<Result<Surface>> = 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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+247
-96
@@ -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<spill::Cell>,
|
||||
}
|
||||
|
||||
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<Self, AllocationError> {
|
||||
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<u8>) -> 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<TileBytes<'_>, 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<u8>>>, Vec<u8>);
|
||||
|
||||
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<u8>>>, Vec<u8>);
|
||||
|
||||
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<Tile>, 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<Tile>)], 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<f32>) {
|
||||
fn decode_tile_row_rgba(fmt: &PixelFormat, t: &[u8], base: usize, dst: &mut [[f32; 4]], row: &mut Vec<f32>) {
|
||||
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::<f32>()).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<Vec<u8>, 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<Rect> {
|
||||
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<Tile>)> = 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());
|
||||
}
|
||||
|
||||
@@ -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<u8>,
|
||||
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<Policy> = 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<Mutex<System>> = 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::<u64>().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<Reservation, String> {
|
||||
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<Reservation, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<u64, u64>,
|
||||
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::<u64>()
|
||||
}
|
||||
|
||||
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<Vec<File>>,
|
||||
path: PathBuf,
|
||||
/// The directory entry still exists (Unix unlink failed): remove it on drop.
|
||||
#[cfg(not(windows))]
|
||||
linked: bool,
|
||||
allocator: Mutex<Allocator>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<File> {
|
||||
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<Extent> {
|
||||
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<u8>) -> 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<u32> {
|
||||
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::<u32>().ok()?;
|
||||
(pid > 0 && nonce.parse::<u128>().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");
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Vec<u8>>>,
|
||||
/// Byte length of the tile (fixed for its lifetime).
|
||||
len: usize,
|
||||
error: Mutex<Option<String>>,
|
||||
/// A copy on disk that matches `data` (kept while the tile is unchanged).
|
||||
slot: Mutex<Option<(Arc<ScratchFile>, Extent)>>,
|
||||
/// Touched since the clock hand last passed.
|
||||
referenced: AtomicBool,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub(crate) fn new(bytes: Vec<u8>) -> Arc<Cell> {
|
||||
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<String> {
|
||||
self.error.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn set_error(&self, error: Option<String>) {
|
||||
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<Vec<u8>>> {
|
||||
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<Vec<u8>>> {
|
||||
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<Vec<u8>>) -> 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<ScratchFile>) -> Result<usize, String> {
|
||||
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<Vec<u8>, 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<Registry>,
|
||||
config: Mutex<Config>,
|
||||
evictions: AtomicU64,
|
||||
reloads: AtomicU64,
|
||||
written: AtomicU64,
|
||||
}
|
||||
|
||||
struct Registry {
|
||||
cells: Vec<Weak<Cell>>,
|
||||
hand: usize,
|
||||
}
|
||||
|
||||
struct Config {
|
||||
dir: Option<PathBuf>,
|
||||
/// Opened at configuration time; startup also reaps abandoned Unix files.
|
||||
file: Option<Arc<ScratchFile>>,
|
||||
last_error: Option<String>,
|
||||
/// The budget [`configure`] / [`set_budget`] asked for; restored when a pause ends.
|
||||
budget: usize,
|
||||
paused: Option<Pause>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
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<Cell>) {
|
||||
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<Arc<ScratchFile>> {
|
||||
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<u64>) -> 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<u64>) {
|
||||
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<PathBuf>,
|
||||
/// 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<String>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<std::fs::File> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn previous_read_region(surface: &Surface, r: Rect) -> Vec<f32> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -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<f32> = (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);
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -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::<u64>())
|
||||
.map(|s| s.tiles().map(|(_, t)| t.len() as u64).sum::<u64>())
|
||||
.sum();
|
||||
let ram = photocraft_ui_egui::gpu_canvas::physical_memory();
|
||||
let vram = g.video_memory();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. استبدله بهذا المستند، أو ألغِ واستخدم ملف › حفظ باسم للاحتفاظ بكليهما.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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í.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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ä.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -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} は、ここで開くか保存した後に別のアプリで変更されました。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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} был изменён другим приложением после того, как его открыли или сохранили здесь.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -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} змінила інша програма після того, як його тут відкрили або зберегли.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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} 在此处打开或存储后已被其他应用更改。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -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 更改。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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<String, Value>, 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<String, Value>, 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("");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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-<pid>.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft <version>, log file <path>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-4
@@ -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 |
|
||||
|
||||
|
||||
@@ -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-<pid>-<nonce>.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.
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user