diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f05aac6037..4662b094c7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -90,8 +90,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | | `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | | `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` | -| `docs/images/multi-channel-editing/{curves,curves-choices,levels,drawing}.png` | Selected colour channel editing (synthetic gradient) | @hidoba | Original synthetic gradient rendered with the offscreen `snapshot` example; adjacent JSON scripts | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` | -| `docs/images/multi-channel-editing/great-wave.jpg`, `rgb.png`, `cmyk.png`, `lab.png` | *The Great Wave off Kanagawa*, c. 1830–1832, and PhotoCraft channel-selection captures | Katsushika Hokusai (artwork); PhotoCraft contributors (UI) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg), Metropolitan Museum reproduction; adjacent JSON capture scripts | Public domain artwork (PD-Art), adjacent `LICENSE-PUBLIC-DOMAIN.txt`; UI/scripts MIT OR Apache-2.0 | +| `docs/images/multi-channel-editing/{curves,curves-choices,levels,drawing,mask-view-before,mask-view-after}.png` | Selected colour channel editing (synthetic gradient) | @hidoba | Original synthetic gradient rendered with the offscreen `snapshot` example; adjacent JSON scripts | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` | +| `docs/images/multi-channel-editing/great-wave.jpg`, `rgb.png`, `cmyk.png`, `lab.png`, `gallery-channel-preview-{before,after}.png` | *The Great Wave off Kanagawa*, c. 1830–1832, and PhotoCraft channel-selection captures | Katsushika Hokusai (artwork); PhotoCraft contributors (UI) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg), Metropolitan Museum reproduction; adjacent JSON capture scripts | Public domain artwork (PD-Art), adjacent `LICENSE-PUBLIC-DOMAIN.txt`; UI/scripts MIT OR Apache-2.0 | Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in [`docs/images/SOURCES.md`](docs/images/SOURCES.md)): diff --git a/docs/benchmarks/multi-channel-editing/environment.json b/docs/benchmarks/multi-channel-editing/environment.json index a23f272cf1..8d9c106976 100644 --- a/docs/benchmarks/multi-channel-editing/environment.json +++ b/docs/benchmarks/multi-channel-editing/environment.json @@ -19,5 +19,27 @@ "candidate_source_commit": "aeff53449221e8b42a628d832abc595c9503a146", "candidate_tone_source_sha256": "da42e686db09ed4e5bf2c19316a6b47261c97185aec83c6bba86831e29e39c6e", "before_fused_rerun_resources": "elapsed_seconds=16.47 peak_rss_kib=690516", - "before_fused_commit": "15bf0873ba03622e54189e668df20a1403e41d56" + "before_fused_commit": "15bf0873ba03622e54189e668df20a1403e41d56", + "liquify_preview": { + "source_content_commit": "009c4fa6128dad1e564d731656a304953d879842", + "build_git_head": "a37d21de7e1a90451d5afcad6772ae358f69e07a", + "binary_sha256": "309bc5e882403080ade9410103fba6eb73b296da897274a7816380f50e413c3a", + "files_sha256": { + "crates/ui-egui/examples/multi_channel_preview_bench.rs": "84f4e6fd4a0f79a35da706a06144401ad06509a06608ea5ed89729d394f6e0b1", + "crates/ui-egui/src/liquify_ui.rs": "1179cb7403fa189a8e0d1872212e150bd98b10844d189140e954cd0cdd279f29", + "crates/ui-egui/src/gallery_ui.rs": "118249270993f1443882df4808fd0f6e05b129a091441f3560bdcc0e4d80a4ca", + "crates/ui-egui/src/wide_angle_ui.rs": "6ebf9f1d291642dc3cc42c72980830f79e9f82c6d9b743c145e9048adddb1b36", + "crates/engine/src/color_channels.rs": "e6737a5dfb79a5324f08173d18cf271a539dddb386b9488249c150810537313e" + }, + "measured_at_utc": "2026-10-25T17:45:11.612486+00:00", + "profile": "release, thin LTO", + "rayon_threads": 8, + "measurement": "CPU field deformation plus dirty native proxy render. GPU texture upload/presentation excluded. One warmup plus six interleaved updates; p50 is upper middle; max_ms is max of six. open_ms is one sequential setup timing per mode, not a median.", + "resources": { + "elapsed_seconds": 0.72, + "peak_rss_kib": 389664 + }, + "isolation": "Measured after final tests, builds and cargo xtask perf --quick ended; no Cargo or rustc processes were running.", + "correctness_proof": "Each workspace reports a nonzero Liquify maxDisplacement after the measured updates." + } } diff --git a/docs/benchmarks/multi-channel-editing/liquify-preview.json b/docs/benchmarks/multi-channel-editing/liquify-preview.json new file mode 100644 index 0000000000..27c2ba338e --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/liquify-preview.json @@ -0,0 +1,54 @@ +{ + "depth": 16, + "operation": "Liquify 120px live move; native proxy preview", + "repeats": 6, + "rows": [ + { + "max_ms": 0.120119, + "open_ms": 22.648719, + "p50_ms": 0.110929, + "samples_ms": [ + 0.111069, + 0.097649, + 0.10152, + 0.120119, + 0.10755999999999999, + 0.110929 + ], + "target": "all" + }, + { + "max_ms": 0.36013799999999996, + "open_ms": 246.61311, + "p50_ms": 0.34547799999999995, + "samples_ms": [ + 0.310718, + 0.30684799999999995, + 0.320127, + 0.352098, + 0.34547799999999995, + 0.36013799999999996 + ], + "target": "red" + }, + { + "max_ms": 0.461107, + "open_ms": 317.70011900000003, + "p50_ms": 0.447017, + "samples_ms": [ + 0.397908, + 0.394428, + 0.416098, + 0.448617, + 0.447017, + 0.461107 + ], + "target": "red_blue" + } + ], + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/images/multi-channel-editing/gallery-channel-preview-after.png b/docs/images/multi-channel-editing/gallery-channel-preview-after.png new file mode 100644 index 0000000000..228dcb584c Binary files /dev/null and b/docs/images/multi-channel-editing/gallery-channel-preview-after.png differ diff --git a/docs/images/multi-channel-editing/gallery-channel-preview-before.png b/docs/images/multi-channel-editing/gallery-channel-preview-before.png new file mode 100644 index 0000000000..c4896b97ba Binary files /dev/null and b/docs/images/multi-channel-editing/gallery-channel-preview-before.png differ diff --git a/docs/images/multi-channel-editing/gallery-channel-preview-script.json b/docs/images/multi-channel-editing/gallery-channel-preview-script.json new file mode 100644 index 0000000000..ef1cb026c1 --- /dev/null +++ b/docs/images/multi-channel-editing/gallery-channel-preview-script.json @@ -0,0 +1,51 @@ +[ + [ + "engine.execute", + { + "command": "image.imageSize", + "params": { + "width": 1000, + "resample": "bicubic" + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "engine.execute", + { + "command": "channel.setVisible", + "params": { + "channel": "composite", + "visible": true + } + } + ], + [ + "ui.menu.invoke", + { + "id": "filter.filterGallery" + } + ], + [ + "ui.menu.invoke", + { + "id": "filter.filterGallery", + "params": { + "ui": { + "filter": "cutout" + } + } + } + ] +] diff --git a/docs/images/multi-channel-editing/mask-view-after.png b/docs/images/multi-channel-editing/mask-view-after.png new file mode 100644 index 0000000000..0f09d0f8dd Binary files /dev/null and b/docs/images/multi-channel-editing/mask-view-after.png differ diff --git a/docs/images/multi-channel-editing/mask-view-before.png b/docs/images/multi-channel-editing/mask-view-before.png new file mode 100644 index 0000000000..d062f0a241 Binary files /dev/null and b/docs/images/multi-channel-editing/mask-view-before.png differ diff --git a/docs/images/multi-channel-editing/mask-view-script.json b/docs/images/multi-channel-editing/mask-view-script.json new file mode 100644 index 0000000000..e466a1532f --- /dev/null +++ b/docs/images/multi-channel-editing/mask-view-script.json @@ -0,0 +1,128 @@ +[ + [ + "engine.execute", + { + "command": "file.new", + "params": { + "width": 800, + "height": 560, + "depth": 16, + "background": "white" + } + } + ], + [ + "engine.execute", + { + "command": "paint.gradient", + "params": { + "from": [ + 0, + 0 + ], + "to": [ + 800, + 560 + ], + "colors": [ + "#143b9b", + "#f6b552" + ] + } + } + ], + [ + "engine.execute", + { + "command": "layer.layerMask.revealAll", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "engine.execute", + { + "command": "view.layerMask", + "params": { + "mode": "gray" + } + } + ], + [ + "ui.set", + { + "tool": "brush", + "activeTabs": { + "middle": "channels" + }, + "fit": true + } + ], + [ + "engine.execute", + { + "command": "tools.setColors", + "params": { + "foreground": [ + 0, + 0, + 0, + 1 + ] + } + } + ], + [ + "engine.execute", + { + "command": "tools.setBrush", + "params": { + "brush": { + "size": 70, + "hardness": 1.0, + "smoothing": { + "amount": 0.0 + } + } + } + } + ], + [ + "ui.pointer", + { + "events": [ + { + "kind": "down", + "x": 150, + "y": 200 + }, + { + "kind": "move", + "x": 400, + "y": 280 + }, + { + "kind": "move", + "x": 650, + "y": 200 + }, + { + "kind": "up", + "x": 650, + "y": 200 + } + ] + } + ] +] diff --git a/docs/multi-channel-editing.md b/docs/multi-channel-editing.md index b19c8016ad..425c62e306 100644 --- a/docs/multi-channel-editing.md +++ b/docs/multi-channel-editing.md @@ -146,15 +146,30 @@ Raw samples: [original pre-feature base](benchmarks/multi-channel-editing/baseli [environment and binary hashes](benchmarks/multi-channel-editing/environment.json). The three `candidate*-before-fused-original.json` files preserve the original feature run. -Validation: 6,472 engine and UI tests passed (38 ignored), including 53 focused engine -regressions. Strict all-target Clippy, formatting, layering, -wasm, parity generation and the adversarial command panic hunt passed. The complete -`cargo xtask perf --quick` runner was attempted: its layout fixture fails because -it tries to convert an already imported Smart Object. This unrelated runner failure is not -reported as a passing full performance gate. The feature-specific benchmark above -completed successfully. Eleven original review/fix rounds and four follow-up reviews were completed; the -latest review found no actionable issues. Visual captures were rendered offscreen -and inspected. +Validation after the main merge and review fixes: 6,542 engine and UI tests passed +(38 ignored), including 58 focused engine regressions. Strict all-target Clippy, +formatting, layering, wasm, parity generation and the adversarial command panic hunt passed. +The complete `cargo xtask perf --quick` runner now finishes successfully after the main +merge; its report still lists unrelated, non-enforced budget overruns. Six further local +review/fix rounds followed the latest CodeRabbit review; the last found no actionable issues. +All visual captures were rendered offscreen and inspected. + +The additional 24 MP RGB16 Liquify benchmark uses +`crates/ui-egui/examples/multi_channel_preview_bench.rs`, with eight Rayon workers and +no concurrent builds, tests or performance scenarios. It measures field deformation plus +the dirty CPU proxy render; GPU uploads/presentation are excluded. One warmup and six +measured updates use interleaved target modes. The field's nonzero displacement is checked. + +| Liquify, 120 px brush | All channels | Red only | Red + Blue | +|---|---:|---:|---:| +| Preview update p50 | 0.111 ms | 0.345 ms | 0.447 ms | +| Maximum of six updates | 0.120 ms | 0.360 ms | 0.461 ms | +| Open workspace, one setup measurement | 22.6 ms | 246.6 ms | 317.7 ms | + +Opening a component workspace builds its native proxy once. Open timings are sequential +single observations, not medians; setup is excluded from update timings. The whole process, +including all three workspaces, peaked at 380.5 MiB RSS. [Raw samples](benchmarks/multi-channel-editing/liquify-preview.json) +and [source/binary hashes](benchmarks/multi-channel-editing/environment.json) are retained. ## Visual evidence @@ -200,3 +215,59 @@ retouch-preview restriction issues are fixed, with regression tests. The propose clipboard inversion is unnecessary: destination extraction and scatter already convert between tone and ink. Tests cover implicit/explicit singleton copying into explicit targets and normal/Paste Into/Paste Outside paths at 8/16/32-bit depths without adding an inversion. + +The post-merge review identified missing Copy/Cut parameter documentation; both commands +now describe explicit colour targets and layer ids/names. Auditing that contract also found +that Copy ignored an explicit layer with an implicit single-component target. Copy now +resolves that layer just as Cut does, with a regression that failed before the correction. + +The proposed Lab histogram index change is unnecessary: `HistSource::Colors` reserves +slot 0 for the combined selection and stores native components at `k + 1`, including Lab. +Ordinary Lab histograms instead use slots 0–2 for L/a/b. The histogram accessor returns +empty bins for an unavailable slot, rather than panicking. New tests use actual native +subset histograms for components `[0, 2]`, render every choice in both editors, and check +Levels Auto against those bins in RGB/CMYK/Lab at 8/16/32-bit depths. + +The next local review found that viewing a layer mask after selecting colour channels +could still send canvas strokes to those colours. Mask view now takes priority in canvas +painting, implicit engine commands and captured tone-dialog targets. Regressions cover +live previews and commits plus adjustments/filters at all depths, while explicit native +targets can still override the viewed mask. Selecting colour rows again leaves mask view +and restores their editing target and visibility. + +A further review caught mask Cut/Clear falling back to layer pixels, dialog capture retaining +the old colour set over a viewed mask, and Desaturate dropping explicit target parameters. +Cut/Clear now copy/fill only the selected mask samples, respecting locks and feathering. +Adjustment/filter dialogs preserve their captured mask through preview, confirmation and +Curves Reset, and Desaturate forwards its target and layer parameters. Tests cover pixel and +clipboard preservation, undo/redo, refused edits and changed panel selections. +Move/Free Transform still work on the viewed unlinked mask when colour rows remain selected +behind it; native colour subsets retain their geometry restriction. Curves canvas-point +gestures also honour the captured scalar target after the panel selection changes. + +The named-destination visibility guard now resolves layer names just as painting does. +Filter Gallery, Liquify and Adaptive Wide Angle capture the native colour target, preserve +unselected components and alpha in their specialized previews, and retain the target for OK. +Liquify samples only the dirty proxy or detail viewport, with a native proxy built once. +These full-window workspaces require layer pixels or native colour channels; masks, saved +channels and Quick Mask remain available through ordinary filter commands/dialogs. +Geometric writeback retains the original alpha layout, copying selected native bytes even +when the warp produces an alpha-bearing surface. Changed defaults materialize only within +the supplied document/preview extent, under the same bounded-pixel limit. + +The public-domain Filter Gallery comparison uses R+B with Green manually made visible. +The original preview filters Green too; the corrected preview retains its original detail. +The [capture script](images/multi-channel-editing/gallery-channel-preview-script.json) +records that explicit visibility override. + +![Before: Cutout also changes Green](images/multi-channel-editing/gallery-channel-preview-before.png) + +![After: Cutout preserves unselected Green](images/multi-channel-editing/gallery-channel-preview-after.png) + +The same synthetic gradient and [pointer script](images/multi-channel-editing/mask-view-script.json) +show the correction: before, the mask stays white while the stroke changes the layer; +after, the stroke paints the mask while the layer's gradient stays intact. + +![Before: the displayed mask stays white](images/multi-channel-editing/mask-view-before.png) + +![After: the displayed mask receives the stroke](images/multi-channel-editing/mask-view-after.png)