Files
photocraft/crates/engine/tests/it/atn_stroke_width.rs
T
Khawar Zamman Wani 86ddfb72f7 Reuse large buffers so returning freed memory to the OS doesn't slow filters (#10479) (#10514)
* perf: reuse large buffers so a pinned glibc trim threshold costs no speed (#10479)

With MALLOC_TRIM_THRESHOLD_ set (#8743), glibc maps every allocation over
128 KiB fresh, so tiles, compositor bands and filter scratch were page-faulted
in on every call: viewport compose 3.6x slower, Gaussian Blur up to 1.8x,
.pcraft save 2.4x.

- photocraft_raster::scratch: a process-wide, capped pool of large Vec
  buffers (u8/u16/f32/[f32; 4]). A miss drops the oldest buffers worth its
  size down to a 256 MiB floor, so peak memory stays near glibc's defaults.
  The engine releases the pool after any command that closes a document,
  Headless::close does too, and an idle thread releases it after 2 s unused.
- Tiles, compose Buffer/masks/bands, algo Image and the blur paths, engine
  adjustment/filter scratch, GPU tile upload and io import buffers go through
  the pool.
- .pcraft save reuses one zstd FrameCompressor per worker (byte-identical).
- interactive_bench reports minor page faults per run.

36 MP, trim threshold set vs glibc defaults: viewport 5.6 vs 6.1 ms (was
22.7), Blur r4 445 vs 437 ms (was 852), Curves CPU proxy 35.0 vs 35.7 ms
(was 50), flatten 146 vs 147 ms (was 237), save .pcraft 251 vs 230 ms (was
545). RSS after closing a 5400x7200 document: 21 MB with trim set.

* fix: corpus tests build again; return remaining pooled buffers (#10479 review)

- Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a
  `Buffer`, which now implements `Drop` (E0509 with `--features corpus`):
  use `into_px()`.
- io: masks, the merged image, extra channels, smart-filter masks,
  smart-object pixels and patterns give their `interleave` buffers back to
  the scratch pool after writing them.
- compose: recycle the mask values of a pass-through group, `effect_shape`
  and both `split_parts` callers.
- Styled Free Transform preview copies each band out instead of moving it,
  so the band returns to the pool.
- interactive_bench: the fault count covers each whole run, untimed setup
  included; say so.

* fix: recycle the masks of adjustment-atop and shape layers (#10479 review)

* fix: recycle effect hiding masks (#10479 review)

* fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review)

* fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review)

* fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review)

* fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review)

* test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review)

* fix: pool the final GPU effect shape buffer and return it after use (#10479 review)

* docs: describe scratch pool lifecycle regression invariants

* test: verify polling releases cancelled document scratch buffers

* perf: recycle remaining transform selection and PSD scratch buffers

* fix: free PSD gather bands before allocating channel planes

* test: assert exact scratch take reuses pooled storage
2026-11-02 09:29:59 +01:00

168 lines
8.3 KiB
Rust

//! #7671: exported Stroke widths must have the same effect as command execution.
use photocraft_engine::actions_cmds::Action;
use photocraft_engine::{Session, atn_cmds};
use photocraft_psd::{atn, descriptor::Value as D};
use serde_json::{Value, json};
/// Synthetic colours and partial transparency make lost edge pixels observable at every depth.
fn fixture(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap();
s.edit("procedural fixture", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let format = surface.format();
let bytes: Vec<_> =
(0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 0.6])).collect();
surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 6, "y": 4, "width": 12, "height": 10})).unwrap();
s
}
/// Compare the flattened document, including alpha, rather than only imported JSON.
fn pixels(s: &Session) -> Vec<[f32; 4]> {
photocraft_compose::flatten(&s.active().unwrap().doc).into_px()
}
/// Direct execution, native playback and strict binary ATN playback must agree exactly.
fn check_pixels(widths: &[(f64, u32)]) {
let mut lost = Vec::new();
for depth in [8, 16, 32] {
for location in ["inside", "center", "outside"] {
for &(width, effective) in widths {
let params = json!({"width": width, "color": "#d03060", "location": location, "opacity": 63, "mode": "multiply"});
let action = Action::new("Stroke", vec![("edit.stroke".into(), params.clone())]);
let mut direct = fixture(depth);
let before = pixels(&direct);
direct.execute("edit.stroke", params.clone()).unwrap();
let expected = pixels(&direct);
assert_ne!(expected, before, "ineffective fixture: {depth}, {params}");
let mut whole = fixture(depth);
let mut whole_params = params.clone();
whole_params["width"] = json!(effective);
whole.execute("edit.stroke", whole_params).unwrap();
assert_eq!(pixels(&whole), expected, "command width normalization: {depth}, {params}");
if width == 2.4 {
let mut rounded = fixture(depth);
let mut rounded_params = params.clone();
rounded_params["width"] = json!(2);
rounded.execute("edit.stroke", rounded_params).unwrap();
assert_ne!(pixels(&rounded), expected, "2.4 must differ from 2: {depth}, {location}");
}
let mut native = fixture(depth);
native.actions.list.push(action.clone());
native.execute("actions.play", json!({"action": "Stroke"})).unwrap();
assert_eq!(pixels(&native), expected);
let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = atn_cmds::import(&bytes).unwrap();
assert!(imported.unsupported.is_empty());
assert!(imported.warnings.is_empty());
assert_eq!(imported.actions.len(), 1);
assert_eq!(imported.actions[0].steps.len(), 1);
assert_eq!(imported.actions[0].steps[0].0, "edit.stroke");
let imported_width = imported.actions[0].steps[0].1["width"].clone();
let mut replay = fixture(depth);
replay.actions.list = imported.actions;
replay.execute("actions.play", json!({"action": "Stroke"})).unwrap();
if pixels(&replay) != expected {
lost.push(format!("depth {depth}, {location}, width {width} -> {imported_width}, expected {effective}"));
}
}
}
}
assert!(lost.is_empty(), "ATN changed Stroke pixels:\n{}", lost.join("\n"));
}
/// In particular 2.4 renders as 3 px, while the old exporter serialized 2 px.
#[test]
fn fractional_stroke_width_preserves_pixels_at_every_depth_and_location() {
check_pixels(&[(0.5, 1), (1.01, 2), (2.01, 3), (2.4, 3), (2.5, 3), (2.6, 3), (3.1, 4)]);
}
/// Existing whole-pixel strokes remain supported, with their original rendering.
#[test]
fn integer_stroke_width_controls_still_preserve_pixels() {
check_pixels(&[(1.0, 1), (2.0, 2), (3.0, 3), (4.0, 4)]);
}
/// Export the command's bounded/default width as an integer, retaining the other options.
#[test]
fn descriptor_width_matches_defaults_limits_and_other_stroke_options() {
for (width, effective) in [
(None, 1),
(Some(Value::Null), 1),
(Some(json!("2.4")), 1),
(Some(json!(true)), 1),
(Some(json!([])), 1),
(Some(json!({})), 1),
(Some(json!(-100)), 1),
(Some(json!(0)), 1),
(Some(json!(249.01)), 250),
(Some(json!(249.5)), 250),
(Some(json!(250)), 250),
(Some(json!(250.1)), 250),
(Some(json!(1e300)), 250),
] {
let mut params =
json!({"color": "#d03060", "location": "outside", "opacity": 63, "mode": "multiply", "preserveTransparency": true, "target": "pixels"});
if let Some(width) = width {
params["width"] = width;
}
let action = Action::new("Stroke", vec![("edit.stroke".into(), params.clone())]);
let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap();
assert!(skipped.is_empty());
let file = atn::parse(&bytes).unwrap();
let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap();
assert_eq!(descriptor.get("Wdth"), Some(&D::Integer(effective)), "{params}");
let imported = atn_cmds::import(&bytes).unwrap();
assert!(imported.unsupported.is_empty());
assert!(imported.warnings.is_empty());
let p = &imported.actions[0].steps[0].1;
assert_eq!(p["width"].as_f64(), Some(f64::from(effective)));
for key in ["color", "location", "preserveTransparency"] {
assert_eq!(p[key], params[key]);
}
// Import uses the canonical blend-mode label ("Multiply"); execution accepts both cases.
assert!(p["mode"].as_str().unwrap().eq_ignore_ascii_case(params["mode"].as_str().unwrap()));
assert_eq!(p["opacity"].as_f64(), Some(63.0));
let mut direct = fixture(8);
direct.execute("edit.stroke", params).unwrap();
let mut replay = fixture(8);
replay.actions.list = imported.actions;
replay.execute("actions.play", json!({"action": "Stroke"})).unwrap();
assert_eq!(pixels(&replay), pixels(&direct));
}
// A null parameter object is also a valid call to the command with its default width.
let action = Action::new("Default", vec![("edit.stroke".into(), Value::Null)]);
let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = atn_cmds::import(&bytes).unwrap();
assert!(imported.unsupported.is_empty());
assert!(imported.warnings.is_empty());
assert_eq!(imported.actions[0].steps[0].1["width"].as_f64(), Some(1.0));
let mut direct = fixture(8);
direct.execute("edit.stroke", Value::Null).unwrap();
let mut replay = fixture(8);
replay.actions.list = imported.actions;
replay.execute("actions.play", json!({"action": "Default"})).unwrap();
assert_eq!(pixels(&replay), pixels(&direct));
}
/// A custom Stroke converter must retain the plain-event mask/channel target guard.
#[test]
fn stroke_mask_and_channel_targets_are_still_refused_and_reported() {
for target in [json!("mask"), json!("quickMask"), json!({"channel": 0})] {
let action = Action::new("Targeted", vec![("edit.stroke".into(), json!({"width": 2.4, "target": target}))]);
assert!(atn_cmds::export("Widths", &[&action], &[], false).is_err());
let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], true).unwrap();
assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Targeted".into(), step: 0, id: "edit.stroke".into() }]);
assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty());
}
}